Compare commits

..

46 Commits

Author SHA1 Message Date
Maurice 3e9626fce9 feat(places): enrich list-imported places via the Places API (#886) (#1161)
* feat(places): enrich list-imported places via the Places API (#886)

Google/Naver list imports only carry a name and coordinates, so the places open
as bare pins — the Maps tab jumps to coordinates, with no photo, address or
open/closed. Add an opt-in "Enrich places via Google" toggle to the list-import
dialog, shown only when a Google Maps key is configured.

When enabled, after the (fast, unchanged) import the server runs a background
pass that re-resolves each place by name — biased to and validated against the
imported coordinates so a common-name search cannot overwrite the wrong place —
and fills the empty address/website/phone/photo columns plus the resolved
google_place_id, pushing each row over the live sync. Opening hours and the
proper Maps link then work on demand from the stored id.

Enrichment only fills empty fields, runs detached so a long list never blocks
the import, and no-ops when no key is configured.

* fix(places): use the ToggleSwitch component for the enrich toggle

Match the rest of the app — the import-enrichment opt-in used a raw checkbox;
swap it for the shared ToggleSwitch (text left, switch right) like the settings
toggles.
2026-06-14 00:54:11 +02:00
rossanorbr 3398da633b fix(planner): make route tools reachable in mobile day plan sheet (#1142)
* wiki: update dev env

* wiki: small precision in dev env

* fix(planner): make route tools reachable in mobile day plan sheet

On mobile, selecting a day closes the plan sheet immediately, so the
route tools footer (Route toggle / Optimize / routing profile) - gated
on the selected day - was never reachable. Desktop was unaffected.

- Add showRouteToolsWhenExpanded prop to DayPlanSidebar: when set,
  route tools render on any expanded day with 2+ assigned places
- Make handleOptimize accept an explicit dayId (defaulting to
  selectedDayId, preserving desktop behavior)
- Keep the distance/duration pill gated on the selected day, since
  routeInfo belongs to the selected day's calculated route
- Enable the prop on the mobile plan sheet in TripPlannerPage

* fix(planner): correct route-tools prop doc and dev-environment wiki

- Reword the showRouteToolsWhenExpanded JSDoc to list the controls the
  footer actually renders (Route toggle / Optimize / travel profile);
  there is no "Open in Google Maps" action in that block.
- Wiki: drop the non-existent server test:parity script, document the
  real shared i18n:parity checks, and fix the i18n note (the translation
  layer already lives in @trek/shared, it is not "upcoming").

---------

Co-authored-by: jubnl <jgunther021@gmail.com>
Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-06-13 15:24:27 +02:00
Maurice 31f99f0e4e Various fixes: 2FA autofocus, viewer-timezone times, duplicate place guard (#1159)
* fix(auth): autofocus the 2FA code input when the MFA step appears (#767)

* fix(notifications): show notification and admin times in the viewer timezone (#1149)

SQLite CURRENT_TIMESTAMP is UTC but the string has no Z, so the client parsed
it as local time. Normalize in-app notification created_at to ISO-UTC, and stop
forcing the admin user table to render in the server timezone.

* fix(places): warn before adding a duplicate place (#1152)

Manually adding a place did not check the existing pool, so the same POI could
land in Unplanned twice. Flag a likely duplicate by Google Place ID, name or
near-identical coordinates and require a confirming second click to add anyway.
2026-06-13 15:02:18 +02:00
Maurice 56655d53b4 AirTrail integration: import flights & two-way sync (#214) (#1158)
* feat(admin): register AirTrail as an integration addon

Off by default; toggle lives in Admin -> Addons with a Plane icon. The
per-user connection (URL + API key) follows in integration settings.

* feat(integrations): add per-user AirTrail connection

Settings -> Integrations gains an AirTrail section: instance URL + Bearer
API key (encrypted at rest via apiKeyCrypto), a self-signed-TLS opt-in and
a test-connection check. Served by a small Nest controller under
/api/integrations/airtrail, gated on the airtrail addon and SSRF-guarded.
The key is per-user, so it only ever returns that user's own flights.

* feat(transport): import flights from AirTrail

Adds an AirTrail Import button next to Manual Transport that lists the
user's AirTrail flights and highlights the ones inside the trip dates.
Selected flights become reservations linked to their AirTrail origin
(external_* columns), deduped against flights already in the trip, then
broadcast to every member. The mapping resolves airports, airport-local
times and flight metadata; the linkage is what the two-way sync rides on.

* feat(transport): badge AirTrail-linked flights as synced

Linked reservations show an 'AirTrail synced' badge, or 'no longer
synced' once the flight is gone from AirTrail.

* feat(transport): keep TREK and AirTrail flights in sync both ways

A scheduled poll reconciles each connected owner's flights: field edits
(detected by snapshot hash, since AirTrail has no updated_at) flow into
the linked reservation and broadcast live; a flight deleted in AirTrail
keeps the TREK row but stops syncing. Editing a linked flight in TREK
pushes back to AirTrail under the importer's credentials, preserving the
existing seat manifest; if the owner disconnected the link detaches so the
poll can't revert the local edit. Deleting in TREK never touches AirTrail.

* i18n(airtrail): add AirTrail strings across all locales

* test(airtrail): cover flight mapping, timezones and snapshot hashing

* fix(airtrail): reduce airline/aircraft objects to codes

The flight list/get response returns airline and aircraft as joined
objects ({icao, iata, name, ...}), not bare codes. Mapping them straight
through produced '[object Object]' titles and stored objects in metadata,
which crashed reservation rendering. Extract the ICAO/IATA code instead,
and title flights by their flight number.

* fix(airtrail): clear error on non-JSON responses, tolerate /api in URL

A misconfigured instance URL made AirTrail serve its SPA/login HTML, and
the raw JSON.parse failure surfaced as 'Unexpected token <'. Surface an
actionable message instead, and strip a pasted trailing /api so the base
URL still resolves.

* feat(transport): sync AirTrail edits on trip open, not just on the poll

Add a per-user on-demand sync (POST /integrations/airtrail/sync) triggered
when a connected user opens a trip, so AirTrail-side edits appear right away
instead of waiting up to a full poll cycle. Lower the background poll from 15
to 5 minutes as a safety net.

* fix(transport): refresh imported AirTrail flights without a reload

loadTrip doesn't fetch reservations, so a freshly imported flight only
appeared after a full page reload — use loadReservations instead. Also show
flight dates in the user's locale format (e.g. 13.06.2026) rather than the
raw ISO string.

* style(settings): align AirTrail connection with the photo-provider layout

Match the Immich section: stacked URL/key fields, a ToggleSwitch for
self-signed TLS, and a Save / Test-connection row with a status badge.

* feat(transport): add a seat field when editing flights

The transport editor only offered a seat field for trains; flights had
none even though imports store metadata.seat. Show and persist a seat for
flights too.

* style(transport): match the AirTrail button height to Manual Transport

* feat(transport): put the flight seat next to flight number and sync it to AirTrail

Move the seat from a standalone row to the per-leg flight details (beside
the flight number), stored per leg in metadata.legs[].seat with the first
leg mirrored to metadata.seat. On push, set the seat number on the user's
own AirTrail seat (the one with a userId), leaving co-passengers untouched;
import/poll read that same seat back.

* refactor(planner): move the AirTrail trip-open sync into useTripPlanner

Page containers must not own state/effects (lint:pages). Same logic,
relocated from the page into its data hook.

* test(db): pin the region-reconciliation test to its schema version

The test re-ran 'the last migration' assuming the reconciliation is last;
it no longer is once later migrations are appended. Pin to version 135 and
re-run from there (the appended migrations are idempotent).
2026-06-13 13:11:35 +02:00
jubnl f91721c73e fix(packing): respect per-item quantity in bulk import (#1157) 2026-06-13 03:23:37 +02:00
Maurice 0a58e3270b fix(packing): add more bag colors so sub-bags stop repeating (#1156)
The auto-assigned bag palette only had 8 colors, so the 9th bag reused the first one. Double it to 16 (keeping the existing 8 and their order) and keep the server and client lists in sync - both cycle BAG_COLORS[count % length].
2026-06-13 00:52:49 +02:00
Maurice e224befde7 Map/planner/dashboard polish and small community features (#1155)
* feat(planner): reorder days in a modal instead of a dropdown

The day-reorder control opened a small anchored dropdown; move it into the shared Modal (portal, dimmed backdrop, Esc/backdrop close) so it matches the Add activity dialog. Drag handles, up/down arrows and the day badges are unchanged.

* feat(map): explore reliability, Mapbox popups + compass, region-biased search

POI explore: clamp oversized viewports, query the Overpass mirrors in parallel (first valid response wins) with a per-request timeout and a short-lived cache, and surface a retry when every mirror fails - so it returns results at any zoom instead of timing out.

Mapbox renderer: add the place/POI hover popups (name, category, address, photo) the Leaflet map already had, plus a compass pill next to the explore pill that resets the view to north.

/api/maps/search: accept an optional locationBias to fix foreign-region bias and expose Google's place types in the result.

* feat(dashboard): list-view and mobile polish

Use the Archived status label for the filter and show Open dates for trips without dates; drop the unused settings button next to the view toggle. Desktop list view renders the date as a stat-style block separated from the counts.

Mobile list rows are stacked (slim cover banner + centred date), trip actions stay visible (touch has no hover), and the hero card's hover lift is disabled on touch; small spacing fix under the sidebar.

* feat: small community-requested options

Raise the plan-note subtitle limit to 250 characters and add more note icons. Expose is_archived and cover_image on the update_trip MCP tool. Add place coordinates to the PDF export. Allow creating a category from an existing to-do, and add a show/hide toggle on the admin password fields.

* test(shared): bump day-note subtitle limit assertion to 250

* test: align specs with the new search param order and archive label

Keep lang as the 3rd positional arg of the maps search controller so the existing unit test stays valid, and forward locationBias as the 4th. Add the now-used Popup to the MapViewGL mapbox mock, switch the dashboard archive-filter query to the Archived label, and expect the 4-arg search call.
2026-06-12 20:23:34 +02:00
Maurice f46cc8a98e Reorder whole days and insert a day (#589) (#1148)
* feat(days): reorder whole days and insert a day at a position

Adds reorderDays + insertDay to the day service and a PUT /days/reorder route
(plus an optional position on create). Day rows stay stable so a day's
assignments, notes, bookings and accommodations ride along by id; on a dated
trip the calendar dates stay pinned to their slots while the content moves
across them, and each booking's date is re-stamped onto its day's new date
(time-of-day preserved) so day_id stays consistent. Renumbering uses the
two-phase write to avoid the UNIQUE(trip_id, day_number) collision, and a move
that would invert an accommodation's check-in/out span is rejected.

* feat(planner): reorder days from a toolbar popup, and add days

A new toolbar button opens a popup listing the days; drag a row by its grip or
use the up/down arrows to reorder, and add a day from there. Reorders apply
optimistically with rollback and sync over WebSocket; the day headers are left
untouched, so the existing place drop-targets are unaffected.

* i18n: add day-reorder strings across all languages
2026-06-12 00:17:49 +02:00
Maurice 1378c95078 Explore places on the map, planner route fixes, and instance-wide Mapbox (#1147)
* feat(maps): add an OSM POI search endpoint (category within a viewport)

New /api/maps/pois queries OpenStreetMap via Overpass for places of a category
(restaurants, cafes, hotels, sights, …) inside a bounding box. OSM-only by design
— it never calls Google, even when a Google key is configured.

* feat(map): explore nearby places on the trip map (OSM category pill)

A floating, icon-only pill over the planner map lets you toggle a POI category and
see those OpenStreetMap places in the current view; clicking a marker opens the
add-place form pre-filled (name, address, website, phone). Single-select with a
'search this area' action after the map moves. Renders on both the Leaflet and
Mapbox maps, and can be turned off in settings (discussion #841).

* fix(planner): anchor timed places when optimising and route transports by location

- The day optimiser no longer reshuffles places that have a set time — they stay
  anchored to their time, like locked places.
- The route now uses a transport's departure/arrival location as a waypoint when it
  has one (e.g. a flight's airport), instead of breaking the route at every booking;
  transports without a location are ignored for routing but still show their leg's
  distance/duration under the booking.

* feat(admin): instance-wide Mapbox defaults in default user settings

Admins can set a shared Mapbox token (plus style, 3D and quality) as instance
defaults, so the whole instance can use Mapbox without each user pasting their own
key. Users without their own value inherit it via the existing admin-defaults
merge; the shared token is stored encrypted (discussion #920).
2026-06-11 23:42:16 +02:00
Maurice bb477645a3 Support multi-leg (layover) flights (#1146)
* feat(transport): support multi-leg (layover) flights in the booking form

A flight booking can now hold an ordered chain of airports (e.g. FRA -> BER ->
HND) instead of a single departure/arrival pair. The route is entered as a list
of waypoints with a '+ add stop' button; each stop carries its own arrival and
departure time plus the airline/flight number of the segment leaving it, while
the whole booking keeps one price.

Stored without a schema change: the existing reservation_endpoints rows carry the
ordered waypoints (from/stop/to by sequence) and a metadata.legs array holds the
per-leg detail. Top-level metadata (departure_airport/arrival_airport/airline/
flight_number) mirrors the first and last leg, so a single-leg flight persists
exactly as before and legacy readers keep working.

* feat(planner): show each flight leg as its own day-plan entry, ordered by time

A multi-leg flight now expands into one entry per leg (BER -> FRA, then FRA ->
HND), each on its own day with its own times, instead of a single span. Each leg
is an addressable slot (reservation id + leg index) so places and notes can be
dropped into the layover gap between legs; the per-leg position is persisted in
metadata.legs[i].day_positions and survives a reload.

Day-plan items are now ordered chronologically: anything with a time (a place's
time, a flight leg, a timed note) sorts by that time, and untimed items inherit
the time of the item before them so they stay where they were placed.

* feat(planner): show the full multi-stop route in the bookings panel

The route row now lists every waypoint (FRA -> BER -> HND) by sequence instead of
just the first and last airport.

* feat(map): draw multi-leg flights as connected legs with a marker per airport

Both the Leaflet and Mapbox overlays now render a flight over all its waypoints:
one great-circle arc per leg and a marker at every airport, with the label
showing the full route and the summed distance. A single-leg flight is unchanged.

Also drops the floating stats badge that was drawn on transport arcs.

* fix(map): centre a clicked place above the bottom inspector panel

Selecting a place panned/flew it to the dead centre of the screen, where it sat
behind the detail card. Both overlays now bias the target into the visible area
above the bottom panel (Leaflet offsets the pan by the inspector inset; Mapbox
passes the padding to flyTo).

* feat: show the full multi-stop flight route in PDF and calendar export

The PDF day list and the ICS export now render the whole route (FRA → BER → HND)
for a multi-leg flight instead of just the first and last airport, falling back to
the flat metadata for single-leg flights. The ICS keeps a single event per booking.

* feat(import): group connecting flight legs into one multi-leg booking

When a booking confirmation contains several flight legs sharing a PNR that
connect at the same airport with a short layover (under 24h), they are now
imported as a single multi-leg booking (from/stop/to endpoints + metadata.legs)
instead of one booking per leg. A round trip (same PNR, multi-day gap) stays two
separate bookings, and a single flight is unchanged.

* i18n: translate the new flight-route strings into all languages

* i18n: translate the Costs page into every language

The Budget → Costs rework left the new costs.* strings untranslated in every
non-English locale (they fell back to English). Translate them across all
supported languages.

* Revert "fix(map): centre a clicked place above the bottom inspector panel"

This reverts commit 0936103f04.
2026-06-11 22:17:14 +02:00
Maurice e65acb3de7 Fix a batch of reported bugs (#1145)
* fix(maps): fall back to OSM/Wikipedia for place photos and normalize non-standard language codes (#1137)

* fix(auth): refuse password reset for OIDC/SSO-linked accounts (#1129)

* fix(docker): ship server/assets (airports + atlas geo) in the runtime image (#1133, #1119)

* fix(unraid): point the template at a PNG icon Unraid can render (#1073)

* fix(offline): serve cached file blobs when offline or on network failure (#1046, #1069)

* fix(map): centre the selected pin in the visible map area above the bottom panel (#1125)

* fix(pdf): render persisted place-photo proxy URLs as images (#1130)

* fix(planner): show the selected place category in the edit form (#1134)

* fix(dashboard): collapse list-view trip cards to a compact row on mobile (#1132)
2026-06-11 13:31:43 +02:00
jubnl 3c040fab11 fix: miscellaneous bug fixes (#1139)
* fix(share): serve place thumbnails in shared trip links (#1100)

Google-sourced place photos are stored as image_url pointing at the
JWT-guarded /api/maps/place-photo/:placeId/bytes endpoint, so they 401
for an unauthenticated shared-trip viewer and render as broken images.

Rewrite place image_url values in the shared payload to a public,
token-scoped proxy (/api/shared/:token/place-photo/:placeId/bytes) and
add an unguarded SharedController route that validates the token and that
the place belongs to its trip before streaming the cached bytes. Mirrors
the existing JourneyPublicController precedent. No client changes needed.

* fix(atlas): replace Natural Earth with geoBoundaries for up-to-date regions (#1119)

Atlas sourced country and sub-national boundaries from Natural Earth's GitHub
`master` at runtime. That data is stale (e.g. it still shows Norway's pre-2020
counties such as Oppland/Hordaland) and depicts some contested territory in
unwanted ways (nvkelso/natural-earth-vector#391), so Natural Earth is dropped
entirely.

- Country borders (admin0) now come from the geoBoundaries CGAZ composite;
  sub-national regions (admin1) from per-country gbOpen, which carries ISO 3166-2
  codes. A new script (server/scripts/build-atlas-geo.mjs) normalizes and quantizes
  them into committed gzipped bundles under server/assets/atlas, read server-side at
  runtime (no network at boot, no GitHub CSP allowlist entry).
- New GET /addons/atlas/countries/geo serves the country layer; the client fetches
  it from the API instead of GitHub.
- A migration reconciles manually-marked visited_regions against the new bundle
  (valid code -> keep; region name still matches -> re-code; curated merge crosswalk
  for renamed reforms; else leave intact), with UNIQUE-safe dedup. bucket_list and
  visited_countries hold only invariant alpha-2 country codes, so they are untouched.
- Attribution added (NOTICE.md + README) per geoBoundaries CC BY 4.0.

Closes #1119

* fix(packing): make templates admin-only to create, usable by members

Creating a packing-list template was gated only by trip access, so any
trip member could create one from the Lists feature, while applying a
template silently failed for non-admins because the apply dropdown was
populated from the AdminGuard-protected /api/admin/packing-templates
endpoint.

- save-as-template now returns 403 for non-admins; the Save-as-Template
  button is hidden unless the user is an admin (both the TripPlanner
  toolbar and the inline packing header).
- add member-accessible GET /api/trips/:tripId/packing/templates so the
  apply dropdown lists templates for any trip member; client fetches
  from it instead of the admin endpoint.

Closes #1120
Closes #1121

* fix(packing): show bag tracking to non-admin members

The global Bag Tracking toggle was only readable via the admin-gated
GET /api/admin/bag-tracking, so non-admin trip members got 403 and the
weight fields, bag circles, and BAGS sidebar never rendered (#1124).

Surface the flag through the already-authenticated GET /api/addons
(loaded into the client addon store on app start for every user); the
packing hook reads it from the store instead of the admin endpoint. The
admin write path stays admin-gated and unchanged.
2026-06-09 16:02:37 +02:00
Maurice 49b3af8b0d feat: optimize routes around accommodation, confirm note deletions (#1123)
Optimize day routes around the accommodation

When a day has an accommodation set, the route optimizer now treats it as
the day's home base: it optimizes a loop that leaves the hotel and returns
to it, so the stop nearest the hotel comes first. On a transfer day -
checking out of one hotel and into another - the route runs from the first
hotel to the second instead.

The optimizer also gained a 2-opt pass on top of the nearest-neighbor
ordering, which removes the crossings the greedy pass used to leave behind.
A new display setting ("optimize route from accommodation", on by default)
lets you turn the anchoring off.

Confirm before deleting notes

Deleting a plan note or a collab note now asks for confirmation first. On
phones and tablets the edit and delete icons sit close together and were
easy to mis-tap, which deleted notes with no way back.
2026-06-07 12:52:06 +02:00
Maurice 093e069ccc Backend/frontend hardening & consistency cleanups (#1113)
* refactor(auth): session token validation and password-change consistency

* refactor(journey): entry field allow-list and public share-link consistency

* refactor(mcp): align tool authorization with the REST permission checks

* chore: input validation and sanitisation touch-ups (uploads, pdf, maps, backup, csp)
2026-06-06 16:37:03 +02:00
jubnl 070ef01328 chore: update kitinerary version 2026-06-05 19:26:34 +02:00
Maurice a876fb2634 feat: Passkey (WebAuthn) login (#1111)
* feat(auth): passkey (WebAuthn) login — server endpoints, schema + admin toggle

Add @simplewebauthn/server registration and primary (discoverable) login ceremonies under /api/auth/passkey, a webauthn_credentials + single-use webauthn_challenges schema (migration), the instance-wide passkey_login toggle (default off) enforced before auth by a guard, and require_mfa satisfaction via a verified passkey. RP ID/origin come only from server config (webauthn_rp_id/origins -> APP_URL), never request headers.

* feat(auth): passkey enrolment, login button + admin settings UI

PasskeysSection in account settings (add/rename/remove with a current-password step-up), a 'Sign in with a passkey' button on the login page, the admin enable + RP-ID/origins controls, and a per-user admin reset action.

* i18n(auth): passkey strings across all locales

Add login/settings/admin passkey keys to en and all 19 translated locales.
2026-06-05 18:54:13 +02:00
Maurice 247433fb2a feat(costs): rework Budget into Costs — Splitwise-style, multi-currency, mobile (#1106)
* fix(journey): authorize reads of the journey share link

GET /api/journeys/:id/share-link now requires journey access (canAccessJourney),
matching the create/delete share-link routes and the get_journey_share_link MCP
tool. Returns no link when the caller lacks access to the journey.

* feat(costs): rework Budget into Costs — Splitwise-style, multi-currency, mobile

Renames the Budget addon to "Costs" (UI only) and reworks it into a Tricount/
Splitwise-style cost tracker: multiple payers per expense, equal split across
chosen members, settle-up with persisted history + undo, 12 fixed categories,
per-expense currency with live FX conversion to a user-set display currency
(Settings -> Display), and locale-correct money formatting. Adds a desktop and a
dedicated mobile layout. A migration backfills existing budget items (single
payer, split members, currency). Closes #551 (per-expense currency).

Also switches the app font to self-hosted Poppins (Geist for secondary subtext),
replacing the Google Fonts CDN dependency.

* fix(costs): neutral dashboard dark palette + liquid glass, full page width, entry-count badge

- Dark mode used a warm oklch palette that read brownish; switch to the
  neutral zinc tokens used by the dashboard (#121215 bg, #f4f4f5 ink) and add a
  subtle backdrop-blur glass on cards.
- Costs now uses the full available page width on desktop instead of a 1280px cap.
- Render the expense count next to the Expenses title as a badge.
- Adapt budget/journey unit tests to the new payer-based settlement model and the
  Costs rename (category default 'other', Costs tab/CostsPanel).

* fix(costs): drop the entry-count badge, always show row edit/delete actions

Removes the count badge next to the Expenses title and makes the per-row
edit/delete actions permanently visible (no longer hover-only) on desktop too.

* feat(costs): currency-native money formatting, custom select/date, rename addon to Costs

- Format every amount in its own currency convention (symbol position, grouping
  and decimal separators) regardless of app language, via a currency->locale map
  (EUR -> '12,00 €', USD -> '$12.00', JPY -> '¥12', ...). Previously Intl used the
  app locale, so EUR showed the symbol in front under an English UI.
- Use TREK's CustomSelect (searchable, with symbols) and CustomDatePicker in the
  add/edit expense modal instead of the native <select>/<input type=date>.
- Rename the 'Budget Planner' add-on to 'Costs' in the admin list (display only;
  id/tables/permissions/MCP stay 'budget') via seed + a migration for existing DBs.

* feat(auth): configurable session duration via SESSION_DURATION

Adds a SESSION_DURATION env var (ms-style strings: 1h, 7d, 30d, ...) controlling
how long a session stays valid before re-login. It drives both the trek_session
JWT exp claim and the cookie maxAge from one source, so they never drift. Invalid
values warn at startup and fall back to the default (24h — unchanged). The MFA
challenge token and MCP OAuth tokens keep their own TTL.

Implements the request from discussion #946. Documented in the env-var wiki page,
.env.example and docker-compose.yml.
2026-06-05 01:38:25 +02:00
jubnl 6ef3c7ae6b feat(reservations): native booking-confirmation import via KDE KItinerary (#1102)
* feat(reservations): native booking-confirmation import via KDE KItinerary

Adds a two-step preview → confirm flow for importing booking emails,
PDFs, PKPass and HTML confirmations. The server invokes the KDE
kitinerary-extractor binary, maps JSON-LD schema.org output to TREK
reservation shapes, and persists via the existing createReservation
pipeline (accommodations, budget, places, WebSocket broadcasts).

- NestJS BookingImportModule: preview + confirm endpoints under
  /api/trips/:tripId/reservations/import/booking{,/confirm}
- KitineraryExtractorService: spawns the binary, filters stderr noise,
  handles QDateTime (@value) timezone-aware datetimes
- kitinerary-mapper: FlightReservation, TrainReservation, BusReservation,
  BoatReservation, LodgingReservation, FoodEstablishmentReservation,
  RentalCarReservation, EventReservation → typed preview items
- BookingImportService: auto-creates place rows; geocodes venues without
  coordinates via Nominatim (name+address → address → name fallback);
  resolves day IDs for accommodation linking
- BookingImportModal: drag-and-drop multi-file upload, preview cards
  with type icons, per-item exclude toggle, confirm step
- Shared Zod contracts: BookingImportPreviewItem, PreviewResponse,
  ConfirmRequest, ConfirmResponse — consumed by controller, service,
  API client and modal
- Dockerfile: node:24-trixie-slim runtime; amd64 downloads KDE static
  binary + locales; arm64 installs libkitinerary-bin + symlinks to
  fixed path; ENV KITINERARY_EXTRACTOR_PATH set for both arches
- /api/health/features exposes { bookingImport: boolean } so the UI
  hides the Import button when the binary is absent
- i18n keys (English), wiki docs, API.md, README one-liner

* i18n: add booking import translations for all 19 non-English locales

Adds 17 reservations.import.* keys and undo.importBooking to ar, br, cs,
de, es, fr, gr, hu, id, it, ja, ko, nl, pl, ru, tr, uk, zh, zh-TW.

* chore: enforce i18n parity

* docs(wiki): add KItinerary local setup instructions to dev environment guide
2026-06-04 20:40:57 +02:00
Maurice abe1c549bd feat(transport): add bus, taxi, bicycle, ferry and other transport types (#1105)
Closes #718. Adds five new transport reservation types alongside the
existing flight/train/car/cruise: bus, taxi, bicycle, ferry and a generic
'transport_other' catch-all. The new types are treated as first-class
transports everywhere — the transport modal, day plan, route calculation,
map overlays, file grouping and the PDF export — and are translated across
all 20 locales.

A dedicated 'transport_other' value is used for the catch-all so existing
'other' bookings are not reclassified as transport.
2026-06-04 20:39:11 +02:00
jubnl 10bea35a91 fix(journey): raise PhotoLightbox z-index above MobileEntryView (#1101) 2026-06-03 12:53:45 +02:00
Larinel a77ee4b4d5 fix(pwa): removed orientation from the manifest (#1058) 2026-06-01 22:08:43 +02:00
Maurice 9bec97fc19 Fix a batch of reported bugs: Atlas regions, planner overlays, imports, Safari modals (#1094)
* Start the Journey date picker week on Monday (#1078)

The Journey entry date picker started the week on Sunday (firstDow = getDay(), headers Su-first) while every other picker (CustomDateTimePicker, VacayCalendar) starts on Monday. Align it: Monday-first leading offset ((getDay()+6)%7) and Mo-first weekday headers.

* Fix Taiwan resolving to CN-TW in the Atlas country search (#1049)

natural-earth gives Taiwan ISO_A2='CN-TW' (a subdivision-style value) with ADM0_A3='TWN'. The dynamic A2_TO_A3 augmentation added 'CN-TW'->'TWN', which then overwrote the legitimate TWN->TW entry in the reverse map, so Taiwan's country option resolved to 'CN-TW' — unresolvable by Intl.DisplayNames (no name, broken flag, not searchable). Only augment A2_TO_A3 with real 2-letter codes.

* Drop empty leftover dateless days when a trip gets a shorter dated range (#1083)

generateDays kept all unused dateless placeholder days after switching to an explicit (shorter) date range, so day_count (COUNT(*) FROM days) stayed inflated. Delete the empty leftovers (no assignments/notes/accommodations) like the dateless path already does, while preserving any that still hold content. Adds TRIP-SVC-017.

* Render GPX and route overlays once the Mapbox style has loaded (#1036)

The GPX and route geojson effects ran before the map 'load' event had
attached their sources, so on the first paint they hit the early return
and never re-ran. Add mapReady to their dependencies so they fire again
the moment the sources exist.

* Convert HEIC trip and journey covers to JPEG before upload (#1085)

HEIC/HEIF covers coming straight off an iPhone could not be rendered in
the preview or stored as a usable image. Route both cover pickers through
normalizeImageFile, the same conversion the journal entry editor already
uses, so the file becomes a JPEG before it leaves the browser.

* Name GPX routes and tracks after their source file so multiple imports stick (#1054)

Unnamed routes and tracks all fell back to the same generic 'GPX Route' /
'GPX Track' label, so the name-based import dedup dropped every one after
the first - importing several files (or one file with several tracks) only
kept a single place. Derive the default name from the source filename with
an index suffix when a file holds more than one geometry, thread the
filename down through the controller, and let the import modal take more
than one file at a time. Adds PLACE-SVC-037/038.

* Namespace the modal backdrop class so content blockers stop hiding it (#1027)

Generic class names like .modal-backdrop sit on the cosmetic filter lists
that content blockers (1Blocker, EasyList Annoyances) ship, and get hidden
with display:none. The shared Modal - used by New Trip and Add Place -
carried that class, so Safari users running such a blocker saw the modal
silently fail to open with no error and no network request. Rename it to
.trek-modal-backdrop.

* Highlight GB regions by resolving England/Scotland/Wales/NI to finer admin-1 codes (#1067)

A zoom-8 reverse geocode of a UK place only resolves to the constituent
country (GB-ENG/SCT/WLS/NIR), but Natural Earth's admin-1 polygons for GB
are counties and boroughs (GB-LND, GB-MAN, GB-CON, ...). Those four codes
match no polygon, so places in England never highlighted in the Atlas
while CH/IT/NL/etc. worked. When a GB lookup lands on a constituent
country, re-resolve it at a finer zoom where Nominatim exposes the
county/borough code the polygons actually carry. Other countries keep the
exact zoom-8 behaviour. Adds ATLAS-UNIT-021.

* Surface the real place-search error instead of a generic toast (#1092)

When a place search or detail lookup fails, the backend already forwards the
upstream reason - including descriptive Google Places API messages such as
'Places API (New) has not been used in project ... or it is disabled'. The
planner discarded it and always showed 'Place search failed', so a key that
is mis-enabled, unbilled, or pointed at the legacy API instead of Places API
(New) looked like an unexplained silent failure. Show the server-provided
message when present, and stop the Atlas bucket-list search from swallowing
its error without a trace.

* Await the async cover normalization in the TripFormModal paste test (#1085)

handleCoverSelect now normalizes the pasted file before previewing it, so
URL.createObjectURL is called a microtask later. The assertion moves into
waitFor; a non-HEIC file still passes through unchanged.
2026-05-31 23:28:16 +02:00
Maurice 20791a29a7 Migrate TREK 3 to NestJS + React 19 (shared Zod contracts) (#1087)
* Migrate TREK 3 to NestJS + React 19 with a shared Zod contract layer

Brownfield strangler migration of the backend onto NestJS modules
(auth, trips, days, places, assignments, packing, todo, budget,
reservations, collab, files, photos, journey, share, settings, backup,
oidc, oauth, admin, atlas, vacay, weather, airports, maps, categories,
tags, notifications, system-notices) served through a per-prefix
dispatcher, keeping the existing SQLite/better-sqlite3 DB and JWT
httpOnly cookie auth, with behavioural parity for every route.

Client: React 19 upgrade, "page = wiring container + data hook"
pattern across all pages, per-domain Zustand stores bound to
@trek/shared contracts, and decomposition of the large components
(DayPlanSidebar, PackingListPanel, CollabNotes, FileManager,
MemoriesPanel, PlacesSidebar, CollabChat, SystemNoticeModal,
BudgetPanel, PlaceFormModal, ...) into focused render units backed by
in-file hooks.

Apply the shared global request pipeline (helmet/CSP, CORS, HSTS,
forced HTTPS, the global MFA policy and request logging) to the NestJS
instance as well, so a migrated route is protected identically to the
legacy fallback rather than bypassing it.

* Finish the NestJS migration — drop the legacy Express app

NestJS now serves the whole surface: every /api domain plus the platform
routes (uploads, /mcp, the OAuth/MCP SDK + /.well-known metadata and the
production SPA fallback). Removed server/src/app.ts, all of
server/src/routes/* and the strangler dispatcher; index.ts and the
integration suite share a single buildApp() bootstrap so prod and tests
can't drift.

- Platform/transport routes extracted to nest/platform/platform.routes.ts
  and mounted before app.init() — Nest's router answers an unmatched
  request with a 404, so a route registered after init is never reached.
  The SPA fallback is a NotFoundException filter and the catch-all uses a
  RegExp (Express 5's path-to-regexp rejects a bare '*').
- New modules: memories (/api/integrations/memories — the Journey
  gallery's Immich/Synology proxy), addons (GET /api/addons) and the
  cross-trip GET /api/reservations/upcoming.
- TrekExceptionFilter reproduces the old multer / err.statusCode handling
  so upload rejections keep their 400/413 { error } body and non-ASCII
  filenames survive (defParamCharset).
- addTripToJourney and the MCP get_journey_share_link tool gained the
  trip-access check they were missing.
- Re-pointed the 34 integration tests + the websocket test onto the Nest
  app; removed the now-meaningless Express-vs-Nest parity tests and a few
  orphaned client components.

* Restore the reset-password rate limit and fix copyTrip reservation links

Two correctness/security gaps the NestJS migration introduced:

- POST /api/auth/reset-password lost its per-IP rate limiter. Restore it
  (5 attempts / 15 min on a dedicated bucket, same as the old resetLimiter)
  so reset tokens can't be brute-forced unthrottled. Covered by AUTH-019.
- copyTripById did not copy reservations.end_day_id (a day reference — now
  remapped through dayMap like day_id) or needs_review, so a duplicated trip
  lost multi-day transport end-day links and reset the review flag.

* Clean up dead code, dedupe helpers, fix the reset-password contract

- Remove server exports orphaned by the Express removal: the immich
  album-link helpers, seven route-only service exports, getFileByIdFull;
  de-export internal-only helpers (utcSuffix).
- De-duplicate verifyTripAccess (9 identical copies -> services/tripAccess.ts)
  and avatarUrl (3 -> services/avatarUrl.ts); name the bcrypt cost
  (BCRYPT_COST) and the email regex (EMAIL_REGEX). Public API unchanged.
- resetPasswordRequestSchema declared `password`, but the client sends and
  the service reads `new_password` — rename it so the contract matches and
  the client types resolve.
- Make ATLAS-013 deterministic: stub the admin-1 GeoJSON download instead of
  fetching ~4600 features from GitHub during the test (it hung the suite).

* Make the client typecheck runnable (vitest/vite ambient types)

The client had no `typecheck` script and tsc couldn't even start (the
baseUrl deprecation errored out, same as server/shared already silence).
Add `ignoreDeprecations: "6.0"` to match the other workspaces, a `typecheck`
npm script, and a src/vite-env.d.ts referencing vite/client + vitest/globals
so tsc knows the test globals (describe/it/expect/vi). This turns ~3600
phantom "Cannot find name" errors into a real, measurable count (~590 actual
type errors remain, to be worked down). Type-only; no runtime change.

* Derive client domain types from the shared schema contracts

Add entity/response Zod schemas to @trek/shared (place, trip, assignment, day, budget, packing, reservation), each matched against the producing server service, and re-export them from client types.ts instead of the hand-written duplicates that had drifted (name/title, amount/total_price, owner_id/user_id, cover_url/cover_image, ...). Updates the call sites and test fixtures the corrected types surfaced; type-only, no runtime behaviour change.

* chore(db): log swallowed errors in addon-disable migration + guard against destructive migrations

The migration that disables the legacy "memories" addon swallowed any
error in an empty catch, as did ~30 other catch blocks in the migration
runner (column adds, the journey rebuild, index probes). Replace each
silent catch with the existing console.warn('[migrations] ...') log so
failures are visible. Control flow is unchanged: every step stays
non-fatal, nothing new is thrown.

Add a static guardrail test that scans the migration source and fails
when a new destructive statement (DROP TABLE / DROP COLUMN / TRUNCATE /
DELETE FROM / ALTER ... DROP) appears outside a reviewed allowlist, and
when an empty/silent catch block is reintroduced. The existing
destructive statements are all legitimate table rebuilds or
bounded cleanups and are recorded in the allowlist with a reason.

* Re-check SSRF on every redirect hop when resolving short links

Replace the one-shot checkSsrf + fetch(redirect:'follow') in the maps and place short-link resolvers with safeFetchFollow, which follows redirects manually and re-runs checkSsrf against the DNS-pinned IP of each hop (max 5). A redirect to an internal/loopback address is now blocked even when the initial URL is public, while legitimate cross-host redirects (goo.gl -> maps.google.com) still resolve.

* Reject WebSocket tokens minted before a password change

Stamp the user's password_version onto the ephemeral ws token and verify it on connect, closing the socket (4001) when it no longer matches, so a token issued before a password reset can't be replayed. Tokens minted without a version are treated as version 0, matching the JWT pv-claim semantics.

* fix(i18n): guard locale key parity and finish the OAuth consent page strings

Every non-en locale now exposes the exact same flat key set as en. Keys that
had drifted out of sync are backfilled with the English source value (tagged
en-fallback) so t() resolves a real string instead of relying on the silent
runtime fallback; no existing translation was touched and no key was removed.

Add a parity test that imports each aggregated locale bundle and asserts its
key set matches en, with a diagnostic listing of any missing/extra keys. This
complements the file-level check in shared/scripts by guarding the merged
export the app actually serves.

Finish internationalising OAuthAuthorizePage: the ~15 remaining hardcoded
English chrome strings now go through oauth.authorize.* keys (English source
in en, en-fallback placeholders elsewhere). Markup and behaviour are unchanged.

* Add semantic theme color tokens to Tailwind

Map the CSS theme variables from src/index.css (:root light / .dark dark) to named Tailwind utilities — bg-surface, text-content, border-edge, bg-accent and their variants. This gives components a Tailwind-native target for the theme colors so we can replace inline `style={{ ... 'var(--...)' }}` with utility classes without changing the rendered values.

* Surface silent store failures to the user and validate API responses in dev

Reservation toggle, todo/packing toggle and budget reorder were swallowing API errors after rolling back, so the user saw the change silently snap back with no explanation. Route those failures through the existing toast channel (new store/notify.ts bridges to window.__addToast, the same channel SystemNoticeBanner uses); the reservation toggle re-throws so ReservationsPanel's own translated toast finally fires. Also wire the existing parseInDev/checkInDev response validation into the maps and notification-test endpoints to catch contract drift in dev.

* Migrate static theme inline styles to Tailwind utilities and extract page sub-components

Replace the static, color-only inline `style={{ ... 'var(--bg-primary)' ... }}` props with the new semantic Tailwind utilities (bg-surface, text-content, border-edge, ...) wherever the result is byte-identical; dynamic/conditional theme styles and hardcoded status colors are left inline. Extract the Atlas country-search autocomplete, the Admin update banner, and two Journey dialogs into their own presentational components to shrink the oversized page files, keeping behaviour and markup identical.

* Remove the unrouted photos page and its dead photo components

PhotosPage was never wired into the router and its usePhotos hook read a tripStore photos slice that was never implemented; the Photos gallery, lightbox and upload components were only reachable through it. Per-trip photos now live in the Journey gallery (Immich/Synology). Removed the dead page, hook and components — the live Journey PhotoLightbox is a separate component and stays.

* Resolve the remaining client type errors and the trip.title navbar bug

Drive the client typecheck to zero without any/ts-ignore: convert the tripId route param to a number once at the page boundary so it matches the numeric props and store actions it feeds, fix trip.name -> trip.title (the wire field is title, so the old read rendered blank in the files/offline views), and tighten the scattered handler-arity, DOM-cast and untyped-payload sites. No runtime behaviour change.

* Convert the remaining dynamic and hardcoded inline styles to Tailwind utilities

Second styling pass over the components and pages: move conditional theme colors into className ternaries (bg-accent / bg-surface-hover etc.), turn reused CSSProperties constants into className constants, and express static hardcoded hex/rgba colors as Tailwind arbitrary values so the exact rendered colour is preserved. Truly dynamic styling (computed geometry, gradients, multi-part shadows, data-driven colours, the undefined --sidebar/--nav layout vars) stays inline as it cannot be expressed as a static class. Updated three component tests that asserted the old inline active-state styles to assert the equivalent utility class instead.

Verified: client typecheck 0, full client suite green, and a live light/dark render check in the dev server confirms the semantic theme tokens resolve correctly (the earlier 'transparent popups' were a stale dev server that pre-dated the tailwind.config token addition, not a code issue).

* Add eslint flat-config for client and server and gate typecheck, lint and pages in CI

client and server had lint scripts but no eslint config (only shared was linted in CI). Add flat configs mirroring shared's stack (js + typescript-eslint recommended + eslint-config-prettier) plus the client's react-hooks/react-refresh plugins. Pre-existing patterns in this never-linted code (explicit any, require() in the CommonJS server, empty catches, exhaustive-deps) are set to 'warn' rather than 'error' so the gate passes at 0 errors without a repo-wide reformat — these can be ratcheted to errors over time. Wire blocking typecheck + lint + lint:pages steps into the client and server CI jobs (now that both typechecks are clean) and promote the server typecheck from informational to blocking.

* Decompose the remaining God Components into hooks, helpers and sub-components

FE6: split the oversized page and panel components into thin layout shells plus colocated use<Component> hooks, .constants.ts, .helpers.ts (with tests) and presentational sub-components, following the established 'logic in a hook, render in slices' pattern. Behaviour, markup, classes and effect order are unchanged. Largest reductions: PackingListPanel 1598->42, FileManager 1055->36, AdminPage 1525->167, BudgetPanel 1266->146, JourneyDetailPage 2822->547, PlacesSidebar 945->66, CollabChat 861->106, CollabNotes 1417->532. DayPlanSidebar's drag-and-drop render body was left intact (ref-identity sensitive) and only its toolbar/modals/constants were extracted.

* Fix duplicate React keys in the file-assign place list

When a place is assigned to the same day more than once it appeared twice in a day's list, so the place-button key={p.id} collided and React warned about duplicate keys. Key by place id + render index so siblings stay unique. Pre-existing in the old FileManager; behaviour unchanged.

* Format the shared package and drop an unused import to satisfy the lint gate

The i18n and schema changes added code that wasn't prettier-formatted, and place.schema.ts imported categorySchema without using it. Run prettier over shared and remove the import so 'npm run lint' + 'format:check' pass.

* Install all workspaces in the server CI job so SWC's native binary is present

The server vitest config transforms via unplugin-swc, which needs @swc/core's platform-specific native binary. A workspace-scoped 'npm ci --workspace server' skips that optional dependency, so vitest failed to load the config on the Linux runner. Use a full 'npm ci'.

* Re-resolve dependencies with npm install in the server CI job for SWC

Full 'npm ci' still skipped @swc/core's Linux native binary because the committed lockfile was generated on Windows and lacks the Linux optional-dep install metadata. 'npm install' re-resolves and fetches the platform-matching binary, which the server's unplugin-swc transform needs to load vitest.config.ts.

* Install @swc/core's Linux binary explicitly in the server CI job

Neither npm ci nor npm install fetched @swc/core-linux-x64-gnu on the Linux runner because the lockfile was generated on Windows and lacks the Linux optional-dep metadata. Add a step that installs the matching @swc/core-linux-x64-gnu version (no-save, no-lockfile) so unplugin-swc can load the server's vitest config.

* Use legacy-peer-deps when installing the SWC Linux binary in CI

The explicit @swc/core-linux-x64-gnu install re-resolved the tree and hit the pre-existing lucide-react/react-19 peer conflict that the lockfile was generated around. Add --legacy-peer-deps so the step matches the project's resolution and installs the binary.

* Keep the lockfile when installing the SWC binary so other deps stay pinned

Dropping --no-package-lock made npm re-resolve the whole tree and upgrade eslint, whose newer recommended config flagged no-useless-assignment as an error in the server lint step. Keep the lockfile so only @swc/core-linux-x64-gnu is added and every other dependency (incl. eslint) stays at its locked version.
2026-05-31 21:10:00 +02:00
Maurice 6d2dd37414 feat(dashboard): mobile layout, glass UI, context bottom nav + OIDC PKCE (#1079)
* feat(dashboard): mobile layout, glass tiles, plain-text countdown, place photos

- Rework the mobile dashboard: cover hero, separate boarding-pass card,
  trimmed atlas (trips + days only), stacked widgets
- New floating bottom tab bar with a centred context-aware + button
  (new trip / place / journey / entry depending on the page)
- Move profile + notifications into a small top strip on the dashboard
- Desktop: glassmorphic tiles (light + dark), neutral dark palette,
  plain-text countdown module, real place photos in the boarding pass

* i18n(dashboard): translate new dashboard keys across all locales

Fill the dashboard-rework keys (hero, atlas, fx, tz, upcoming, copy
dialog, aria labels, countdown) that were left as English placeholders,
plus the new startsIn/aria keys, for all 19 languages.

* feat(oidc): send PKCE (S256) in the OIDC login flow

The OIDC client now generates a code_verifier per login, sends the
S256 code_challenge on the authorize request and the code_verifier on
the token exchange. Works whether the provider has PKCE optional or
required (fixes login against providers that require PKCE, e.g. Pocket ID).
2026-05-27 23:19:03 +02:00
jufy111 0d2657ee37 feat: Updated border of map markers to reflect category color. (#1062) 2026-05-27 22:54:41 +02:00
Julien G. 0a8fb1f53b Merge branch 'feat/dashboard-rework' into dev 2026-05-27 17:53:46 +02:00
jubnl 2fe6657edd chore: enforce prettier & lint on shared package 2026-05-27 17:42:23 +02:00
jubnl 5f964b9524 chore: prettier + lint 2026-05-27 17:35:10 +02:00
Ahmet Yılmaz 8bda980028 i18n: complete Turkish (tr) translation (#1075)
Fill in the remaining ~2100 UI strings in shared/src/i18n/tr so Turkish
matches the English catalog. Brand names, URLs, and technical placeholders
are left untranslated by design.
2026-05-27 17:31:37 +02:00
Dimitris Kafetzis 831a4fd478 feat(i18n): add Greek translation (#1061) 2026-05-27 17:31:03 +02:00
Maurice 4ff4435f8b refactor(dashboard): replace hardcoded strings with i18n keys
Hero, atlas row, trip cards, filters, currency and timezone widgets now resolve all visible copy through t() instead of hardcoded English/German.
2026-05-26 23:25:51 +02:00
Maurice 69b699c9bf i18n(dashboard): sync all locales to one key set + German copy-dialog strings
Brings every locale's dashboard namespace to the same 149-key set (missing keys backfilled from English) and translates the previously English-only copy-trip dialog into German.
2026-05-26 23:25:50 +02:00
Maurice 98032fda0c feat(dashboard): boarding-pass hero, atlas row, live widgets + modal portal fix
Reworked dashboard layout: boarding-pass hero with hover + days-left countdown, atlas stats row with real flags, searchable currency widget, editable timezone widget, new-trip FAB. Modals now portal to document.body to avoid inheriting dashboard-scoped button/font styles.
2026-05-26 23:12:08 +02:00
Maurice e04ceeb1ee i18n(dashboard): dashboard keys across locales 2026-05-26 23:12:08 +02:00
Maurice e5000ff7dd feat(dashboard): upcoming reservations endpoint + travel-stats country/distance
Adds GET /api/reservations/upcoming for the dashboard widget, switches travel-stats to the same country source as Atlas (manual + place-derived, ISO codes), and a distance service for flown km.
2026-05-26 23:12:07 +02:00
Julien G. 126f2df21b chore: move i18n to shared package (#1066)
* chore: move i18n to shared package

* chore: move server translations to shared package and apply linter and prettier on entire shared package
2026-05-26 20:27:29 +02:00
Maurice 324d930ca3 remove route_calculation setting, always use OSRM routing (#1064)
The per-user route_calculation toggle was a second, hidden on/off layer
on top of the day footer's show-route button, and made it easy to end up
with straight-line routes for no obvious reason. Drop the setting
entirely: routing is always on, the footer toggle stays the single
switch. Old stored values are simply ignored (settings are key-value, no
migration needed).
2026-05-26 16:21:10 +02:00
Maurice e050814c42 feat(planner): real road routes (OSRM) with travel-time connectors (#1060)
* feat(planner): real road routes (OSRM) with travel-time connectors

Replace the straight-line "as the crow flies" route with real OSRM road
geometry (FOSSGIS routed-car/-foot) and an Apple-Maps style render
(blue casing under a lighter core) on both the Leaflet and Mapbox GL
maps. Routes are off by default and toggled per session, with a
driving/walking mode switch in the day footer.

Each day shows per-segment travel time/distance connectors between
places, computed from the OSRM legs and split at transport bookings.

Also redesigns the day header for visual consistency: vertical
number+weather capsule, name with a divider before the date, subtle
hotel/rental pills that stay on one line, and a hover-revealed 2x2
action square (edit / add transport / add note / collapse). Drops the
Google Maps button.

* test(planner): update route hook tests for calculateRouteWithLegs
2026-05-25 22:27:49 +02:00
Julien G. c130ed41be chore: fix monorepo build pipeline and migrate shared to built package (#1056)
* chore: fix monorepo build pipeline and migrate shared to built package

- Root package.json: add workspace scripts (dev, build, test, test:cov, test:e2e)
  that delegate to actual scripts in shared/server/client workspaces
- shared: add tsup build step (CJS + ESM dual output, .d.ts); consumers now import
  from the built dist instead of raw TS source via path aliases
- server: replace tsc-alias with tsconfig-paths (tsc-alias mangled node_modules
  paths); fix MCP SDK path aliases to point to root node_modules (../node_modules)
- server/scripts/dev.mjs: delay node --watch until tsc -w signals first-pass done,
  eliminating the spurious restart on every dev startup
- client/vite.config.js + vitest.config.ts: remove @trek/shared path alias (no longer
  needed now that shared is a proper package)
- Consolidate package-lock.json at the workspace root; drop per-workspace lock files

* chore: fix test script to reflect root package.json

* chore: add missing lint and prettier script in root package.json

* fix(ci): build shared before tests; fix vitest MCP SDK alias paths

vitest.config.ts aliases pointed at ./node_modules/ (server-local) but
packages are hoisted to the root node_modules/ in the npm workspace —
changed to ../node_modules/.

CI jobs now install and build shared before running server/client tests
so that @trek/shared's dist/ exists when vitest resolves the package.

* fix(docker): update Dockerfile and CI for monorepo workspace structure

Dockerfile:
- Add shared-builder stage that produces @trek/shared dist before
  client and server stages need it
- Each build stage carries root package.json + package-lock.json so npm
  can resolve @trek/shared as a workspace dependency
- Production stage installs via workspace context (npm ci --workspace=server
  --omit=dev) so node_modules/@trek/shared symlinks to shared/dist correctly
- Copy server/tsconfig.json into the image so tsconfig-paths/register can
  find the MCP SDK path aliases at runtime
- CMD cds into /app/server before starting node so tsconfig-paths baseUrl
  resolves and ../node_modules points to /app/node_modules
- Remove mkdir for /app/server (now a real dir); keep symlinks for uploads/data

docker.yml version-bump:
- Replace manual per-workspace cd+npm-version calls with single:
  npm version --workspaces --include-workspace-root --no-git-tag-version
  (mirrors the version:* scripts in root package.json)
- git add now references root package-lock.json; adds shared/package.json

.dockerignore: add shared/dist
package.json: fix version:prerelease preid (alpha → pre)

* fix(tests): use in-memory SQLite per worker in test mode

vitest pool:forks spawns parallel worker processes that all called
initDb() on the same data/travel.db, causing SQLite "database is locked"
and "duplicate column name" races.

When NODE_ENV=test each fork now gets an isolated :memory: DB so migrations
run independently with no file contention.

* chore(ci): add ACT guards to skip DockerHub steps in local act runs

act sets ACT=true automatically. Guards added:
- docker login: if: ${{ !env.ACT }}
- build outputs: type=docker (local load) when ACT, push-by-digest when CI
- digest export/upload: if: ${{ !env.ACT }}
- merge job: if: ${{ !env.ACT }}
- release-helm job (docker.yml): if: ${{ !env.ACT }}
- version-bump git push (docker.yml): wrapped in [ -z "$ACT" ] shell guard

Run locally with:
  ./bin/act -j build -W .github/workflows/docker.yml \
    -P ubuntu-latest=catthehacker/ubuntu:act-latest

* fix(ci): move ACT guards to step level; add guards to security.yml

env context is invalid in job-level if conditions — moved all ACT
guards down to individual steps. Also guards docker login + scout
in security.yml so act can run the build-only part of that workflow.

* fix(ci): skip git fetch and tag logic in act (no remote access in local containers)

* Revert "fix(ci): skip git fetch and tag logic in act (no remote access in local containers)"

This reverts commit 67cf290cda.

* Revert "fix(ci): move ACT guards to step level; add guards to security.yml"

This reverts commit f92b95e054.

* Revert "chore(ci): add ACT guards to skip DockerHub steps in local act runs"

This reverts commit 797183de08.

* fix(docker): add musl optional deps so alpine builds find native rollup/sharp binaries

npm prunes libc-constrained optional deps to the host libc (glibc) when
generating the lockfile, leaving no musl entry for Alpine containers.
Declaring the x64/arm64 musl variants as explicit root optionalDependencies
forces them into the lockfile so npm ci on Alpine can install them.

Covers shared-builder (tsup/rollup) and client-builder (vite/rollup + sharp
icon generation) for both linux/amd64 and linux/arm64 CI targets.

* fix(docker): copy client dist into server/public so the server resolves static files correctly

The server runs from /app/server and serves static files relative to that
directory, so the client build output must land at /app/server/public, not /app/public.
2026-05-25 21:44:58 +02:00
Maurice db5c403239 i18n: register Korean + add Ukrainian translation (#1055)
Korean translation by @ppuassi (#977) — now registered. Ukrainian by @JeffyOLOLO (#902) — lifted onto a clean branch. Both at full en.ts key parity (2258 keys).
2026-05-25 18:37:15 +02:00
SkyLostTR bd29fcb0c0 Add Turkish (tr) translation + language registry (#1029)
Turkish translation by @SkyLostTR, at full en.ts key parity, registered in supportedLanguages + TranslationContext.
2026-05-25 18:26:29 +02:00
sss3978 be71cae0d3 feat(i18n): add Japanese (ja) translation (#829)
Japanese translation by @soma3978, at full en.ts key parity, registered in supportedLanguages + TranslationContext.
2026-05-25 18:22:39 +02:00
ppuassi ee2089e81d feat(i18n): add Korean (ko) translation (#977)
Korean translation by @ppuassi, topped up to full en.ts key parity. Language registration follows separately.
2026-05-25 18:22:35 +02:00
gzor 352f94612d fix(packing): multiply item weight by quantity in bag/total weight calcs (#898)
Quantity now counts toward bag and total weights. Generalised to an itemWeight() helper used by every weight sum (bag totals + max, unassigned, grand total; sidebar + bag modal) with unit tests.
2026-05-25 17:59:54 +02:00
Maurice 0257e4e71e feat(weather): migrate /api/weather to the NestJS pilot module (L1) (#1053)
First strangler migration (L1): /api/weather is served by a NestJS module.

- @trek/shared/weather Zod contract; Nest controller byte-identical to the legacy Express route (paths, query params, status codes, { error } bodies, lang default, ApiError/500 passthrough). Service reuses getWeather/getDetailedWeather (+ shared cache; MCP tools unchanged).
- Strangler routes /api/weather to Nest by default; the legacy Express route + its migration-time parity test were decommissioned in this PR.
- Frontend (FE2): weatherApi typed against the @trek/shared WeatherResult contract.
- Harness: reusable Nest-vs-Express parity harness, e2e harness (temp SQLite + seed/cookie helpers, real JwtAuthGuard), src/nest coverage gate raised to >=80%, src/nest test guide.
- Verified end-to-end on a prod mirror (dev1): 401/400/200 via Nest with real Open-Meteo data, Express route gone.
2026-05-25 17:00:58 +02:00
Maurice 0b218d53b2 Phase 0 — NestJS + Zod foundation harness (F1–F8) (#1050)
Co-hosted NestJS app behind the existing Express server via a strangler-fig dispatcher, sharing the same better-sqlite3 connection and JWT httpOnly cookie. Additive and dormant: default routing stays on Express, Nest only serves its own /api/_nest diagnostics until a module opts in.

F1 @trek/shared Zod contract package; F2 Nest bootstrap co-hosted (fall-through, single Dockerfile/port); F3 shared better-sqlite3 provider; F4 JWT cookie auth guard (+ @CurrentUser, admin guard); F5 Zod validation pipe + error-envelope parity; F6 Nest test + coverage gates; F7 per-prefix strangler toggle (env, default Express); F8 CI build/typecheck/test/coverage.

Remaining F4/F6/F8 checklist items (trip-access + permission levels + MFA policy, e2e harness/seed + 80% gate, Nest↔Express parity test, Playwright PR-comment workflow) are tracked on the first consuming module cards (L1/A1/C1).
2026-05-25 14:29:30 +02:00
3863 changed files with 65889 additions and 520449 deletions
+2 -3
View File
@@ -15,8 +15,6 @@ uploads
chart/
docs/
docker-compose.yml
docker-compose.minio-test.yml
minio-licence.key
unraid-template.xml
*.sqlite
*.sqlite-shm
@@ -32,7 +30,8 @@ Thumbs.db
sonar-project.properties
server/tests/
server/vitest.config.ts
server/reset-admin.js
**/*.test.ts
**/*.spec.ts
wiki/
scripts/
charts/
-1
View File
@@ -15,7 +15,6 @@
*.sh text eol=lf
# Binary files — no line ending conversion
*.png binary
*.webp binary
*.jpg binary
*.jpeg binary
*.gif binary
+2 -2
View File
@@ -8,11 +8,11 @@ body:
attributes:
label: Pre-flight checklist
options:
- label: I have searched [existing issues](https://github.com/liketrek/TREK/issues) and this bug has not been reported yet
- label: I have searched [existing issues](https://github.com/mauriceboe/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/liketrek/TREK/wiki/Troubleshooting) and my issue is not covered there
- label: I have read the [Troubleshooting guide](https://github.com/mauriceboe/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/liketrek/TREK/wiki
url: https://github.com/mauriceboe/TREK/wiki
about: Check the docs before opening an issue
- name: Feature Request
url: https://github.com/liketrek/TREK/discussions/new?category=feature-requests
url: https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests
about: Suggest a new feature or improvement in Discussions
- name: Questions & Help
url: https://github.com/liketrek/TREK/discussions
url: https://github.com/mauriceboe/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/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)
- [ ] 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)
- [ ] 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
@@ -9,7 +9,6 @@ permissions:
jobs:
close-stale:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-latest
steps:
- name: Close stale invalid-title issues
@@ -10,7 +10,6 @@ permissions:
jobs:
close-stale:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-latest
steps:
- name: Close stale wrong-base-branch PRs
+1 -2
View File
@@ -9,7 +9,6 @@ permissions:
jobs:
check-title:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-latest
steps:
- name: Flag or redirect issue
@@ -77,7 +76,7 @@ jobs:
body: [
'## Wrong place for feature requests',
'',
'Feature requests should be submitted in [Discussions](https://github.com/liketrek/TREK/discussions/new?category=feature-requests), not as issues.',
'Feature requests should be submitted in [Discussions](https://github.com/mauriceboe/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'),
-1
View File
@@ -18,7 +18,6 @@ concurrency:
jobs:
version-bump:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-latest
outputs:
version: ${{ steps.bump.outputs.VERSION }}
+12 -10
View File
@@ -1,6 +1,16 @@
name: Build & Push Docker Image
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:
@@ -22,22 +32,15 @@ concurrency:
jobs:
version-bump:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-latest
outputs:
version: ${{ steps.bump.outputs.VERSION }}
steps:
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
token: ${{ steps.app-token.outputs.token }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Determine bump type and update version
id: bump
@@ -109,7 +112,7 @@ jobs:
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 -a "v$NEW_VERSION" -m "v$NEW_VERSION"
git tag "v$NEW_VERSION"
git push origin main --follow-tags
build:
@@ -214,4 +217,3 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
charts_dir: charts
charts_url: https://chart.liketrek.com
@@ -6,7 +6,6 @@ on:
jobs:
check-target:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-latest
permissions:
pull-requests: write
@@ -68,38 +67,6 @@ jobs:
return;
}
// Dependabot cannot be pointed at dev. `target-branch` in dependabot.yml
// applies to version updates only; security updates always open against
// the default branch, and security updates are all this repository gets
// (there is no dependabot.yml). 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 {
-33
View File
@@ -1,33 +0,0 @@
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-latest
defaults:
run:
working-directory: plugin-sdk
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
# 22 matches the TREK server runtime and has node:sqlite, which the
# dev-server tests exercise.
node-version: 22
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
-4
View File
@@ -11,9 +11,6 @@ permissions:
jobs:
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
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -37,5 +34,4 @@ jobs:
command: cves
image: trek:scan
only-severities: critical,high
only-fixed: true
exit-code: true
+2 -131
View File
@@ -10,33 +10,9 @@ on:
- 'server/**'
- 'client/**'
- 'shared/**'
- 'plugin-sdk/**'
- '.github/workflows/test.yml'
jobs:
plugin-facts:
name: Plugin Permission Facts
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci --workspace server
# The plugin permission facts live once, in
# server/src/nest/plugins/protocol/envelope.ts. Every other copy - the SDK's
# generated/host-facts.ts and shared's plugin-permissions.ts - is generated from it
# and checked in, because publish-plugin-sdk builds from a standalone checkout with
# no server present. This fails the PR if a copy drifts, if a new permission has no
# admin.plugins.perm.* translation, or if the CLI has no hint for it.
- name: Check the generated plugin facts are in sync
run: cd server && npm run check:plugin-facts
i18n-parity:
name: i18n Key Parity
runs-on: ubuntu-latest
@@ -104,22 +80,9 @@ jobs:
- name: Build server (tsc -> dist)
run: cd server && npm run build
- name: Smoke production require chain
# Vitest aliases the MCP SDK deep imports, so only this exercises what
# production runs: the built dist resolving the MCP SDK's subpath
# exports through the tsconfig-paths/register runtime hook.
run: cd server && node --require tsconfig-paths/register -e "require('./dist/nest-mcp')"
- name: Typecheck
run: cd server && npm run typecheck
- name: Typecheck tests
# tests/ is outside the build tsconfig, so a service that grows a
# constructor parameter does not break its hand-wired call sites at
# compile time: the argument arrives as undefined and throws at runtime,
# if anything reads it at all. This is the step that catches it.
run: cd server && npm run typecheck:tests
- name: Lint
run: cd server && npm run lint:check
@@ -134,81 +97,8 @@ jobs:
path: server/coverage/
retention-days: 7
s3-contract:
name: S3 Driver Contract (MinIO)
runs-on: ubuntu-latest
# Fork PRs don't get repo secrets, so MINIO_LICENSE_KEY would be empty
# and the AIStor container would fail to boot.
if: github.event.pull_request.head.repo.full_name == github.repository
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci
- name: Ensure @swc/core's Linux binary for unplugin-swc
# The lockfile was generated on Windows and omits @swc/core's Linux
# optional native binary, so npm ci/install skips it on the runner.
# Install the matching version explicitly so the server's SWC transform
# (server/vitest.config.ts) can load.
run: |
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
- name: Build shared
run: npm run build --workspace=shared
- name: Write MinIO license
run: printf '%s' "${{ secrets.MINIO_LICENSE_KEY }}" > minio-licence.key
- name: Start MinIO
# `services:` can't pass the `server /data --license ...` command to
# the AIStor image, so it starts via docker run. 127.0.0.1 endpoint is
# load-bearing: aws-lite picks path-style only for IP/localhost hosts,
# which is all stock MinIO/AIStor speaks.
run: |
docker run -d --name minio -p 127.0.0.1:9000:9000 \
-v "$PWD/minio-licence.key:/minio.license:ro" \
-e MINIO_ROOT_USER=trekci -e MINIO_ROOT_PASSWORD=trekci-secret \
quay.io/minio/aistor/minio:RELEASE.2026-08-07T18-34-35Z server /data --license /minio.license
- name: Wait for MinIO
# curl was removed from the AIStor image — poll from the runner instead.
# 60 x 2s gives a wider margin on cold runners than the 30 x 1s the
# pre-6fbff835 job used.
run: |
for i in $(seq 1 60); do
curl -sf http://127.0.0.1:9000/minio/health/live && exit 0
sleep 2
done
echo "MinIO never became healthy" >&2
docker logs minio >&2
exit 1
- name: Run the S3 driver contract suite
# No --coverage: the src/nest/storage/** 94/84/97/94 pin must keep
# holding from hermetic tests alone. The suite's beforeAll creates
# the trek-test bucket idempotently — no separate bucket-init step.
env:
TREK_TEST_S3_ENDPOINT: http://127.0.0.1:9000
TREK_TEST_S3_BUCKET: trek-test
TREK_TEST_S3_ACCESS_KEY_ID: trekci
TREK_TEST_S3_SECRET_ACCESS_KEY: trekci-secret
run: cd server && npx vitest run tests/unit/nest/storage/s3.driver.contract.test.ts
client-quality:
# Split out of client-tests: the suite takes ~11 minutes, and having the
# gates in front of it meant a single lint finding threw away the whole test
# signal for that run. Both jobs pay the install/build, which is cheap next
# to running the two in series.
name: Client Types & Lint
client-tests:
name: Client Tests
runs-on: ubuntu-latest
steps:
@@ -235,25 +125,6 @@ jobs:
- name: Page pattern check
run: cd client && npm run lint:pages
client-tests:
name: Client Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared && npm ci --workspace client
- name: Build shared
run: npm run build --workspace=shared
- name: Run tests
run: cd client && npm run test:coverage
-1
View File
@@ -17,7 +17,6 @@ concurrency:
jobs:
deploy:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+1 -5
View File
@@ -51,7 +51,6 @@ yarn-error.log*
# Coverage
coverage
coverage-*/
*.lcov
.nyc_output
@@ -65,8 +64,5 @@ 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
-114
View File
@@ -1,114 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What TREK is
A self-hosted, real-time collaborative travel planner. npm-workspaces monorepo with four workspaces:
- **`shared/`** (`@trek/shared`) — Zod schemas that are the **single source of truth** for API contracts, consumed by both server and client. Also owns all i18n locale files. Must be built before server/client typecheck or run.
- **`nest-mcp/`** (`@trek/nest-mcp`) — decorator-driven MCP tool/resource/prompt registration for NestJS, consumed by the server. Deliberately **extraction-clean**: no imports from `@trek/server`/`@trek/shared`, no TREK semantics.
- **`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`) also lives in the repo but is **not a root workspace** — it has its own lockfile, is published to npm independently, and must ship standalone. Run its commands from `plugin-sdk/`, not the root.
Each package has its own `CLAUDE.md` with internals; this file covers the monorepo picture.
## Commands
Run from the repo root unless noted. Per the user's preference, **use `npm run ... --workspace=<pkg>`, not `npm -w <pkg>`**.
```bash
npm run dev # build shared + nest-mcp, then watch shared + server + client concurrently
npm run build # build shared → nest-mcp → server → client (order matters)
npm run lint # lint all four workspaces
npm run format # prettier --write all four
```
Tests (the user runs client tests as `cd client && npm run test`):
```bash
npm run test # all workspaces
npm run test --workspace=server # vitest run (server)
npm run test:unit --workspace=server # tests/unit only
npm run test:integration --workspace=server
npm run test:ws --workspace=server # websocket tests
npm run test:e2e # server e2e (boots Nest against temp SQLite)
npm run test:cov # coverage, server + client
cd client && npm run test # client vitest run
cd client && npm run e2e # Playwright
cd client && npm run lint:pages # enforce the Page pattern (see below)
cd client && npm run theme:lint # flag styling that bypasses appearance tokens (--strict in CI mindset)
npm run typecheck --workspace=server # tsc --noEmit (also client/shared)
```
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 (enforced in CI — every non-`en` locale must have identical file set + 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: server listens on **3001**, Vite dev server proxies `/api`, `/ws`, `/uploads`, `/oauth`, etc. to `http://localhost:3001` (see `client/vite.config.js`).
## Server architecture
Nest owns everything: the strangler migration **completed 2026-08-10**, and the last seam — the pre-`app.init()` MCP/OAuth mount — moved behind the container 2026-08-11: **zero `*.bridge.ts` files remain**. Legacy `src/services/` is deleted (ESLint errors on any import of it) and all business logic lives in DI modules under `server/src/nest/<domain>/`. `server/src/nest/README.md` is the module blueprint. Every cron is Nest-owned (`server/src/nest/scheduling/`, `@nestjs/schedule`).
- **`bootstrap.ts` — `buildApp()`** is the single shared builder used by both production (`index.ts`) and the integration/e2e test harness. **Composition order is load-bearing**: global middleware → platform uploads → the discovery metadata middleware → static assets → the named body-parser wrappers are all registered on the raw Express instance **before `app.init()`**, because Nest's router throws `NotFoundException` on unmatched routes and won't fall through to anything registered after init. The transport surface (`/api/health`, `/mcp`, `/.well-known/*` documents, `/oauth/*`) is Nest controllers/middleware. The SPA `index.html` fallback and the global error envelope are `APP_FILTER`s (`SpaFallbackFilter`, `TrekExceptionFilter`).
- **`/mcp` bodies are raw by design** — bootstrap's parser wrappers exempt `/mcp` so the MCP SDK reads the untouched stream; never `@Body()` a `/mcp` route.
- **Per-domain module shape** (one folder under `server/src/nest/<domain>/`): `<domain>.controller.ts` + `<domain>.service.ts` + `<domain>.module.ts`, registered in `app.module.ts`. **`weather/` is the reference implementation** — copy its shape. The Zod contract for the domain lives in `shared/src/<domain>/<domain>.schema.ts`.
- **Auth is default-deny**: `GlobalAuthGuard` + `MfaPolicyGuard` run as `APP_GUARD`s; public routes opt out with `@Public()`, and a boot-time ratchet (`validate-route-guards.ts`) refuses unknown public routes. New domain logic goes in the module's `.service.ts` — never resurrect a `services/*`-style plain module.
- **Trip-scoped routes** must verify trip access (404 on no access) and the relevant permission (403) — the standard shape is `@UseGuards(JwtAuthGuard, TripAccessGuard)` + `@RequirePermission('<action>')` per handler (a few domains still check inline; see `roadmap.md`) — and forward `X-Socket-Id` to the WebSocket broadcast so the originating client doesn't echo its own change. Never gate a multipart upload with a guard (ECONNRESET instead of 403 — check in the handler).
- **DB** (`server/src/db/`): `better-sqlite3`, WAL mode, FK on. `database.ts` initializes; `schema.ts` creates tables; `migrations.ts` + `seeds.ts` run on boot. In `NODE_ENV=test` each vitest worker gets an isolated `:memory:` DB; the Playwright harness uses `TREK_DB_FILE` for a throwaway file DB.
- **WebSocket** (`server/src/websocket.ts`): JWT-authed, room-per-trip (`tripId → Set<WebSocket>`), heartbeat keep-alive, per-connection rate limiting, served on `/ws`.
- **MCP** (`server/src/mcp/`): OAuth 2.1-authenticated MCP server exposing TREK to AI assistants (150+ tools in `tools/`, resources, scopes, session manager). See `MCP.md`.
- **Addons** are admin-toggleable feature modules keyed by `ADDON_IDS` in `server/src/addons.ts` (mcp, packing, budget, documents, vacay, atlas, collab, journey, airtrail, llm_parsing, collections).
- **Plugins** (`server/src/nest/plugins/`): sandboxed third-party plugin runtime — one forked child process per plugin (`supervisor/`), permission-gated RPC (`host/rpc-host.ts` registers a handler only if the plugin holds the unlocking permission), install-time manifest/signature/egress checks (`install/`, `runtime/egress-policy.ts`). Author tooling lives in `plugin-sdk/`; the `trek-plugin-dev` skill covers plugin authoring.
## Client architecture
Offline-first, with a layered data flow. A Page never owns state directly:
- **Page pattern** (enforced by `lint:pages`, see `client/src/pages/PATTERN.md`): a `*Page.tsx` default export is a **wiring container** — it composes a co-located `use<Page>()` hook into JSX and must **not** call `useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef` in its own body. Stateful logic lives in the hook.
- **State**: Zustand stores in `client/src/store/`. The big one is `tripStore.ts`, assembled from **slices** in `store/slices/` (places, packing, todo, budget, reservations, files, …). `slices/remoteEventHandler.ts` applies incoming WebSocket events to local state.
- **Data flow (offline-first)**: `store → repo (client/src/repo/) → api (client/src/api/) | Dexie (client/src/db/offlineDb.ts)`. Repos check `navigator.onLine`: online they hit the REST API and upsert into Dexie; offline they read from Dexie. Writes go through `sync/mutationQueue.ts` — optimistic Dexie write, then on reconnect `flush()` replays REST with an `X-Idempotency-Key` header so retried writes don't double-apply. `sync/tripSyncManager.ts` and `sync/syncTriggers.ts` drive reconnection sync; `sync/tilePrefetcher.ts` caches map tiles.
- **Styling**: use the appearance tokens (`var(--token)` inline or `bg-[var(--...)]` Tailwind classes), never hardcoded color literals or numeric `fontSize` — `theme:lint` flags bypasses so user-chosen scheme/transparency/text-size keep working. Suppress intentional exceptions (map/PDF/brand colors) with a `theme-lint-disable` line comment.
- **PWA**: `vite-plugin-pwa` + Workbox caches tiles/API/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 in `shared/src/<domain>/`, rebuild shared, then both server (validation + DTO types) and client (typed requests) pick it up.
- **i18n locale files live in `shared/src/i18n/<locale>/`**, one file per domain. `en/` is canonical. When you add/change a translation key, **add it to every locale** or `i18n:parity:strict` fails CI. The client resolves keys via `client/src/i18n/TranslationContext.tsx` (`t(key)`), which only reads top-level string keys.
## Project direction (from the 2026 full-repo audit)
A verified audit of the whole repo lives in `audit_report/TREK_audit_reports/` (`00_MASTER_SUMMARY.md` is the entrypoint, `01_STEERING_ROADMAP.md` the plan). Its verdict shapes how all new code should be written:
- **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 (historically Nest controllers over legacy `services/*` — that migration is done; on the client it's feature components calling the raw API past the offline core, still ~190 files). 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.
- **DI over global mutable module state.** The `db` proxy, `ws` singleton, and event-sink globals are what force `require()`-to-dodge-cycles and load-bearing boot ordering. 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 (DI providers, pipes, guards, `@nestjs/schedule`) over hand-rolled equivalents of the same machinery.
- **Fail closed, gates stay on.** Security switches default to safe; misconfiguration must refuse, not silently degrade. Never lower a quality gate (strict TS, lint severity, coverage, parity checks) to land a change — no new `any`, no new `eslint-disable`, no downgrading rules to `warn`.
## Conventions (from CONTRIBUTING.md)
- **Target the `dev` branch** for PRs, not `main` (exception: `wiki/`-only changes).
- **Discuss first**: outside contributions must be pitched in the `#github-pr` Discord channel before any code is written — undiscussed PRs are closed.
- **PRs follow `.github/PULL_REQUEST_TEMPLATE.md`** and require a linked issue (`Closes #N`) for bug fixes or an approved feature-request discussion for features — no issue/discussion, no PR.
- **Conventional commits** (`fix(maps): ...`, `feat(budget): ...`). Per user preference, **do not add Co-Authored-By Claude attribution**.
- One focused change per PR; no breaking changes; no unrelated reformatting/refactors. Tests required — the project holds **80%+ coverage** (the `src/nest/**` vitest coverage gate enforces ≥80%).
- When migrating/adding a route, **parity is law**: same URL, method, query/body, HTTP status, `Set-Cookie`, and JSON body — including bespoke error strings (e.g. reproduce `{ error: 'Admin only' }` exactly rather than relying on a generic guard message). Note Nest defaults POST to 201; add `@HttpCode(200)` where the legacy contract returned 200. Declare static sub-routes (`/reorder`, `/in-app/all`) **before** `:id` param routes.
## Reference docs
- `MCP.md` — MCP server/tools/scopes. `README.md` — deployment, env vars, reverse-proxy setup. `server/src/nest/README.md` — per-module blueprint and test layout (unit / parity / e2e).
-136
View File
@@ -1,136 +0,0 @@
# 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/liketrek/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/mauriceboe/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/liketrek/TREK/wiki/Development-environment) for more information on setting up your development environment.
See the [Developer Environment page](https://github.com/mauriceboe/TREK/wiki/Development-environment) for more information on setting up your development environment.
## More Details
See the [Contributing wiki page](https://github.com/liketrek/TREK/wiki/Contributing) for the full tech stack, architecture overview, and detailed guidelines.
See the [Contributing wiki page](https://github.com/mauriceboe/TREK/wiki/Contributing) for the full tech stack, architecture overview, and detailed guidelines.
+25 -50
View File
@@ -1,10 +1,3 @@
# ── Stage 0: gosu ────────────────────────────────────────────────────────────
# 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
# ── Stage 1: shared ──────────────────────────────────────────────────────────
FROM node:24-alpine AS shared-builder
WORKDIR /app
@@ -46,23 +39,26 @@ COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY server/package.json ./server/
# 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.
# 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)
RUN apt-get update && \
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential \
libkitinerary-bin && \
apt-get install -y --no-install-recommends tzdata dumb-init gosu wget ca-certificates python3 build-essential && \
npm ci --workspace=server --omit=dev && \
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
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 && \
chown -R node:node /app
# gosu rebuilt with a current Go toolchain (stage 0) — used by CMD to drop to node.
COPY --from=gosu-build /out/gosu /usr/local/bin/gosu
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
ENV XDG_CACHE_HOME=/tmp/kf6-cache
# Prevent Qt from probing for a display in headless containers.
@@ -71,42 +67,24 @@ 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 --chown=node:node --from=server-builder /app/server/dist ./server/dist
COPY --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 --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
COPY --from=server-builder /app/server/assets ./server/assets
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
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 --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
COPY server/tsconfig.json ./server/
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
# 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 && \
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
ln -s /app/uploads /app/server/uploads && \
ln -s /app/data /app/server/data && \
chown -R node:node /app/data /app/uploads && \
chown -h node:node /app/server/uploads /app/server/data
chown -R node:node /app
ENV NODE_ENV=production
ENV NODE_USE_ENV_PROXY=1
ENV PORT=3000
ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}
@@ -117,8 +95,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://localhost:3000/api/health || exit 1
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 ["sh", "-c", "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"]
+575 -1
View File
@@ -1,3 +1,577 @@
# MCP Integration
Please refer to the [MCP wiki](https://github.com/liketrek/TREK/wiki/MCP-Overview) for more information.
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)
+2 -24
View File
@@ -21,34 +21,12 @@ 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, place details & descriptions
## OpenStreetMap — geocoding
Atlas reverse-geocodes places via the **Nominatim** service, and place search,
details and descriptions come from Nominatim and **Overpass**. Data is
Atlas reverse-geocodes places via the **Nominatim** service. Geocoding 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**
+88 -252
View File
@@ -1,16 +1,17 @@
<div align="center">
<picture>
<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" />
<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" />
</picture>
<br />
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/subtitle-light.png" />
<source media="(prefers-color-scheme: light)" srcset="docs/subtitle-dark.png" />
<img src="docs/subtitle-dark.png" alt="your trip. your plan." height="28" />
<img src="docs/subtitle-dark.png" alt="Your trips. Your plan. Your server." height="28" />
</picture>
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
@@ -30,9 +31,9 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
<a href="https://www.buymeacoffee.com/mauriceboe"><img alt="BMAC" src="https://img.shields.io/badge/BMAC-support-FFDD00?style=for-the-badge" /></a>
<br />
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
<a href="https://github.com/liketrek/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/liketrek/trek?include_prereleases&style=flat-square&color=6B7280" /></a>
<a href="https://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://hub.docker.com/r/mauriceboe/trek"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/trek?style=flat-square&color=6B7280" /></a>
<a href="https://github.com/liketrek/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/liketrek/trek?style=flat-square&color=6B7280" /></a>
<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>
</div>
@@ -40,62 +41,60 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
<div align="center">
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK1.webp" alt="TREK, a tour of the app" width="100%" />
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK1.gif" alt="TREK — 60-second tour" 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="Budget tracker" 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="Iceland Ring Road" 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" />
<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%" />
<img src="docs/tiles/grid-desktop.svg" alt="TREK feature tiles" 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">
#### 🧭 Planning
#### 🧭 Trip planning
- **Day plans**: drag places between days and reorder inside a day, with undo. Notes and bookings drag the same way, and a map marker drops straight onto a day
- **Maps**: Leaflet, Mapbox GL or MapLibre GL (OpenFreeMap, no token), with clustering, photo markers and route lines. 3D buildings and terrain are Mapbox only
- **Place search**: Google Places when a key is set (photos, ratings, opening hours), otherwise OpenStreetMap with no key
- **Place enrichment**: descriptions, facts, hours and photo candidates from OpenStreetMap, Wikipedia, Wikidata and Wikimedia Commons
- **POI explore**: pull OpenStreetMap POIs by category for the current viewport over Overpass
- **Import**: shared Google Maps and Naver Maps lists, plus GPX, KML and KMZ files
- **Export**: GPX of a trip's places and tracks, and an ICS feed per trip or across all of them
- **Routes**: auto-sort a day (nearest neighbour then 2-opt, locked stops and hotel anchors stay put), driving, walking or cycling profiles over OSRM, then open it in Google Maps or CoMaps
- **Public transport**: door-to-door itineraries over Transitous
- **Weather**: 16-day forecast from Open-Meteo, no key. Dates outside that window read the archive for the same date instead
- **Day notes**: markdown body with an icon and a colour, reordered by drag and drop or moved to another day
- **Trip dates**: move a trip and the days re-date themselves, either dragging the bookings along or re-anchoring them. Trips also copy and archive
- **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)
- **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
</td>
<td width="50%" valign="top">
#### 🧳 Bookings and money
#### 🧳 Travel management
- **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
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files; import from booking confirmation emails and PDFs ([KDE Itinerary](https://invent.kde.org/pim/kitinerary))
- **Budget tracking** — category-based expenses with pie chart, per-person / per-day splits, 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
</td>
</tr>
@@ -104,71 +103,62 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
#### 👥 Collaboration
- **Real-time sync** (WebSocket): edits land live for everyone who has that trip open
- **Members**: add by email or username, hand ownership to someone else, or add guests who have no login at all
- **Permissions**: an admin maps each of 16 trip actions to admin, trip owner, trip member or everybody
- **Invite links**: one reusable link per trip with an optional expiry. Admins can also issue signup invites with a use limit that drop the new account straight into a trip
- **Public share**: a read-only trip page anyone can open without an account
- **Collab**: group chat with replies, reactions and link previews, shared notes with attachments, polls, and a What's Next list of upcoming activities. Each of the four switches on its own
#### 📔 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
- **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
- **Collab suite** — group chat, shared notes, polls, day check-ins
</td>
<td width="50%" valign="top">
#### 🧩 Plugins
#### 📱 Mobile & PWA
- **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
- **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
</td>
</tr>
<tr>
<td width="50%" valign="top">
#### 📱 Mobile and offline
#### 🧩 Addons (admin-toggleable)
- **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
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
- **Budget** — expense tracker with splits, pie chart, 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
- **Naver List Import** — one-click import from shared Naver Maps lists
- **MCP** — expose TREK to AI assistants via OAuth 2.1
</td>
<td width="50%" valign="top">
#### ⚙️ Admin, accounts and security
#### 🤖 AI / MCP
- **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 and ntfy, 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
- **23 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
- **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
- **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
- **15 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
</td>
</tr>
@@ -178,26 +168,6 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
<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
@@ -210,7 +180,7 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
<div align="center">
<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>&nbsp;&nbsp;·&nbsp;&nbsp;
</div>
@@ -221,9 +191,9 @@ 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)
![NestJS](https://img.shields.io/badge/NestJS_11-E0234E?style=flat-square&logo=nestjs&logoColor=white)
![Express](https://img.shields.io/badge/Express-000000?style=flat-square&logo=express&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)
![React](https://img.shields.io/badge/React_18-61DAFB?style=flat-square&logo=react&logoColor=black)
![Vite](https://img.shields.io/badge/Vite-646CFF?style=flat-square&logo=vite&logoColor=white)
![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?style=flat-square&logo=typescript&logoColor=white)
![Tailwind](https://img.shields.io/badge/Tailwind-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white)
@@ -232,7 +202,7 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
</div>
Real-time sync via WebSocket (`ws`). Backend on NestJS 11. State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + Passkeys (WebAuthn) + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
Real-time sync via WebSocket (`ws`). State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
<br />
@@ -293,7 +263,7 @@ Then:
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.
**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 Express how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
</details>
@@ -302,12 +272,12 @@ docker compose up -d
<h2 id="helm-kubernetes">Helm (Kubernetes)</h2>
```bash
helm repo add trek https://chart.liketrek.com
helm repo add trek https://mauriceboe.github.io/TREK
helm repo update
helm install trek trek/trek
```
See [`charts/README.md`](https://github.com/liketrek/TREK/blob/main/charts/README.md) for values.
See [`charts/README.md`](https://github.com/mauriceboe/TREK/blob/main/charts/README.md) for values.
<h2 id="install-as-app-pwa">Install as App (PWA)</h2>
@@ -341,9 +311,6 @@ docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/upl
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`):
@@ -358,8 +325,6 @@ The script creates a timestamped DB backup before making changes and prompts for
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>
@@ -397,19 +362,6 @@ 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;
}
}
```
@@ -432,13 +384,6 @@ Caddy handles TLS and WebSockets automatically.
## Environment variables
> [!NOTE]
> Variables are validated at startup (fail-fast). An unset or blank variable
> always falls back to its default, but a variable set to a malformed value
> (e.g. `PORT=abc`, `SESSION_DURATION=bogus`, `DEMO_MODE=maybe`) aborts boot
> with a report listing every offending variable. Boolean switches accept
> `true`/`false`, `1`/`0`, `on`/`off` and `yes`/`no` (any casing).
<details>
<summary><b>Full reference</b></summary>
@@ -452,15 +397,12 @@ Caddy handles TLS and WebSockets automatically.
| `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` |
| `TREK_WIKI_DIR` | Where the in-app Help pages (`/help`) read their content from. TREK ships its wiki and serves it from disk, so Help always matches the version you are running — you should not need to set this. Point it at your own directory to serve custom docs. If the path does not exist, Help falls back to fetching the public GitHub wiki (needs outbound network, and tracks the latest release). | bundled `wiki/` |
| `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` |
| `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` | `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` |
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express 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** | | |
@@ -477,125 +419,18 @@ Caddy handles TLS and WebSockets automatically.
| `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** | | |
| `TREK_MANAGED` | Marks the install as centrally administered: the operator owns the configuration, credentials and upgrades, so the instance admin is not offered settings the operator sets. Leave unset when you run TREK yourself. | `false` |
| `PLACES_API_KEY` | Places credential supplied by whoever operates the install. When set, it is used for every lookup and the per-user keys in Admin > Settings are ignored. Leave unset to keep the stored keys. | — |
| `PLACES_API_BASE` | Send the Places calls to this origin instead of `https://places.googleapis.com` (an egress proxy, a cache, a gateway holding the key). Path and query are unchanged, so it has to speak the same API. | — |
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
| `UNSPLASH_ACCESS_KEY` | Optional Unsplash Access Key for trip-cover and place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from. Get a free key at [unsplash.com/developers](https://unsplash.com/developers). Overrides any per-admin key set in Admin > Settings (where it can also be configured instead). | — |
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user. At the cap, the least-recently-active session is closed to make room | `20` |
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
</details>
<br />
<h2 id="storage">Storage</h2>
TREK separates *what* it stores (eight content categories — trip documents,
journey photos, cover images, profile pictures, place images, the two photo
caches, and backups) from *where* it stores it (named backends). Out of the
box everything lives on local disk (`uploads/` and `data/backups`). From
**Admin → Storage** you can:
- **Add S3-compatible backends** (AWS S3, Cloudflare R2, Backblaze B2, Garage,
MinIO/AIStor) and assign any category to them.
- **Replicate a backend**: edit it and pick **Mirror targets** — every write is
then also copied to each target. The classic off-site backup setup is:
add an S3 backend, edit `backups-local`, tick the S3 backend as a mirror
target, save. Replica writes happen one after another during each upload, so
a slow or unreachable target slows every upload of every category on that
backend — fine for backups, worth weighing for hot categories. Existing
objects are not replicated when you add a target — press **Sync now** on
the backend to copy them (one sync at a time; progress and cancel in the
panel).
- **Watch the Health strip**: replica failures never fail the original
request; they are recorded and shown there instead. All-clear means every
replicated write landed. Failures also notify admins over the configured
notification channels (first failure immediately, then hourly summaries) —
tune it under Admin → Notifications.
- **Test** any backend from its row (for a replicated backend it probes the
primary and each target individually). Targets must be saved before Test
can probe them.
- **Usage**: per-category and per-backend object counts and sizes, scanned
nightly and on demand.
Backend credentials are encrypted at rest. Without an explicit
`ENCRYPTION_KEY` the implicit key from the data directory is used — that
works, but the implicit key rides inside backups, so setting `ENCRYPTION_KEY`
explicitly is recommended for credentialed backends. On an existing install
that has been running without an explicit key, do not just set a new one —
that would orphan already-stored secrets; use the key-rotation procedure (see
*Rotating the Encryption Key* under Updating) to move to an explicit key.
Notes and limits: reassigning a populated category prompts to move its
existing objects — accepting copies everything to the new backend, flips the
category, then sweeps any writes that raced the copy; source objects are kept
(not deleted) and reported as reclaimable (count + size) to reclaim manually;
declining routes only new writes to the new backend, leaving old objects in
place as before. Only one storage job (a mirror sync or a category migration)
runs at a time, cancelling before the flip is always safe, and a failed copy
never flips the category; media categories on S3 work but every served byte
proxies through the server — the proxy answers conditional GETs (ETag /
Last-Modified) and a single `Range` request, while a multi-range request is
answered with the full object; AWS buckets
with Object-Lock or checksum-requiring policies
reject the server's uploads; self-hosted endpoints (MinIO/Garage) should be
addressed by IP or `localhost` unless configured for virtual-hosted buckets.
The legacy `/uploads/photos` directory from older TREK versions is still
served and included in backups, but it is not a configurable category.
Running the storage contract suite against a live MinIO/AIStor is a
license-gated procedure — CI runs it automatically for same-repo pull
requests (fork PRs skip it, since they have no access to the license
secret); to run it yourself locally, see `docker-compose.minio-test.yml`'s
header for the manual procedure.
### Provisioning at first boot (seed file)
For infrastructure-as-code setups, mount a `storage-config.json` into the
data directory — it is imported **once**, on the first boot that has no
stored storage configuration, and loudly ignored afterwards:
```jsonc
// storage-config.json — secrets may be plaintext (encrypted on import)
// or already-encrypted enc:v1: values.
{
"backends": [
{ "name": "off-site", "type": "s3", "options": {
"endpoint": "https://s3.example.com", "bucket": "trek",
"accessKeyId": "…", "secretAccessKey": "…" } },
{ "name": "backups-mirror", "type": "mirror", "options": {
"primary": "backups-local", "replicas": ["off-site"] } }
],
"categories": { "backups": "backups-mirror" }
}
```
(The panel presents this exact setup as **Mirror targets** on `backups-local`.)
```yaml
# docker-compose: add under the trek service's volumes
- ./storage-config.json:/app/data/storage-config.json:ro
```
An invalid seed file aborts boot with the exact validation error — an
actively-provisioning operator sees the problem instead of a silent default.
### Recovery and restore
To reset storage configuration to the built-in defaults (or to re-import a
seed file): stop the server, run
`sqlite3 data/travel.db "DELETE FROM app_settings WHERE key LIKE 'storage.%';"`,
and start it again.
Backups include the storage configuration (it lives in the database). If your
only backup sits on S3 and the credentials for it sit inside that backup:
start a fresh instance, enter the S3 credentials in **Admin → Storage** (or
mount a seed file), then restore the backup from the Backup panel.
## Data & Backups
- **Database** — SQLite, stored in `./data/travel.db`
- **Uploads** — stored in `./uploads/` by default; every content category can be reassigned to another backend in **Admin → Storage**
- **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
@@ -612,3 +447,4 @@ for full third-party attributions.
## 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
@@ -0,0 +1,25 @@
#!/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"
-9
View File
@@ -1,9 +0,0 @@
<?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/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>
+2 -6
View File
@@ -15,13 +15,11 @@ This is a minimal Helm chart for deploying the TREK app.
A hosted Helm repository is available:
```sh
helm repo add trek https://chart.liketrek.com
helm repo add trek https://mauriceboe.github.io/TREK
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:
@@ -41,9 +39,7 @@ 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.
- PVCs require a default StorageClass or specify one as needed.
- `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.
- 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.
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 3.4.1
version: 3.0.22
description: Minimal Helm chart for TREK app
appVersion: "3.4.1"
appVersion: "3.0.22"
-6
View File
@@ -21,9 +21,3 @@
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.
-15
View File
@@ -13,9 +13,6 @@ 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.ALLOWED_ORIGINS }}
ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }}
{{- end }}
@@ -31,12 +28,6 @@ data:
{{- if .Values.env.COOKIE_SECURE }}
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
{{- end }}
{{- if .Values.env.SESSION_DURATION }}
SESSION_DURATION: {{ .Values.env.SESSION_DURATION | quote }}
{{- end }}
{{- if .Values.env.SESSION_DURATION_REMEMBER }}
SESSION_DURATION_REMEMBER: {{ .Values.env.SESSION_DURATION_REMEMBER | quote }}
{{- end }}
{{- if .Values.env.TRUST_PROXY }}
TRUST_PROXY: {{ .Values.env.TRUST_PROXY | quote }}
{{- end }}
@@ -73,9 +64,3 @@ data:
{{- if .Values.env.MCP_RATE_LIMIT }}
MCP_RATE_LIMIT: {{ .Values.env.MCP_RATE_LIMIT | 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 }}
+2 -22
View File
@@ -6,12 +6,6 @@ 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. Override to RollingUpdate only with a ReadWriteMany volume.
strategy:
type: {{ .Values.updateStrategy | default "Recreate" }}
selector:
matchLabels:
app: {{ include "trek.name" . }}
@@ -69,12 +63,6 @@ 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
volumeMounts:
- name: data
mountPath: /app/data
@@ -94,16 +82,8 @@ spec:
periodSeconds: 10
volumes:
- name: data
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ default (printf "%s-data" (include "trek.fullname" .)) .Values.persistence.data.existingClaim }}
{{- else }}
emptyDir: {}
{{- end }}
claimName: {{ include "trek.fullname" . }}-data
- name: uploads
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ default (printf "%s-uploads" (include "trek.fullname" .)) .Values.persistence.uploads.existingClaim }}
{{- else }}
emptyDir: {}
{{- end }}
claimName: {{ include "trek.fullname" . }}-uploads
+1 -17
View File
@@ -1,42 +1,26 @@
{{- if and .Values.persistence.enabled (not .Values.persistence.data.existingClaim) }}
{{- if .Values.persistence.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "trek.fullname" . }}-data
labels:
app: {{ include "trek.name" . }}
{{- with .Values.persistence.data.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
accessModes:
- ReadWriteOnce
{{- with .Values.persistence.data.storageClassName }}
storageClassName: {{ . | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.data.size }}
{{- end }}
---
{{- if and .Values.persistence.enabled (not .Values.persistence.uploads.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "trek.fullname" . }}-uploads
labels:
app: {{ include "trek.name" . }}
{{- with .Values.persistence.uploads.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
accessModes:
- ReadWriteOnce
{{- with .Values.persistence.uploads.storageClassName }}
storageClassName: {{ . | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.uploads.size }}
-6
View File
@@ -17,9 +17,6 @@ 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 }}
{{- end }}
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
@@ -47,7 +44,4 @@ stringData:
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET }}
{{- end }}
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
{{- end }}
{{- end }}
-37
View File
@@ -4,11 +4,6 @@ image:
# tag: latest
pullPolicy: IfNotPresent
# Deployment update strategy. Recreate is the safe default for the single-writer SQLite
# DB on a ReadWriteOnce volume (the old pod is torn down before the new one starts).
# Set to RollingUpdate only if you back the data volume with ReadWriteMany storage.
updateStrategy: Recreate
# Optional image pull secrets for private registries
imagePullSecrets: []
# - name: my-registry-secret
@@ -24,12 +19,6 @@ 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.
@@ -45,10 +34,6 @@ env:
# When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP.
# COOKIE_SECURE: "true"
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
# SESSION_DURATION: "24h"
# How long a login session stays valid when "Remember me" is unchecked (the default): trek_session JWT exp + a browser-session cookie. Accepts 1h, 12h, 7d, 30d, 90d. Defaults to 24h.
# SESSION_DURATION_REMEMBER: "30d"
# Session length when "Remember me" is ticked: a longer-lived JWT + persistent cookie that survives browser restarts. Same format as SESSION_DURATION. Defaults to 30d.
# TRUST_PROXY: "1"
# Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production. Must be set for FORCE_HTTPS to work.
# ALLOW_INTERNAL_NETWORK: "false"
@@ -78,12 +63,6 @@ env:
# 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.
# 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.
# Secret environment variables stored in a Kubernetes Secret.
@@ -103,12 +82,6 @@ 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: ""
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
generateEncryptionKey: false
@@ -118,21 +91,11 @@ 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: {}
resources:
requests:
-3
View File
@@ -3,6 +3,3 @@ e2e/.tmp/
test-results/
playwright-report/
playwright/.cache/
# vite-plugin-pwa dev output (devOptions.enabled)
dev-dist/
+10 -7
View File
@@ -1,5 +1,4 @@
import { test as setup, expect } from '@playwright/test'
import { dismissSystemNotices } from './helpers'
// Relative to the config dir (client/), matching `storageState` in
// playwright.config.ts. Playwright runs from the client workspace root.
@@ -28,12 +27,16 @@ setup('authenticate the seeded admin (incl. forced password change)', async ({ p
await page.waitForURL('**/dashboard', { timeout: 30_000 })
// 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)
// 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)
}
await page.context().storageState({ path: stateFile })
})
+6 -12
View File
@@ -1,5 +1,4 @@
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
@@ -8,23 +7,18 @@ import { dismissSystemNotices } from './helpers'
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 (.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')
// 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')
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.getByPlaceholder('e.g. Summer in Japan').fill(title)
await modal.locator('input[type="text"]').first().fill(title)
await modal.getByRole('button', { name: 'Create New Trip' }).click()
await expect(page.getByText(title).first()).toBeVisible({ timeout: 15_000 })
-43
View File
@@ -1,43 +0,0 @@
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 two 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.
* 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 })
if (await dismiss.isVisible().catch(() => false)) await dismiss.click()
else if (await ok.isVisible().catch(() => false)) await ok.click()
else break
// Exit animation + the next queued notice mounting.
await page.waitForTimeout(400)
}
await dialog.waitFor({ state: 'detached', timeout: 5_000 }).catch(() => {})
}
-79
View File
@@ -1,79 +0,0 @@
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. Drag
// is now gated on `(pointer: coarse)` (useIsTouch), and only a real device context proves
// it — 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: places list is scrollable, not draggable', 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 })
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. Rows must not be draggable — a draggable row is what swallowed the scroll gesture.
const row = page.locator('div[draggable]').filter({ hasText: 'Place 1' }).first()
await expect(row).toHaveAttribute('draggable', 'false')
// 2. The list must scroll, and no drop-to-import overlay may appear.
const scroller = page.locator('div[draggable]').first().locator('xpath=ancestor::div[@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. Drag being off means the arrow buttons are the only reorder affordance left —
// they must be visible (they were opacity:0 above 767px).
const arrowOpacity = await page.evaluate(() => {
const el = document.querySelector('.reorder-buttons')
return el ? getComputedStyle(el).opacity : 'absent'
})
expect(['1', 'absent']).toContain(arrowOpacity)
// 4. The iPad must still get the desktop two-pane layout — isMobile stayed width-based.
await expect(page.locator('.leaflet-container')).toBeVisible()
})
-61
View File
@@ -1,61 +0,0 @@
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
@@ -1,26 +0,0 @@
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')
})
-69
View File
@@ -1,69 +0,0 @@
import { test, clearNotices, expect } from './shot'
import type { Page, Locator } from '@playwright/test'
import { readFileSync } from 'node:fs'
import path from 'node:path'
/**
* 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.
*/
const seed = JSON.parse(
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
) as { tripId: number }
/**
* The panel card containing a given piece of seeded content — see cardClass in
* CollabPanel.tsx:20.
*
* 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-card.rounded-2xl')
.filter({ hasText: contains })
.last()
}
test.beforeEach(async ({ page }) => {
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')
})
-88
View File
@@ -1,88 +0,0 @@
import { test, clearNotices, expect } from './shot'
import { readFileSync } from 'node:fs'
import path from 'node:path'
/**
* 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.
*/
const seed = JSON.parse(
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
) as { tripId: number; collectionId?: number; journeyId?: number }
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')
})
-42
View File
@@ -1,42 +0,0 @@
import { test, clearNotices, expect } from './shot'
import { readFileSync } from 'node:fs'
import path from 'node:path'
/**
* 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.
*/
const seed = JSON.parse(
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
) as { tripId: number }
/**
* 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))
})
-67
View File
@@ -1,67 +0,0 @@
import { test, clearNotices } from './shot'
import { readFileSync } from 'node:fs'
import path from 'node:path'
/**
* 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.
*/
const seed = JSON.parse(
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
) as { tripId: number; collectionId?: number; journeyId?: number }
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 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}/files`)
await shot.page_('Files')
})
-47
View File
@@ -1,47 +0,0 @@
import { test, clearNotices } from './shot'
import { readFileSync } from 'node:fs'
import path from 'node:path'
/**
* 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.
*/
const seed = JSON.parse(
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
) as { tripId: number }
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: label, exact: true }).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('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
@@ -1,59 +0,0 @@
// 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
@@ -1,37 +0,0 @@
import { test as setup, expect } from '@playwright/test'
import { writeFileSync, mkdirSync } from 'node:fs'
import path from 'node:path'
import { seedDemoData } from './seed'
/**
* 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: 'http://localhost:5173',
// 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)
const dir = path.join(process.cwd(), 'e2e', '.tmp')
mkdirSync(dir, { recursive: true })
writeFileSync(path.join(dir, 'seed.json'), JSON.stringify(result, null, 2))
})
-347
View File
@@ -1,347 +0,0 @@
import path from 'node:path'
import type { APIRequestContext } from '@playwright/test'
/**
* 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 sit ~2 months out so "upcoming" surfaces (What's Next, reservations)
* have something to show.
*/
const TRIP = {
title: 'Autumn in Japan',
description: 'Two weeks chasing momiji season from Tokyo down to Kyoto.',
start_date: '2026-09-12',
end_date: '2026-09-21',
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: '2026-09-12', note: 'Booked with miles, taxes only.' },
{ name: 'Ryokan in Hakone', category: 'accommodation', total_price: 48000, currency: 'JPY',
expense_date: '2026-09-15', note: '2 nights, kaiseki dinner included.' },
{ name: 'JR Pass (14 days)', category: 'transport', total_price: 80000, currency: 'JPY',
expense_date: '2026-09-12', note: 'Green car, activated on arrival.' },
{ name: 'teamLab Planets tickets', category: 'activities', total_price: 11400, currency: 'JPY',
expense_date: '2026-09-13' },
{ name: 'Dinner at Nishiki', category: 'food', total_price: 7200, currency: 'JPY',
expense_date: '2026-09-17' },
]
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'] },
]
const TODOS = [
{ name: 'Book teamLab Planets slot', category: 'Before departure', due_date: '2026-08-15', priority: 2 },
{ name: 'Activate JR Pass', category: 'On arrival', due_date: '2026-09-12', priority: 1 },
{ name: 'Reserve ryokan dinner', category: 'Before departure', due_date: '2026-08-20' },
]
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: '2026-09-12T13:05:00',
reservation_end_time: '2026-09-13T08:25:00',
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: '2026-09-12', local_time: '13:05' },
{ role: 'to', sequence: 1, name: 'Tokyo Haneda', code: 'HND',
lat: 35.5494, lng: 139.7798, timezone: 'Asia/Tokyo',
local_date: '2026-09-13', 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: ['Wed, Sep 16', 'Thu, Sep 17', 'Sat, Sep 19'],
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 13th.'],
['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', 'Booked the teamLab slot for the 13th, 14:00. Tickets are in the Files tab.'],
['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
@@ -1,105 +0,0 @@
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')
})
})
-119
View File
@@ -1,119 +0,0 @@
import { test as base, expect, type Page, type Locator } from '@playwright/test'
import { mkdirSync } from 'node:fs'
import path from 'node:path'
/**
* 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 }
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 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> {
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)
}
}
}
+2 -8
View File
@@ -1,5 +1,4 @@
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
@@ -7,17 +6,12 @@ import { dismissSystemNotices } from './helpers'
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('.trek-modal-backdrop')
const modal = page.locator('.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.getByPlaceholder('e.g. Summer in Japan').fill(title)
await modal.locator('input[type="text"]').first().fill(title)
await modal.getByRole('button', { name: 'Create New Trip' }).click()
// Open it from the dashboard.
-105
View File
@@ -1,105 +0,0 @@
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.
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()
await page.evaluate(() => window.scrollBy(0, 300))
expect(await page.evaluate(() => document.scrollingElement!.scrollTop)).toBe(locked.top)
await page.keyboard.press('Escape')
await expect(page.getByRole('dialog')).toHaveCount(0)
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)
})
-21
View File
@@ -75,25 +75,4 @@ export default tseslint.config(
'preserve-caught-error': 'warn',
},
},
{
// 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>.',
}],
},
},
);
+10 -6
View File
@@ -5,10 +5,6 @@
<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>
<!-- PWA / iOS -->
<meta name="theme-color" content="#09090b" />
<meta name="apple-mobile-web-app-capable" content="yes" />
@@ -17,9 +13,17 @@
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg" />
<link rel="icon" type="image/svg+xml" href="/icons/icon-dark.svg" />
<!-- Fonts are bundled from @fontsource in src/main.tsx, see the note there. -->
<!-- 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="" />
</head>
<body>
<div id="root"></div>
+12 -28
View File
@@ -1,13 +1,12 @@
{
"name": "@trek/client",
"version": "3.4.1",
"version": "3.0.22",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"prebuild": "node scripts/generate-icons.mjs",
"build": "vite build",
"build:analyze": "vite build --mode analyze",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run",
@@ -18,52 +17,38 @@
"lint": "eslint .",
"lint:check": "eslint .",
"lint:pages": "node scripts/check-page-pattern.mjs",
"check:gl-split": "node scripts/check-gl-split.mjs",
"theme:lint": "node scripts/theme-lint.mjs",
"theme:lint:strict": "node scripts/theme-lint.mjs --strict",
"e2e": "playwright test",
"shots": "playwright test --project=screenshots",
"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\""
},
"dependencies": {
"@fontsource/bebas-neue": "^5.3.0",
"@fontsource/eb-garamond": "^5.3.0",
"@fontsource/geist-sans": "^5.2.5",
"@fontsource/inter": "^5.3.0",
"@fontsource/lora": "^5.3.0",
"@fontsource/museomoderno": "^5.3.0",
"@fontsource/playfair-display": "^5.3.0",
"@fontsource/poppins": "^5.2.7",
"@react-pdf/renderer": "^4.5.1",
"@simplewebauthn/browser": "^13.1.2",
"@trek/shared": "*",
"axios": "^1.6.7",
"dexie": "^4.4.2",
"drag-drop-touch": "^1.3.1",
"heic-to": "^1.4.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",
"plyr": "^3.8.4",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-dropzone": "^14.4.1",
"react-leaflet": "^5.0.0",
"react-leaflet-cluster": "^4.1.3",
"react-markdown": "^10.1.0",
"react-router": "^7.18.2",
"react-router-dom": "^6.22.2",
"react-window": "^2.2.7",
"rehype-sanitize": "^6.0.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"topojson-client": "^3.1.0",
"tz-lookup": "^6.1.25",
"zod": "^4.3.6",
"zustand": "^5.0.14"
"zustand": "^4.5.2"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -73,11 +58,11 @@
"@testing-library/user-event": "^14.6.1",
"@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",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/coverage-v8": "^4.1.9",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^4.2.1",
"@vitest/coverage-v8": "^3.2.4",
"autoprefixer": "^10.4.18",
"eslint": "^10.2.1",
"eslint-config-flat-gitignore": "^2.3.0",
@@ -91,13 +76,12 @@
"prettier": "^3.8.3",
"prettier-plugin-organize-imports": "^4.3.0",
"prettier-plugin-tailwindcss": "^0.8.0",
"rollup-plugin-visualizer": "^7.0.1",
"sharp": "^0.35.0",
"sharp": "^0.33.0",
"tailwindcss": "^3.4.1",
"typescript": "^6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "8.1.0",
"vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.9"
"vite": "^5.1.4",
"vite-plugin-pwa": "^0.21.0",
"vitest": "^3.2.4"
}
}
-22
View File
@@ -35,28 +35,6 @@ export default defineConfig({
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.tmp/state.json' },
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/.tmp/state.json' },
dependencies: ['setup'],
},
{
name: 'screenshots',
testMatch: /\.shot\.ts/,
use: {
...devices['Desktop Chrome'],
storageState: 'e2e/.tmp/state.json',
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 2,
},
dependencies: ['seed'],
},
],
webServer: [
{
-58
View File
@@ -1,58 +0,0 @@
/*
* Pre-paint appearance boot — kills the flash of default/wrong theme (FOUC).
*
* Loaded as an external, render-blocking CLASSIC script in <head> (NOT a module)
* so it runs before first paint AND complies with the production CSP
* (script-src 'self'; inline scripts are blocked). It reads the compact snapshot
* written by client/src/theme/applyAppearance.ts and applies it verbatim. Keep
* this in sync with that module's snapshot shape + apply logic.
*
* It must never throw — any failure silently falls back to the default look.
*/
(function () {
try {
var raw = localStorage.getItem('trek_appearance');
if (!raw) return;
var s = JSON.parse(raw);
if (!s || s.v !== 1) return;
var root = document.documentElement;
var path = location.pathname;
var isShared = path.indexOf('/shared/') === 0 || path.indexOf('/public/') === 0;
var dark;
if (isShared) dark = false;
else if (s.darkMode === 'auto') dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
else dark = s.darkMode === true || s.darkMode === 'dark';
root.classList.toggle('dark', dark);
var scheme = isShared ? 'default' : s.scheme;
if (scheme && scheme !== 'default') root.setAttribute('data-scheme', scheme);
if (!isShared && s.noTransparency) root.setAttribute('data-no-transparency', '');
if (s.density === 'compact') root.setAttribute('data-density', 'compact');
if (s.reduceMotion) root.setAttribute('data-reduce-motion', '');
if (!isShared && scheme === 'custom' && s.accent) {
root.style.setProperty('--accent-custom-light', s.accent.light);
root.style.setProperty('--accent-custom-dark', s.accent.dark);
if (s.accentText) {
root.style.setProperty('--accent-custom-text-light', s.accentText.light);
root.style.setProperty('--accent-custom-text-dark', s.accentText.dark);
}
}
var ts = s.typeScale || {};
var fs = typeof s.fontScale === 'number' ? s.fontScale : 1;
setScale('--fs-scale-title', fs * (ts.title || 1));
setScale('--fs-scale-subtitle', fs * (ts.subtitle || 1));
setScale('--fs-scale-body', fs * (ts.body || 1));
setScale('--fs-scale-caption', fs * (ts.caption || 1));
if (fs !== 1) root.style.fontSize = fs * 100 + '%';
function setScale(name, v) {
if (typeof v === 'number' && v !== 1) root.style.setProperty(name, String(v));
}
} catch (e) {
/* never block boot */
}
})();
-278
View File
@@ -1,278 +0,0 @@
#!/usr/bin/env node
/**
* Country silhouettes for TREK Studio, built from the boundaries the server
* already bundles for Atlas.
*
* node scripts/build-country-shapes.mjs
*
* Writes `src/components/Studio/countryShapes.ts`. Run it when
* `server/assets/atlas/admin0.geojson.gz` changes; the output is committed, so
* a normal build never runs this.
*
* ── Why a second, tiny copy of the boundaries ─────────────────────────────
*
* Atlas's admin-0 file is 4.3MB gzipped and ~30MB parsed, sized for a slippy
* map you can zoom into. A book page draws a country at 20mm across, where the
* ten thousand points describing a fjord land inside one printer dot. Shipping
* the map data to draw a stamp would cost more than the entire Studio bundle.
*
* So: project, drop the islands nobody can see at that size, simplify hard, and
* normalise each country into its own 0..100 box. What comes out is a few
* hundred bytes per country and prints identically, because at 20mm it *is*
* identical.
*
* ── The three things that are easy to get wrong ───────────────────────────
*
* 1. **Project before simplifying.** Simplifying in degrees and projecting
* afterwards removes the wrong points: a degree of longitude is 111km at the
* equator and 30km at Reykjavík, so a fixed tolerance eats Iceland's coast
* while leaving Kenya's untouched.
* 2. **Countries that cross the antimeridian.** Russia and Fiji have parts at
* +179 and -179. Taken literally their bounding box is the whole world and
* the silhouette is a horizontal smear. Rings that span more than half the
* globe get their negative longitudes lifted by 360 so the country is
* contiguous again.
* 3. **Islands.** Keeping every ring turns Norway into a dust cloud and Greece
* into static. Rings below a fraction of the largest one are dropped — the
* mainland is what makes a country recognisable at a glance.
*/
import fs from 'node:fs'
import path from 'node:path'
import url from 'node:url'
import zlib from 'node:zlib'
const HERE = path.dirname(url.fileURLToPath(import.meta.url))
const SRC = path.join(HERE, '..', '..', 'server', 'assets', 'atlas', 'admin0.geojson.gz')
const OUT = path.join(HERE, '..', 'src', 'components', 'Studio', 'countryShapes.ts')
/** Rings smaller than this share of the biggest one are not drawn. */
const ISLAND_CUTOFF = 0.012
/** Never draw more than this many rings for one country. */
const MAX_RINGS = 14
/** Point budget per country, across every ring it keeps. */
const MAX_POINTS = 190
/** Web Mercator, unscaled. y is flipped so it grows downwards like SVG's. */
function project([lng, lat]) {
const clamped = Math.max(-85.05, Math.min(85.05, lat))
const rad = (clamped * Math.PI) / 180
return [lng, -(Math.log(Math.tan(Math.PI / 4 + rad / 2)) * 180) / Math.PI]
}
function ringArea(ring) {
let a = 0
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
a += ring[j][0] * ring[i][1] - ring[i][0] * ring[j][1]
}
return Math.abs(a / 2)
}
/** Perpendicular distance from p to the segment ab, squared. */
function segDistSq(p, a, b) {
let x = a[0], y = a[1]
let dx = b[0] - x, dy = b[1] - y
if (dx !== 0 || dy !== 0) {
const t = ((p[0] - x) * dx + (p[1] - y) * dy) / (dx * dx + dy * dy)
if (t > 1) { x = b[0]; y = b[1] } else if (t > 0) { x += dx * t; y += dy * t }
}
dx = p[0] - x
dy = p[1] - y
return dx * dx + dy * dy
}
/** Douglas-Peucker, iterative so a long coastline cannot blow the stack. */
function simplify(points, tolerance) {
if (points.length < 3) return points
const tolSq = tolerance * tolerance
const keep = new Uint8Array(points.length)
keep[0] = 1
keep[points.length - 1] = 1
const stack = [[0, points.length - 1]]
while (stack.length) {
const [first, last] = stack.pop()
let maxSq = 0
let index = 0
for (let i = first + 1; i < last; i++) {
const d = segDistSq(points[i], points[first], points[last])
if (d > maxSq) { maxSq = d; index = i }
}
if (maxSq > tolSq) {
keep[index] = 1
stack.push([first, index], [index, last])
}
}
return points.filter((_, i) => keep[i])
}
/** Every outer ring of a Polygon or MultiPolygon, holes discarded. */
function outerRings(geometry) {
if (!geometry) return []
if (geometry.type === 'Polygon') return geometry.coordinates.slice(0, 1)
if (geometry.type === 'MultiPolygon') return geometry.coordinates.map(poly => poly[0])
return []
}
/**
* Lift a ring that straddles the antimeridian into a continuous longitude
* range. A ring whose points span more than half the globe is, in practice,
* always a wrap rather than a genuinely hemisphere-wide landmass.
*/
function unwrap(ring) {
let min = Infinity
let max = -Infinity
for (const [lng] of ring) { if (lng < min) min = lng; if (lng > max) max = lng }
if (max - min <= 180) return ring
return ring.map(([lng, lat]) => [lng < 0 ? lng + 360 : lng, lat])
}
function build(feature) {
const rings = outerRings(feature.geometry)
.map(unwrap)
.map(ring => ring.map(project))
.map(ring => ({ ring, area: ringArea(ring) }))
.sort((a, b) => b.area - a.area)
if (!rings.length) return null
const biggest = rings[0].area
const kept = rings
.filter(r => r.area >= biggest * ISLAND_CUTOFF)
.slice(0, MAX_RINGS)
.map(r => r.ring)
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
for (const ring of kept) {
for (const [x, y] of ring) {
if (x < minX) minX = x
if (x > maxX) maxX = x
if (y < minY) minY = y
if (y > maxY) maxY = y
}
}
const spanX = maxX - minX
const spanY = maxY - minY
if (!(spanX > 0) || !(spanY > 0)) return null
// The longer side becomes 100 and the shorter one keeps its proportion, so a
// country never has to be squashed to fit the box it is drawn in.
const scale = 100 / Math.max(spanX, spanY)
const w = spanX * scale
const h = spanY * scale
const normalised = kept.map(ring =>
ring.map(([x, y]) => [(x - minX) * scale, (y - minY) * scale]))
// Tolerance climbs until the whole country fits its point budget. Starting
// from the diagonal keeps the loop short for a country of any size.
let tolerance = 0.14
let simplified = normalised.map(r => simplify(r, tolerance))
let guard = 0
while (simplified.reduce((n, r) => n + r.length, 0) > MAX_POINTS && guard++ < 40) {
tolerance *= 1.35
simplified = normalised.map(r => simplify(r, tolerance))
}
const d = simplified
.filter(ring => ring.length >= 3)
.map(ring => `M${ring.map(([x, y]) => `${round(x)} ${round(y)}`).join('L')}Z`)
.join('')
// The projected bounds, kept so the same path can be replayed in world
// coordinates. A country drawn on its own wants its own box; the same country
// on a route map has to sit where it actually is, next to its neighbours. One
// normalised path plus these four numbers serves both, where storing two sets
// of coordinates would be twice the bytes and one more thing to disagree.
return d ? { d, w: round(w), h: round(h), b: [minX, minY, maxX, maxY].map(v => Math.round(v * 100) / 100) } : null
}
const round = n => Math.round(n * 10) / 10
const raw = zlib.gunzipSync(fs.readFileSync(SRC))
const collection = JSON.parse(raw.toString('utf8'))
const shapes = new Map()
for (const feature of collection.features) {
const code = String(feature.properties?.ISO_A2 || '').toUpperCase()
if (code.length !== 2 || code === '-9') continue
const built = build(feature)
if (!built) continue
// A code appearing twice means two features for one country; keep the one
// with more outline, which is the mainland rather than a dependency.
const prev = shapes.get(code)
if (!prev || built.d.length > prev.d.length) shapes.set(code, built)
}
const codes = [...shapes.keys()].sort()
const body = codes
.map(code => {
const s = shapes.get(code)
return ` ${code}: { d: '${s.d}', w: ${s.w}, h: ${s.h}, b: [${s.b.join(', ')}] },`
})
.join('\n')
const file = `/**
* GENERATED — do not edit. Run \`node scripts/build-country-shapes.mjs\`.
*
* Country silhouettes, simplified from the admin-0 boundaries the server
* bundles for Atlas (geoBoundaries, CC BY 4.0). Each path is Web Mercator,
* normalised so the longer side is 100 units; \`w\` and \`h\` give the shape's
* own proportions inside that box so it can be drawn without being squashed.
*
* ${codes.length} countries, built for print at postage-stamp size — islands
* below ${ISLAND_CUTOFF * 100}% of the mainland are dropped and each outline is
* simplified to at most ${MAX_POINTS} points.
*/
export interface CountryShape {
/** Path on a 0..100 box, outer rings only. */
d: string
/** The shape's own width and height inside that box. */
w: number
h: number
/** Projected bounds \`[minX, minY, maxX, maxY]\`, for drawing it in place. */
b: [number, number, number, number]
}
/**
* Web Mercator, matching the projection the paths were built with. Longitude
* passes through as degrees; latitude is flipped so y grows downwards like
* SVG's does.
*/
export function projectMercator(lng: number, lat: number): { x: number; y: number } {
const clamped = Math.max(-85.05, Math.min(85.05, lat))
const rad = (clamped * Math.PI) / 180
return { x: lng, y: -(Math.log(Math.tan(Math.PI / 4 + rad / 2)) * 180) / Math.PI }
}
/**
* The same outline, placed in projected world coordinates.
*
* The stored path is normalised into its own box; multiplying by the box's span
* and shifting by its origin puts it back where the country actually is, which
* is what a map with more than one country on it needs.
*/
export function countryWorldPath(shape: CountryShape): string {
const span = Math.max(shape.b[2] - shape.b[0], shape.b[3] - shape.b[1]) / 100
let axis = 0
return shape.d.replace(/-?\\d*\\.?\\d+/g, m => {
const isX = axis++ % 2 === 0
const v = Number(m) * span + (isX ? shape.b[0] : shape.b[1])
return String(Math.round(v * 1000) / 1000)
})
}
export const COUNTRY_SHAPES: Record<string, CountryShape> = {
${body}
}
/** The silhouette for an ISO-3166-1 alpha-2 code, or null if we have none. */
export function countryShape(code: string | null | undefined): CountryShape | null {
if (!code) return null
return COUNTRY_SHAPES[code.toUpperCase()] ?? null
}
`
fs.writeFileSync(OUT, file, 'utf8')
const kb = Math.round(Buffer.byteLength(file) / 102.4) / 10
console.log(`[country-shapes] ${codes.length} countries, ${kb} kB → ${path.relative(process.cwd(), OUT)}`)
-47
View File
@@ -1,47 +0,0 @@
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join } from 'node:path'
/**
* Fails when a built chunk contains both WebGL map engines.
*
* mapbox-gl and maplibre-gl used to be static imports in the same three
* components, so rollup emitted a single 2.8 MB chunk carrying both. Every map
* user downloaded 1.8 MB of mapbox plus 1.1 MB of maplibre and ran one of them.
* A stray static import anywhere in the map tree brings that straight back, and
* nothing else would notice — the build stays green and the app still works.
*
* The markers are chosen to appear only inside the SDKs themselves, verified in
* node_modules. Do NOT test for the bare string "maplibre": the provider value
* 'maplibre-gl' and the setting key maplibre_style also appear in app code, so it
* matches the shared core chunk too.
*/
const DIR = 'dist/assets'
const MAPBOX = 'events.mapbox.com' // 1 hit in mapbox-gl, 0 in maplibre-gl
const MAPLIBRE = 'maplibregl-canvas' // 2 hits in maplibre-gl, 0 in mapbox-gl
let mixed = 0
let found = 0
for (const file of readdirSync(DIR).filter(f => f.endsWith('.js'))) {
const path = join(DIR, file)
const src = readFileSync(path, 'utf8')
const hasMapbox = src.includes(MAPBOX)
const hasMaplibre = src.includes(MAPLIBRE)
if (!hasMapbox && !hasMaplibre) continue
found++
const size = statSync(path).size
if (hasMapbox && hasMaplibre) {
console.error(`FAIL both GL engines in one chunk: ${file} (${size} B)`)
mixed++
} else {
console.log(`${hasMapbox ? 'mapbox ' : 'maplibre'} ${file} ${size} B`)
}
}
if (!found) {
console.error('FAIL no chunk contains either GL engine — did the build run?')
process.exit(1)
}
process.exit(mixed ? 1 : 0)
-73
View File
@@ -1,73 +0,0 @@
#!/usr/bin/env node
/*
* theme:lint — guards the appearance token system.
*
* Flags styling that bypasses the design tokens and therefore won't follow a
* user's chosen scheme / transparency / text-size:
* - inline color literals (color: '#111', background: 'rgba(...)', boxShadow: '...rgba...')
* - inline numeric fontSize (fontSize: 13)
* - arbitrary-value Tailwind color classes (bg-[#..], text-[rgba(..)])
*
* ALLOWED (never flagged): var(--token) inline styles, bg-[var(--..)] classes,
* and genuinely dynamic values (data-driven colors, computed sizes/positions).
*
* Mirrors the i18n:parity gate. Default mode reports a baseline and exits 0;
* `--strict` exits non-zero when any violations remain (for once the backlog is
* burned down, or wired to changed files only). Add `theme-lint-disable` in a
* line comment to suppress an intentional exception (map/PDF/brand colors).
*/
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
let SRC = new URL('../src', import.meta.url).pathname;
if (process.platform === 'win32' && SRC.startsWith('/')) SRC = SRC.slice(1);
// Surfaces where CSS variables genuinely cannot reach (injected map HTML, WebGL
// paint, standalone PDF documents) — colors there must stay literal.
const EXEMPT = [
/Mapbox/i, /placePopup/i, /marker/i, /popup/i, /TripPDF/, /JourneyBookPDF/,
/MapViewGL/, /MapView\./, /JourneyMapGL/, /reservationsMapbox/, /useAtlas/,
/ReservationOverlay/, /\.test\./, /\.spec\./,
];
const ARB_CLASS = /\b(?:bg|text|border|ring|fill|stroke|from|via|to|shadow|outline|decoration|divide|caret)-\[\s*(?:#|rgba?\(|hsla?\(|oklch\()/;
const INLINE_COLOR = /(?:color|background|backgroundColor|borderColor|border|borderTop|borderBottom|borderLeft|borderRight|boxShadow|fill|stroke|outline|textDecorationColor)\s*:\s*['"`]?\s*(?:#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(|oklch\()/;
const INLINE_FONTSIZE = /fontSize\s*:\s*['"`]?\d/;
function walk(dir, files = []) {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
if (statSync(p).isDirectory()) walk(p, files);
else if (/\.(ts|tsx)$/.test(name)) files.push(p);
}
return files;
}
const strict = process.argv.includes('--strict');
const offenders = [];
let total = 0;
for (const f of walk(SRC)) {
if (EXEMPT.some((re) => re.test(f))) continue;
let count = 0;
for (const line of readFileSync(f, 'utf8').split('\n')) {
if (line.includes('theme-lint-disable')) continue;
if (ARB_CLASS.test(line) || INLINE_COLOR.test(line) || INLINE_FONTSIZE.test(line)) count++;
}
if (count) {
offenders.push([relative(SRC, f).replace(/\\/g, '/'), count]);
total += count;
}
}
offenders.sort((a, b) => b[1] - a[1]);
console.log(`theme:lint — ${total} hardcoded-style hits across ${offenders.length} files (map/PDF excluded).`);
for (const [f, c] of offenders.slice(0, 20)) console.log(` ${String(c).padStart(4)} ${f}`);
if (offenders.length > 20) console.log(` … and ${offenders.length - 20} more files.`);
console.log('\nNew/changed code must use tokens (bg-surface / text-content / bg-accent / var(--..)) and the');
console.log('text-title/subtitle/body/caption tiers — never inline #hex, never bg-[#..]. See src/theme/README.md.');
if (strict && total > 0) {
console.error(`\n✖ theme:lint:strict — ${total} violations remain.`);
process.exit(1);
}
+3 -234
View File
@@ -1,6 +1,6 @@
import React from 'react'
import { render, screen, waitFor, act } from '@testing-library/react'
import { MemoryRouter, useLocation } from 'react-router'
import { render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../tests/helpers/msw/server'
@@ -8,7 +8,6 @@ import { useAuthStore } from './store/authStore'
import { useSettingsStore } from './store/settingsStore'
import { resetAllStores } from '../tests/helpers/store'
import { buildUser, buildSettings } from '../tests/helpers/factories'
import { SETTINGS_WAIT_MS } from './utils/startDestination'
import App from './App'
// ── Mock page components ───────────────────────────────────────────────────────
@@ -30,19 +29,10 @@ vi.mock('./hooks/useInAppNotificationListener.ts', () => ({
// ── Helpers ────────────────────────────────────────────────────────────────────
/** Reports the router's current location, so a test can assert where App sent it. */
function LocationProbe() {
const loc = useLocation()
return <span data-testid="loc">{loc.pathname + loc.search}</span>
}
const currentPath = () => screen.getByTestId('loc').textContent
function renderApp(initialPath = '/') {
return render(
<MemoryRouter initialEntries={[initialPath]}>
<App />
<LocationProbe />
</MemoryRouter>
)
}
@@ -91,81 +81,6 @@ describe('RootRedirect', () => {
})
})
// ── RootRedirect — startup destination ────────────────────────────────────────
describe('RootRedirect — startup destination', () => {
/** Serves GET /api/trips/active and reports whether it was asked at all. */
function stubActiveTrip(trip: { id: number; title: string } | null) {
const calls: string[] = []
server.use(
http.get('/api/trips/active', ({ request }) => {
calls.push(request.url)
return HttpResponse.json({ trip })
}),
)
return calls
}
it('FE-COMP-APP-026: opens the active trip on the chosen tab', async () => {
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({
isLoaded: true,
settings: buildSettings({ start_page: 'active_trip', start_trip_tab: 'finanzplan' }),
})
stubActiveTrip({ id: 42, title: 'Japan' })
renderApp('/')
await waitFor(() => expect(screen.getByText('TripPlanner')).toBeInTheDocument())
})
it('FE-COMP-APP-027: falls back to the dashboard when the user has no trip', async () => {
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({
isLoaded: true,
settings: buildSettings({ start_page: 'active_trip', start_trip_tab: 'finanzplan' }),
})
stubActiveTrip(null)
renderApp('/')
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
})
it('FE-COMP-APP-028: falls back to the dashboard when the lookup fails', async () => {
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({
isLoaded: true,
settings: buildSettings({ start_page: 'active_trip' }),
})
server.use(http.get('/api/trips/active', () => HttpResponse.error()))
renderApp('/')
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
})
// The whole point of the localStorage mirror: the default launch must not pay
// for a lookup it doesn't need.
it('FE-COMP-APP-029: never asks for the active trip when starting on the dashboard', async () => {
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({ isLoaded: true, settings: buildSettings({ start_page: 'dashboard' }) })
const calls = stubActiveTrip({ id: 42, title: 'Japan' })
renderApp('/')
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
expect(calls).toHaveLength(0)
})
it('FE-COMP-APP-030: reads the preference from localStorage before settings have loaded', async () => {
localStorage.setItem('trek_start_page', 'active_trip')
localStorage.setItem('trek_start_trip_tab', 'finanzplan')
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({ isLoaded: false })
stubActiveTrip({ id: 42, title: 'Japan' })
renderApp('/')
await waitFor(() => expect(screen.getByText('TripPlanner')).toBeInTheDocument())
})
})
// ── ProtectedRoute — unauthenticated ──────────────────────────────────────────
describe('ProtectedRoute — unauthenticated', () => {
@@ -175,22 +90,6 @@ describe('ProtectedRoute — unauthenticated', () => {
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument())
})
// A session that ran out should return you to the page you were on. Pressing
// "log out" should not: clearing isAuthenticated re-renders this route for the
// page still on screen, and the ?redirect= it stamped there beat the user's
// startup destination on every login after the first.
it('FE-COMP-APP-034: keeps the return ticket when the session merely ended', async () => {
seedAuth({ isAuthenticated: false, loggingOut: false })
renderApp('/trips/1/files')
await waitFor(() => expect(currentPath()).toBe('/login?redirect=%2Ftrips%2F1%2Ffiles'))
})
it('FE-COMP-APP-035: drops it on a deliberate sign-out, so the startup destination decides', async () => {
seedAuth({ isAuthenticated: false, loggingOut: true })
renderApp('/dashboard')
await waitFor(() => expect(currentPath()).toBe('/login'))
})
it('FE-COMP-APP-005: /trips/42 redirects to /login when not authenticated', async () => {
seedAuth({ isAuthenticated: false })
renderApp('/trips/42')
@@ -270,8 +169,6 @@ describe('ProtectedRoute — admin role check', () => {
// ── Public routes ──────────────────────────────────────────────────────────────
describe('Public routes', () => {
// Synchronous on purpose: LoginPage is the one page still statically imported,
// so this also holds the line against someone making it lazy later.
it('FE-COMP-APP-012: /login is accessible without authentication', async () => {
seedAuth({ isAuthenticated: false })
renderApp('/login')
@@ -281,7 +178,7 @@ describe('Public routes', () => {
it('FE-COMP-APP-013: /shared/:token is accessible without authentication', async () => {
seedAuth({ isAuthenticated: false })
renderApp('/shared/sometoken')
expect(await screen.findByText('SharedTrip')).toBeInTheDocument()
expect(screen.getByText('SharedTrip')).toBeInTheDocument()
})
it('FE-COMP-APP-014: unknown routes redirect to / which then redirects to /login', async () => {
@@ -291,57 +188,6 @@ describe('Public routes', () => {
})
})
// ── PublicRoute — redirect already-authenticated visitors ─────────────────────
describe('PublicRoute — already authenticated', () => {
/**
* The bounce goes to START_DESTINATION_ROUTE, so RootRedirect resolves where
* it actually lands — which needs settings, exactly like the cases above.
*/
function seedAuthedOnDashboard() {
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({
isLoaded: true,
settings: buildSettings({ start_page: 'dashboard' }),
})
}
it('FE-COMP-APP-012a: /login sends an authenticated visitor to the start destination', async () => {
seedAuthedOnDashboard()
renderApp('/login')
await waitFor(() => expect(currentPath()).toBe('/dashboard'))
expect(screen.queryByText('Login')).not.toBeInTheDocument()
})
it('FE-COMP-APP-012b: /register does the same', async () => {
seedAuthedOnDashboard()
renderApp('/register')
await waitFor(() => expect(currentPath()).toBe('/dashboard'))
expect(screen.queryByText('Login')).not.toBeInTheDocument()
})
it('FE-COMP-APP-012c: /login with a ?redirect= target is left to useLogin (OAuth consent handoff), not bounced', async () => {
// The consent page parks its URL in ?redirect= when it needs a login; that
// flow must reach the form even for an authenticated visitor, so the guard
// stays out of the way whenever a redirect target is present.
seedAuthedOnDashboard()
const target = '/login?redirect=' + encodeURIComponent('/oauth/consent?client_id=x')
renderApp(target)
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument())
expect(currentPath()).toBe(target)
})
it('FE-COMP-APP-012d: /register?invite= reaches the form so the token can be validated', async () => {
// An invite link is the one way someone joins a trip they were invited to
// from an account they are already signed into; bouncing it would swallow
// the token silently.
seedAuthedOnDashboard()
renderApp('/register?invite=abc123')
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument())
expect(currentPath()).toBe('/register?invite=abc123')
})
})
// ── App — on-mount effects ─────────────────────────────────────────────────────
describe('App — on-mount effects', () => {
@@ -387,30 +233,6 @@ describe('App — on-mount effects', () => {
await waitFor(() => expect(setDemoMode).toHaveBeenCalledWith(true))
})
it('FE-COMP-APP-018b: setManaged mirrors the config flag, and defaults to false when it is absent', async () => {
server.use(
http.get('/api/auth/app-config', () => HttpResponse.json({ managed: true }))
)
const setManaged = vi.fn()
useAuthStore.setState({
isLoading: false,
isAuthenticated: false,
loadUser: vi.fn().mockResolvedValue(undefined),
setManaged,
})
renderApp('/')
await waitFor(() => expect(setManaged).toHaveBeenCalledWith(true))
// An older server that does not send the field must not leave the flag
// stuck on from a previous install in the same browser profile.
setManaged.mockClear()
server.use(
http.get('/api/auth/app-config', () => HttpResponse.json({}))
)
renderApp('/')
await waitFor(() => expect(setManaged).toHaveBeenCalledWith(false))
})
it('FE-COMP-APP-019: loadSettings is called once the user is authenticated', async () => {
const loadSettings = vi.fn().mockResolvedValue(undefined)
seedAuth({ isAuthenticated: true, user: buildUser() })
@@ -498,56 +320,3 @@ describe('Version cache-busting', () => {
await waitFor(() => expect(reload).toHaveBeenCalled())
})
})
// Regression: a device that has never mirrored the preference must not treat
// "nothing mirrored" as "user wants the dashboard".
describe('RootRedirect — preference not mirrored on this device', () => {
it('FE-COMP-APP-031: honours the server setting when localStorage is empty', async () => {
seedAuth({ isAuthenticated: true, user: buildUser() })
// Cold start: settings are still in flight when RootRedirect first runs.
useSettingsStore.setState({ isLoaded: false })
server.use(http.get('/api/trips/active', () => HttpResponse.json({ trip: { id: 42, title: 'Japan' } })))
renderApp('/')
// ...and land a moment later, exactly as loadSettings() does.
await act(async () => {
useSettingsStore.setState({
isLoaded: true,
settings: buildSettings({ start_page: 'active_trip', start_trip_tab: 'finanzplan' }),
})
})
await waitFor(() => expect(screen.getByText('TripPlanner')).toBeInTheDocument())
})
// Waiting for someone else's effect to fetch the settings made the decision
// ride on where that request landed in the queue — behind ~380 others on a
// cold start, which is how the redirect silently lost the race and fell
// through to the dashboard. It now asks for them itself.
it('FE-COMP-APP-033: asks for the settings itself instead of waiting to be told', async () => {
const loadSettings = vi.fn().mockResolvedValue(undefined)
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({ isLoaded: false, loadSettings })
renderApp('/')
await waitFor(() => expect(loadSettings).toHaveBeenCalled())
})
// loadSettings leaves isLoaded false on a failed request so it can retry, so
// the wait above needs a floor or a launch with no backend never resolves.
it('FE-COMP-APP-032: gives up on the settings after the timeout and opens the dashboard', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({ isLoaded: false })
renderApp('/')
expect(screen.queryByText('Dashboard')).not.toBeInTheDocument()
await act(async () => { vi.advanceTimersByTime(SETTINGS_WAIT_MS + 100) })
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
vi.useRealTimers()
})
})
+142 -388
View File
@@ -1,26 +1,28 @@
import React, { useEffect, useState, useRef, ReactNode, Suspense } from 'react'
import { Routes, Route, Navigate, useLocation } from 'react-router'
import React, { useEffect, ReactNode } from 'react'
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { useAuthStore } from './store/authStore'
import { useSettingsStore } from './store/settingsStore'
import { applyAppearance } from './theme/applyAppearance'
import { useAddonStore } from './store/addonStore'
import { usePluginStore } from './store/pluginStore'
// The one page that stays in the entry chunk. Anyone logged out lands here, and
// every other route redirects here first — a chunk round trip in front of the login
// form would slow down the single screen that has to be there immediately.
import LoginPage from './pages/LoginPage'
import ForgotPasswordPage from './pages/ForgotPasswordPage'
import ResetPasswordPage from './pages/ResetPasswordPage'
import DashboardPage from './pages/DashboardPage'
import TripPlannerPage from './pages/TripPlannerPage'
import FilesPage from './pages/FilesPage'
import AdminPage from './pages/AdminPage'
import SettingsPage from './pages/SettingsPage'
import VacayPage from './pages/VacayPage'
import AtlasPage from './pages/AtlasPage'
import JourneyPage from './pages/JourneyPage'
import JourneyDetailPage from './pages/JourneyDetailPage'
import JourneyPublicPage from './pages/JourneyPublicPage'
import SharedTripPage from './pages/SharedTripPage'
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'
import OAuthAuthorizePage from './pages/OAuthAuthorizePage'
import { ToastContainer } from './components/shared/Toast'
import SaveToCollectionModal from './components/Collections/SaveToCollectionModal'
import MSaveToCollectionSheet from './components/Collections/MSaveToCollectionSheet'
import BackgroundTasksWidget from './components/BackgroundTasks/BackgroundTasksWidget'
import MobileShell from './mobile/MobileShell'
import MRouteFallback from './mobile/components/MRouteFallback'
import ErrorBoundary from './components/shared/ErrorBoundary'
import { lazyWithRetry } from './utils/lazyWithRetry'
import { useIsPhone } from './mobile/useIsPhone'
import BottomNav from './components/Layout/BottomNav'
import { TranslationProvider, useTranslation } from './i18n'
import { authApi, tripsApi } from './api/client'
import { readStartDestination, tripStartPath, DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB, SETTINGS_WAIT_MS, START_DESTINATION_ROUTE } from './utils/startDestination'
import { authApi } from './api/client'
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
@@ -28,50 +30,6 @@ import OfflineBanner from './components/Layout/OfflineBanner'
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
// Notice action registrations (side-effect imports):
import './pages/Trips/noticeActions.js'
import { managedRoutes } from './managed'
// Every page below loads on demand. The entry chunk used to carry all twenty of
// them eagerly, so opening /dashboard also paid for the planner, the journal, the
// atlas and the vacation planner. lazyWithRetry rather than lazy: a chunk that
// fails once gets a second, cache-busted attempt before the route boundary reaches
// for a reload.
const PluginPage = lazyWithRetry(() => import('./pages/PluginPage'))
const ForgotPasswordPage = lazyWithRetry(() => import('./pages/ForgotPasswordPage'))
const ResetPasswordPage = lazyWithRetry(() => import('./pages/ResetPasswordPage'))
const DashboardPage = lazyWithRetry(() => import('./pages/DashboardPage'))
const TripPlannerPage = lazyWithRetry(() => import('./pages/TripPlannerPage'))
const FilesPage = lazyWithRetry(() => import('./pages/FilesPage'))
const AdminPage = lazyWithRetry(() => import('./pages/AdminPage'))
const SettingsPage = lazyWithRetry(() => import('./pages/SettingsPage'))
const VacayPage = lazyWithRetry(() => import('./pages/VacayPage'))
const HelpPage = lazyWithRetry(() => import('./pages/HelpPage'))
const AtlasPage = lazyWithRetry(() => import('./pages/AtlasPage'))
const JourneyPage = lazyWithRetry(() => import('./pages/JourneyPage'))
const JourneyDetailPage = lazyWithRetry(() => import('./pages/JourneyDetailPage'))
const JourneyStudioPage = lazyWithRetry(() => import('./pages/JourneyStudioPage'))
const CollectionsPage = lazyWithRetry(() => import('./pages/CollectionsPage'))
const JourneyPublicPage = lazyWithRetry(() => import('./pages/JourneyPublicPage'))
const SharedTripPage = lazyWithRetry(() => import('./pages/SharedTripPage'))
const JoinTripPage = lazyWithRetry(() => import('./pages/JoinTripPage'))
const InAppNotificationsPage = lazyWithRetry(() => import('./pages/InAppNotificationsPage.tsx'))
const OAuthAuthorizePage = lazyWithRetry(() => import('./pages/OAuthAuthorizePage'))
// The ten phone screens are chunks of their own, alongside the desktop pages
// rather than inside them. Each page used to import its M screen statically and
// decide while rendering, so the route chunk always carried both trees and the
// viewport only picked which half stayed dark. Here the branch decides the
// chunk: a phone never loads the desktop planner, a desktop never the mobile
// shell.
const MDashboardScreen = lazyWithRetry(() => import('./mobile/screens/dashboard/MDashboard'))
const MTripScreen = lazyWithRetry(() => import('./mobile/screens/trip/MTripShell'))
const MAdminScreen = lazyWithRetry(() => import('./mobile/screens/admin/MAdmin'))
const MSettingsScreen = lazyWithRetry(() => import('./mobile/screens/settings/MSettings'))
const MVacayScreen = lazyWithRetry(() => import('./mobile/screens/vacay/MVacay'))
const MAtlasScreen = lazyWithRetry(() => import('./mobile/screens/atlas/MAtlas'))
const MJourneyScreen = lazyWithRetry(() => import('./mobile/screens/journey/MJourney'))
const MJourneyDetailScreen = lazyWithRetry(() => import('./mobile/screens/journey/MJourneyDetail'))
const MCollectionsScreen = lazyWithRetry(() => import('./mobile/screens/collections/MCollections'))
const MNotificationsScreen = lazyWithRetry(() => import('./mobile/screens/notifications/MNotifications'))
interface ProtectedRouteProps {
children: ReactNode
@@ -84,11 +42,9 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
const user = useAuthStore((s) => s.user)
const isLoading = useAuthStore((s) => s.isLoading)
const appRequireMfa = useAuthStore((s) => s.appRequireMfa)
const loggingOut = useAuthStore((s) => s.loggingOut)
const addonStore = useAddonStore()
const { t } = useTranslation()
const location = useLocation()
const isPhone = useIsPhone()
if (isLoading) {
return (
@@ -102,10 +58,6 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
}
if (!isAuthenticated) {
// A session that ended on its own should come back to where it left off; a
// deliberate sign-out is a fresh start and gets no return ticket, so the
// startup destination decides where the next login lands.
if (loggingOut) return <Navigate to="/login" replace />
const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash)
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
}
@@ -127,130 +79,18 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
return <Navigate to="/dashboard" replace />
}
// Below the md breakpoint the new mobile shell owns chrome (tokens, dock,
// sheets, toasts); from 768px up the legacy wrapper stays untouched. The
// shell branches internally so pages keep their state when the viewport
// crosses the breakpoint.
// The boundary sits inside the shell so a broken page keeps the navigation the
// user needs to leave it — outside, the route would be a dead end, and the
// mobile --m-* tokens live on the shell's .m-root anyway.
//
// key, not resetKeys: ProtectedRoute is the same component at the same position
// for all 16 protected routes, so without it React could keep the failed
// instance across a navigation and the error would follow the user around.
return (
<MobileShell isPhone={isPhone}>
<ErrorBoundary
key={location.pathname}
boundaryId="route"
level="route"
variant={isPhone ? 'mobile' : 'desktop'}
>
{children}
</ErrorBoundary>
</MobileShell>
<div className="flex flex-col h-screen md:block md:h-auto">
<div className="flex-1 overflow-y-auto md:overflow-visible">{children}</div>
<BottomNav />
</div>
)
}
/**
* The public routes render outside ProtectedRoute, so the route boundary above
* never sees them — including /login, where someone lands when everything else
* failed, and the two anonymous share pages.
*/
function PublicRoute({ children, redirectAuthed = false }: { children: React.ReactNode; redirectAuthed?: boolean }) {
const location = useLocation()
// redirectAuthed (only /login and /register) bounces a visitor who is already
// authenticated when they land here — manual URL, browser back button (#1810).
// Only on a bare URL, because every parameter this page takes is a flow that
// has to reach useLogin intact: ?redirect= carries the OAuth consent handoff
// (bouncing drops it, or loops if the server still reports login_required),
// ?invite= puts the form into register mode against a validated token, and
// ?oidc_code= / ?oidc_error= complete an external login. A visitor with any of
// those in the URL asked for this page on purpose.
// Capture the flag at mount so a fresh login is left alone: it flips
// isAuthenticated to true while the takeoff animation still plays here, before
// useLogin navigates away.
// The target is START_DESTINATION_ROUTE, not /dashboard, so the bounce honours
// the start-page preference the same way useLogin does.
const wasAuthenticated = useRef(useAuthStore.getState().isAuthenticated)
if (redirectAuthed && wasAuthenticated.current && !location.search) {
return <Navigate to={START_DESTINATION_ROUTE} replace />
}
return (
<ErrorBoundary key={location.pathname} boundaryId="public-route" level="route">
{children}
</ErrorBoundary>
)
}
/**
* Picks the chunk, not just the branch. Both sides come through lazyWithRetry,
* so exactly one of them is fetched for a given viewport — which is the whole
* point: the ternary that used to sit in each page only ran once the browser
* had already paid for both trees.
*/
function ViewportRoute({ phone: Phone, desktop: Desktop }: {
phone: React.ComponentType
desktop: React.ComponentType
}): React.ReactElement {
const isPhone = useIsPhone()
return isPhone ? <Phone /> : <Desktop />
}
/**
* The entry point every shortcut, bookmark and the installed PWA share
* (manifest start_url is '/'), which is why the startup destination is decided
* here rather than on /dashboard — landing on the dashboard directly has to keep
* working, or there would be no way back to it.
*
* Once this device has seen the preference it comes from the localStorage
* mirror, so the common launch waits for nothing. A device that has never seen
* it waits for the settings instead of assuming the default — the preference
* lives on the server, and guessing here would ignore it on every browser that
* didn't set it. Only 'active_trip' costs the one lookup that finds the trip,
* and any failure along the way lands on the dashboard.
*/
function RootRedirect() {
const { isAuthenticated, isLoading } = useAuthStore()
const settingsLoaded = useSettingsStore((s) => s.isLoaded)
const settings = useSettingsStore((s) => s.settings)
const loadSettings = useSettingsStore((s) => s.loadSettings)
const [target, setTarget] = useState<string | null>(null)
// Bounds the wait below: loadSettings deliberately leaves isLoaded false on a
// failed request so it can retry, which would otherwise strand a launch that
// started offline on the spinner.
const [settingsGaveUp, setSettingsGaveUp] = useState(false)
useEffect(() => {
if (settingsLoaded) return
const timer = setTimeout(() => setSettingsGaveUp(true), SETTINGS_WAIT_MS)
return () => clearTimeout(timer)
}, [settingsLoaded])
useEffect(() => {
if (isLoading || !isAuthenticated || target) return
const mirrored = readStartDestination()
if (!mirrored && !settingsLoaded && !settingsGaveUp) {
// Ask for the settings instead of waiting for whoever else might. The
// store de-dupes concurrent loads, so this is free when one is already in
// flight — and it stops the decision from riding on where that request
// happens to land in the queue behind everything else a launch fires off.
loadSettings()
return
}
// Loaded settings are the truth; the mirror is what we knew last time.
const { page, tab } = settingsLoaded
? { page: settings.start_page ?? DEFAULT_START_PAGE, tab: settings.start_trip_tab ?? DEFAULT_START_TRIP_TAB }
: (mirrored ?? { page: DEFAULT_START_PAGE, tab: DEFAULT_START_TRIP_TAB })
if (page !== 'active_trip') { setTarget('/dashboard'); return }
let cancelled = false
tripsApi.active()
.then(({ trip }) => { if (!cancelled) setTarget(trip ? tripStartPath(trip.id, tab) : '/dashboard') })
.catch(() => { if (!cancelled) setTarget('/dashboard') })
return () => { cancelled = true }
}, [isLoading, isAuthenticated, settingsLoaded, settingsGaveUp, settings, target])
if (isLoading || (isAuthenticated && !target)) {
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50">
<div className="w-10 h-10 border-4 border-slate-200 border-t-slate-900 rounded-full animate-spin"></div>
@@ -258,32 +98,13 @@ function RootRedirect() {
)
}
return <Navigate to={isAuthenticated ? (target ?? '/dashboard') : '/login'} replace />
}
/**
* Shown while a route chunk is in flight. Same geometry as the auth spinner above,
* but on theme tokens — that one predates the styleguide and a new surface does not
* get to inherit its raw slate.
*/
function RouteFallback() {
// The fallback renders above MobileShell, so it has to pick its own palette —
// on a phone the desktop spinner on bg-surface would be a foreign white sheet
// in front of the mobile screen.
const isPhone = useIsPhone()
if (isPhone) return <MRouteFallback />
return (
<div className="min-h-screen flex items-center justify-center bg-surface">
<div className="w-10 h-10 border-4 border-edge border-t-content rounded-full animate-spin"></div>
</div>
)
return <Navigate to={isAuthenticated ? '/dashboard' : '/login'} replace />
}
export default function App() {
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled } = useAuthStore()
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore()
const { loadAddons } = useAddonStore()
const { loadPlugins } = usePluginStore()
useEffect(() => {
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) {
@@ -297,8 +118,7 @@ export default function App() {
loadUser()
}
}
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
setManaged(!!config?.managed)
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
setDemoMode(!!config?.demo_mode)
if (config?.dev_mode) setDevMode(true)
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
@@ -310,7 +130,6 @@ export default function App() {
if (config?.places_photos_enabled !== undefined) setPlacesPhotosEnabled(config.places_photos_enabled)
if (config?.places_autocomplete_enabled !== undefined) setPlacesAutocompleteEnabled(config.places_autocomplete_enabled)
if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled)
if (config?.places_enrich_enabled !== undefined) setPlacesEnrichEnabled(config.places_enrich_enabled)
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
if (config?.version) {
@@ -343,7 +162,6 @@ export default function App() {
if (isAuthenticated) {
loadSettings()
loadAddons()
loadPlugins()
}
}, [isAuthenticated])
@@ -356,23 +174,31 @@ export default function App() {
const isSharedPage = location.pathname.startsWith('/shared/')
useEffect(() => {
const run = () =>
applyAppearance({
darkMode: settings.dark_mode,
appearance: settings.appearance,
isSharedPage,
})
run()
// Re-resolve on OS theme change while in auto mode.
if (!isSharedPage && settings.dark_mode === 'auto') {
// Shared page always forces light mode
if (isSharedPage) {
document.documentElement.classList.remove('dark')
const meta = document.querySelector('meta[name="theme-color"]')
if (meta) meta.setAttribute('content', '#ffffff')
return
}
const mode = settings.dark_mode
const applyDark = (isDark: boolean) => {
document.documentElement.classList.toggle('dark', isDark)
const meta = document.querySelector('meta[name="theme-color"]')
if (meta) meta.setAttribute('content', isDark ? '#09090b' : '#ffffff')
}
if (mode === 'auto') {
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const handler = () => run()
applyDark(mq.matches)
const handler = (e: MediaQueryListEvent) => applyDark(e.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}
}, [settings.dark_mode, settings.appearance, isSharedPage])
applyDark(mode === true || mode === 'dark')
}, [settings.dark_mode, isSharedPage])
const isPhone = useIsPhone()
const isAuthPage = location.pathname.startsWith('/login')
|| location.pathname.startsWith('/register')
|| location.pathname.startsWith('/forgot-password')
@@ -380,173 +206,101 @@ export default function App() {
return (
<TranslationProvider>
{!isAuthPage && <ErrorBoundary boundaryId="widget:system-notice" fallback={null}><SystemNoticeHost /></ErrorBoundary>}
<ErrorBoundary boundaryId="widget:toast" fallback={null}><ToastContainer /></ErrorBoundary>
{!isAuthPage && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
{!isAuthPage && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
{/* One boundary for all route chunks, above <Routes> so it stays mounted
across navigations. react-router runs location updates inside a transition,
so a mounted boundary keeps the current page on screen instead of flashing
a spinner on every jump — the spinner is for the first paint of a deep link. */}
<Suspense fallback={<RouteFallback />}>
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<PublicRoute redirectAuthed><LoginPage /></PublicRoute>} />
<Route path="/shared/:token" element={<PublicRoute><SharedTripPage /></PublicRoute>} />
<Route path="/public/journey/:token" element={<PublicRoute><JourneyPublicPage /></PublicRoute>} />
<Route path="/register" element={<PublicRoute redirectAuthed><LoginPage /></PublicRoute>} />
<Route path="/forgot-password" element={<PublicRoute><ForgotPasswordPage /></PublicRoute>} />
<Route path="/reset-password" element={<PublicRoute><ResetPasswordPage /></PublicRoute>} />
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
<Route path="/oauth/consent" element={<PublicRoute><OAuthAuthorizePage /></PublicRoute>} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<ViewportRoute phone={MDashboardScreen} desktop={DashboardPage} />
</ProtectedRoute>
}
/>
{/* Trip invite link (#1143) — behind ProtectedRoute so an anonymous
visitor is redirected to /login (never registration) and returns here. */}
<Route
path="/join/:token"
element={
<ProtectedRoute>
<JoinTripPage />
</ProtectedRoute>
}
/>
<Route
path="/help"
element={
<ProtectedRoute>
<HelpPage />
</ProtectedRoute>
}
/>
<Route
path="/help/:slug"
element={
<ProtectedRoute>
<HelpPage />
</ProtectedRoute>
}
/>
<Route
path="/trips/:id"
element={
<ProtectedRoute>
<ViewportRoute phone={MTripScreen} desktop={TripPlannerPage} />
</ProtectedRoute>
}
/>
<Route
path="/trips/:id/files"
element={
<ProtectedRoute>
<FilesPage />
</ProtectedRoute>
}
/>
<Route
path="/admin"
element={
<ProtectedRoute adminRequired>
<ViewportRoute phone={MAdminScreen} desktop={AdminPage} />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<ViewportRoute phone={MSettingsScreen} desktop={SettingsPage} />
</ProtectedRoute>
}
/>
<Route
path="/plugins/:pluginId"
element={
<ProtectedRoute>
<PluginPage />
</ProtectedRoute>
}
/>
{/* Empty in this repository, and read unconditionally so the public
build walks the same path as any other. See client/src/managed. */}
{managedRoutes.map(r => (
<Route
key={r.path}
path={r.path}
element={<ProtectedRoute adminRequired={r.adminOnly}>{r.element}</ProtectedRoute>}
/>
))}
<Route
path="/vacay"
element={
<ProtectedRoute>
<ViewportRoute phone={MVacayScreen} desktop={VacayPage} />
</ProtectedRoute>
}
/>
<Route
path="/atlas"
element={
<ProtectedRoute>
<ViewportRoute phone={MAtlasScreen} desktop={AtlasPage} />
</ProtectedRoute>
}
/>
<Route
path="/journey"
element={
<ProtectedRoute addonId="journey">
<ViewportRoute phone={MJourneyScreen} desktop={JourneyPage} />
</ProtectedRoute>
}
/>
<Route
path="/journey/:id"
element={
<ProtectedRoute addonId="journey">
<ViewportRoute phone={MJourneyDetailScreen} desktop={JourneyDetailPage} />
</ProtectedRoute>
}
>
{/* Studio is nested so the journey stays mounted underneath it and
shows through the panel's margin — it is opened on top of the
journey, not navigated away to. */}
<Route path="studio" element={<JourneyStudioPage />} />
</Route>
<Route
path="/collections"
element={
<ProtectedRoute addonId="collections">
<ViewportRoute phone={MCollectionsScreen} desktop={CollectionsPage} />
</ProtectedRoute>
}
/>
<Route
path="/collections/:id"
element={
<ProtectedRoute addonId="collections">
<ViewportRoute phone={MCollectionsScreen} desktop={CollectionsPage} />
</ProtectedRoute>
}
/>
<Route
path="/notifications"
element={
<ProtectedRoute>
<ViewportRoute phone={MNotificationsScreen} desktop={InAppNotificationsPage} />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
{!isAuthPage && <SystemNoticeHost />}
<ToastContainer />
<OfflineBanner />
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/shared/:token" element={<SharedTripPage />} />
<Route path="/public/journey/:token" element={<JourneyPublicPage />} />
<Route path="/register" element={<LoginPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
<Route path="/oauth/consent" element={<OAuthAuthorizePage />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
<Route
path="/trips/:id"
element={
<ProtectedRoute>
<TripPlannerPage />
</ProtectedRoute>
}
/>
<Route
path="/trips/:id/files"
element={
<ProtectedRoute>
<FilesPage />
</ProtectedRoute>
}
/>
<Route
path="/admin"
element={
<ProtectedRoute adminRequired>
<AdminPage />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<SettingsPage />
</ProtectedRoute>
}
/>
<Route
path="/vacay"
element={
<ProtectedRoute>
<VacayPage />
</ProtectedRoute>
}
/>
<Route
path="/atlas"
element={
<ProtectedRoute>
<AtlasPage />
</ProtectedRoute>
}
/>
<Route
path="/journey"
element={
<ProtectedRoute addonId="journey">
<JourneyPage />
</ProtectedRoute>
}
/>
<Route
path="/journey/:id"
element={
<ProtectedRoute addonId="journey">
<JourneyDetailPage />
</ProtectedRoute>
}
/>
<Route
path="/notifications"
element={
<ProtectedRoute>
<InAppNotificationsPage />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</TranslationProvider>
)
}
-111
View File
@@ -1,111 +0,0 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useAuthStore } from './store/authStore'
import { useAddonStore } from './store/addonStore'
import { resetAllStores } from '../tests/helpers/store'
import { buildUser } from '../tests/helpers/factories'
import App from './App'
/**
* The viewport decides which chunk gets fetched, so the switch lives in App.tsx
* and no longer in the pages. This is that contract, for all ten routes in one
* place — it used to be five scattered spot checks, one per page test.
*
* A stub per screen keeps the test about one thing: below the breakpoint the M
* root hangs off the route, and the desktop page is never rendered at all.
*/
const isPhone = vi.hoisted(() => ({ value: false }))
vi.mock('./mobile/useIsPhone', () => ({ useIsPhone: () => isPhone.value }))
vi.mock('./mobile/screens/dashboard/MDashboard', () => ({ default: () => <div>m-dashboard</div> }))
vi.mock('./mobile/screens/trip/MTripShell', () => ({ default: () => <div>m-trip</div> }))
vi.mock('./mobile/screens/admin/MAdmin', () => ({ default: () => <div>m-admin</div> }))
vi.mock('./mobile/screens/settings/MSettings', () => ({ default: () => <div>m-settings</div> }))
vi.mock('./mobile/screens/vacay/MVacay', () => ({ default: () => <div>m-vacay</div> }))
vi.mock('./mobile/screens/atlas/MAtlas', () => ({ default: () => <div>m-atlas</div> }))
vi.mock('./mobile/screens/journey/MJourney', () => ({ default: () => <div>m-journey</div> }))
vi.mock('./mobile/screens/journey/MJourneyDetail', () => ({ default: () => <div>m-journey-detail</div> }))
vi.mock('./mobile/screens/collections/MCollections', () => ({ default: () => <div>m-collections</div> }))
vi.mock('./mobile/screens/notifications/MNotifications', () => ({ default: () => <div>m-notifications</div> }))
vi.mock('./pages/DashboardPage', () => ({ default: () => <div>d-dashboard</div> }))
vi.mock('./pages/TripPlannerPage', () => ({ default: () => <div>d-trip</div> }))
vi.mock('./pages/AdminPage', () => ({ default: () => <div>d-admin</div> }))
vi.mock('./pages/SettingsPage', () => ({ default: () => <div>d-settings</div> }))
vi.mock('./pages/VacayPage', () => ({ default: () => <div>d-vacay</div> }))
vi.mock('./pages/AtlasPage', () => ({ default: () => <div>d-atlas</div> }))
vi.mock('./pages/JourneyPage', () => ({ default: () => <div>d-journey</div> }))
vi.mock('./pages/JourneyDetailPage', () => ({ default: () => <div>d-journey-detail</div> }))
vi.mock('./pages/CollectionsPage', () => ({ default: () => <div>d-collections</div> }))
vi.mock('./pages/InAppNotificationsPage.tsx', () => ({ default: () => <div>d-notifications</div> }))
// The notification listener opens a WebSocket on mount.
vi.mock('./hooks/useInAppNotificationListener.ts', () => ({
useInAppNotificationListener: vi.fn(),
}))
/** path, mobile marker, desktop marker */
const ROUTES: [string, string, string][] = [
['/dashboard', 'm-dashboard', 'd-dashboard'],
['/trips/1', 'm-trip', 'd-trip'],
['/admin', 'm-admin', 'd-admin'],
['/settings', 'm-settings', 'd-settings'],
['/vacay', 'm-vacay', 'd-vacay'],
['/atlas', 'm-atlas', 'd-atlas'],
['/journey', 'm-journey', 'd-journey'],
['/journey/1', 'm-journey-detail', 'd-journey-detail'],
['/collections', 'm-collections', 'd-collections'],
['/collections/1', 'm-collections', 'd-collections'],
['/notifications', 'm-notifications', 'd-notifications'],
]
function renderAt(path: string) {
return render(
<MemoryRouter initialEntries={[path]}>
<App />
</MemoryRouter>
)
}
beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
isPhone.value = false
// An admin passes the role check on /admin; every other route ignores it.
useAuthStore.setState({
isLoading: false,
isAuthenticated: true,
user: buildUser({ role: 'admin', mfa_enabled: true }),
appRequireMfa: false,
loadUser: vi.fn().mockResolvedValue(undefined),
})
// /journey and /collections sit behind addons; without this they redirect to
// the dashboard and the route under test never renders.
useAddonStore.setState({ loaded: true, isEnabled: () => true })
})
describe('App — viewport routing', () => {
it.each(ROUTES)(
'FE-COMP-APPVP-001: %s renders the mobile screen below the breakpoint',
async (path, mobile, desktop) => {
isPhone.value = true
renderAt(path)
expect(await screen.findByText(mobile)).toBeInTheDocument()
expect(screen.queryByText(desktop)).not.toBeInTheDocument()
}
)
it.each(ROUTES)(
'FE-COMP-APPVP-002: %s renders the desktop page above the breakpoint',
async (path, mobile, desktop) => {
renderAt(path)
expect(await screen.findByText(desktop)).toBeInTheDocument()
expect(screen.queryByText(mobile)).not.toBeInTheDocument()
}
)
})
-490
View File
@@ -1,490 +0,0 @@
// FE-APIWIRE-001 to FE-APIWIRE-036
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { AxiosError, type AxiosAdapter, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
import { http, HttpResponse } from 'msw'
import { server } from '../../tests/helpers/msw/server'
import { weatherResultSchema } from '@trek/shared'
// client.ts probes the health endpoint to tell an edge-proxy auth wall apart
// from a plain offline boot — the probe result decides whether it tears down
// the service worker, so the tests drive it directly.
const { probeNow } = vi.hoisted(() => ({
probeNow: vi.fn(async (): Promise<'online' | 'offline' | 'proxy-wall'> => 'offline'),
}))
vi.mock('../sync/connectivity', () => ({ probeNow }))
const { apiClient, adminApi, mapsApi, pluginsApi, parseInDev } = await import('./client')
interface FakeLocation {
href: string
origin: string
pathname: string
search: string
hash: string
reload: () => void
}
let reload: ReturnType<typeof vi.fn<() => void>>
function setLocation(pathname: string, search = '', hash = ''): FakeLocation {
reload = vi.fn<() => void>()
const loc: FakeLocation = {
href: `http://localhost:3000${pathname}${search}${hash}`,
origin: 'http://localhost:3000',
pathname,
search,
hash,
reload,
}
Object.defineProperty(window, 'location', { writable: true, configurable: true, value: loc })
return loc
}
const realLocation = window.location
/** Records the outgoing config and answers 200 without touching the network. */
function okAdapter(sink: InternalAxiosRequestConfig[]): AxiosAdapter {
return (config) => {
sink.push(config)
return Promise.resolve({
data: { ok: true }, status: 200, statusText: 'OK', headers: {}, config,
} as AxiosResponse)
}
}
/** Rejects the way a CORS/offline failure does: an error with no `response`. */
const networkErrorAdapter: AxiosAdapter = (config) =>
Promise.reject(new AxiosError('Network Error', AxiosError.ERR_NETWORK, config))
async function captureError(run: () => Promise<unknown>): Promise<AxiosError> {
const err = await run().then(() => null, (e: unknown) => e as AxiosError)
expect(err, 'expected the request to reject').not.toBeNull()
return err as AxiosError
}
beforeEach(() => {
probeNow.mockResolvedValue('offline')
setLocation('/dashboard')
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
Object.defineProperty(window, 'location', { writable: true, configurable: true, value: realLocation })
delete (navigator as { serviceWorker?: unknown }).serviceWorker
})
describe('client > request interceptor', () => {
it('FE-APIWIRE-001: mutating requests get an idempotency key, reads do not', async () => {
const sink: InternalAxiosRequestConfig[] = []
const adapter = okAdapter(sink)
await apiClient.get('/probe', { adapter })
await apiClient.post('/probe', {}, { adapter })
await apiClient.put('/probe', {}, { adapter })
await apiClient.patch('/probe', {}, { adapter })
await apiClient.delete('/probe', { adapter })
const keys = sink.map(c => c.headers['X-Idempotency-Key'])
expect(keys[0]).toBeUndefined()
for (const key of keys.slice(1)) expect(typeof key).toBe('string')
})
it('FE-APIWIRE-002: each write gets its own key so retries can be deduplicated', async () => {
const sink: InternalAxiosRequestConfig[] = []
const adapter = okAdapter(sink)
await apiClient.post('/probe', {}, { adapter })
await apiClient.post('/probe', {}, { adapter })
expect(sink[0].headers['X-Idempotency-Key']).not.toBe(sink[1].headers['X-Idempotency-Key'])
})
it('FE-APIWIRE-003: a pre-generated key from the mutation queue is left alone', async () => {
const sink: InternalAxiosRequestConfig[] = []
await apiClient.post('/probe', {}, {
adapter: okAdapter(sink),
headers: { 'X-Idempotency-Key': 'queued-key' },
})
expect(sink[0].headers['X-Idempotency-Key']).toBe('queued-key')
})
it('FE-APIWIRE-004: falls back to a random token when crypto.randomUUID is missing', async () => {
const realCrypto = globalThis.crypto
vi.stubGlobal('crypto', {
getRandomValues: realCrypto.getRandomValues.bind(realCrypto),
} as unknown as Crypto)
const sink: InternalAxiosRequestConfig[] = []
await apiClient.post('/probe', {}, { adapter: okAdapter(sink) })
const key = String(sink[0].headers['X-Idempotency-Key'])
expect(key).toMatch(/^[a-z0-9]+$/)
expect(key).not.toMatch(/-/)
})
it('FE-APIWIRE-005: the socket id header is omitted while no socket is connected', async () => {
const sink: InternalAxiosRequestConfig[] = []
await apiClient.get('/probe', { adapter: okAdapter(sink) })
expect(sink[0].headers['X-Socket-Id']).toBeUndefined()
})
it('FE-APIWIRE-034: a rejection from an earlier request interceptor is passed on untouched', async () => {
const boom = new Error('interceptor refused the request')
const id = apiClient.interceptors.request.use(() => Promise.reject(boom))
const sink: InternalAxiosRequestConfig[] = []
try {
await expect(apiClient.post('/probe', {}, { adapter: okAdapter(sink) })).rejects.toBe(boom)
} finally {
apiClient.interceptors.request.eject(id)
}
expect(sink).toHaveLength(0)
})
})
describe('client > rate-limit translation', () => {
beforeEach(() => {
server.use(http.get('/api/limited', () => HttpResponse.json({ error: 'Too Many Requests' }, { status: 429 })))
})
it('FE-APIWIRE-006: a 429 is rewritten in the stored app language', async () => {
localStorage.setItem('app_language', 'de')
const err = await captureError(() => apiClient.get('/limited'))
expect(err.message).toBe('Zu viele Versuche. Bitte versuchen Sie es später erneut.')
expect((err.response?.data as { error: string }).error)
.toBe('Zu viele Versuche. Bitte versuchen Sie es später erneut.')
})
it('FE-APIWIRE-007: an unsupported language falls back to English', async () => {
localStorage.setItem('app_language', 'kl')
const err = await captureError(() => apiClient.get('/limited'))
expect(err.message).toBe('Too many attempts. Please try again later.')
})
it('FE-APIWIRE-008: no stored language falls back to English', async () => {
const err = await captureError(() => apiClient.get('/limited'))
expect(err.message).toBe('Too many attempts. Please try again later.')
})
it('FE-APIWIRE-009: a blocked localStorage still yields the English message', async () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('storage disabled')
})
const err = await captureError(() => apiClient.get('/limited'))
expect(err.message).toBe('Too many attempts. Please try again later.')
})
it('FE-APIWIRE-010: a non-object 429 body is replaced with the translated error object', async () => {
server.use(http.get('/api/limited', () => new HttpResponse('slow down', { status: 429 })))
const err = await captureError(() => apiClient.get('/limited'))
expect(err.response?.data).toEqual({ error: 'Too many attempts. Please try again later.' })
})
it('FE-APIWIRE-035: an array 429 body is replaced, not grafted onto', async () => {
server.use(http.get('/api/limited', () => HttpResponse.json([{ field: 'email' }], { status: 429 })))
const err = await captureError(() => apiClient.get('/limited'))
expect(err.response?.data).toEqual({ error: 'Too many attempts. Please try again later.' })
})
it('FE-APIWIRE-036: Catalan, Greek and Vietnamese have their own 429 message', async () => {
for (const lang of ['ca', 'gr', 'vi']) {
localStorage.setItem('app_language', lang)
const err = await captureError(() => apiClient.get('/limited'))
expect(err.message).not.toBe('Too many attempts. Please try again later.')
}
})
})
describe('client > proxy auth challenges', () => {
function installServiceWorker(unregister: () => Promise<boolean>) {
const getRegistration = vi.fn(async () => ({ unregister }))
Object.defineProperty(navigator, 'serviceWorker', {
writable: true, configurable: true, value: { getRegistration },
})
return getRegistration
}
it('FE-APIWIRE-011: an HTML 401 unregisters the service worker and reloads', async () => {
const unregister = vi.fn(async () => true)
installServiceWorker(unregister)
server.use(http.get('/api/auth/me', () =>
new HttpResponse('<html>login</html>', { status: 401, headers: { 'Content-Type': 'text/html' } })))
await captureError(() => apiClient.get('/auth/me'))
expect(unregister).toHaveBeenCalled()
expect(reload).toHaveBeenCalledTimes(1)
expect(sessionStorage.getItem('proxy_reauth_attempted')).toBe('1')
})
it('FE-APIWIRE-012: the reauth reload only fires once per session', async () => {
installServiceWorker(vi.fn(async () => true))
sessionStorage.setItem('proxy_reauth_attempted', '1')
server.use(http.get('/api/auth/me', () =>
new HttpResponse('<html>login</html>', { status: 401, headers: { 'Content-Type': 'text/html' } })))
await captureError(() => apiClient.get('/auth/me'))
expect(reload).not.toHaveBeenCalled()
})
it('FE-APIWIRE-013: an HTML 401 on a public path never reloads', async () => {
setLocation('/login')
server.use(http.get('/api/auth/me', () =>
new HttpResponse('<html>login</html>', { status: 401, headers: { 'Content-Type': 'text/html' } })))
await captureError(() => apiClient.get('/auth/me'))
expect(reload).not.toHaveBeenCalled()
expect(sessionStorage.getItem('proxy_reauth_attempted')).toBeNull()
})
it('FE-APIWIRE-014: a response-less failure that probes proxy-wall reloads', async () => {
probeNow.mockResolvedValue('proxy-wall')
installServiceWorker(vi.fn(async () => true))
await captureError(() => apiClient.get('/auth/me', { adapter: networkErrorAdapter }))
expect(probeNow).toHaveBeenCalled()
expect(reload).toHaveBeenCalledTimes(1)
})
it('FE-APIWIRE-015: a response-less failure that probes offline keeps the SW (#1346)', async () => {
probeNow.mockResolvedValue('offline')
const getRegistration = installServiceWorker(vi.fn(async () => true))
await captureError(() => apiClient.get('/auth/me', { adapter: networkErrorAdapter }))
expect(getRegistration).not.toHaveBeenCalled()
expect(reload).not.toHaveBeenCalled()
expect(sessionStorage.getItem('proxy_reauth_attempted')).toBeNull()
})
it('FE-APIWIRE-016: a failing unregister still reloads into the proxy challenge', async () => {
probeNow.mockResolvedValue('proxy-wall')
Object.defineProperty(navigator, 'serviceWorker', {
writable: true, configurable: true,
value: { getRegistration: vi.fn(async () => { throw new Error('SW gone') }) },
})
await captureError(() => apiClient.get('/auth/me', { adapter: networkErrorAdapter }))
expect(reload).toHaveBeenCalledTimes(1)
})
it('FE-APIWIRE-017: a proxy-wall probe on a shared page does not reload', async () => {
setLocation('/shared/tok123')
probeNow.mockResolvedValue('proxy-wall')
await captureError(() => apiClient.get('/auth/me', { adapter: networkErrorAdapter }))
expect(reload).not.toHaveBeenCalled()
})
it('FE-APIWIRE-035: a 401 without a content-type is not mistaken for a proxy login page', async () => {
installServiceWorker(vi.fn(async () => true))
server.use(http.get('/api/auth/me', () => new HttpResponse(null, { status: 401 })))
await captureError(() => apiClient.get('/auth/me'))
expect(reload).not.toHaveBeenCalled()
expect(sessionStorage.getItem('proxy_reauth_attempted')).toBeNull()
})
it('FE-APIWIRE-018: a successful response clears the reauth marker', async () => {
sessionStorage.setItem('proxy_reauth_attempted', '1')
server.use(http.get('/api/auth/me', () => HttpResponse.json({ ok: true })))
await apiClient.get('/auth/me')
expect(sessionStorage.getItem('proxy_reauth_attempted')).toBeNull()
})
})
describe('client > redirect handling', () => {
it('FE-APIWIRE-019: a JSON AUTH_REQUIRED 401 redirects with the full current path', async () => {
const loc = setLocation('/trips/7', '?tab=map', '#day-2')
server.use(http.get('/api/auth/me', () => HttpResponse.json({ code: 'AUTH_REQUIRED' }, { status: 401 })))
await captureError(() => apiClient.get('/auth/me'))
expect(loc.href).toBe('/login?redirect=' + encodeURIComponent('/trips/7?tab=map#day-2'))
})
it('FE-APIWIRE-020: an MFA_REQUIRED 403 sends the user to the settings page', async () => {
const loc = setLocation('/dashboard')
server.use(http.get('/api/auth/me', () => HttpResponse.json({ code: 'MFA_REQUIRED' }, { status: 403 })))
await captureError(() => apiClient.get('/auth/me'))
expect(loc.href).toBe('/settings?mfa=required')
})
})
describe('client > dev-only contract drift checks', () => {
it('FE-APIWIRE-021: parseInDev passes a matching payload straight through', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const payload = { temp: 21, main: 'Clear', description: 'clear sky', type: 'sun' }
expect(parseInDev(weatherResultSchema, payload, 'weather.get')).toBe(payload)
expect(warn).not.toHaveBeenCalled()
})
it('FE-APIWIRE-022: parseInDev warns but still returns a drifting payload', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const payload = { temp: 'warm', main: 'Clear', description: 'clear sky', type: 'sun' }
expect(parseInDev(weatherResultSchema, payload, 'weather.get')).toBe(payload)
expect(warn).toHaveBeenCalledWith(
'[api] weather.get: response did not match the @trek/shared schema',
expect.anything(),
)
})
it('FE-APIWIRE-023: a drifting maps response is reported under its own label', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
server.use(http.post('/api/maps/search', () => HttpResponse.json({ nonsense: true })))
await expect(mapsApi.search('Rome')).resolves.toEqual({ nonsense: true })
expect(warn).toHaveBeenCalledWith(
'[api] maps.search: response did not match the @trek/shared schema',
expect.anything(),
)
})
})
describe('client > pluginsApi.invoke namespace guard', () => {
it('FE-APIWIRE-024: a relative sub-path stays inside the plugin namespace', async () => {
let seen = ''
server.use(http.get('/api/plugins/koffi/ping', ({ request }) => {
seen = new URL(request.url).pathname
return HttpResponse.json({ pong: true })
}))
await expect(pluginsApi.invoke('koffi', '/ping')).resolves.toEqual({ pong: true })
expect(seen).toBe('/api/plugins/koffi/ping')
})
it('FE-APIWIRE-025: method, body and query string survive the rewrite', async () => {
let received: unknown
let query = ''
server.use(http.post('/api/plugins/koffi/sync', async ({ request }) => {
received = await request.json()
query = new URL(request.url).search
return HttpResponse.json({ ok: true })
}))
await pluginsApi.invoke('koffi', 'sync?full=1', { method: 'POST', body: { since: 5 } })
expect(received).toEqual({ since: 5 })
expect(query).toBe('?full=1')
})
it('FE-APIWIRE-026: traversal out of the plugin prefix is refused', async () => {
await expect(pluginsApi.invoke('koffi', '/../../auth/me'))
.rejects.toThrow('plugin route escapes its namespace')
})
it('FE-APIWIRE-027: an absolute off-origin target is refused', async () => {
await expect(pluginsApi.invoke('koffi', 'https://evil.test/steal'))
.rejects.toThrow('plugin route escapes its namespace')
})
it('FE-APIWIRE-028: an unparseable sub-path is refused before any request', async () => {
await expect(pluginsApi.invoke('koffi', 'http://')).rejects.toThrow('invalid plugin route')
})
})
describe('client > adminApi.llmLocalPull', () => {
function streamingResponse(chunks: string[]): Response {
let i = 0
const encoder = new TextEncoder()
return {
ok: true,
status: 200,
body: {
getReader: () => ({
read: async () => (i < chunks.length
? { done: false, value: encoder.encode(chunks[i++]) }
: { done: true, value: undefined }),
cancel: async () => {},
}),
},
} as unknown as Response
}
it('FE-APIWIRE-029: NDJSON progress lines are reported even when split across chunks', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(streamingResponse([
'{"status":"pulling","total":100,"completed":10}\n{"status":"pul',
'ling","total":100,"completed":90}\n{"status":"success"}\n',
]))
const onProgress = vi.fn((_p: { status?: string }) => {})
await adminApi.llmLocalPull('http://ollama:11434', 'qwen3:8b', onProgress)
expect(onProgress.mock.calls.map(c => c[0])).toEqual([
{ status: 'pulling', total: 100, completed: 10 },
{ status: 'pulling', total: 100, completed: 90 },
{ status: 'success' },
])
})
it('FE-APIWIRE-030: blank and half-written lines are skipped instead of throwing', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(streamingResponse([
'\n \n{"status":"a"}\nnot-json\n{"status":"b"}\n',
]))
const onProgress = vi.fn((_p: { status?: string }) => {})
await adminApi.llmLocalPull('http://ollama:11434', 'qwen3:8b', onProgress)
expect(onProgress.mock.calls.map(c => c[0])).toEqual([{ status: 'a' }, { status: 'b' }])
})
it('FE-APIWIRE-031: a JSON error body becomes the thrown message', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
ok: false, status: 502, body: null,
json: async () => ({ error: 'ollama unreachable' }),
} as unknown as Response)
await expect(adminApi.llmLocalPull('http://ollama:11434', 'x', vi.fn()))
.rejects.toThrow('ollama unreachable')
})
it('FE-APIWIRE-032: a non-JSON error body falls back to the status code', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
ok: false, status: 500, body: null,
json: async () => { throw new SyntaxError('not json') },
} as unknown as Response)
await expect(adminApi.llmLocalPull('http://ollama:11434', 'x', vi.fn()))
.rejects.toThrow('Pull failed (500)')
})
it('FE-APIWIRE-036: a throw from onProgress aborts the pull', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(streamingResponse([
'{"status":"pulling manifest"}\n{"error":"manifest not found"}\n{"status":"success"}\n',
]))
const onProgress = vi.fn((p: { error?: string }) => {
if (p.error) throw new Error(p.error)
})
await expect(adminApi.llmLocalPull('http://ollama:11434', 'x', onProgress))
.rejects.toThrow('manifest not found')
expect(onProgress).toHaveBeenCalledTimes(2)
})
it('FE-APIWIRE-033: a 200 without a readable body reports the missing stream', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
ok: true, status: 200, body: null,
json: async () => ({}),
} as unknown as Response)
await expect(adminApi.llmLocalPull('http://ollama:11434', 'x', vi.fn()))
.rejects.toThrow('Pull returned no progress stream')
})
})
-851
View File
@@ -1,851 +0,0 @@
// FE-APISURF-001 to FE-APISURF-052
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import type { AxiosResponse } from 'axios'
import { http, HttpResponse } from 'msw'
import { server } from '../../tests/helpers/msw/server'
import {
apiClient,
authApi, oauthApi, tripsApi, daysApi, placesApi, assignmentsApi, packingApi, todoApi,
tagsApi, categoriesApi, adminApi, addonsApi, pluginsApi, airtrailApi, journeyApi,
mapsApi, airportsApi, budgetApi, filesApi, reservationsApi, healthApi, weatherApi,
configApi, helpApi, settingsApi, accommodationsApi, dayNotesApi, collabApi, backupApi,
shareApi, transitApi, tripInviteApi, notificationsApi, inAppNotificationsApi,
} from './client'
interface Recorded { method: string; url: string; body: unknown }
let log: Recorded[] = []
/** One record per outgoing request: verb, path+query and (parsed) JSON body. */
function recorder() {
return http.all(/\/api\//, async ({ request }) => {
const url = new URL(request.url)
const raw = await request.text()
let body: unknown
if (raw) {
try { body = JSON.parse(raw) } catch { body = raw }
}
log.push({ method: request.method, url: url.pathname + url.search, body })
return HttpResponse.json({ ok: true })
})
}
beforeEach(() => {
log = []
server.use(recorder())
// parseInDev/checkInDev warn on every stub payload that doesn't match its
// @trek/shared schema — expected here, so keep the output readable.
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
})
interface Call { n: string; r: () => Promise<unknown>; e: string }
/** Runs every call in isolation and checks the verb + path it produced. */
async function assertCalls(calls: Call[]): Promise<void> {
for (const c of calls) {
log = []
await c.r()
expect(log.length, `${c.n}: expected exactly one request`).toBe(1)
const rec = log[0]
const [path] = rec.url.split('?')
expect(`${rec.method} ${path}`, c.n).toBe(c.e)
}
}
/** Runs one call and returns the request it produced. */
async function traceOne(run: () => Promise<unknown>): Promise<Recorded> {
log = []
await run()
expect(log).toHaveLength(1)
return log[0]
}
describe('client > endpoint wiring', () => {
it('FE-APISURF-001: authApi maps every method to its auth endpoint', async () => {
await assertCalls([
{ n: 'register', r: () => authApi.register({ email: 'a@b.c', password: 'pw' }), e: 'POST /api/auth/register' },
{ n: 'validateInvite', r: () => authApi.validateInvite('inv-tok'), e: 'GET /api/auth/invite/inv-tok' },
{ n: 'login', r: () => authApi.login({ email: 'a@b.c', password: 'pw' }), e: 'POST /api/auth/login' },
{ n: 'verifyMfaLogin', r: () => authApi.verifyMfaLogin({ mfa_token: 'm', code: '123456' }), e: 'POST /api/auth/mfa/verify-login' },
{ n: 'mfaSetup', r: () => authApi.mfaSetup(), e: 'POST /api/auth/mfa/setup' },
{ n: 'mfaEnable', r: () => authApi.mfaEnable({ code: '123456' }), e: 'POST /api/auth/mfa/enable' },
{ n: 'mfaDisable', r: () => authApi.mfaDisable({ password: 'pw', code: '123456' }), e: 'POST /api/auth/mfa/disable' },
{ n: 'me', r: () => authApi.me(), e: 'GET /api/auth/me' },
{ n: 'updateMapsKey', r: () => authApi.updateMapsKey('gkey'), e: 'PUT /api/auth/me/maps-key' },
{ n: 'updateApiKeys', r: () => authApi.updateApiKeys({ google_maps: null }), e: 'PUT /api/auth/me/api-keys' },
{ n: 'updateSettings', r: () => authApi.updateSettings({ theme: 'dark' }), e: 'PUT /api/auth/me/settings' },
{ n: 'getSettings', r: () => authApi.getSettings(), e: 'GET /api/auth/me/settings' },
{ n: 'listUsers', r: () => authApi.listUsers(), e: 'GET /api/auth/users' },
{ n: 'deleteAvatar', r: () => authApi.deleteAvatar(), e: 'DELETE /api/auth/avatar' },
{ n: 'getAppConfig', r: () => authApi.getAppConfig(), e: 'GET /api/auth/app-config' },
{ n: 'updateAppSettings', r: () => authApi.updateAppSettings({ registration_enabled: true }), e: 'PUT /api/auth/app-settings' },
{ n: 'validateKeys', r: () => authApi.validateKeys(), e: 'GET /api/auth/validate-keys' },
{ n: 'travelStats', r: () => authApi.travelStats(), e: 'GET /api/auth/travel-stats' },
{ n: 'changePassword', r: () => authApi.changePassword({ current_password: 'a', new_password: 'b' }), e: 'PUT /api/auth/me/password' },
{ n: 'forgotPassword', r: () => authApi.forgotPassword({ email: 'a@b.c' }), e: 'POST /api/auth/forgot-password' },
{ n: 'resetPassword', r: () => authApi.resetPassword({ token: 't', new_password: 'b' }), e: 'POST /api/auth/reset-password' },
{ n: 'deleteOwnAccount', r: () => authApi.deleteOwnAccount(), e: 'DELETE /api/auth/me' },
{ n: 'demoLogin', r: () => authApi.demoLogin(), e: 'POST /api/auth/demo-login' },
{ n: 'mcpTokens.list', r: () => authApi.mcpTokens.list(), e: 'GET /api/auth/mcp-tokens' },
{ n: 'mcpTokens.create', r: () => authApi.mcpTokens.create('cli'), e: 'POST /api/auth/mcp-tokens' },
{ n: 'mcpTokens.delete', r: () => authApi.mcpTokens.delete(7), e: 'DELETE /api/auth/mcp-tokens/7' },
{ n: 'passkey.registerOptions', r: () => authApi.passkey.registerOptions('pw'), e: 'POST /api/auth/passkey/register/options' },
{ n: 'passkey.registerVerify', r: () => authApi.passkey.registerVerify({ id: 'cred' }, 'Yubikey'), e: 'POST /api/auth/passkey/register/verify' },
{ n: 'passkey.loginOptions', r: () => authApi.passkey.loginOptions(), e: 'POST /api/auth/passkey/login/options' },
{ n: 'passkey.loginVerify', r: () => authApi.passkey.loginVerify({ id: 'cred' }), e: 'POST /api/auth/passkey/login/verify' },
{ n: 'passkey.list', r: () => authApi.passkey.list(), e: 'GET /api/auth/passkey/credentials' },
{ n: 'passkey.rename', r: () => authApi.passkey.rename(3, 'Phone'), e: 'PATCH /api/auth/passkey/credentials/3' },
{ n: 'passkey.delete', r: () => authApi.passkey.delete(3, 'pw'), e: 'DELETE /api/auth/passkey/credentials/3' },
])
})
it('FE-APISURF-002: oauthApi maps consent + client/session management endpoints', async () => {
const params = {
response_type: 'code', client_id: 'cid', redirect_uri: 'https://app/cb',
scope: 'trips:read', code_challenge: 'chal', code_challenge_method: 'S256',
}
await assertCalls([
{ n: 'validate', r: () => oauthApi.validate(params), e: 'GET /api/oauth/authorize/validate' },
{ n: 'authorize', r: () => oauthApi.authorize({ ...params, approved: true }), e: 'POST /api/oauth/authorize' },
{ n: 'clients.list', r: () => oauthApi.clients.list(), e: 'GET /api/oauth/clients' },
{ n: 'clients.create', r: () => oauthApi.clients.create({ name: 'App', allowed_scopes: ['trips:read'] }), e: 'POST /api/oauth/clients' },
{ n: 'clients.rotate', r: () => oauthApi.clients.rotate('cid'), e: 'POST /api/oauth/clients/cid/rotate' },
{ n: 'clients.delete', r: () => oauthApi.clients.delete('cid'), e: 'DELETE /api/oauth/clients/cid' },
{ n: 'sessions.list', r: () => oauthApi.sessions.list(), e: 'GET /api/oauth/sessions' },
{ n: 'sessions.revoke', r: () => oauthApi.sessions.revoke(4), e: 'DELETE /api/oauth/sessions/4' },
])
})
it('FE-APISURF-003: tripsApi maps trip, member and guest endpoints', async () => {
await assertCalls([
{ n: 'list', r: () => tripsApi.list(), e: 'GET /api/trips' },
{ n: 'create', r: () => tripsApi.create({ title: 'Rome' }), e: 'POST /api/trips' },
{ n: 'get', r: () => tripsApi.get(3), e: 'GET /api/trips/3' },
{ n: 'update', r: () => tripsApi.update(3, { title: 'Rome 2' }), e: 'PUT /api/trips/3' },
{ n: 'delete', r: () => tripsApi.delete(3), e: 'DELETE /api/trips/3' },
{ n: 'searchCoverImages', r: () => tripsApi.searchCoverImages('rome'), e: 'GET /api/trips/cover-images/search' },
{ n: 'archive', r: () => tripsApi.archive(3), e: 'PUT /api/trips/3' },
{ n: 'unarchive', r: () => tripsApi.unarchive(3), e: 'PUT /api/trips/3' },
{ n: 'getMembers', r: () => tripsApi.getMembers(3), e: 'GET /api/trips/3/members' },
{ n: 'addMember', r: () => tripsApi.addMember(3, 'bob'), e: 'POST /api/trips/3/members' },
{ n: 'removeMember', r: () => tripsApi.removeMember(3, 9), e: 'DELETE /api/trips/3/members/9' },
{ n: 'transferOwnership', r: () => tripsApi.transferOwnership(3, 9), e: 'POST /api/trips/3/transfer' },
{ n: 'createGuest', r: () => tripsApi.createGuest(3, 'Anna'), e: 'POST /api/trips/3/guests' },
{ n: 'renameGuest', r: () => tripsApi.renameGuest(3, 9, 'Ana'), e: 'PUT /api/trips/3/guests/9' },
{ n: 'deleteGuest', r: () => tripsApi.deleteGuest(3, 9), e: 'DELETE /api/trips/3/guests/9' },
{ n: 'copy', r: () => tripsApi.copy(3, { title: 'Copy' }), e: 'POST /api/trips/3/copy' },
{ n: 'bundle', r: () => tripsApi.bundle(3), e: 'GET /api/trips/3/bundle' },
])
})
it('FE-APISURF-004: daysApi and dayNotesApi map their nested trip endpoints', async () => {
await assertCalls([
{ n: 'days.list', r: () => daysApi.list(1), e: 'GET /api/trips/1/days' },
{ n: 'days.create', r: () => daysApi.create(1, { date: '2026-06-01' }), e: 'POST /api/trips/1/days' },
{ n: 'days.update', r: () => daysApi.update(1, 2, { notes: 'hi' }), e: 'PUT /api/trips/1/days/2' },
{ n: 'days.updateTransport', r: () => daysApi.updateTransport(1, 2, 'car'), e: 'PUT /api/trips/1/days/2/transport' },
{ n: 'days.delete', r: () => daysApi.delete(1, 2), e: 'DELETE /api/trips/1/days/2' },
{ n: 'days.reorder', r: () => daysApi.reorder(1, [2, 1]), e: 'PUT /api/trips/1/days/reorder' },
{ n: 'dayNotes.list', r: () => dayNotesApi.list(1, 2), e: 'GET /api/trips/1/days/2/notes' },
{ n: 'dayNotes.create', r: () => dayNotesApi.create(1, 2, { text: 'note' }), e: 'POST /api/trips/1/days/2/notes' },
{ n: 'dayNotes.update', r: () => dayNotesApi.update(1, 2, 5, { text: 'edit' }), e: 'PUT /api/trips/1/days/2/notes/5' },
{ n: 'dayNotes.delete', r: () => dayNotesApi.delete(1, 2, 5), e: 'DELETE /api/trips/1/days/2/notes/5' },
])
})
it('FE-APISURF-005: placesApi maps CRUD, rating and list-import endpoints', async () => {
await assertCalls([
{ n: 'list', r: () => placesApi.list(1), e: 'GET /api/trips/1/places' },
{ n: 'create', r: () => placesApi.create(1, { name: 'Colosseum' }), e: 'POST /api/trips/1/places' },
{ n: 'get', r: () => placesApi.get(1, 5), e: 'GET /api/trips/1/places/5' },
{ n: 'update', r: () => placesApi.update(1, 5, { name: 'Forum' }), e: 'PUT /api/trips/1/places/5' },
{ n: 'delete', r: () => placesApi.delete(1, 5), e: 'DELETE /api/trips/1/places/5' },
{ n: 'searchImage', r: () => placesApi.searchImage(1, 5), e: 'GET /api/trips/1/places/5/image' },
{ n: 'importGoogleList', r: () => placesApi.importGoogleList(1, 'https://maps.app/x'), e: 'POST /api/trips/1/places/import/google-list' },
{ n: 'importNaverList', r: () => placesApi.importNaverList(1, 'https://naver/x'), e: 'POST /api/trips/1/places/import/naver-list' },
{ n: 'bulkDelete', r: () => placesApi.bulkDelete(1, [5, 6]), e: 'POST /api/trips/1/places/bulk-delete' },
{ n: 'bulkUpdate', r: () => placesApi.bulkUpdate(1, [5], { category_id: 2 }), e: 'POST /api/trips/1/places/bulk-update' },
])
})
it('FE-APISURF-006: assignmentsApi maps day-plan endpoints', async () => {
await assertCalls([
{ n: 'list', r: () => assignmentsApi.list(1, 2), e: 'GET /api/trips/1/days/2/assignments' },
{ n: 'create', r: () => assignmentsApi.create(1, 2, { place_id: 5 }), e: 'POST /api/trips/1/days/2/assignments' },
{ n: 'delete', r: () => assignmentsApi.delete(1, 2, 7), e: 'DELETE /api/trips/1/days/2/assignments/7' },
{ n: 'reorder', r: () => assignmentsApi.reorder(1, 2, [7, 8]), e: 'PUT /api/trips/1/days/2/assignments/reorder' },
{ n: 'move', r: () => assignmentsApi.move(1, 7, 3, 0), e: 'PUT /api/trips/1/assignments/7/move' },
{ n: 'update', r: () => assignmentsApi.update(1, 2, 7, { notes: 'x' }), e: 'PUT /api/trips/1/days/2/assignments/7' },
{ n: 'getParticipants', r: () => assignmentsApi.getParticipants(1, 7), e: 'GET /api/trips/1/assignments/7/participants' },
{ n: 'setParticipants', r: () => assignmentsApi.setParticipants(1, 7, [4]), e: 'PUT /api/trips/1/assignments/7/participants' },
{ n: 'updateTime', r: () => assignmentsApi.updateTime(1, 7, { place_time: '09:00' }), e: 'PUT /api/trips/1/assignments/7/time' },
{ n: 'updateTransport', r: () => assignmentsApi.updateTransport(1, 7, null), e: 'PUT /api/trips/1/assignments/7/transport' },
])
})
it('FE-APISURF-007: packingApi maps item, bag and template endpoints', async () => {
await assertCalls([
{ n: 'list', r: () => packingApi.list(1), e: 'GET /api/trips/1/packing' },
{ n: 'create', r: () => packingApi.create(1, { name: 'Towel' }), e: 'POST /api/trips/1/packing' },
{ n: 'bulkImport', r: () => packingApi.bulkImport(1, [{ name: 'Socks' }]), e: 'POST /api/trips/1/packing/import' },
{ n: 'update', r: () => packingApi.update(1, 4, { checked: true }), e: 'PUT /api/trips/1/packing/4' },
{ n: 'delete', r: () => packingApi.delete(1, 4), e: 'DELETE /api/trips/1/packing/4' },
{ n: 'reorder', r: () => packingApi.reorder(1, [4, 5]), e: 'PUT /api/trips/1/packing/reorder' },
{ n: 'setSharing', r: () => packingApi.setSharing(1, 4, { visibility: 'shared' }), e: 'PUT /api/trips/1/packing/4/sharing' },
{ n: 'clone', r: () => packingApi.clone(1, 4), e: 'POST /api/trips/1/packing/4/clone' },
{ n: 'addContributor', r: () => packingApi.addContributor(1, 4), e: 'POST /api/trips/1/packing/4/contributors' },
{ n: 'removeContributor', r: () => packingApi.removeContributor(1, 4, 9), e: 'DELETE /api/trips/1/packing/4/contributors/9' },
{ n: 'getCategoryAssignees', r: () => packingApi.getCategoryAssignees(1), e: 'GET /api/trips/1/packing/category-assignees' },
{ n: 'listTemplates', r: () => packingApi.listTemplates(1), e: 'GET /api/trips/1/packing/templates' },
{ n: 'applyTemplate', r: () => packingApi.applyTemplate(1, 6), e: 'POST /api/trips/1/packing/apply-template/6' },
{ n: 'saveAsTemplate', r: () => packingApi.saveAsTemplate(1, 'Beach'), e: 'POST /api/trips/1/packing/save-as-template' },
{ n: 'setBagMembers', r: () => packingApi.setBagMembers(1, 2, [9]), e: 'PUT /api/trips/1/packing/bags/2/members' },
{ n: 'listBags', r: () => packingApi.listBags(1), e: 'GET /api/trips/1/packing/bags' },
{ n: 'createBag', r: () => packingApi.createBag(1, { name: 'Carry-on' }), e: 'POST /api/trips/1/packing/bags' },
{ n: 'updateBag', r: () => packingApi.updateBag(1, 2, { name: 'Hold' }), e: 'PUT /api/trips/1/packing/bags/2' },
{ n: 'deleteBag', r: () => packingApi.deleteBag(1, 2), e: 'DELETE /api/trips/1/packing/bags/2' },
])
})
it('FE-APISURF-008: todoApi maps todo endpoints', async () => {
await assertCalls([
{ n: 'list', r: () => todoApi.list(1), e: 'GET /api/trips/1/todo' },
{ n: 'create', r: () => todoApi.create(1, { name: 'Book train' }), e: 'POST /api/trips/1/todo' },
{ n: 'update', r: () => todoApi.update(1, 3, { checked: true }), e: 'PUT /api/trips/1/todo/3' },
{ n: 'delete', r: () => todoApi.delete(1, 3), e: 'DELETE /api/trips/1/todo/3' },
{ n: 'reorder', r: () => todoApi.reorder(1, [3, 4]), e: 'PUT /api/trips/1/todo/reorder' },
{ n: 'getCategoryAssignees', r: () => todoApi.getCategoryAssignees(1), e: 'GET /api/trips/1/todo/category-assignees' },
])
})
it('FE-APISURF-009: tagsApi and categoriesApi map their global endpoints', async () => {
await assertCalls([
{ n: 'tags.list', r: () => tagsApi.list(), e: 'GET /api/tags' },
{ n: 'tags.create', r: () => tagsApi.create({ name: 'Food' }), e: 'POST /api/tags' },
{ n: 'tags.update', r: () => tagsApi.update(2, { name: 'Eat' }), e: 'PUT /api/tags/2' },
{ n: 'tags.delete', r: () => tagsApi.delete(2), e: 'DELETE /api/tags/2' },
{ n: 'categories.list', r: () => categoriesApi.list(), e: 'GET /api/categories' },
{ n: 'categories.create', r: () => categoriesApi.create({ name: 'Museum' }), e: 'POST /api/categories' },
{ n: 'categories.update', r: () => categoriesApi.update(2, { name: 'Art' }), e: 'PUT /api/categories/2' },
{ n: 'categories.delete', r: () => categoriesApi.delete(2), e: 'DELETE /api/categories/2' },
])
})
it('FE-APISURF-010: adminApi maps user, addon and settings endpoints', async () => {
await assertCalls([
{ n: 'users', r: () => adminApi.users(), e: 'GET /api/admin/users' },
{ n: 'createUser', r: () => adminApi.createUser({ email: 'a@b.c' }), e: 'POST /api/admin/users' },
{ n: 'updateUser', r: () => adminApi.updateUser(2, { role: 'admin' }), e: 'PUT /api/admin/users/2' },
{ n: 'deleteUser', r: () => adminApi.deleteUser(2), e: 'DELETE /api/admin/users/2' },
{ n: 'resetUserPasskeys', r: () => adminApi.resetUserPasskeys(2), e: 'DELETE /api/admin/users/2/passkeys' },
{ n: 'stats', r: () => adminApi.stats(), e: 'GET /api/admin/stats' },
{ n: 'saveDemoBaseline', r: () => adminApi.saveDemoBaseline(), e: 'POST /api/admin/save-demo-baseline' },
{ n: 'getOidc', r: () => adminApi.getOidc(), e: 'GET /api/admin/oidc' },
{ n: 'updateOidc', r: () => adminApi.updateOidc({ enabled: true }), e: 'PUT /api/admin/oidc' },
{ n: 'addons', r: () => adminApi.addons(), e: 'GET /api/admin/addons' },
{ n: 'updateAddon', r: () => adminApi.updateAddon(3, { enabled: false }), e: 'PUT /api/admin/addons/3' },
{ n: 'checkVersion', r: () => adminApi.checkVersion(), e: 'GET /api/admin/version-check' },
{ n: 'getBagTracking', r: () => adminApi.getBagTracking(), e: 'GET /api/admin/bag-tracking' },
{ n: 'updateBagTracking', r: () => adminApi.updateBagTracking(true), e: 'PUT /api/admin/bag-tracking' },
{ n: 'getPlacesPhotos', r: () => adminApi.getPlacesPhotos(), e: 'GET /api/admin/places-photos' },
{ n: 'updatePlacesPhotos', r: () => adminApi.updatePlacesPhotos(false), e: 'PUT /api/admin/places-photos' },
{ n: 'getPlacesAutocomplete', r: () => adminApi.getPlacesAutocomplete(), e: 'GET /api/admin/places-autocomplete' },
{ n: 'updatePlacesAutocomplete', r: () => adminApi.updatePlacesAutocomplete(true), e: 'PUT /api/admin/places-autocomplete' },
{ n: 'getPlacesDetails', r: () => adminApi.getPlacesDetails(), e: 'GET /api/admin/places-details' },
{ n: 'updatePlacesDetails', r: () => adminApi.updatePlacesDetails(true), e: 'PUT /api/admin/places-details' },
{ n: 'getCollabFeatures', r: () => adminApi.getCollabFeatures(), e: 'GET /api/admin/collab-features' },
{ n: 'updateCollabFeatures', r: () => adminApi.updateCollabFeatures({ polls: true }), e: 'PUT /api/admin/collab-features' },
{ n: 'getPermissions', r: () => adminApi.getPermissions(), e: 'GET /api/admin/permissions' },
{ n: 'updatePermissions', r: () => adminApi.updatePermissions({ edit_trip: 'member' }), e: 'PUT /api/admin/permissions' },
{ n: 'rotateJwtSecret', r: () => adminApi.rotateJwtSecret(), e: 'POST /api/admin/rotate-jwt-secret' },
{ n: 'sendTestNotification', r: () => adminApi.sendTestNotification({ channel: 'email' }), e: 'POST /api/admin/dev/test-notification' },
{ n: 'getNotificationPreferences', r: () => adminApi.getNotificationPreferences(), e: 'GET /api/admin/notification-preferences' },
{ n: 'updateNotificationPreferences', r: () => adminApi.updateNotificationPreferences({ email: { trip_invite: true } }), e: 'PUT /api/admin/notification-preferences' },
{ n: 'getDefaultUserSettings', r: () => adminApi.getDefaultUserSettings(), e: 'GET /api/admin/default-user-settings' },
{ n: 'updateDefaultUserSettings', r: () => adminApi.updateDefaultUserSettings({ language: 'de' }), e: 'PUT /api/admin/default-user-settings' },
{ n: 'getStorage', r: () => adminApi.getStorage(), e: 'GET /api/admin/storage' },
{ n: 'updateStorage', r: () => adminApi.updateStorage({ backends: [], categories: {}, version: 0 }), e: 'PUT /api/admin/storage' },
{ n: 'testStorageBackend', r: () => adminApi.testStorageBackend({ name: 'x', type: 'local', options: { root: '/data' } }), e: 'POST /api/admin/storage/test' },
{ n: 'startStorageBackfill', r: () => adminApi.startStorageBackfill('m'), e: 'POST /api/admin/storage/backends/m/backfill' },
{ n: 'cancelStorageBackfill', r: () => adminApi.cancelStorageBackfill('m'), e: 'DELETE /api/admin/storage/backends/m/backfill' },
{ n: 'startStorageMigration', r: () => adminApi.startStorageMigration('files', 'dest'), e: 'POST /api/admin/storage/migrations' },
{ n: 'cancelStorageMigration', r: () => adminApi.cancelStorageMigration('files'), e: 'DELETE /api/admin/storage/migrations/files' },
{ n: 'refreshStorageStats', r: () => adminApi.refreshStorageStats(), e: 'POST /api/admin/storage/stats/refresh' },
{ n: 'mcpTokens', r: () => adminApi.mcpTokens(), e: 'GET /api/admin/mcp-tokens' },
{ n: 'deleteMcpToken', r: () => adminApi.deleteMcpToken(4), e: 'DELETE /api/admin/mcp-tokens/4' },
{ n: 'oauthSessions', r: () => adminApi.oauthSessions(), e: 'GET /api/admin/oauth-sessions' },
{ n: 'revokeOAuthSession', r: () => adminApi.revokeOAuthSession(4), e: 'DELETE /api/admin/oauth-sessions/4' },
{ n: 'listInvites', r: () => adminApi.listInvites(), e: 'GET /api/admin/invites' },
{ n: 'listInviteTrips', r: () => adminApi.listInviteTrips(), e: 'GET /api/admin/invites/trips' },
{ n: 'createInvite', r: () => adminApi.createInvite({ max_uses: 3 }), e: 'POST /api/admin/invites' },
{ n: 'deleteInvite', r: () => adminApi.deleteInvite(8), e: 'DELETE /api/admin/invites/8' },
{ n: 'auditLog', r: () => adminApi.auditLog(), e: 'GET /api/admin/audit-log' },
])
})
it('FE-APISURF-011: adminApi maps the plugin management endpoints', async () => {
await assertCalls([
{ n: 'plugins', r: () => adminApi.plugins(), e: 'GET /api/admin/plugins' },
{ n: 'pluginBrowse', r: () => adminApi.pluginBrowse(), e: 'GET /api/admin/plugins/registry' },
{ n: 'pluginDetail', r: () => adminApi.pluginDetail('trek/koffi'), e: 'GET /api/admin/plugins/registry/trek%2Fkoffi' },
{ n: 'pluginInstall', r: () => adminApi.pluginInstall('koffi', { version: '1.0.0' }), e: 'POST /api/admin/plugins/install' },
{ n: 'pluginActivate', r: () => adminApi.pluginActivate('koffi'), e: 'POST /api/admin/plugins/koffi/activate' },
{ n: 'pluginDeactivate', r: () => adminApi.pluginDeactivate('koffi'), e: 'POST /api/admin/plugins/koffi/deactivate' },
{ n: 'pluginUpdate', r: () => adminApi.pluginUpdate('koffi'), e: 'POST /api/admin/plugins/koffi/update' },
{ n: 'pluginRetrust', r: () => adminApi.pluginRetrust('koffi', '2.0.0', 'PUBKEY'), e: 'POST /api/admin/plugins/koffi/retrust' },
{ n: 'pluginUninstall', r: () => adminApi.pluginUninstall('koffi', true), e: 'POST /api/admin/plugins/koffi/uninstall' },
{ n: 'pluginRescan', r: () => adminApi.pluginRescan(), e: 'POST /api/admin/plugins/rescan' },
{ n: 'pluginLink', r: () => adminApi.pluginLink('/srv/plugin'), e: 'POST /api/admin/plugins/link' },
{ n: 'pluginReload', r: () => adminApi.pluginReload('koffi'), e: 'POST /api/admin/plugins/koffi/reload' },
{ n: 'pluginEgressHosts', r: () => adminApi.pluginEgressHosts('koffi'), e: 'GET /api/admin/plugins/koffi/egress-hosts' },
{ n: 'pluginSetEgressHosts', r: () => adminApi.pluginSetEgressHosts('koffi', ['a.example']), e: 'PUT /api/admin/plugins/koffi/egress-hosts' },
{ n: 'pluginErrors', r: () => adminApi.pluginErrors('koffi'), e: 'GET /api/admin/plugins/koffi/errors' },
{ n: 'pluginAudit', r: () => adminApi.pluginAudit('koffi'), e: 'GET /api/admin/plugins/koffi/audit' },
{ n: 'llmLocalModels', r: () => adminApi.llmLocalModels('http://ollama:11434'), e: 'GET /api/admin/llm/local/models' },
])
})
it('FE-APISURF-012: adminApi maps the packing-template endpoints', async () => {
await assertCalls([
{ n: 'packingTemplates', r: () => adminApi.packingTemplates(), e: 'GET /api/admin/packing-templates' },
{ n: 'getPackingTemplate', r: () => adminApi.getPackingTemplate(1), e: 'GET /api/admin/packing-templates/1' },
{ n: 'createPackingTemplate', r: () => adminApi.createPackingTemplate({ name: 'Ski' }), e: 'POST /api/admin/packing-templates' },
{ n: 'updatePackingTemplate', r: () => adminApi.updatePackingTemplate(1, { name: 'Ski 2' }), e: 'PUT /api/admin/packing-templates/1' },
{ n: 'deletePackingTemplate', r: () => adminApi.deletePackingTemplate(1), e: 'DELETE /api/admin/packing-templates/1' },
{ n: 'addTemplateCategory', r: () => adminApi.addTemplateCategory(1, { name: 'Clothes' }), e: 'POST /api/admin/packing-templates/1/categories' },
{ n: 'updateTemplateCategory', r: () => adminApi.updateTemplateCategory(1, 2, { name: 'Wear' }), e: 'PUT /api/admin/packing-templates/1/categories/2' },
{ n: 'deleteTemplateCategory', r: () => adminApi.deleteTemplateCategory(1, 2), e: 'DELETE /api/admin/packing-templates/1/categories/2' },
{ n: 'addTemplateItem', r: () => adminApi.addTemplateItem(1, 2, { name: 'Gloves' }), e: 'POST /api/admin/packing-templates/1/categories/2/items' },
{ n: 'updateTemplateItem', r: () => adminApi.updateTemplateItem(1, 3, { name: 'Mittens' }), e: 'PUT /api/admin/packing-templates/1/items/3' },
{ n: 'deleteTemplateItem', r: () => adminApi.deleteTemplateItem(1, 3), e: 'DELETE /api/admin/packing-templates/1/items/3' },
])
})
it('FE-APISURF-013: pluginsApi maps every host-mediated plugin endpoint', async () => {
await assertCalls([
{ n: 'active', r: () => pluginsApi.active(), e: 'GET /api/plugins' },
{ n: 'placeDetails', r: () => pluginsApi.placeDetails(5), e: 'GET /api/place-details/5' },
{ n: 'tripWarnings', r: () => pluginsApi.tripWarnings(1), e: 'GET /api/trip-warnings/1' },
{ n: 'viewContributions', r: () => pluginsApi.viewContributions('places', 1), e: 'GET /api/view-contributions/places/1' },
{ n: 'mapMarkers', r: () => pluginsApi.mapMarkers(1), e: 'GET /api/map-markers/1' },
{ n: 'mapLayers', r: () => pluginsApi.mapLayers(1), e: 'GET /api/map-layers/1' },
{ n: 'pluginRoute', r: () => pluginsApi.pluginRoute('koffi', 'ev', { tripId: 1, waypoints: [{ lat: 1, lng: 2 }] }), e: 'POST /api/plugin-routes/koffi/ev' },
{ n: 'daySchedule', r: () => pluginsApi.daySchedule(1), e: 'GET /api/day-schedule/1' },
{ n: 'pdfSections', r: () => pluginsApi.pdfSections(1), e: 'GET /api/pdf-sections/1' },
{ n: 'atlasLayers', r: () => pluginsApi.atlasLayers(), e: 'GET /api/atlas-layers' },
{ n: 'journalEntryRows', r: () => pluginsApi.journalEntryRows(9), e: 'GET /api/journal-entry-rows/9' },
{ n: 'tripCardContributions', r: () => pluginsApi.tripCardContributions([1, 2]), e: 'GET /api/trip-card-contributions' },
{ n: 'myActivity', r: () => pluginsApi.myActivity(), e: 'GET /api/plugin-activity' },
{ n: 'userSettings', r: () => pluginsApi.userSettings('koffi'), e: 'GET /api/plugin-settings/koffi' },
{ n: 'runAction', r: () => pluginsApi.runAction('koffi', 'test connection'), e: 'POST /api/plugin-settings/koffi/actions/test%20connection' },
{ n: 'saveUserSettings', r: () => pluginsApi.saveUserSettings('koffi', { key: 'v' }), e: 'POST /api/plugin-settings/koffi' },
{ n: 'oauthStatus', r: () => pluginsApi.oauthStatus('koffi'), e: 'GET /api/plugin-oauth/koffi/status' },
{ n: 'oauthConnect', r: () => pluginsApi.oauthConnect('koffi'), e: 'POST /api/plugin-oauth/koffi/connect' },
{ n: 'oauthDisconnect', r: () => pluginsApi.oauthDisconnect('koffi'), e: 'POST /api/plugin-oauth/koffi/disconnect' },
])
})
it('FE-APISURF-014: airtrailApi maps the integration endpoints', async () => {
await assertCalls([
{ n: 'getSettings', r: () => airtrailApi.getSettings(), e: 'GET /api/integrations/airtrail/settings' },
{ n: 'saveSettings', r: () => airtrailApi.saveSettings({ url: 'https://at' }), e: 'PUT /api/integrations/airtrail/settings' },
{ n: 'status', r: () => airtrailApi.status(), e: 'GET /api/integrations/airtrail/status' },
{ n: 'test', r: () => airtrailApi.test({ url: 'https://at' }), e: 'POST /api/integrations/airtrail/test' },
{ n: 'sync', r: () => airtrailApi.sync(), e: 'POST /api/integrations/airtrail/sync' },
{ n: 'flights', r: () => airtrailApi.flights(), e: 'GET /api/integrations/airtrail/flights' },
{ n: 'import', r: () => airtrailApi.import(1, ['f1']), e: 'POST /api/trips/1/reservations/import/airtrail' },
])
})
it('FE-APISURF-015: journeyApi maps journal, entry and photo endpoints', async () => {
await assertCalls([
{ n: 'list', r: () => journeyApi.list(), e: 'GET /api/journeys' },
{ n: 'create', r: () => journeyApi.create({ title: 'Asia' }), e: 'POST /api/journeys' },
{ n: 'get', r: () => journeyApi.get(2), e: 'GET /api/journeys/2' },
{ n: 'update', r: () => journeyApi.update(2, { title: 'Asia 24' }), e: 'PATCH /api/journeys/2' },
{ n: 'delete', r: () => journeyApi.delete(2), e: 'DELETE /api/journeys/2' },
{ n: 'suggestions', r: () => journeyApi.suggestions(), e: 'GET /api/journeys/suggestions' },
{ n: 'availableTrips', r: () => journeyApi.availableTrips(), e: 'GET /api/journeys/available-trips' },
{ n: 'addTrip', r: () => journeyApi.addTrip(2, 1), e: 'POST /api/journeys/2/trips' },
{ n: 'removeTrip', r: () => journeyApi.removeTrip(2, 1), e: 'DELETE /api/journeys/2/trips/1' },
{ n: 'listEntries', r: () => journeyApi.listEntries(2), e: 'GET /api/journeys/2/entries' },
{ n: 'createEntry', r: () => journeyApi.createEntry(2, { title: 'Day 1' }), e: 'POST /api/journeys/2/entries' },
{ n: 'updateEntry', r: () => journeyApi.updateEntry(9, { title: 'Day 2' }), e: 'PATCH /api/journeys/entries/9' },
{ n: 'deleteEntry', r: () => journeyApi.deleteEntry(9), e: 'DELETE /api/journeys/entries/9' },
{ n: 'reorderEntries', r: () => journeyApi.reorderEntries(2, [9, 8]), e: 'PUT /api/journeys/2/entries/reorder' },
{ n: 'addProviderPhotosToGallery', r: () => journeyApi.addProviderPhotosToGallery(2, 'immich', ['a1']), e: 'POST /api/journeys/2/gallery/provider-photos' },
{ n: 'addProviderPhoto', r: () => journeyApi.addProviderPhoto(9, 'immich', 'a1'), e: 'POST /api/journeys/entries/9/provider-photos' },
{ n: 'addProviderPhotos', r: () => journeyApi.addProviderPhotos(9, 'immich', ['a1']), e: 'POST /api/journeys/entries/9/provider-photos' },
{ n: 'linkPhoto', r: () => journeyApi.linkPhoto(9, 11), e: 'POST /api/journeys/entries/9/link-photo' },
{ n: 'unlinkPhoto', r: () => journeyApi.unlinkPhoto(9, 11), e: 'DELETE /api/journeys/entries/9/photos/11' },
{ n: 'deleteGalleryPhoto', r: () => journeyApi.deleteGalleryPhoto(2, 11), e: 'DELETE /api/journeys/2/gallery/11' },
{ n: 'updatePhoto', r: () => journeyApi.updatePhoto(11, { caption: 'x' }), e: 'PATCH /api/journeys/photos/11' },
{ n: 'deletePhoto', r: () => journeyApi.deletePhoto(11), e: 'DELETE /api/journeys/photos/11' },
{ n: 'addContributor', r: () => journeyApi.addContributor(2, 4, 'editor'), e: 'POST /api/journeys/2/contributors' },
{ n: 'updateContributor', r: () => journeyApi.updateContributor(2, 4, 'viewer'), e: 'PATCH /api/journeys/2/contributors/4' },
{ n: 'removeContributor', r: () => journeyApi.removeContributor(2, 4), e: 'DELETE /api/journeys/2/contributors/4' },
{ n: 'updatePreferences', r: () => journeyApi.updatePreferences(2, { hide_skeletons: true }), e: 'PATCH /api/journeys/2/preferences' },
{ n: 'getShareLink', r: () => journeyApi.getShareLink(2), e: 'GET /api/journeys/2/share-link' },
{ n: 'createShareLink', r: () => journeyApi.createShareLink(2, { share_map: true }), e: 'POST /api/journeys/2/share-link' },
{ n: 'deleteShareLink', r: () => journeyApi.deleteShareLink(2), e: 'DELETE /api/journeys/2/share-link' },
{ n: 'getPublicJourney', r: () => journeyApi.getPublicJourney('pub-tok'), e: 'GET /api/public/journey/pub-tok' },
])
})
it('FE-APISURF-016: mapsApi and airportsApi map the geo endpoints', async () => {
await assertCalls([
{ n: 'maps.search', r: () => mapsApi.search('Rome'), e: 'POST /api/maps/search' },
{ n: 'maps.autocomplete', r: () => mapsApi.autocomplete('Rom'), e: 'POST /api/maps/autocomplete' },
{ n: 'maps.details', r: () => mapsApi.details('place/1'), e: 'GET /api/maps/details/place%2F1' },
{ n: 'maps.placePhoto', r: () => mapsApi.placePhoto('place/1'), e: 'GET /api/maps/place-photo/place%2F1' },
{ n: 'maps.reverse', r: () => mapsApi.reverse(41.9, 12.5), e: 'GET /api/maps/reverse' },
{ n: 'maps.resolveUrl', r: () => mapsApi.resolveUrl('https://maps.app.goo.gl/x'), e: 'POST /api/maps/resolve-url' },
{ n: 'maps.pois', r: () => mapsApi.pois('cafe', { south: 1, west: 2, north: 3, east: 4 }), e: 'GET /api/maps/pois' },
{ n: 'airports.search', r: () => airportsApi.search('BER'), e: 'GET /api/airports/search' },
{ n: 'airports.byIata', r: () => airportsApi.byIata('b/er'), e: 'GET /api/airports/b%2Fer' },
])
})
it('FE-APISURF-017: budgetApi maps item, member and settlement endpoints', async () => {
await assertCalls([
{ n: 'list', r: () => budgetApi.list(1), e: 'GET /api/trips/1/budget' },
{ n: 'create', r: () => budgetApi.create(1, { name: 'Hotel' }), e: 'POST /api/trips/1/budget' },
{ n: 'update', r: () => budgetApi.update(1, 2, { name: 'Hostel' }), e: 'PUT /api/trips/1/budget/2' },
{ n: 'delete', r: () => budgetApi.delete(1, 2), e: 'DELETE /api/trips/1/budget/2' },
{ n: 'setMembers', r: () => budgetApi.setMembers(1, 2, [4, 5]), e: 'PUT /api/trips/1/budget/2/members' },
{ n: 'togglePaid', r: () => budgetApi.togglePaid(1, 2, 4, true), e: 'PUT /api/trips/1/budget/2/members/4/paid' },
{ n: 'setPayers', r: () => budgetApi.setPayers(1, 2, [{ user_id: 4, amount: 10 }]), e: 'PUT /api/trips/1/budget/2/payers' },
{ n: 'perPersonSummary', r: () => budgetApi.perPersonSummary(1), e: 'GET /api/trips/1/budget/summary/per-person' },
{ n: 'settlement', r: () => budgetApi.settlement(1), e: 'GET /api/trips/1/budget/settlement' },
{ n: 'createSettlement', r: () => budgetApi.createSettlement(1, { from_user_id: 4, to_user_id: 5, amount: 10 }), e: 'POST /api/trips/1/budget/settlements' },
{ n: 'updateSettlement', r: () => budgetApi.updateSettlement(1, 6, { from_user_id: 4, to_user_id: 5, amount: 12 }), e: 'PUT /api/trips/1/budget/settlements/6' },
{ n: 'deleteSettlement', r: () => budgetApi.deleteSettlement(1, 6), e: 'DELETE /api/trips/1/budget/settlements/6' },
{ n: 'reorderItems', r: () => budgetApi.reorderItems(1, [2, 3]), e: 'PUT /api/trips/1/budget/reorder/items' },
{ n: 'reorderCategories', r: () => budgetApi.reorderCategories(1, ['Food']), e: 'PUT /api/trips/1/budget/reorder/categories' },
])
})
it('FE-APISURF-018: filesApi maps file, trash and link endpoints', async () => {
await assertCalls([
{ n: 'list', r: () => filesApi.list(1), e: 'GET /api/trips/1/files' },
{ n: 'update', r: () => filesApi.update(1, 3, { description: 'x' }), e: 'PUT /api/trips/1/files/3' },
{ n: 'delete', r: () => filesApi.delete(1, 3), e: 'DELETE /api/trips/1/files/3' },
{ n: 'toggleStar', r: () => filesApi.toggleStar(1, 3), e: 'PATCH /api/trips/1/files/3/star' },
{ n: 'restore', r: () => filesApi.restore(1, 3), e: 'POST /api/trips/1/files/3/restore' },
{ n: 'permanentDelete', r: () => filesApi.permanentDelete(1, 3), e: 'DELETE /api/trips/1/files/3/permanent' },
{ n: 'emptyTrash', r: () => filesApi.emptyTrash(1), e: 'DELETE /api/trips/1/files/trash/empty' },
{ n: 'addLink', r: () => filesApi.addLink(1, 3, { place_id: 5 }), e: 'POST /api/trips/1/files/3/link' },
{ n: 'removeLink', r: () => filesApi.removeLink(1, 3, 7), e: 'DELETE /api/trips/1/files/3/link/7' },
{ n: 'getLinks', r: () => filesApi.getLinks(1, 3), e: 'GET /api/trips/1/files/3/links' },
])
})
it('FE-APISURF-019: reservationsApi and accommodationsApi map booking endpoints', async () => {
await assertCalls([
{ n: 'reservations.list', r: () => reservationsApi.list(1), e: 'GET /api/trips/1/reservations' },
{ n: 'reservations.upcoming', r: () => reservationsApi.upcoming(), e: 'GET /api/reservations/upcoming' },
{ n: 'reservations.create', r: () => reservationsApi.create(1, { title: 'Hotel' }), e: 'POST /api/trips/1/reservations' },
{ n: 'reservations.update', r: () => reservationsApi.update(1, 2, { title: 'Hostel' }), e: 'PUT /api/trips/1/reservations/2' },
{ n: 'reservations.delete', r: () => reservationsApi.delete(1, 2), e: 'DELETE /api/trips/1/reservations/2' },
{ n: 'reservations.setTravelers', r: () => reservationsApi.setTravelers(1, 2, [4]), e: 'PUT /api/trips/1/reservations/2/travelers' },
{ n: 'reservations.updatePositions', r: () => reservationsApi.updatePositions(1, [{ id: 2, day_plan_position: 0 }], 3), e: 'PUT /api/trips/1/reservations/positions' },
{ n: 'reservations.importBookingConfirm', r: () => reservationsApi.importBookingConfirm(1, []), e: 'POST /api/trips/1/reservations/import/booking/confirm' },
{ n: 'reservations.importJobStatus', r: () => reservationsApi.importJobStatus(1, 'job-1'), e: 'GET /api/trips/1/reservations/import/jobs/job-1' },
{ n: 'accommodations.list', r: () => accommodationsApi.list(1), e: 'GET /api/trips/1/accommodations' },
{ n: 'accommodations.create', r: () => accommodationsApi.create(1, { place_id: 5, start_day_id: 1, end_day_id: 2 }), e: 'POST /api/trips/1/accommodations' },
{ n: 'accommodations.update', r: () => accommodationsApi.update(1, 4, { end_day_id: 3 }), e: 'PUT /api/trips/1/accommodations/4' },
{ n: 'accommodations.delete', r: () => accommodationsApi.delete(1, 4), e: 'DELETE /api/trips/1/accommodations/4' },
])
})
it('FE-APISURF-020: collabApi maps note, poll and message endpoints', async () => {
await assertCalls([
{ n: 'getNotes', r: () => collabApi.getNotes(1), e: 'GET /api/trips/1/collab/notes' },
{ n: 'createNote', r: () => collabApi.createNote(1, { title: 'Ideas' }), e: 'POST /api/trips/1/collab/notes' },
{ n: 'updateNote', r: () => collabApi.updateNote(1, 2, { title: 'More' }), e: 'PUT /api/trips/1/collab/notes/2' },
{ n: 'deleteNote', r: () => collabApi.deleteNote(1, 2), e: 'DELETE /api/trips/1/collab/notes/2' },
{ n: 'deleteNoteFile', r: () => collabApi.deleteNoteFile(1, 2, 3), e: 'DELETE /api/trips/1/collab/notes/2/files/3' },
{ n: 'getPolls', r: () => collabApi.getPolls(1), e: 'GET /api/trips/1/collab/polls' },
{ n: 'createPoll', r: () => collabApi.createPoll(1, { question: 'Where?', options: ['A', 'B'] }), e: 'POST /api/trips/1/collab/polls' },
{ n: 'votePoll', r: () => collabApi.votePoll(1, 2, 1), e: 'POST /api/trips/1/collab/polls/2/vote' },
{ n: 'closePoll', r: () => collabApi.closePoll(1, 2), e: 'PUT /api/trips/1/collab/polls/2/close' },
{ n: 'deletePoll', r: () => collabApi.deletePoll(1, 2), e: 'DELETE /api/trips/1/collab/polls/2' },
{ n: 'getMessages', r: () => collabApi.getMessages(1), e: 'GET /api/trips/1/collab/messages' },
{ n: 'sendMessage', r: () => collabApi.sendMessage(1, { text: 'hi' }), e: 'POST /api/trips/1/collab/messages' },
{ n: 'deleteMessage', r: () => collabApi.deleteMessage(1, 2), e: 'DELETE /api/trips/1/collab/messages/2' },
{ n: 'reactMessage', r: () => collabApi.reactMessage(1, 2, '👍'), e: 'POST /api/trips/1/collab/messages/2/react' },
{ n: 'linkPreview', r: () => collabApi.linkPreview(1, 'https://x.test/a?b=1'), e: 'GET /api/trips/1/collab/link-preview' },
])
})
it('FE-APISURF-021: the remaining namespaces map their endpoints', async () => {
await assertCalls([
{ n: 'addons.enabled', r: () => addonsApi.enabled(), e: 'GET /api/addons' },
{ n: 'health.features', r: () => healthApi.features(), e: 'GET /api/health/features' },
{ n: 'weather.get', r: () => weatherApi.get(41.9, 12.5, '2026-06-01'), e: 'GET /api/weather' },
{ n: 'weather.getCurrent', r: () => weatherApi.getCurrent(41.9, 12.5), e: 'GET /api/weather' },
{ n: 'weather.getDetailed', r: () => weatherApi.getDetailed(41.9, 12.5, '2026-06-01'), e: 'GET /api/weather/detailed' },
{ n: 'config.getPublicConfig', r: () => configApi.getPublicConfig(), e: 'GET /api/config' },
{ n: 'help.index', r: () => helpApi.index(), e: 'GET /api/help/index' },
{ n: 'help.page', r: () => helpApi.page('getting started'), e: 'GET /api/help/page/getting%20started' },
{ n: 'settings.get', r: () => settingsApi.get(), e: 'GET /api/settings' },
{ n: 'settings.set', r: () => settingsApi.set('theme', 'dark'), e: 'PUT /api/settings' },
{ n: 'settings.setBulk', r: () => settingsApi.setBulk({ theme: 'dark' }), e: 'POST /api/settings/bulk' },
{ n: 'backup.list', r: () => backupApi.list(), e: 'GET /api/backup/list' },
{ n: 'backup.create', r: () => backupApi.create(), e: 'POST /api/backup/create' },
{ n: 'backup.delete', r: () => backupApi.delete('b.zip'), e: 'DELETE /api/backup/b.zip' },
{ n: 'backup.restore', r: () => backupApi.restore('b.zip'), e: 'POST /api/backup/restore/b.zip' },
{ n: 'backup.getAutoSettings', r: () => backupApi.getAutoSettings(), e: 'GET /api/backup/auto-settings' },
{ n: 'backup.setAutoSettings', r: () => backupApi.setAutoSettings({ enabled: true }), e: 'PUT /api/backup/auto-settings' },
{ n: 'share.getLink', r: () => shareApi.getLink(1), e: 'GET /api/trips/1/share-link' },
{ n: 'share.createLink', r: () => shareApi.createLink(1, { edit: false }), e: 'POST /api/trips/1/share-link' },
{ n: 'share.deleteLink', r: () => shareApi.deleteLink(1), e: 'DELETE /api/trips/1/share-link' },
{ n: 'share.getSharedTrip', r: () => shareApi.getSharedTrip('tok'), e: 'GET /api/shared/tok' },
{ n: 'transit.geocode', r: () => transitApi.geocode('Roma Termini'), e: 'GET /api/transit/geocode' },
{ n: 'transit.plan', r: () => transitApi.plan({ from: 'a', to: 'b' }), e: 'GET /api/transit/plan' },
{ n: 'tripInvite.getLink', r: () => tripInviteApi.getLink(1), e: 'GET /api/trips/1/invite-link' },
{ n: 'tripInvite.createLink', r: () => tripInviteApi.createLink(1, 7), e: 'POST /api/trips/1/invite-link' },
{ n: 'tripInvite.deleteLink', r: () => tripInviteApi.deleteLink(1), e: 'DELETE /api/trips/1/invite-link' },
{ n: 'tripInvite.preview', r: () => tripInviteApi.preview('tok'), e: 'GET /api/trip-invites/tok' },
{ n: 'tripInvite.accept', r: () => tripInviteApi.accept('tok'), e: 'POST /api/trip-invites/tok/accept' },
{ n: 'notifications.getPreferences', r: () => notificationsApi.getPreferences(), e: 'GET /api/notifications/preferences' },
{ n: 'notifications.updatePreferences', r: () => notificationsApi.updatePreferences({ email: { trip_invite: true } }), e: 'PUT /api/notifications/preferences' },
{ n: 'notifications.testSmtp', r: () => notificationsApi.testSmtp('a@b.c'), e: 'POST /api/notifications/test-smtp' },
{ n: 'notifications.testWebhook', r: () => notificationsApi.testWebhook('https://hook'), e: 'POST /api/notifications/test-webhook' },
{ n: 'notifications.testNtfy', r: () => notificationsApi.testNtfy({ topic: 't' }), e: 'POST /api/notifications/test-ntfy' },
{ n: 'notifications.testChannel', r: () => notificationsApi.testChannel('plugin/ch'), e: 'POST /api/notifications/test/plugin%2Fch' },
{ n: 'inApp.list', r: () => inAppNotificationsApi.list(), e: 'GET /api/notifications/in-app' },
{ n: 'inApp.unreadCount', r: () => inAppNotificationsApi.unreadCount(), e: 'GET /api/notifications/in-app/unread-count' },
{ n: 'inApp.markRead', r: () => inAppNotificationsApi.markRead(3), e: 'PUT /api/notifications/in-app/3/read' },
{ n: 'inApp.markUnread', r: () => inAppNotificationsApi.markUnread(3), e: 'PUT /api/notifications/in-app/3/unread' },
{ n: 'inApp.markAllRead', r: () => inAppNotificationsApi.markAllRead(), e: 'PUT /api/notifications/in-app/read-all' },
{ n: 'inApp.delete', r: () => inAppNotificationsApi.delete(3), e: 'DELETE /api/notifications/in-app/3' },
{ n: 'inApp.deleteAll', r: () => inAppNotificationsApi.deleteAll(), e: 'DELETE /api/notifications/in-app/all' },
{ n: 'inApp.respond', r: () => inAppNotificationsApi.respond(3, 'positive'), e: 'POST /api/notifications/in-app/3/respond' },
])
})
})
describe('client > request payloads', () => {
it('FE-APISURF-022: reorder helpers wrap their ids in the contract field', async () => {
expect((await traceOne(() => daysApi.reorder(1, [3, 1, 2]))).body).toEqual({ orderedIds: [3, 1, 2] })
expect((await traceOne(() => packingApi.reorder(1, [2, 1]))).body).toEqual({ orderedIds: [2, 1] })
expect((await traceOne(() => todoApi.reorder(1, [9]))).body).toEqual({ orderedIds: [9] })
expect((await traceOne(() => budgetApi.reorderItems(1, [4, 5]))).body).toEqual({ orderedIds: [4, 5] })
expect((await traceOne(() => budgetApi.reorderCategories(1, ['Food', 'Fun']))).body)
.toEqual({ orderedCategories: ['Food', 'Fun'] })
expect((await traceOne(() => journeyApi.reorderEntries(2, [8, 7]))).body).toEqual({ orderedIds: [8, 7] })
})
it('FE-APISURF-023: user-id collections are sent as user_ids', async () => {
expect((await traceOne(() => assignmentsApi.setParticipants(1, 7, [4, 5]))).body).toEqual({ user_ids: [4, 5] })
expect((await traceOne(() => budgetApi.setMembers(1, 2, [4]))).body).toEqual({ user_ids: [4] })
expect((await traceOne(() => packingApi.setBagMembers(1, 2, [6]))).body).toEqual({ user_ids: [6] })
expect((await traceOne(() => reservationsApi.setTravelers(1, 2, [4, 6]))).body).toEqual({ user_ids: [4, 6] })
})
it('FE-APISURF-024: single-value helpers wrap their argument in the documented key', async () => {
expect((await traceOne(() => authApi.updateMapsKey(null))).body).toEqual({ maps_api_key: null })
expect((await traceOne(() => tripsApi.addMember(1, 'bob@x.test'))).body).toEqual({ identifier: 'bob@x.test' })
expect((await traceOne(() => tripsApi.transferOwnership(1, 9))).body).toEqual({ newOwnerId: 9 })
expect((await traceOne(() => tripsApi.createGuest(1, 'Anna'))).body).toEqual({ name: 'Anna' })
expect((await traceOne(() => daysApi.updateTransport(1, 2, 'walk'))).body).toEqual({ transport_mode: 'walk' })
expect((await traceOne(() => assignmentsApi.updateTransport(1, 7, null))).body).toEqual({ transport_mode: null })
expect((await traceOne(() => collabApi.votePoll(1, 2, 3))).body).toEqual({ option_index: 3 })
expect((await traceOne(() => collabApi.reactMessage(1, 2, '🎉'))).body).toEqual({ emoji: '🎉' })
expect((await traceOne(() => settingsApi.set('theme', 'dark'))).body).toEqual({ key: 'theme', value: 'dark' })
expect((await traceOne(() => settingsApi.setBulk({ a: 1 }))).body).toEqual({ settings: { a: 1 } })
expect((await traceOne(() => budgetApi.togglePaid(1, 2, 4, false))).body).toEqual({ paid: false })
expect((await traceOne(() => adminApi.updateBagTracking(true))).body).toEqual({ enabled: true })
expect((await traceOne(() => adminApi.updatePermissions({ edit: 'owner' }))).body)
.toEqual({ permissions: { edit: 'owner' } })
expect((await traceOne(() => pluginsApi.saveUserSettings('koffi', { k: 'v' }))).body)
.toEqual({ config: { k: 'v' } })
})
it('FE-APISURF-025: tripsApi.archive/unarchive send the is_archived flag', async () => {
expect((await traceOne(() => tripsApi.archive(3))).body).toEqual({ is_archived: true })
expect((await traceOne(() => tripsApi.unarchive(3))).body).toEqual({ is_archived: false })
})
it('FE-APISURF-026: placesApi bulk operations merge ids with the patch', async () => {
expect((await traceOne(() => placesApi.bulkDelete(1, [5, 6]))).body).toEqual({ ids: [5, 6] })
expect((await traceOne(() => placesApi.bulkUpdate(1, [5], { category_id: null }))).body)
.toEqual({ ids: [5], category_id: null })
})
it('FE-APISURF-027: placesApi.rate deletes on null and PUTs the value otherwise', async () => {
const cleared = await traceOne(() => placesApi.rate(1, 5, null))
expect(cleared.method).toBe('DELETE')
expect(cleared.url).toBe('/api/trips/1/places/5/rating')
const set = await traceOne(() => placesApi.rate(1, 5, 4))
expect(set.method).toBe('PUT')
expect(set.url).toBe('/api/trips/1/places/5/rating')
expect(set.body).toEqual({ rating: 4 })
})
it('FE-APISURF-028: airtrailApi.import only sends connections when there are any', async () => {
expect((await traceOne(() => airtrailApi.import(1, ['f1', 'f2']))).body).toEqual({ flightIds: ['f1', 'f2'] })
expect((await traceOne(() => airtrailApi.import(1, ['f1'], []))).body).toEqual({ flightIds: ['f1'] })
expect((await traceOne(() => airtrailApi.import(1, ['f1', 'f2'], [['f1', 'f2']]))).body)
.toEqual({ flightIds: ['f1', 'f2'], connections: [['f1', 'f2']] })
})
it('FE-APISURF-029: journeyApi provider-photo calls omit optional passphrase and media types', async () => {
expect((await traceOne(() => journeyApi.addProviderPhotosToGallery(2, 'immich', ['a1']))).body)
.toEqual({ provider: 'immich', asset_ids: ['a1'] })
expect((await traceOne(() => journeyApi.addProviderPhotosToGallery(2, 'immich', ['a1'], 'secret', ['video']))).body)
.toEqual({ provider: 'immich', asset_ids: ['a1'], passphrase: 'secret', media_types: ['video'] })
expect((await traceOne(() => journeyApi.addProviderPhoto(9, 'immich', 'a1', 'cap', 'secret'))).body)
.toEqual({ provider: 'immich', asset_id: 'a1', caption: 'cap', passphrase: 'secret' })
expect((await traceOne(() => journeyApi.addProviderPhotos(9, 'immich', ['a1'], 'cap'))).body)
.toEqual({ provider: 'immich', asset_ids: ['a1'], caption: 'cap' })
expect((await traceOne(() => journeyApi.addProviderPhotos(9, 'immich', ['a1'], 'cap', 'secret', ['image', 'video']))).body)
.toEqual({ provider: 'immich', asset_ids: ['a1'], caption: 'cap', passphrase: 'secret', media_types: ['image', 'video'] })
})
it('FE-APISURF-030: adminApi.pluginActivate only sends consent when granted', async () => {
expect((await traceOne(() => adminApi.pluginActivate('koffi'))).body).toEqual({})
expect((await traceOne(() => adminApi.pluginActivate('koffi', true))).body).toEqual({ consent: true })
})
it('FE-APISURF-031: adminApi.pluginInstall spreads its options next to the id', async () => {
expect((await traceOne(() => adminApi.pluginInstall('koffi'))).body).toEqual({ id: 'koffi' })
expect((await traceOne(() => adminApi.pluginInstall('koffi', { version: '2.0.0', withDependencies: true }))).body)
.toEqual({ id: 'koffi', version: '2.0.0', withDependencies: true })
})
it('FE-APISURF-032: tripInviteApi.createLink normalises a missing expiry to null', async () => {
expect((await traceOne(() => tripInviteApi.createLink(1))).body).toEqual({ expires_in_days: null })
expect((await traceOne(() => tripInviteApi.createLink(1, 14))).body).toEqual({ expires_in_days: 14 })
})
it('FE-APISURF-033: tripsApi.copy and shareApi.createLink default to an empty body', async () => {
expect((await traceOne(() => tripsApi.copy(3))).body).toEqual({})
expect((await traceOne(() => shareApi.createLink(1))).body).toEqual({})
})
it('FE-APISURF-034: authApi.passkey.delete sends the password in the DELETE body', async () => {
const rec = await traceOne(() => authApi.passkey.delete(3, 'hunter2'))
expect(rec.method).toBe('DELETE')
expect(rec.body).toEqual({ password: 'hunter2' })
})
})
describe('client > query parameters', () => {
it('FE-APISURF-035: tripsApi.list forwards arbitrary filters as query params', async () => {
const rec = await traceOne(() => tripsApi.list({ archived: true, q: 'rome' }))
const qs = new URLSearchParams(rec.url.split('?')[1])
expect(qs.get('archived')).toBe('true')
expect(qs.get('q')).toBe('rome')
})
it('FE-APISURF-036: filesApi.list only sets the trash flag when asked', async () => {
expect((await traceOne(() => filesApi.list(1))).url).toBe('/api/trips/1/files')
expect((await traceOne(() => filesApi.list(1, true))).url).toBe('/api/trips/1/files?trash=true')
})
it('FE-APISURF-037: budgetApi.settlement adds the base currency only when given', async () => {
expect((await traceOne(() => budgetApi.settlement(1))).url).toBe('/api/trips/1/budget/settlement')
expect((await traceOne(() => budgetApi.settlement(1, 'EUR'))).url).toBe('/api/trips/1/budget/settlement?base=EUR')
})
it('FE-APISURF-038: collabApi.getMessages appends the before cursor', async () => {
expect((await traceOne(() => collabApi.getMessages(1))).url).toBe('/api/trips/1/collab/messages')
expect((await traceOne(() => collabApi.getMessages(1, '2026-01-01'))).url)
.toBe('/api/trips/1/collab/messages?before=2026-01-01')
})
it('FE-APISURF-039: adminApi.pluginBrowse only sets refresh when forced', async () => {
expect((await traceOne(() => adminApi.pluginBrowse())).url).toBe('/api/admin/plugins/registry')
expect((await traceOne(() => adminApi.pluginBrowse(true))).url).toBe('/api/admin/plugins/registry?refresh=1')
})
it('FE-APISURF-040: adminApi.auditLog and llmLocalModels pass their params through', async () => {
const audit = await traceOne(() => adminApi.auditLog({ limit: 50, offset: 100 }))
expect(new URLSearchParams(audit.url.split('?')[1]).get('limit')).toBe('50')
expect(new URLSearchParams(audit.url.split('?')[1]).get('offset')).toBe('100')
const models = await traceOne(() => adminApi.llmLocalModels('http://ollama:11434'))
expect(new URLSearchParams(models.url.split('?')[1]).get('baseUrl')).toBe('http://ollama:11434')
})
it('FE-APISURF-041: mapsApi flattens the POI bbox into the query string', async () => {
const rec = await traceOne(() => mapsApi.pois('cafe', { south: 41.8, west: 12.4, north: 42.0, east: 12.6 }, 'de'))
const qs = new URLSearchParams(rec.url.split('?')[1])
expect(qs.get('category')).toBe('cafe')
expect(qs.get('south')).toBe('41.8')
expect(qs.get('west')).toBe('12.4')
expect(qs.get('north')).toBe('42')
expect(qs.get('east')).toBe('12.6')
expect(qs.get('lang')).toBe('de')
})
it('FE-APISURF-042: weatherApi sends lat/lng plus the date or language', async () => {
const forecast = await traceOne(() => weatherApi.get(41.9, 12.5, '2026-06-01'))
const fq = new URLSearchParams(forecast.url.split('?')[1])
expect([fq.get('lat'), fq.get('lng'), fq.get('date')]).toEqual(['41.9', '12.5', '2026-06-01'])
const current = await traceOne(() => weatherApi.getCurrent(41.9, 12.5, 'de'))
expect(new URLSearchParams(current.url.split('?')[1]).get('lang')).toBe('de')
})
it('FE-APISURF-043: pluginsApi joins trip ids and defaults the activity limit', async () => {
expect((await traceOne(() => pluginsApi.tripCardContributions([1, 2, 3]))).url)
.toBe('/api/trip-card-contributions?tripIds=1,2,3')
expect((await traceOne(() => pluginsApi.myActivity())).url).toBe('/api/plugin-activity?limit=200')
expect((await traceOne(() => pluginsApi.myActivity(5))).url).toBe('/api/plugin-activity?limit=5')
})
it('FE-APISURF-044: packing/todo category assignees encode the category name', async () => {
const packing = await traceOne(() => packingApi.setCategoryAssignees(1, 'Rain gear/Wet', [4]))
expect(packing.url).toBe('/api/trips/1/packing/category-assignees/Rain%20gear%2FWet')
expect(packing.body).toEqual({ user_ids: [4] })
const todo = await traceOne(() => todoApi.setCategoryAssignees(1, 'Before & after', [5]))
expect(todo.url).toBe('/api/trips/1/todo/category-assignees/Before%20%26%20after')
expect(todo.body).toEqual({ user_ids: [5] })
})
it('FE-APISURF-045: collabApi.linkPreview URL-encodes the previewed link', async () => {
const rec = await traceOne(() => collabApi.linkPreview(1, 'https://x.test/a?b=1&c=2'))
expect(rec.url).toBe('/api/trips/1/collab/link-preview?url=https%3A%2F%2Fx.test%2Fa%3Fb%3D1%26c%3D2')
})
})
describe('client > multipart uploads', () => {
// jsdom FormData bodies deadlock inside MSW, so uploads are asserted at the
// axios boundary instead (same approach as tests/integration/api/client.test.ts).
function spyPost() {
return vi.spyOn(apiClient, 'post')
.mockResolvedValue({ data: { ok: true } } as unknown as AxiosResponse)
}
it('FE-APISURF-046: every upload opts out of the 8s global timeout', async () => {
const post = spyPost()
const fd = new FormData()
await authApi.uploadAvatar(fd)
await tripsApi.uploadCover(3, fd)
await filesApi.upload(1, fd)
await journeyApi.uploadPhotos(9, fd)
await journeyApi.uploadGalleryPhotos(2, fd)
await journeyApi.uploadGalleryVideo(2, fd)
await journeyApi.uploadCover(2, fd)
await collabApi.uploadNoteFile(1, 2, fd)
expect(post.mock.calls.map(c => c[0])).toEqual([
'/auth/avatar',
'/trips/3/cover',
'/trips/1/files',
'/journeys/entries/9/photos',
'/journeys/2/gallery/photos',
'/journeys/2/gallery/video',
'/journeys/2/cover',
'/trips/1/collab/notes/2/files',
])
for (const call of post.mock.calls) {
expect(call[1]).toBeInstanceOf(FormData)
expect(call[2]).toMatchObject({ timeout: 0 })
expect((call[2] as { headers: Record<string, string> }).headers['Content-Type']).toBe('multipart/form-data')
}
})
it('FE-APISURF-047: postMultipart forwards progress, abort signal and idempotency key', async () => {
const post = spyPost()
const onUploadProgress = vi.fn((_e: unknown) => {})
const controller = new AbortController()
await filesApi.upload(1, new FormData(), {
onUploadProgress,
signal: controller.signal,
idempotencyKey: 'fixed-key',
})
const config = post.mock.calls[0][2] as {
headers: Record<string, string>
onUploadProgress?: unknown
signal?: AbortSignal
timeout: number
}
expect(config.headers['X-Idempotency-Key']).toBe('fixed-key')
expect(config.onUploadProgress).toBe(onUploadProgress)
expect(config.signal).toBe(controller.signal)
expect(config.timeout).toBe(0)
})
it('FE-APISURF-048: placesApi.uploadImage posts the file under the image field', async () => {
const post = spyPost()
const file = new File(['bytes'], 'shot.jpg', { type: 'image/jpeg' })
await placesApi.uploadImage(1, 5, file)
expect(post.mock.calls[0][0]).toBe('/trips/1/places/5/image')
const fd = post.mock.calls[0][1] as FormData
expect((fd.get('image') as File).name).toBe('shot.jpg')
})
it('FE-APISURF-049: placesApi.importGpx only appends the flags it was given', async () => {
const post = spyPost()
const file = new File(['<gpx/>'], 'track.gpx')
await placesApi.importGpx(1, file)
expect(post.mock.calls[0][0]).toBe('/trips/1/places/import/gpx')
const bare = post.mock.calls[0][1] as FormData
expect(bare.get('importWaypoints')).toBeNull()
expect(bare.get('importRoutes')).toBeNull()
expect(bare.get('importTracks')).toBeNull()
await placesApi.importGpx(1, file, { waypoints: true, routes: false, tracks: true })
const flagged = post.mock.calls[1][1] as FormData
expect(flagged.get('importWaypoints')).toBe('true')
expect(flagged.get('importRoutes')).toBe('false')
expect(flagged.get('importTracks')).toBe('true')
})
it('FE-APISURF-050: placesApi.importMapFile appends the point/path flags', async () => {
const post = spyPost()
const file = new File(['{}'], 'map.kml')
await placesApi.importMapFile(1, file)
expect(post.mock.calls[0][0]).toBe('/trips/1/places/import/map')
expect((post.mock.calls[0][1] as FormData).get('importPoints')).toBeNull()
await placesApi.importMapFile(1, file, { points: true, paths: false })
const flagged = post.mock.calls[1][1] as FormData
expect(flagged.get('importPoints')).toBe('true')
expect(flagged.get('importPaths')).toBe('false')
})
it('FE-APISURF-051: booking import posts every file plus the extraction mode', async () => {
const post = spyPost()
const files = [new File(['a'], 'a.pdf'), new File(['b'], 'b.pdf')]
await reservationsApi.importBookingPreview(1, files, 'force-ai')
expect(post.mock.calls[0][0]).toBe('/trips/1/reservations/import/booking')
const preview = post.mock.calls[0][1] as FormData
expect(preview.getAll('files')).toHaveLength(2)
expect(preview.get('mode')).toBe('force-ai')
await reservationsApi.importBookingAsync(1, files)
expect(post.mock.calls[1][0]).toBe('/trips/1/reservations/import/booking/async')
expect((post.mock.calls[1][1] as FormData).get('mode')).toBe('no-ai')
})
it('FE-APISURF-052: adminApi.pluginUpload and backupApi.uploadRestore name their form fields', async () => {
const post = spyPost()
await adminApi.pluginUpload(new File(['zip'], 'plugin.zip'))
expect(post.mock.calls[0][0]).toBe('/admin/plugins/upload')
expect(((post.mock.calls[0][1] as FormData).get('file') as File).name).toBe('plugin.zip')
await backupApi.uploadRestore(new File(['zip'], 'backup.zip'))
expect(post.mock.calls[1][0]).toBe('/backup/upload-restore')
expect(((post.mock.calls[1][1] as FormData).get('backup') as File).name).toBe('backup.zip')
})
})
+59 -526
View File
@@ -1,6 +1,5 @@
import axios, { AxiosInstance } from 'axios'
import type { z } from 'zod'
import type { Place } from '../types'
import {
weatherResultSchema, type WeatherResult,
inAppListResultSchema, type InAppListResult,
@@ -8,33 +7,29 @@ import {
channelTestResultSchema,
mapsSearchResultSchema, mapsAutocompleteResultSchema, mapsPlaceDetailsResultSchema,
mapsPlacePhotoResultSchema, mapsReverseResultSchema, mapsResolveUrlResultSchema,
mapsPlaceEnrichmentResultSchema,
type NotificationRespondRequest,
type SettingUpsertRequest, type SettingsBulkRequest,
type JourneyCreateRequest, type JourneyAddTripRequest, type JourneyTracksResponse,
type JourneyStats, type BookRecord, type BookSaveRequest,
type JourneyCreateRequest, type JourneyAddTripRequest,
type JourneyReorderEntriesRequest, type JourneyProviderPhotosRequest,
type JourneyShareLinkRequest,
type RegisterRequest, type LoginRequest, type ForgotPasswordRequest,
type ResetPasswordRequest, type ChangePasswordRequest,
type MfaVerifyLoginRequest, type MfaEnableRequest, type McpTokenCreateRequest,
type TripAddMemberRequest, type TripTransferOwnershipRequest,
type TripCreateGuestRequest, type TripRenameGuestRequest, type AssignmentReorderRequest,
type TripAddMemberRequest, type AssignmentReorderRequest,
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest, type ActiveTripResponse,
type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest,
type DayCreateRequest, type DayUpdateRequest, type DayReorderRequest,
type PlaceCreateRequest, type PlaceUpdateRequest,
type ReservationCreateRequest, type ReservationUpdateRequest,
type AccommodationCreateRequest, type AccommodationUpdateRequest,
type BudgetCreateItemRequest, type BudgetUpdateItemRequest,
type PackingCreateItemRequest, type PackingUpdateItemRequest, type PackingSetSharingRequest,
type PackingCreateItemRequest, type PackingUpdateItemRequest,
type TodoCreateItemRequest, type TodoUpdateItemRequest,
type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest, type AssignmentTransportRequest,
type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest,
type PlaceBulkDeleteRequest,
type PlaceBulkUpdateRequest,
type DayNoteCreateRequest, type DayNoteUpdateRequest,
type PackingImportRequest, type PackingBagMembersRequest, type PackingUpdateBagRequest,
type PackingCategoryAssigneesRequest, type PackingApplyTemplateRequest,
type PackingCategoryAssigneesRequest,
type BudgetUpdateMembersRequest, type BudgetToggleMemberPaidRequest, type BudgetReorderCategoriesRequest,
type TodoCategoryAssigneesRequest,
type CollabNoteCreateRequest, type CollabNoteUpdateRequest, type CollabPollCreateRequest,
@@ -46,15 +41,9 @@ import {
type BookingImportPreviewItem,
type BookingImportPreviewResponse,
type BookingImportConfirmResponse,
type BookingImportMode,
type StorageAdminState,
type StorageBackend,
type StorageConfigPut,
type StorageTestResponse,
type StorageUsage,
} from '@trek/shared'
import { getSocketId } from './websocket'
import { probeNow } from '../sync/connectivity'
import { isReachable, probeNow } from '../sync/connectivity'
/**
* Validate a response payload against its @trek/shared Zod schema — but only in
@@ -111,10 +100,6 @@ const RATE_LIMIT_MESSAGES: Record<string, string> = {
ja: '試行回数が多すぎます。時間をおいて再度お試しください。',
ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
uk: 'Занадто багато спроб. Спробуйте пізніше.',
sv: 'För många försök. Prova igen senare.',
ca: 'Massa intents. Torneu-ho a provar més tard.',
gr: 'Πάρα πολλές προσπάθειες. Δοκιμάστε ξανά αργότερα.',
vi: 'Quá nhiều lần thử. Vui lòng thử lại sau.',
}
function translateRateLimit(): string {
@@ -189,17 +174,13 @@ apiClient.interceptors.response.use(
// distinguish a proxy auth challenge from a genuine outage. If the server
// is reachable, a top-level reload lets the edge proxy run its auth flow.
if (!error.response && navigator.onLine) {
// Only an actual edge-proxy auth wall warrants tearing down the SW to
// reauth: a reachable proxy (CF Access / Pangolin) that intercepts /api
// with a cross-origin redirect or an HTML login page. A genuine offline
// boot ALSO lands here — navigator.onLine reflects a network interface,
// not reachability, and is routinely true on mobile while offline. So
// gate strictly on a positive proxy signal; on plain offline do nothing
// and let the request reject so the cached shell + IndexedDB serve the
// app. Unregistering the SW here reloaded into a dead network and broke
// PWA offline mode (#1346).
const state = await probeNow()
if (state === 'proxy-wall') {
await probeNow()
// Both the original request and the health probe failed while the device
// has a network interface. This matches the proxy-auth-challenge pattern
// (CF Access / Pangolin intercept all requests and CORS-block XHR).
// Guard with sessionStorage to prevent reload loops (server genuinely
// down would also land here, but only reloads once).
if (!isReachable()) {
const { pathname } = window.location
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
sessionStorage.setItem('proxy_reauth_attempted', '1')
@@ -238,11 +219,9 @@ apiClient.interceptors.response.use(
}
if (error.response?.status === 429) {
const translated = translateRateLimit()
const data = error.response.data
// Only a plain object body carries an `error` field worth overwriting;
// an array (a validation-error list) or a string is replaced outright.
if (data && typeof data === 'object' && !Array.isArray(data)) {
(data as { error?: string }).error = translated
const data = error.response.data as { error?: string } | undefined
if (data && typeof data === 'object') {
data.error = translated
} else {
error.response.data = { error: translated }
}
@@ -252,40 +231,6 @@ apiClient.interceptors.response.use(
}
)
/**
* POST a FormData body — the ONLY way this client should upload a file.
*
* The shared axios instance carries `timeout: 8000`, and axios' timeout is a whole-
* request deadline rather than an idle one. A file upload that takes longer than 8s to
* push its body — a phone photo on a slow uplink, a 500 MB document — is aborted
* mid-stream, which the server reports as a multer "Request aborted" (#1495).
*
* Every upload therefore has to opt out with `timeout: 0`. That opt-out used to be
* hand-written per call site, so it was forgotten on 7 of 15 — including the two 500 MB
* endpoints (documents, backup restore). Centralizing makes the correct behavior the
* default instead of something you have to remember.
*
* The Content-Type is set for clarity only: axios unsets it for FormData in the browser
* so the platform can generate the multipart boundary.
*/
export interface UploadOptions {
onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void
idempotencyKey?: string
signal?: AbortSignal
}
export function postMultipart<T = any>(url: string, formData: FormData, opts?: UploadOptions): Promise<T> {
return apiClient.post(url, formData, {
headers: {
'Content-Type': 'multipart/form-data',
...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}),
},
timeout: 0,
onUploadProgress: opts?.onUploadProgress,
signal: opts?.signal,
}).then(r => r.data as T)
}
export const authApi = {
register: (data: RegisterRequest) => apiClient.post('/auth/register', data).then(r => r.data),
validateInvite: (token: string) => apiClient.get(`/auth/invite/${token}`).then(r => r.data),
@@ -300,7 +245,7 @@ export const authApi = {
updateSettings: (data: Record<string, unknown>) => apiClient.put('/auth/me/settings', data).then(r => r.data),
getSettings: () => apiClient.get('/auth/me/settings').then(r => r.data),
listUsers: () => apiClient.get('/auth/users').then(r => r.data),
uploadAvatar: (formData: FormData) => postMultipart('/auth/avatar', formData),
uploadAvatar: (formData: FormData) => apiClient.post('/auth/avatar', formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
deleteAvatar: () => apiClient.delete('/auth/avatar').then(r => r.data),
getAppConfig: () => apiClient.get('/auth/app-config').then(r => r.data),
updateAppSettings: (data: Record<string, unknown>) => apiClient.put('/auth/app-settings', data).then(r => r.data),
@@ -379,22 +324,14 @@ export const tripsApi = {
list: (params?: Record<string, unknown>) => apiClient.get('/trips', { params }).then(r => r.data),
create: (data: TripCreateRequest) => apiClient.post('/trips', data).then(r => r.data),
get: (id: number | string) => apiClient.get(`/trips/${id}`).then(r => r.data),
// The startup redirect's one lookup — deliberately not list(), which would pull
// every trip with its counts just to read one id.
active: (): Promise<ActiveTripResponse> => apiClient.get('/trips/active').then(r => r.data),
update: (id: number | string, data: TripUpdateRequest) => apiClient.put(`/trips/${id}`, data).then(r => r.data),
delete: (id: number | string) => apiClient.delete(`/trips/${id}`).then(r => r.data),
uploadCover: (id: number | string, formData: FormData) => postMultipart(`/trips/${id}/cover`, formData),
searchCoverImages: (query: string) => apiClient.get('/trips/cover-images/search', { params: { query } }).then(r => r.data),
uploadCover: (id: number | string, formData: FormData) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
archive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data),
unarchive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data),
getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data),
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier } satisfies TripAddMemberRequest).then(r => r.data),
removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
transferOwnership: (id: number | string, newOwnerId: number) => apiClient.post(`/trips/${id}/transfer`, { newOwnerId } satisfies TripTransferOwnershipRequest).then(r => r.data),
createGuest: (id: number | string, name: string) => apiClient.post(`/trips/${id}/guests`, { name } satisfies TripCreateGuestRequest).then(r => r.data),
renameGuest: (id: number | string, userId: number, name: string) => apiClient.put(`/trips/${id}/guests/${userId}`, { name } satisfies TripRenameGuestRequest).then(r => r.data),
deleteGuest: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/guests/${userId}`).then(r => r.data),
copy: (id: number | string, data?: TripCopyRequest) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data),
bundle: (id: number | string) => apiClient.get(`/trips/${id}/bundle`).then(r => r.data),
}
@@ -403,8 +340,6 @@ export const daysApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data),
create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
// Whole-day default route mode (#1281); per-segment leg modes override it.
updateTransport: (tripId: number | string, dayId: number | string, mode: string | null) => apiClient.put(`/trips/${tripId}/days/${dayId}/transport`, { transport_mode: mode }).then(r => r.data),
delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/reorder`, { orderedIds } satisfies DayReorderRequest).then(r => r.data),
}
@@ -416,29 +351,20 @@ export const placesApi = {
update: (tripId: number | string, id: number | string, data: PlaceUpdateRequest) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
uploadImage: (tripId: number | string, id: number | string, file: File) => {
const fd = new FormData()
fd.append('image', file)
return postMultipart<{ place: Place }>(`/trips/${tripId}/places/${id}/image`, fd)
},
rate: (tripId: number | string, id: number | string, rating: number | null): Promise<{ place: Place }> =>
rating === null
? apiClient.delete(`/trips/${tripId}/places/${id}/rating`).then(r => r.data)
: apiClient.put(`/trips/${tripId}/places/${id}/rating`, { rating }).then(r => r.data),
importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean }) => {
const fd = new FormData()
fd.append('file', file)
if (opts?.waypoints !== undefined) fd.append('importWaypoints', String(opts.waypoints))
if (opts?.routes !== undefined) fd.append('importRoutes', String(opts.routes))
if (opts?.tracks !== undefined) fd.append('importTracks', String(opts.tracks))
return postMultipart(`/trips/${tripId}/places/import/gpx`, fd)
return apiClient.post(`/trips/${tripId}/places/import/gpx`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
},
importMapFile: (tripId: number | string, file: File, opts?: { points?: boolean; paths?: boolean }) => {
const fd = new FormData()
fd.append('file', file)
if (opts?.points !== undefined) fd.append('importPoints', String(opts.points))
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
return postMultipart(`/trips/${tripId}/places/import/map`, fd)
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
},
importGoogleList: (tripId: number | string, url: string, enrich?: boolean) =>
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
@@ -446,8 +372,6 @@ export const placesApi = {
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
bulkDelete: (tripId: number | string, ids: number[]) =>
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data),
bulkUpdate: (tripId: number | string, ids: number[], data: Omit<PlaceBulkUpdateRequest, 'ids'>) =>
apiClient.post(`/trips/${tripId}/places/bulk-update`, { ids, ...data } satisfies PlaceBulkUpdateRequest).then(r => r.data),
}
export const assignmentsApi = {
@@ -460,10 +384,6 @@ export const assignmentsApi = {
getParticipants: (tripId: number | string, id: number) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data),
setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds } satisfies AssignmentParticipantsRequest).then(r => r.data),
updateTime: (tripId: number | string, id: number, times: AssignmentTimeRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data),
// Per-segment travel mode (#1281): mode of the leg leaving this stop (null = inherit day default).
// direction defaults to 'outgoing' server-side, so only send it for the incoming (boundary-leg) case
// and keep the outgoing payload byte-for-byte identical to the pre-#1281 shape.
updateTransport: (tripId: number | string, id: number, mode: string | null, direction: 'outgoing' | 'incoming' = 'outgoing') => apiClient.put(`/trips/${tripId}/assignments/${id}/transport`, (direction === 'incoming' ? { transport_mode: mode, direction } : { transport_mode: mode }) satisfies Partial<AssignmentTransportRequest>).then(r => r.data),
}
export const packingApi = {
@@ -473,14 +393,10 @@ export const packingApi = {
update: (tripId: number | string, id: number, data: PackingUpdateItemRequest) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data),
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds } satisfies PackingReorderRequest).then(r => r.data),
setSharing: (tripId: number | string, id: number, data: PackingSetSharingRequest) => apiClient.put(`/trips/${tripId}/packing/${id}/sharing`, data).then(r => r.data),
clone: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/packing/${id}/clone`).then(r => r.data),
addContributor: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/packing/${id}/contributors`).then(r => r.data),
removeContributor: (tripId: number | string, id: number, userId: number) => apiClient.delete(`/trips/${tripId}/packing/${id}/contributors/${userId}`).then(r => r.data),
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data),
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies PackingCategoryAssigneesRequest).then(r => r.data),
listTemplates: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/templates`).then(r => r.data),
applyTemplate: (tripId: number | string, templateId: number, visibility: 'common' | 'personal' = 'common') => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`, { visibility } satisfies PackingApplyTemplateRequest).then(r => r.data),
applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data),
saveAsTemplate: (tripId: number | string, name: string) => apiClient.post(`/trips/${tripId}/packing/save-as-template`, { name }).then(r => r.data),
setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds } satisfies PackingBagMembersRequest).then(r => r.data),
listBags: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/bags`).then(r => r.data),
@@ -525,80 +441,6 @@ export const adminApi = {
updateOidc: (data: Record<string, unknown>) => apiClient.put('/admin/oidc', data).then(r => r.data),
addons: () => apiClient.get('/admin/addons').then(r => r.data),
updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
plugins: () => apiClient.get('/admin/plugins').then(r => r.data),
pluginBrowse: (refresh?: boolean) => apiClient.get('/admin/plugins/registry', { params: refresh ? { refresh: 1 } : undefined }).then(r => r.data),
pluginDetail: (id: string) => apiClient.get(`/admin/plugins/registry/${encodeURIComponent(id)}`).then(r => r.data),
pluginInstall: (id: string, opts?: { version?: string; constraint?: string; withDependencies?: boolean }) =>
apiClient.post('/admin/plugins/install', { id, ...opts }).then(r => r.data),
pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data),
pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data),
pluginUpdate: (id: string) => apiClient.post(`/admin/plugins/${id}/update`).then(r => r.data),
// Re-trust a ROTATED author signing key and update, in ONE call. `publicKey` is the
// full key the admin was shown (not a fingerprint): the server compares it exactly, so
// it can refuse if the registry entry was re-keyed again since the dialog rendered.
pluginRetrust: (id: string, version: string, publicKey: string) =>
apiClient.post(`/admin/plugins/${id}/retrust`, { version, publicKey }).then(r => r.data),
pluginUninstall: (id: string, deleteData: boolean) => apiClient.post(`/admin/plugins/${id}/uninstall`, { deleteData }).then(r => r.data),
pluginRescan: () => apiClient.post('/admin/plugins/rescan').then(r => r.data),
pluginUpload: (file: File) => { const fd = new FormData(); fd.append('file', file); return postMultipart('/admin/plugins/upload', fd) },
// Dev-link (dev-only): register a plugin from a local built dir + hot-reload it.
pluginLink: (path: string) => apiClient.post('/admin/plugins/link', { path }).then(r => r.data),
pluginReload: (id: string) => apiClient.post(`/admin/plugins/${id}/reload`).then(r => r.data),
// Operator-supplied egress hosts: a plugin talking to a SELF-HOSTED service can't name
// the operator's hostname in its manifest, so the admin adds it here. Saving re-spawns
// the plugin with the widened allow-list.
pluginEgressHosts: (id: string): Promise<{ supported: boolean; hosts: string[] }> =>
apiClient.get(`/admin/plugins/${id}/egress-hosts`).then(r => r.data),
pluginSetEgressHosts: (id: string, hosts: string[]): Promise<{ hosts: string[] }> =>
apiClient.put(`/admin/plugins/${id}/egress-hosts`, { hosts }).then(r => r.data),
pluginErrors: (id: string) => apiClient.get(`/admin/plugins/${id}/errors`).then(r => r.data),
pluginAudit: (id: string) => apiClient.get(`/admin/plugins/${id}/audit`).then(r => r.data),
// Local LLM (Ollama) management for the AI-parsing addon.
llmLocalModels: (baseUrl: string): Promise<{ models: { name: string; size: number }[] }> =>
apiClient.get('/admin/llm/local/models', { params: { baseUrl } }).then(r => r.data),
/** Pull a model, streaming Ollama's NDJSON progress to `onProgress`. */
llmLocalPull: async (
baseUrl: string,
model: string,
onProgress: (p: { status?: string; total?: number; completed?: number; error?: string }) => void,
): Promise<void> => {
const res = await fetch('/api/admin/llm/local/pull', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ baseUrl, model }),
})
if (!res.ok) {
let msg = `Pull failed (${res.status})`
try { msg = (await res.json())?.error ?? msg } catch { /* non-json */ }
throw new Error(msg)
}
// An accepted request without a stream can't be followed to completion.
if (!res.body) throw new Error('Pull returned no progress stream')
const reader = res.body.getReader()
const dec = new TextDecoder()
let buf = ''
try {
for (;;) {
const { done, value } = await reader.read()
if (done) break
buf += dec.decode(value, { stream: true })
// split() always yields at least one element; the last one is the
// trailing (possibly partial) line carried into the next chunk.
const lines = buf.split('\n')
buf = lines.pop()!
for (const line of lines) {
if (!line.trim()) continue
// Only the parse is swallowed — a throw from onProgress aborts the pull.
let frame: { status?: string; total?: number; completed?: number; error?: string }
try { frame = JSON.parse(line) } catch { continue }
onProgress(frame)
}
}
} finally {
reader.cancel().catch(() => {})
}
},
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
@@ -608,8 +450,6 @@ export const adminApi = {
updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data),
getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data),
updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { enabled }).then(r => r.data),
getPlacesEnrich: () => apiClient.get('/admin/places-enrich').then(r => r.data),
updatePlacesEnrich: (enabled: boolean) => apiClient.put('/admin/places-enrich', { enabled }).then(r => r.data),
getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data),
updateCollabFeatures: (features: Record<string, boolean>) => apiClient.put('/admin/collab-features', features).then(r => r.data),
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
@@ -624,8 +464,7 @@ export const adminApi = {
updateTemplateItem: (templateId: number, itemId: number, data: { name: string }) => apiClient.put(`/admin/packing-templates/${templateId}/items/${itemId}`, data).then(r => r.data),
deleteTemplateItem: (templateId: number, itemId: number) => apiClient.delete(`/admin/packing-templates/${templateId}/items/${itemId}`).then(r => r.data),
listInvites: () => apiClient.get('/admin/invites').then(r => r.data),
listInviteTrips: () => apiClient.get('/admin/invites/trips').then(r => r.data),
createInvite: (data: { max_uses: number; expires_in_days?: number; trip_id?: number | null }) => apiClient.post('/admin/invites', data).then(r => r.data),
createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data),
deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data),
auditLog: (params?: { limit?: number; offset?: number }) =>
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
@@ -642,250 +481,15 @@ export const adminApi = {
updateNotificationPreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data),
getDefaultUserSettings: () => apiClient.get('/admin/default-user-settings').then(r => r.data),
updateDefaultUserSettings: (settings: Record<string, unknown>) => apiClient.put('/admin/default-user-settings', settings).then(r => r.data),
getStorage: (): Promise<StorageAdminState> => apiClient.get('/admin/storage').then(r => r.data),
updateStorage: (config: StorageConfigPut): Promise<StorageAdminState> =>
apiClient.put('/admin/storage', config).then(r => r.data),
// A probe is bounded by the target driver's own timeout (default 30s, ×2 with
// one retry) and a mirror probes its targets sequentially — the instance-wide
// 8s axios timeout would abort a legitimate slow probe, so this call carries
// its own generous ceiling.
testStorageBackend: (backend: StorageBackend): Promise<StorageTestResponse> =>
apiClient.post('/admin/storage/test', { backend }, { timeout: 120_000 }).then(r => r.data),
startStorageBackfill: (backend: string): Promise<{ started: true }> =>
apiClient.post(`/admin/storage/backends/${encodeURIComponent(backend)}/backfill`).then(r => r.data),
cancelStorageBackfill: (backend: string): Promise<{ cancelled: true }> =>
apiClient.delete(`/admin/storage/backends/${encodeURIComponent(backend)}/backfill`).then(r => r.data),
startStorageMigration: (category: string, to: string): Promise<{ started: true }> =>
apiClient.post('/admin/storage/migrations', { category, to }).then(r => r.data),
cancelStorageMigration: (category: string): Promise<{ cancelled: true }> =>
apiClient.delete(`/admin/storage/migrations/${encodeURIComponent(category)}`).then(r => r.data),
// A full scan of a large install can exceed the 8s instance timeout.
refreshStorageStats: (): Promise<StorageUsage> =>
apiClient.post('/admin/storage/stats/refresh', undefined, { timeout: 120_000 }).then(r => r.data),
}
export const addonsApi = {
enabled: () => apiClient.get('/addons').then(r => r.data),
}
/** A host-rendered column/action a plugin contributes into a native planner view
* (reservations/places/day) via the tableContributor hook. Every field is bounded +
* normalized server-side; a column url is guaranteed http/https/mailto. */
export type ViewContribution =
| { kind: 'column'; pluginId: string; entityId: number; id: string; label: string; value?: string; url?: string; icon?: string; tone: 'default' | 'success' | 'warn' | 'danger' }
| { kind: 'action'; pluginId: string; entityId: number; id: string; label: string; icon?: string; target: { kind: 'frame'; sub: string } | { kind: 'route'; method: 'GET' | 'POST'; sub: string } }
/** A badge a plugin adds to a dashboard trip card via the tripCardProvider hook.
* Bounded + normalized server-side; the url is guaranteed http/https/mailto. */
export interface TripCardBadge {
pluginId: string; tripId: number; id: string; label: string;
value?: string; icon?: string; tone: 'default' | 'success' | 'warn' | 'danger'; url?: string;
}
export interface PluginMapMarker {
pluginId: string; id: string; lat: number; lng: number;
label?: string; popupText?: string; url?: string; icon?: string;
tone: 'default' | 'success' | 'warn' | 'danger'
}
/** One shape of a plugin map layer (mapLayerProvider hook). Server-normalized:
* coordinates range-checked, vertex budget capped, styling clamped to the tone
* palette + bounded numerics — never free-form CSS or markup. */
export interface PluginMapLayerFeature {
type: 'polyline' | 'polygon' | 'circle';
points?: Array<[number, number]>;
center?: [number, number];
radiusM?: number;
tone: 'default' | 'success' | 'warn' | 'danger';
width: number;
dash: 'solid' | 'dash' | 'dot';
opacity: number;
fill: boolean;
label?: string;
}
/** A vector overlay a plugin draws on the trip map (routes, corridors, zones). */
export interface PluginMapLayer {
pluginId: string; id: string; name?: string;
features: PluginMapLayerFeature[];
}
/** A time contribution a dayScheduleProvider plugin attaches to the day plan
* ("35 min charging at this stop"). Server-normalized: dayIds checked against
* the trip, minutes clamped to a day, labels sanitized + capped. */
export interface PluginDayScheduleItem {
pluginId: string; id: string; dayId: number;
assignmentId?: number; reservationId?: number;
position?: 'start' | 'end';
minutes?: number; label: string;
tone: 'default' | 'success' | 'warn' | 'danger';
}
/** The colours a dayTintProvider plugin puts into one day card, so leg membership is
* visible while scrolling the itinerary. The card has three separately tintable
* regions; an absent one is not tinted and renders exactly as it does with no plugin.
* Server-normalized: dayIds checked against the trip, one contribution per day (first
* granted provider wins), the `tone` / `color` shorthands already resolved into the
* regions, labels sanitized + capped.
*
* A region carries EITHER a tone from the fixed palette or the plugin's own colour,
* never both — the server picked the winner. `*Color` is guaranteed `#rrggbb` (nothing
* else survives normalization, because it lands inside a CSS value); the client still
* owns how strongly it renders — alpha per theme and per region, lightness clamped
* into a readable band. */
export type PluginDayTintTone = 'default' | 'success' | 'warn' | 'danger'
export interface PluginDayTint {
pluginId: string; dayId: number;
badgeTone?: PluginDayTintTone;
badgeColor?: string;
headerTone?: PluginDayTintTone;
headerColor?: string;
activityTone?: PluginDayTintTone;
activityColor?: string;
label?: string;
}
/** A route computed by a routeProvider plugin (server-normalized: coordinates
* range-checked, legs forced to waypoints-1, vias capped). null = provider failed
* or refused — the caller falls back to straight lines like on an OSRM outage. */
export interface PluginRouteResult {
pluginId: string; profile: string;
coordinates: Array<[number, number]>;
distance: number; duration: number;
legs: Array<{ distance: number; duration: number; note?: string }>;
viaPoints: Array<{ lat: number; lng: number; label?: string; tone: 'default' | 'success' | 'warn' | 'danger'; dwellSeconds?: number }>;
}
/** A text-only section a pdfSectionProvider plugin appends to the trip PDF export.
* Server-normalized: counts + lengths are capped, cells are plain strings. */
export interface PluginPdfSection {
pluginId: string; title: string; paragraphs: string[];
table?: { headers: string[]; rows: string[][] }
}
/** A country tint layer an atlasLayerProvider plugin draws over the Atlas map for
* the signed-in user. Codes are ISO alpha-2 (server-validated), tone enum-whitelisted. */
export interface PluginAtlasLayer {
pluginId: string; id: string; name?: string;
countries: Array<{ code: string; tone: 'default' | 'success' | 'warn' | 'danger'; label?: string }>
}
export interface PluginUserSettingField {
key: string; label?: string | null; input_type?: string; placeholder?: string | null;
hint?: string | null; required?: boolean; secret?: boolean;
options?: Array<{ value: string; label: string }>
}
/** A button a plugin contributes to its own settings page ("Test connection"). */
export interface PluginAction {
key: string; label: string; hint?: string; danger: boolean
}
export const pluginsApi = {
// Active plugins the client renders (page nav entries, dashboard widgets).
active: () => apiClient.get('/plugins').then(r => r.data),
// Extra place info contributed by placeDetailProvider plugins (#1429). Fail-safe:
// the server skips any slow/failing provider, so this only ever adds rows.
placeDetails: (placeId: number) =>
apiClient.get(`/place-details/${placeId}`).then(r => r.data as { providers: Array<{ pluginId: string; items: Array<{ label: string; value?: string; url?: string }> }> }),
// Validation/warning contributions from warningProvider plugins (#1429). Fail-safe.
tripWarnings: (tripId: number) =>
apiClient.get(`/trip-warnings/${tripId}`).then(r => r.data as { warnings: Array<{ pluginId: string; level: 'info' | 'warning' | 'error'; message: string; dayId?: number; placeId?: number }> }),
// Host-rendered columns/actions plugins add into a native planner view via the
// tableContributor hook. Fetched once per view, keyed by entityId; fail-safe.
viewContributions: (view: 'reservations' | 'transports' | 'places' | 'day' | 'costs' | 'packing' | 'files' | 'todos', tripId: number | string) =>
apiClient.get(`/view-contributions/${view}/${tripId}`).then(r => r.data as { contributions: ViewContribution[] }),
// Bounded markers plugins overlay on the trip map via the mapMarkerProvider hook
// (#587). Host-normalized + range-checked; fail-safe (skips slow/failing providers).
mapMarkers: (tripId: number | string) =>
apiClient.get(`/map-markers/${tripId}`).then(r => r.data as { markers: PluginMapMarker[] }),
// Vector overlays (polylines/polygons/circles) plugins draw on the trip map via
// the mapLayerProvider hook. Host-normalized + vertex-budgeted; fail-safe.
mapLayers: (tripId: number | string) =>
apiClient.get(`/map-layers/${tripId}`).then(r => r.data as { layers: PluginMapLayer[] }),
// Route the given waypoints through ONE routeProvider plugin profile (targeted,
// not a fan-out — the user picked this profile in the route toggle). Slow by
// design (external solvers): the server allows the plugin 20 s.
pluginRoute: (pluginId: string, profileId: string, body: { tripId: number | string; dayId?: number | null; waypoints: Array<{ lat: number; lng: number; name?: string; placeId?: number }> }, opts: { signal?: AbortSignal } = {}) =>
apiClient.post(`/plugin-routes/${pluginId}/${profileId}`, body, { timeout: 25000, signal: opts.signal }).then(r => r.data as { route: PluginRouteResult | null }),
// Time contributions plugins attach to the day plan via the dayScheduleProvider
// hook (charging stops, security buffers). Host-normalized; fail-safe.
daySchedule: (tripId: number | string) =>
apiClient.get(`/day-schedule/${tripId}`).then(r => r.data as { items: PluginDayScheduleItem[] }),
// Per-day colours plugins put behind the day cards via the dayTintProvider hook
// (which leg of the trip a day belongs to). Host-normalized; fail-safe.
dayTints: (tripId: number | string) =>
apiClient.get(`/day-tints/${tripId}`).then(r => r.data as { tints: PluginDayTint[] }),
// Text-only sections plugins append to the trip PDF export via the
// pdfSectionProvider hook. Host-normalized (counts + lengths capped); fail-safe.
pdfSections: (tripId: number | string) =>
apiClient.get(`/pdf-sections/${tripId}`).then(r => r.data as { sections: PluginPdfSection[] }),
// Country tint layers plugins draw over the Atlas map for the signed-in user via
// the atlasLayerProvider hook. No tripId — user-scoped server-side; fail-safe.
atlasLayers: () =>
apiClient.get('/atlas-layers').then(r => r.data as { layers: PluginAtlasLayer[] }),
// Extra rows plugins add under a journal entry via the journalEntryProvider hook.
// Same shape + hardening as placeDetails (label/value/allowlisted url); fail-safe.
journalEntryRows: (entryId: number) =>
apiClient.get(`/journal-entry-rows/${entryId}`).then(r => r.data as { providers: Array<{ pluginId: string; items: Array<{ label: string; value?: string; url?: string }> }> }),
// Badges plugins add to the dashboard trip cards via the tripCardProvider hook.
// One call for all visible cards; host access-checks each tripId + bounds every
// field (label/value/tone/allowlisted url); fail-safe.
tripCardContributions: (tripIds: Array<number | string>) =>
apiClient.get(`/trip-card-contributions?tripIds=${tripIds.join(',')}`).then(r => r.data as { contributions: TripCardBadge[] }),
// The signed-in user's OWN plugin activity log — every host-mediated action a
// plugin took bound to them, across all plugins, newest first. The user-facing
// half of the capability audit; what makes the broad read grants accountable.
myActivity: (limit = 200) =>
apiClient.get(`/plugin-activity?limit=${limit}`).then(r => r.data as { activity: Array<{ ts: string; plugin_id: string; plugin_name: string | null; method: string; resource: string | null; code: string }> }),
// A user's OWN scope:'user' settings for a plugin (API key, prefs). Secrets are
// masked; the write only accepts declared user-scope keys.
userSettings: (id: string) =>
apiClient.get(`/plugin-settings/${id}`).then(r => r.data as {
fields: PluginUserSettingField[]
config: Record<string, unknown>
actions: PluginAction[]
}),
// Run a settings-page action the plugin declared ("Test connection"). It runs AS the
// caller, so it reads the caller's own settings.
runAction: (id: string, key: string) =>
apiClient.post(`/plugin-settings/${id}/actions/${encodeURIComponent(key)}`)
.then(r => r.data as { ok: boolean; message?: string }),
saveUserSettings: (id: string, config: Record<string, unknown>) =>
apiClient.post(`/plugin-settings/${id}`, { config }).then(r => r.data as { config: Record<string, unknown> }),
// Host-brokered outbound OAuth (the host owns the tokens; the plugin only triggers).
oauthStatus: (id: string) =>
apiClient.get(`/plugin-oauth/${id}/status`).then(r => r.data as { configured: boolean; connected: boolean }),
oauthConnect: (id: string) =>
apiClient.post(`/plugin-oauth/${id}/connect`).then(r => r.data as { authorizeUrl: string }),
oauthDisconnect: (id: string) =>
apiClient.post(`/plugin-oauth/${id}/disconnect`).then(r => r.data as { connected: boolean }),
// Call one of a plugin's own declared routes through the host proxy. `sub` is
// supplied by untrusted plugin code (the trekBridge forwards it verbatim), so it
// MUST stay inside the plugin's own /plugins/:id/ namespace. We resolve it with
// the URL parser — which normalizes `../`, encoded traversal and backslashes the
// same way the browser would before sending — and reject anything that escapes
// the prefix or points off-origin. Without this a plugin could send
// sub='/../../auth/me' and drive arbitrary authenticated /api routes as the user.
invoke: (id: string, sub: string, init?: { method?: string; body?: unknown }) => {
const prefix = `/api/plugins/${id}/`
let resolved: URL
try {
resolved = new URL(String(sub).replace(/^\/+/, ''), window.location.origin + prefix)
} catch {
return Promise.reject(new Error('invalid plugin route'))
}
if (resolved.origin !== window.location.origin || !resolved.pathname.startsWith(prefix)) {
return Promise.reject(new Error('plugin route escapes its namespace'))
}
const url = resolved.pathname.slice('/api'.length) + resolved.search
return apiClient.request({ url, method: init?.method || 'GET', data: init?.body }).then(r => r.data)
},
}
export const airtrailApi = {
getSettings: () => apiClient.get('/integrations/airtrail/settings').then(r => r.data),
saveSettings: (data: { url: string; apiKey?: string; allowInsecureTls?: boolean; writeEnabled?: boolean }) =>
saveSettings: (data: { url: string; apiKey?: string; allowInsecureTls?: boolean }) =>
apiClient.put('/integrations/airtrail/settings', data).then(r => r.data),
status: () => apiClient.get('/integrations/airtrail/status').then(r => r.data),
test: (data: { url?: string; apiKey?: string; allowInsecureTls?: boolean }) =>
@@ -893,8 +497,8 @@ export const airtrailApi = {
sync: (): Promise<{ changed: number }> => apiClient.post('/integrations/airtrail/sync').then(r => r.data),
// flights + import are added with the trip-planner import (P2)
flights: () => apiClient.get('/integrations/airtrail/flights').then(r => r.data),
import: (tripId: number, flightIds: string[], connections?: string[][]) =>
apiClient.post(`/trips/${tripId}/reservations/import/airtrail`, connections?.length ? { flightIds, connections } : { flightIds }).then(r => r.data),
import: (tripId: number, flightIds: string[]) =>
apiClient.post(`/trips/${tripId}/reservations/import/airtrail`, { flightIds }).then(r => r.data),
}
export const journeyApi = {
@@ -913,37 +517,29 @@ export const journeyApi = {
// Entries
listEntries: (id: number) => apiClient.get(`/journeys/${id}/entries`).then(r => r.data),
// GPX tracks of the trips this journey's entries came from (#1260).
listTracks: (id: number): Promise<JourneyTracksResponse> => apiClient.get(`/journeys/${id}/tracks`).then(r => r.data),
// What the journey adds up to — distance, days, countries, the route (#1973).
// Read by TREK Studio, which freezes the answer into the book document.
stats: (id: number): Promise<JourneyStats> => apiClient.get(`/journeys/${id}/stats`).then(r => r.data),
// The Studio book (#1973). `book` is null for a journey that has none yet —
// Studio lays one out and the first save creates it.
getBook: (id: number): Promise<{ book: BookRecord | null }> =>
apiClient.get(`/journeys/${id}/book`).then(r => r.data),
// A 409 carries the current record in its body, so a conflict can be shown
// rather than only announced. See useBookStore.
saveBook: (id: number, body: BookSaveRequest): Promise<BookRecord> =>
apiClient.put(`/journeys/${id}/book`, body).then(r => r.data),
deleteBook: (id: number): Promise<void> =>
apiClient.delete(`/journeys/${id}/book`).then(r => r.data),
createEntry: (id: number, data: Record<string, unknown>) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data),
updateEntry: (entryId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data),
deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data),
reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds } satisfies JourneyReorderEntriesRequest).then(r => r.data),
// Photos
uploadPhotos: (entryId: number, formData: FormData, opts?: UploadOptions) =>
postMultipart(`/journeys/entries/${entryId}/photos`, formData, opts),
uploadGalleryPhotos: (journeyId: number, formData: FormData, opts?: UploadOptions) =>
postMultipart(`/journeys/${journeyId}/gallery/photos`, formData, opts),
uploadGalleryVideo: (journeyId: number, formData: FormData, opts?: UploadOptions) =>
postMultipart(`/journeys/${journeyId}/gallery/video`, formData, opts),
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data),
uploadPhotos: (entryId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
apiClient.post(`/journeys/entries/${entryId}/photos`, formData, {
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
timeout: 0,
onUploadProgress: opts?.onUploadProgress,
signal: opts?.signal,
}).then(r => r.data),
uploadGalleryPhotos: (journeyId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, {
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
timeout: 0,
onUploadProgress: opts?.onUploadProgress,
signal: opts?.signal,
}).then(r => r.data),
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data),
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data),
unlinkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/entries/${entryId}/photos/${journeyPhotoId}`).then(r => r.data),
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/${journeyId}/gallery/${journeyPhotoId}`).then(r => r.data),
@@ -951,7 +547,7 @@ export const journeyApi = {
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
// Cover
uploadCover: (id: number, formData: FormData) => postMultipart(`/journeys/${id}/cover`, formData),
uploadCover: (id: number, formData: FormData) => apiClient.post(`/journeys/${id}/cover`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
// Contributors
addContributor: (id: number, userId: number, role: string) => apiClient.post(`/journeys/${id}/contributors`, { user_id: userId, role }).then(r => r.data),
@@ -970,30 +566,17 @@ export const journeyApi = {
export const mapsApi = {
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) =>
apiClient.post('/maps/autocomplete', { input, lang, locationBias, sessionToken }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
details: (placeId: string, lang?: string, sessionToken?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang, sessionToken } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')),
// Pictures and a description for a place that is being looked at but not yet
// saved. Fans out to several providers server-side, so it takes a signal and
// the caller is expected to abort it when the selection changes, and a longer
// timeout than the global 8s — a cold Wikimedia connection alone can eat that.
placeEnrichment: (
body: { placeId?: string; lat: number; lng: number; name: string; lang?: string; details?: Record<string, unknown> },
signal?: AbortSignal,
) => apiClient.post('/maps/enrichment', body, { signal, timeout: 25000 })
.then(r => checkInDev(mapsPlaceEnrichmentResultSchema, r.data, 'maps.placeEnrichment')),
// Author + licence for a picture already stored in the photo cache, keyed by
// the cache key embedded in its proxy URL. Local read, no provider call.
placePhotoCredit: (key: string) =>
apiClient.get(`/maps/enrichment/credit/${encodeURIComponent(key)}`).then(r => r.data as { credit: string | null }),
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) =>
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')),
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => checkInDev(mapsPlacePhotoResultSchema, r.data, 'maps.placePhoto')),
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => checkInDev(mapsReverseResultSchema, r.data, 'maps.reverse')),
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => checkInDev(mapsResolveUrlResultSchema, r.data, 'maps.resolveUrl')),
// OSM-only POI explore: places of a category within the current map viewport bbox.
// Overpass can be slow on a fresh (uncached) area, so this call gets a longer
// timeout than the global default instead of aborting at 8s and showing nothing.
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, lang?: string, signal?: AbortSignal) =>
apiClient.get('/maps/pois', { params: { category, ...bbox, lang }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, signal?: AbortSignal) =>
apiClient.get('/maps/pois', { params: { category, ...bbox }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
}
export const airportsApi = {
@@ -1011,8 +594,7 @@ export const budgetApi = {
setPayers: (tripId: number | string, id: number, payers: { user_id: number; amount: number }[]) => apiClient.put(`/trips/${tripId}/budget/${id}/payers`, { payers }).then(r => r.data),
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data),
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data),
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
@@ -1020,7 +602,9 @@ export const budgetApi = {
export const filesApi = {
list: (tripId: number | string, trash?: boolean) => apiClient.get(`/trips/${tripId}/files`, { params: trash ? { trash: 'true' } : {} }).then(r => r.data),
upload: (tripId: number | string, formData: FormData, opts?: UploadOptions) => postMultipart(`/trips/${tripId}/files`, formData, opts),
upload: (tripId: number | string, formData: FormData) => apiClient.post(`/trips/${tripId}/files`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
}).then(r => r.data),
update: (tripId: number | string, id: number, data: FileUpdateRequest) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}`).then(r => r.data),
toggleStar: (tripId: number | string, id: number) => apiClient.patch(`/trips/${tripId}/files/${id}/star`).then(r => r.data),
@@ -1038,40 +622,22 @@ export const reservationsApi = {
create: (tripId: number | string, data: ReservationCreateRequest) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: ReservationUpdateRequest) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
// Assign trip members / named guests to a booking (#1517).
setTravelers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/reservations/${id}/travelers`, { user_ids: userIds }).then(r => r.data),
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data),
importBookingPreview: (tripId: number | string, files: File[], mode: BookingImportMode = 'no-ai'): Promise<BookingImportPreviewResponse> => {
importBookingPreview: (tripId: number | string, files: File[]): Promise<BookingImportPreviewResponse> => {
const fd = new FormData()
for (const f of files) fd.append('files', f)
fd.append('mode', mode)
// No client-side timeout: kitinerary + LLM extraction routinely exceeds the
// global 8s default (a cold local model alone can take ~45s).
return postMultipart(`/trips/${tripId}/reservations/import/booking`, fd)
return apiClient.post(`/trips/${tripId}/reservations/import/booking`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
},
importBookingConfirm: (tripId: number | string, items: BookingImportPreviewItem[]): Promise<BookingImportConfirmResponse> =>
apiClient.post(`/trips/${tripId}/reservations/import/booking/confirm`, { items }).then(r => r.data),
// Start a background parse: returns a job id at once; progress + result arrive
// over the WebSocket (import:progress / import:done / import:error).
importBookingAsync: (tripId: number | string, files: File[], mode: BookingImportMode = 'no-ai'): Promise<{ jobId: string }> => {
const fd = new FormData()
for (const f of files) fd.append('files', f)
fd.append('mode', mode)
return postMultipart(`/trips/${tripId}/reservations/import/booking/async`, fd)
},
// Poll a background job — recovery path when a WebSocket push was missed.
importJobStatus: (tripId: number | string, jobId: string): Promise<{ status: 'running' | 'done' | 'error'; done: number; total: number; result?: BookingImportPreviewResponse; error?: string }> =>
apiClient.get(`/trips/${tripId}/reservations/import/jobs/${jobId}`).then(r => r.data),
}
export const healthApi = {
features: (): Promise<{ bookingImport: boolean; aiParsing: boolean }> => apiClient.get('/health/features').then(r => r.data),
features: (): Promise<{ bookingImport: boolean }> => apiClient.get('/health/features').then(r => r.data),
}
export const weatherApi = {
// `time` (HH:MM) makes a past date answer for that hour instead of the day (#1614).
get: (lat: number, lng: number, date: string, time?: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date, time } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')),
getCurrent: (lat: number, lng: number, lang?: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getCurrent')),
get: (lat: number, lng: number, date: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')),
getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise<WeatherResult> => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getDetailed')),
}
@@ -1080,17 +646,6 @@ export const configApi = {
apiClient.get('/config').then(r => r.data),
}
export interface HelpNavItem { title: string; slug: string }
export interface HelpNavSection { title: string; pages: HelpNavItem[] }
export interface HelpPageData { slug: string; title: string; markdown: string }
export const helpApi = {
index: (): Promise<{ sections: HelpNavSection[] }> =>
apiClient.get('/help/index').then(r => r.data),
page: (slug: string): Promise<HelpPageData> =>
apiClient.get(`/help/page/${encodeURIComponent(slug)}`).then(r => r.data),
}
export const settingsApi = {
get: () => apiClient.get('/settings').then(r => r.data),
set: (key: string, value: unknown) => {
@@ -1122,7 +677,7 @@ export const collabApi = {
createNote: (tripId: number | string, data: CollabNoteCreateRequest) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data),
updateNote: (tripId: number | string, id: number, data: CollabNoteUpdateRequest) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data),
deleteNote: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data),
uploadNoteFile: (tripId: number | string, noteId: number, formData: FormData) => postMultipart(`/trips/${tripId}/collab/notes/${noteId}/files`, formData),
uploadNoteFile: (tripId: number | string, noteId: number, formData: FormData) => apiClient.post(`/trips/${tripId}/collab/notes/${noteId}/files`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
deleteNoteFile: (tripId: number | string, noteId: number, fileId: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${noteId}/files/${fileId}`).then(r => r.data),
getPolls: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data),
createPoll: (tripId: number | string, data: CollabPollCreateRequest) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data),
@@ -1157,7 +712,7 @@ export const backupApi = {
uploadRestore: (file: File) => {
const form = new FormData()
form.append('backup', file)
return postMultipart('/backup/upload-restore', form)
return apiClient.post('/backup/upload-restore', form, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
},
getAutoSettings: () => apiClient.get('/backup/auto-settings').then(r => r.data),
setAutoSettings: (settings: Record<string, unknown>) => apiClient.put('/backup/auto-settings', settings).then(r => r.data),
@@ -1170,34 +725,12 @@ export const shareApi = {
getSharedTrip: (token: string) => apiClient.get(`/shared/${token}`).then(r => r.data),
}
// Public transit routing (#1065) — Transitous/MOTIS proxied through the server.
export const transitApi = {
geocode: (q: string, opts?: { lang?: string; near?: string }) =>
apiClient.get('/transit/geocode', { params: { q, lang: opts?.lang, near: opts?.near } }).then(r => r.data),
plan: (params: { from: string; to: string; time?: string; arriveBy?: boolean; modes?: string; maxTransfers?: number }) =>
apiClient.get('/transit/plan', { params }).then(r => r.data),
}
// Trip invite links (#1143) — join a trip as an existing, logged-in user.
export const tripInviteApi = {
getLink: (tripId: number | string) => apiClient.get(`/trips/${tripId}/invite-link`).then(r => r.data),
createLink: (tripId: number | string, expires_in_days?: number | null) =>
apiClient.post(`/trips/${tripId}/invite-link`, { expires_in_days: expires_in_days ?? null }).then(r => r.data),
deleteLink: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/invite-link`).then(r => r.data),
preview: (token: string) => apiClient.get(`/trip-invites/${token}`).then(r => r.data),
accept: (token: string) => apiClient.post(`/trip-invites/${token}/accept`).then(r => r.data),
}
export const notificationsApi = {
getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data),
updatePreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testSmtp')),
testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testWebhook')),
testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testNtfy')),
// Generic channel test — this is how a PLUGIN channel's "Send test" button works.
testChannel: (channelId: string) =>
apiClient.post(`/notifications/test/${encodeURIComponent(channelId)}`)
.then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testChannel')),
}
export const inAppNotificationsApi = {
@@ -1219,4 +752,4 @@ export const inAppNotificationsApi = {
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
}
export default apiClient
export default apiClient
-342
View File
@@ -1,342 +0,0 @@
// FE-API-COLLECTIONS-001 to FE-API-COLLECTIONS-032
//
// The Collections addon wrapper is thin, but every method encodes a URL, a verb and a
// request-body shape that the server contract depends on. These tests drive each method
// through MSW and pin the method + path + payload, plus the unwrapping of `r.data`.
import { describe, it, expect, beforeEach } from 'vitest'
import { http, HttpResponse, type JsonBodyType } from 'msw'
import { server } from '../../tests/helpers/msw/server'
import { collectionsApi } from './collections'
import type { Collection, CollectionLabel, CollectionPlace } from '@trek/shared'
const BASE = '/api/addons/collections'
const collection: Collection = { id: 1, owner_id: 1, name: 'Tokyo', place_count: 2, is_owner: true }
const place: CollectionPlace = { id: 10, collection_id: 1, name: 'Shibuya Crossing', status: 'want' }
const label: CollectionLabel = { id: 3, collection_id: 1, name: 'Food', color: '#ef4444' }
let requestUrl = ''
let requestBody: unknown
beforeEach(() => {
requestUrl = ''
requestBody = undefined
})
/** Records url + parsed JSON body of the intercepted request, then answers with `data`. */
function record<T extends JsonBodyType>(data: T) {
return async ({ request }: { request: Request }) => {
requestUrl = request.url
const text = await request.text()
if (text) {
try {
requestBody = JSON.parse(text)
} catch {
requestBody = text
}
}
return HttpResponse.json(data)
}
}
describe('collectionsApi', () => {
it('FE-API-COLLECTIONS-001: list() unwraps the collections + incomingInvites envelope', async () => {
server.use(http.get(BASE, record({ collections: [collection], incomingInvites: [] })))
const res = await collectionsApi.list()
expect(res.collections).toEqual([collection])
expect(res.incomingInvites).toEqual([])
})
it('FE-API-COLLECTIONS-002: get() requests the list by id', async () => {
server.use(http.get(`${BASE}/:id`, record({ collection, places: [place] })))
const res = await collectionsApi.get(1)
expect(requestUrl).toContain(`${BASE}/1`)
expect(res.places).toEqual([place])
expect(res.collection.name).toBe('Tokyo')
})
it('FE-API-COLLECTIONS-003: create() posts the create payload', async () => {
server.use(http.post(BASE, record({ collection })))
const res = await collectionsApi.create({ name: 'Tokyo', color: '#111827' })
expect(requestBody).toEqual({ name: 'Tokyo', color: '#111827' })
expect(res.collection.id).toBe(1)
})
it('FE-API-COLLECTIONS-004: update() patches the list by id', async () => {
server.use(http.patch(`${BASE}/:id`, record({ collection })))
const res = await collectionsApi.update(1, { name: 'Tokyo 2026' })
expect(requestUrl).toContain(`${BASE}/1`)
expect(requestBody).toEqual({ name: 'Tokyo 2026' })
expect(res.collection).toEqual(collection)
})
it('FE-API-COLLECTIONS-005: uploadCover() posts multipart to the cover endpoint', async () => {
server.use(http.post(`${BASE}/:id/cover`, record(collection)))
const fd = new FormData()
fd.append('cover', new File(['x'], 'cover.jpg'))
const res = await collectionsApi.uploadCover(1, fd)
expect(requestUrl).toContain(`${BASE}/1/cover`)
expect(res).toEqual(collection)
})
it('FE-API-COLLECTIONS-006: remove() deletes the list', async () => {
server.use(http.delete(`${BASE}/:id`, record({ success: true })))
const res = await collectionsApi.remove(4)
expect(requestUrl).toContain(`${BASE}/4`)
expect(res).toEqual({ success: true })
})
it('FE-API-COLLECTIONS-007: reorder() posts the ordered ids', async () => {
server.use(http.post(`${BASE}/reorder`, record({ success: true })))
await collectionsApi.reorder([3, 1, 2])
expect(requestBody).toEqual({ orderedIds: [3, 1, 2] })
})
it('FE-API-COLLECTIONS-008: savePlace() posts the place payload', async () => {
server.use(http.post(`${BASE}/places`, record({ place })))
const res = await collectionsApi.savePlace({ collection_id: 1, name: 'Shibuya Crossing', force: true })
expect(requestBody).toEqual({ collection_id: 1, name: 'Shibuya Crossing', force: true })
expect(res.place).toEqual(place)
})
it('FE-API-COLLECTIONS-009: saveFromTrip() posts the provenance-only payload', async () => {
server.use(http.post(`${BASE}/places/from-trip`, record({ duplicate: true, duplicateOf: { id: 9, name: 'Shibuya' } })))
const res = await collectionsApi.saveFromTrip({ collection_id: 1, source_trip_id: 7, source_place_id: 42 })
expect(requestBody).toEqual({ collection_id: 1, source_trip_id: 7, source_place_id: 42 })
expect(res.duplicate).toBe(true)
})
it('FE-API-COLLECTIONS-010: saveFromTripMany() maps its arguments onto the bulk payload', async () => {
server.use(http.post(`${BASE}/places/from-trip-many`, record({ copied: 2, skipped: [] })))
const res = await collectionsApi.saveFromTripMany(1, 7, [11, 12], true)
expect(requestBody).toEqual({ collection_id: 1, source_trip_id: 7, source_place_ids: [11, 12], force: true })
expect(res.copied).toBe(2)
})
it('FE-API-COLLECTIONS-011: updatePlace() patches the place and returns it unwrapped', async () => {
server.use(http.patch(`${BASE}/places/:pid`, record({ ...place, notes: 'busy at night' })))
const res = await collectionsApi.updatePlace(10, { notes: 'busy at night' })
expect(requestUrl).toContain(`${BASE}/places/10`)
expect(requestBody).toEqual({ notes: 'busy at night' })
expect(res.notes).toBe('busy at night')
})
it('FE-API-COLLECTIONS-012: uploadPlaceImage() posts multipart to the place image endpoint', async () => {
server.use(http.post(`${BASE}/places/:pid/image`, record({ ...place, image_url: '/uploads/p.jpg' })))
const fd = new FormData()
fd.append('image', new File(['x'], 'p.jpg'))
const res = await collectionsApi.uploadPlaceImage(10, fd)
expect(requestUrl).toContain(`${BASE}/places/10/image`)
expect(res.image_url).toBe('/uploads/p.jpg')
})
it('FE-API-COLLECTIONS-013: setStatus() posts the status', async () => {
server.use(http.post(`${BASE}/places/:pid/status`, record({ ...place, status: 'visited' })))
const res = await collectionsApi.setStatus(10, 'visited')
expect(requestUrl).toContain(`${BASE}/places/10/status`)
expect(requestBody).toEqual({ status: 'visited' })
expect(res.status).toBe('visited')
})
it('FE-API-COLLECTIONS-014: ratePlace() PUTs a numeric rating', async () => {
server.use(http.put(`${BASE}/places/:pid/rating`, record({ ...place, rating_avg: 4 })))
const res = await collectionsApi.ratePlace(10, 4)
expect(requestUrl).toContain(`${BASE}/places/10/rating`)
expect(requestBody).toEqual({ rating: 4 })
expect(res.rating_avg).toBe(4)
})
it('FE-API-COLLECTIONS-015: ratePlace(null) DELETEs the rating instead', async () => {
let deleted = false
server.use(
http.put(`${BASE}/places/:pid/rating`, () => HttpResponse.json({ error: 'should not be called' }, { status: 500 })),
http.delete(`${BASE}/places/:pid/rating`, () => {
deleted = true
return HttpResponse.json({ ...place, rating_avg: null })
}),
)
const res = await collectionsApi.ratePlace(10, null)
expect(deleted).toBe(true)
expect(res.rating_avg).toBeNull()
})
it('FE-API-COLLECTIONS-016: deletePlace() deletes the saved place', async () => {
server.use(http.delete(`${BASE}/places/:pid`, record({ success: true })))
await collectionsApi.deletePlace(10)
expect(requestUrl).toContain(`${BASE}/places/10`)
})
it('FE-API-COLLECTIONS-017: deleteMany() posts the id list', async () => {
server.use(http.post(`${BASE}/places/delete-many`, record({ deleted: 2 })))
const res = await collectionsApi.deleteMany([10, 11])
expect(requestBody).toEqual({ ids: [10, 11] })
expect(res).toEqual({ deleted: 2 })
})
it('FE-API-COLLECTIONS-018: copyToTrip() posts the copy payload and returns the dedup report', async () => {
server.use(http.post(`${BASE}/copy-to-trip`, record({ copied: 1, skipped: [{ id: 11, name: 'Shibuya' }] })))
const res = await collectionsApi.copyToTrip({ trip_id: 7, place_ids: [10, 11] })
expect(requestBody).toEqual({ trip_id: 7, place_ids: [10, 11] })
expect(res.copied).toBe(1)
expect(res.skipped).toEqual([{ id: 11, name: 'Shibuya' }])
})
it('FE-API-COLLECTIONS-019: membership() sends the lookup as query params', async () => {
server.use(http.get(`${BASE}/membership`, record({ saved: true, lists: [{ collection_id: 1, name: 'Tokyo', place_id: 10 }] })))
const res = await collectionsApi.membership({ google_place_id: 'g1', lat: 35.6, lng: 139.7 })
const params = new URL(requestUrl).searchParams
expect(params.get('google_place_id')).toBe('g1')
expect(params.get('lat')).toBe('35.6')
expect(params.get('lng')).toBe('139.7')
expect(res.saved).toBe(true)
})
it('FE-API-COLLECTIONS-020: invite() posts collection_id, user_id and role', async () => {
server.use(http.post(`${BASE}/invite`, record({ success: true })))
await collectionsApi.invite(1, 5, 'admin')
expect(requestBody).toEqual({ collection_id: 1, user_id: 5, role: 'admin' })
})
it('FE-API-COLLECTIONS-021: setMemberRole() posts the new role', async () => {
server.use(http.post(`${BASE}/members/role`, record({ success: true })))
await collectionsApi.setMemberRole(1, 5, 'viewer')
expect(requestBody).toEqual({ collection_id: 1, user_id: 5, role: 'viewer' })
})
it('FE-API-COLLECTIONS-022: acceptInvite() posts only the collection id', async () => {
server.use(http.post(`${BASE}/invite/accept`, record({ success: true })))
await collectionsApi.acceptInvite(1)
expect(requestBody).toEqual({ collection_id: 1 })
})
it('FE-API-COLLECTIONS-023: declineInvite() posts only the collection id', async () => {
server.use(http.post(`${BASE}/invite/decline`, record({ success: true })))
await collectionsApi.declineInvite(2)
expect(requestBody).toEqual({ collection_id: 2 })
})
it('FE-API-COLLECTIONS-024: cancelInvite() posts collection_id and user_id', async () => {
server.use(http.post(`${BASE}/invite/cancel`, record({ success: true })))
await collectionsApi.cancelInvite(1, 5)
expect(requestBody).toEqual({ collection_id: 1, user_id: 5 })
})
it('FE-API-COLLECTIONS-025: leave() posts the collection id', async () => {
server.use(http.post(`${BASE}/leave`, record({ success: true })))
await collectionsApi.leave(3)
expect(requestBody).toEqual({ collection_id: 3 })
})
it('FE-API-COLLECTIONS-026: removeMember() posts collection_id and user_id', async () => {
server.use(http.post(`${BASE}/members/remove`, record({ success: true })))
await collectionsApi.removeMember(1, 9)
expect(requestBody).toEqual({ collection_id: 1, user_id: 9 })
})
it('FE-API-COLLECTIONS-027: availableUsers() reads the invitable users for a list', async () => {
server.use(http.get(`${BASE}/:id/available-users`, record({ users: [{ id: 5, username: 'bob' }] })))
const res = await collectionsApi.availableUsers(1)
expect(requestUrl).toContain(`${BASE}/1/available-users`)
expect(res.users).toEqual([{ id: 5, username: 'bob' }])
})
it('FE-API-COLLECTIONS-028: createLabel() posts collection_id, name and color', async () => {
server.use(http.post(`${BASE}/labels`, record(label)))
const res = await collectionsApi.createLabel(1, 'Food', '#ef4444')
expect(requestBody).toEqual({ collection_id: 1, name: 'Food', color: '#ef4444' })
expect(res).toEqual(label)
})
it('FE-API-COLLECTIONS-029: updateLabel() patches the label by id', async () => {
server.use(http.patch(`${BASE}/labels/:id`, record({ ...label, name: 'Eats' })))
const res = await collectionsApi.updateLabel(3, { name: 'Eats' })
expect(requestUrl).toContain(`${BASE}/labels/3`)
expect(requestBody).toEqual({ name: 'Eats' })
expect(res.name).toBe('Eats')
})
it('FE-API-COLLECTIONS-030: deleteLabel() deletes the label by id', async () => {
server.use(http.delete(`${BASE}/labels/:id`, record({ success: true })))
await collectionsApi.deleteLabel(3)
expect(requestUrl).toContain(`${BASE}/labels/3`)
})
it('FE-API-COLLECTIONS-031: assignLabels() posts label_ids and place_ids', async () => {
server.use(http.post(`${BASE}/labels/assign`, record({ changed: 2 })))
const res = await collectionsApi.assignLabels([3], [10, 11])
expect(requestBody).toEqual({ label_ids: [3], place_ids: [10, 11] })
expect(res.changed).toBe(2)
})
it('FE-API-COLLECTIONS-032: unassignLabels() posts to the unassign endpoint', async () => {
server.use(http.post(`${BASE}/labels/unassign`, record({ changed: 1 })))
const res = await collectionsApi.unassignLabels([3], [10])
expect(requestUrl).toContain(`${BASE}/labels/unassign`)
expect(requestBody).toEqual({ label_ids: [3], place_ids: [10] })
expect(res.changed).toBe(1)
})
})
-126
View File
@@ -1,126 +0,0 @@
import apiClient, { postMultipart } from './client'
import type { AxiosResponse } from 'axios'
import type {
CollectionListResponse,
CollectionDetailResponse,
CollectionSaveResult,
CollectionMembership,
CollectionCreateRequest,
CollectionUpdateRequest,
CollectionSavePlaceRequest,
CollectionSaveFromTripRequest,
CollectionImportablesResponse,
CollectionPlaceUpdateRequest,
CollectionCopyToTripRequest,
CollectionInviteRequest,
CollectionRole,
CollectionInviteActionRequest,
CollectionInviteCancelRequest,
CollectionStatus,
CollectionSetStatusManyResponse,
Collection,
CollectionPlace,
CollectionLabel,
CollectionLabelCreateRequest,
CollectionLabelUpdateRequest,
} from '@trek/shared'
const ax = apiClient
const base = '/addons/collections'
/** Query for the library-wide "is this place already saved?" lookup. */
export interface MembershipQuery {
google_place_id?: string
google_ftid?: string
name?: string
lat?: number
lng?: number
}
export interface CopyToTripResult {
copied: number
skipped: { id: number; name: string }[]
}
/**
* Axios calls for the Collections addon (/api/addons/collections). Mirrors the
* vacayStore api shape — each method returns the unwrapped response body and
* uses `satisfies` on the request payloads so the shared Zod request types stay
* the single source of truth.
*/
export const collectionsApi = {
list: (): Promise<CollectionListResponse> =>
ax.get(base).then((r: AxiosResponse) => r.data),
get: (id: number): Promise<CollectionDetailResponse> =>
ax.get(`${base}/${id}`).then((r: AxiosResponse) => r.data),
create: (body: CollectionCreateRequest): Promise<{ collection: Collection }> =>
ax.post(base, body satisfies CollectionCreateRequest).then((r: AxiosResponse) => r.data),
update: (id: number, body: CollectionUpdateRequest): Promise<{ collection: Collection }> =>
ax.patch(`${base}/${id}`, body satisfies CollectionUpdateRequest).then((r: AxiosResponse) => r.data),
uploadCover: (id: number, formData: FormData): Promise<Collection> =>
postMultipart(`${base}/${id}/cover`, formData),
remove: (id: number): Promise<unknown> =>
ax.delete(`${base}/${id}`).then((r: AxiosResponse) => r.data),
reorder: (orderedIds: number[]): Promise<unknown> =>
ax.post(`${base}/reorder`, { orderedIds }).then((r: AxiosResponse) => r.data),
savePlace: (body: CollectionSavePlaceRequest): Promise<CollectionSaveResult> =>
ax.post(`${base}/places`, body satisfies CollectionSavePlaceRequest).then((r: AxiosResponse) => r.data),
saveFromTrip: (body: CollectionSaveFromTripRequest): Promise<CollectionSaveResult> =>
ax.post(`${base}/places/from-trip`, body satisfies CollectionSaveFromTripRequest).then((r: AxiosResponse) => r.data),
saveFromTripMany: (collectionId: number, tripId: number, placeIds: number[], force?: boolean): Promise<{ copied: number; skipped: { id: number; name: string }[] }> =>
ax.post(`${base}/places/from-trip-many`, { collection_id: collectionId, source_trip_id: tripId, source_place_ids: placeIds, force }).then((r: AxiosResponse) => r.data),
importable: (collectionId: number, tripId: number): Promise<CollectionImportablesResponse> =>
ax.get(`${base}/${collectionId}/importable/${tripId}`).then((r: AxiosResponse) => r.data),
updatePlace: (pid: number, body: CollectionPlaceUpdateRequest): Promise<CollectionPlace> =>
ax.patch(`${base}/places/${pid}`, body satisfies CollectionPlaceUpdateRequest).then((r: AxiosResponse) => r.data),
uploadPlaceImage: (pid: number, formData: FormData): Promise<CollectionPlace> =>
postMultipart(`${base}/places/${pid}/image`, formData),
setStatus: (pid: number, status: CollectionStatus): Promise<CollectionPlace> =>
ax.post(`${base}/places/${pid}/status`, { status }).then((r: AxiosResponse) => r.data),
setStatusMany: (ids: number[], status: CollectionStatus): Promise<CollectionSetStatusManyResponse> =>
ax.post(`${base}/places/status-many`, { ids, status }).then((r: AxiosResponse) => r.data),
setStatusFromTrip: (tripId: number, placeIds: number[], status: CollectionStatus): Promise<CollectionSetStatusManyResponse> =>
ax.post(`${base}/places/status-from-trip`, { trip_id: tripId, place_ids: placeIds, status }).then((r: AxiosResponse) => r.data),
ratePlace: (pid: number, rating: number | null): Promise<CollectionPlace> =>
rating === null
? ax.delete(`${base}/places/${pid}/rating`).then((r: AxiosResponse) => r.data)
: ax.put(`${base}/places/${pid}/rating`, { rating }).then((r: AxiosResponse) => r.data),
deletePlace: (pid: number): Promise<unknown> =>
ax.delete(`${base}/places/${pid}`).then((r: AxiosResponse) => r.data),
deleteMany: (ids: number[]): Promise<unknown> =>
ax.post(`${base}/places/delete-many`, { ids }).then((r: AxiosResponse) => r.data),
copyToTrip: (body: CollectionCopyToTripRequest): Promise<CopyToTripResult> =>
ax.post(`${base}/copy-to-trip`, body satisfies CollectionCopyToTripRequest).then((r: AxiosResponse) => r.data),
membership: (params: MembershipQuery): Promise<CollectionMembership> =>
ax.get(`${base}/membership`, { params }).then((r: AxiosResponse) => r.data),
invite: (collectionId: number, userId: number, role?: CollectionRole): Promise<unknown> =>
ax.post(`${base}/invite`, { collection_id: collectionId, user_id: userId, role } satisfies CollectionInviteRequest).then((r: AxiosResponse) => r.data),
setMemberRole: (collectionId: number, userId: number, role: CollectionRole): Promise<unknown> =>
ax.post(`${base}/members/role`, { collection_id: collectionId, user_id: userId, role }).then((r: AxiosResponse) => r.data),
acceptInvite: (collectionId: number): Promise<unknown> =>
ax.post(`${base}/invite/accept`, { collection_id: collectionId } satisfies CollectionInviteActionRequest).then((r: AxiosResponse) => r.data),
declineInvite: (collectionId: number): Promise<unknown> =>
ax.post(`${base}/invite/decline`, { collection_id: collectionId } satisfies CollectionInviteActionRequest).then((r: AxiosResponse) => r.data),
cancelInvite: (collectionId: number, userId: number): Promise<unknown> =>
ax.post(`${base}/invite/cancel`, { collection_id: collectionId, user_id: userId } satisfies CollectionInviteCancelRequest).then((r: AxiosResponse) => r.data),
leave: (collectionId: number): Promise<unknown> =>
ax.post(`${base}/leave`, { collection_id: collectionId }).then((r: AxiosResponse) => r.data),
removeMember: (collectionId: number, userId: number): Promise<unknown> =>
ax.post(`${base}/members/remove`, { collection_id: collectionId, user_id: userId }).then((r: AxiosResponse) => r.data),
availableUsers: (id: number): Promise<{ users: { id: number; username: string }[] }> =>
ax.get(`${base}/${id}/available-users`).then((r: AxiosResponse) => r.data),
createLabel: (collectionId: number, name: string, color?: string): Promise<CollectionLabel> =>
ax.post(`${base}/labels`, { collection_id: collectionId, name, color } satisfies CollectionLabelCreateRequest).then((r: AxiosResponse) => r.data),
updateLabel: (labelId: number, body: CollectionLabelUpdateRequest): Promise<CollectionLabel> =>
ax.patch(`${base}/labels/${labelId}`, body satisfies CollectionLabelUpdateRequest).then((r: AxiosResponse) => r.data),
deleteLabel: (labelId: number): Promise<unknown> =>
ax.delete(`${base}/labels/${labelId}`).then((r: AxiosResponse) => r.data),
assignLabels: (labelIds: number[], placeIds: number[]): Promise<{ changed: number }> =>
ax.post(`${base}/labels/assign`, { label_ids: labelIds, place_ids: placeIds }).then((r: AxiosResponse) => r.data),
unassignLabels: (labelIds: number[], placeIds: number[]): Promise<{ changed: number }> =>
ax.post(`${base}/labels/unassign`, { label_ids: labelIds, place_ids: placeIds }).then((r: AxiosResponse) => r.data),
}
+2 -4
View File
@@ -7,7 +7,6 @@ describe('SCOPE_GROUPS', () => {
const expected = [
'trips:read', 'trips:write', 'trips:delete', 'trips:share',
'places:read', 'places:write',
'collections:read', 'collections:write',
'atlas:read', 'atlas:write',
'packing:read', 'packing:write',
'todos:read', 'todos:write',
@@ -17,7 +16,6 @@ describe('SCOPE_GROUPS', () => {
'notifications:read', 'notifications:write',
'vacay:read', 'vacay:write',
'geo:read', 'weather:read',
'journey:read', 'journey:write', 'journey:share',
]
for (const scope of expected) {
expect(SCOPE_GROUPS).toHaveProperty(scope)
@@ -34,8 +32,8 @@ describe('SCOPE_GROUPS', () => {
})
describe('ALL_SCOPES', () => {
it('FE-OAUTH-SCOPES-003: contains exactly 29 scopes', () => {
expect(ALL_SCOPES).toHaveLength(29)
it('FE-OAUTH-SCOPES-003: contains exactly 27 scopes', () => {
expect(ALL_SCOPES).toHaveLength(27)
})
it('FE-OAUTH-SCOPES-004: matches Object.keys(SCOPE_GROUPS)', () => {
-2
View File
@@ -20,8 +20,6 @@ export const SCOPE_GROUPS: Record<string, ScopeKeys> = {
'trips:share': { labelKey: 'oauth.scope.trips:share.label', descriptionKey: 'oauth.scope.trips:share.description', groupKey: 'oauth.scope.group.trips' },
'places:read': { labelKey: 'oauth.scope.places:read.label', descriptionKey: 'oauth.scope.places:read.description', groupKey: 'oauth.scope.group.places' },
'places:write': { labelKey: 'oauth.scope.places:write.label', descriptionKey: 'oauth.scope.places:write.description', groupKey: 'oauth.scope.group.places' },
'collections:read': { labelKey: 'oauth.scope.collections:read.label', descriptionKey: 'oauth.scope.collections:read.description', groupKey: 'oauth.scope.group.collections' },
'collections:write': { labelKey: 'oauth.scope.collections:write.label', descriptionKey: 'oauth.scope.collections:write.description', groupKey: 'oauth.scope.group.collections' },
'atlas:read': { labelKey: 'oauth.scope.atlas:read.label', descriptionKey: 'oauth.scope.atlas:read.description', groupKey: 'oauth.scope.group.atlas' },
'atlas:write': { labelKey: 'oauth.scope.atlas:write.label', descriptionKey: 'oauth.scope.atlas:write.description', groupKey: 'oauth.scope.group.atlas' },
'packing:read': { labelKey: 'oauth.scope.packing:read.label', descriptionKey: 'oauth.scope.packing:read.description', groupKey: 'oauth.scope.group.packing' },
-75
View File
@@ -1,75 +0,0 @@
// FE-API-UPLOAD-001 to FE-API-UPLOAD-013
//
// The shared axios instance carries timeout: 8000, and axios' timeout is a whole-request
// deadline — not an idle one. Any upload whose body takes longer than 8s to push is
// aborted mid-stream and the server reports a multer "Request aborted" (#1495).
//
// The original fix added `timeout: 0` to the three cover uploads by hand, which left the
// same bug live on 7 other endpoints — including the two that accept 500 MB (documents
// and backup restore). Every multipart call now goes through postMultipart(), so this
// suite pins ALL of them, not just the covers.
import { describe, it, expect, vi, afterEach } from 'vitest'
import {
apiClient,
authApi,
tripsApi,
placesApi,
adminApi,
journeyApi,
filesApi,
reservationsApi,
collabApi,
backupApi,
} from './client'
import { collectionsApi } from './collections'
describe('every multipart upload disables the global request timeout', () => {
afterEach(() => {
vi.restoreAllMocks()
})
function spyPost() {
return vi.spyOn(apiClient, 'post').mockResolvedValue({ data: {} } as any)
}
const fd = () => new FormData()
const file = () => new File(['x'], 'f.bin')
// [id, description, invoke, expected url]
const cases: [string, string, () => Promise<unknown>, string][] = [
['FE-API-UPLOAD-001', 'authApi.uploadAvatar (5 MB)', () => authApi.uploadAvatar(fd()), '/auth/avatar'],
['FE-API-UPLOAD-002', 'tripsApi.uploadCover (20 MB)', () => tripsApi.uploadCover(7, fd()), '/trips/7/cover'],
['FE-API-UPLOAD-003', 'placesApi.importGpx (10 MB)', () => placesApi.importGpx(7, file()), '/trips/7/places/import/gpx'],
['FE-API-UPLOAD-004', 'placesApi.importMapFile (10 MB)', () => placesApi.importMapFile(7, file()), '/trips/7/places/import/map'],
['FE-API-UPLOAD-005', 'adminApi.pluginUpload (50 MB)', () => adminApi.pluginUpload(file()), '/admin/plugins/upload'],
['FE-API-UPLOAD-006', 'journeyApi.uploadCover (20 MB)', () => journeyApi.uploadCover(7, fd()), '/journeys/7/cover'],
['FE-API-UPLOAD-007', 'journeyApi.uploadPhotos (20 MB)', () => journeyApi.uploadPhotos(7, fd()), '/journeys/entries/7/photos'],
['FE-API-UPLOAD-008', 'journeyApi.uploadGalleryVideo (500 MB)', () => journeyApi.uploadGalleryVideo(7, fd()), '/journeys/7/gallery/video'],
['FE-API-UPLOAD-009', 'filesApi.upload (500 MB)', () => filesApi.upload(7, fd()), '/trips/7/files'],
['FE-API-UPLOAD-010', 'collabApi.uploadNoteFile (50 MB)', () => collabApi.uploadNoteFile(7, 3, fd()), '/trips/7/collab/notes/3/files'],
['FE-API-UPLOAD-011', 'backupApi.uploadRestore (500 MB)', () => backupApi.uploadRestore(file()), '/backup/upload-restore'],
['FE-API-UPLOAD-012', 'collectionsApi.uploadCover (20 MB)', () => collectionsApi.uploadCover(7, fd()), '/addons/collections/7/cover'],
]
for (const [id, desc, invoke, url] of cases) {
it(`${id}: ${desc} posts with timeout 0`, async () => {
const post = spyPost()
await invoke()
expect(post).toHaveBeenCalledWith(
url,
expect.any(FormData),
expect.objectContaining({ timeout: 0 }),
)
})
}
it('FE-API-UPLOAD-013: reservationsApi booking import posts with timeout 0', async () => {
const post = spyPost()
await reservationsApi.importBookingPreview(7, [file()])
expect(post).toHaveBeenCalledWith(
'/trips/7/reservations/import/booking',
expect.any(FormData),
expect.objectContaining({ timeout: 0 }),
)
})
})
-267
View File
@@ -1,267 +0,0 @@
// vi.unmock must run before the module is imported (tests/setup.ts mocks it globally)
vi.unmock('./websocket')
// FE-WSCORE-001 to FE-WSCORE-014
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../../tests/helpers/msw/server'
import {
connect, disconnect, joinTrip, leaveTrip, getActiveTrips,
setRefetchCallback, setPreReconnectHook,
} from './websocket'
class MockWebSocket {
static CONNECTING = 0
static OPEN = 1
static CLOSING = 2
static CLOSED = 3
static instances: MockWebSocket[] = []
readyState: number = MockWebSocket.OPEN
send = vi.fn((_data: string) => {})
close = vi.fn(() => {})
onopen: (() => void) | null = null
onmessage: ((event: { data: string }) => void) | null = null
onclose: (() => void) | null = null
onerror: (() => void) | null = null
constructor(public url: string) {
MockWebSocket.instances.push(this)
}
}
function lastSocket(): MockWebSocket {
return MockWebSocket.instances[MockWebSocket.instances.length - 1]
}
const realLocation = window.location
beforeEach(() => {
vi.useFakeTimers()
MockWebSocket.instances = []
Object.defineProperty(globalThis, 'WebSocket', {
writable: true, configurable: true, value: MockWebSocket,
})
server.use(http.post('/api/auth/ws-token', () => HttpResponse.json({ token: 'ws-tok' })))
})
afterEach(() => {
disconnect()
setRefetchCallback(null)
setPreReconnectHook(null)
vi.useRealTimers()
vi.restoreAllMocks()
Object.defineProperty(window, 'location', { writable: true, configurable: true, value: realLocation })
})
/** connect() + settle the token fetch so a socket exists. */
async function openSocket(): Promise<MockWebSocket> {
connect()
await vi.advanceTimersByTimeAsync(0)
return lastSocket()
}
describe('websocket > active trips', () => {
it('FE-WSCORE-001: getActiveTrips lists the joined trips as strings', async () => {
expect(getActiveTrips()).toEqual([])
joinTrip(42)
joinTrip('7')
expect(getActiveTrips()).toEqual(['42', '7'])
disconnect()
expect(getActiveTrips()).toEqual([])
})
it('FE-WSCORE-013: join/leave still bookkeep while no socket is open', () => {
joinTrip(5)
expect(getActiveTrips()).toEqual(['5'])
leaveTrip(5)
expect(getActiveTrips()).toEqual([])
})
it('FE-WSCORE-014: a trip joined before onopen is not re-sent while the socket is closing', async () => {
joinTrip(11)
const sock = await openSocket()
sock.readyState = MockWebSocket.CLOSING
sock.onopen!()
expect(sock.send).not.toHaveBeenCalled()
})
})
describe('websocket > reconnect refetch hook', () => {
it('FE-WSCORE-002: the pre-reconnect hook is awaited before the refetch runs', async () => {
const order: string[] = []
setPreReconnectHook(async () => { order.push('flush') })
setRefetchCallback(() => { order.push('refetch') })
joinTrip(3)
const sock = await openSocket()
sock.onopen!()
await vi.advanceTimersByTimeAsync(0)
expect(order).toEqual(['flush', 'refetch'])
})
it('FE-WSCORE-003: a rejecting pre-reconnect hook still lets the refetch run', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const refetch = vi.fn((_tripId: string) => {})
setPreReconnectHook(async () => { throw new Error('queue flush failed') })
setRefetchCallback(refetch)
joinTrip(3)
const sock = await openSocket()
sock.onopen!()
await vi.advanceTimersByTimeAsync(0)
expect(refetch).toHaveBeenCalledWith('3')
expect(consoleError).toHaveBeenCalled()
})
it('FE-WSCORE-004: a throwing refetch callback is logged, not propagated', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
setRefetchCallback(() => { throw new Error('store blew up') })
joinTrip(3)
const sock = await openSocket()
expect(() => sock.onopen!()).not.toThrow()
expect(consoleError).toHaveBeenCalledWith(
'Failed to refetch trip data on reconnect:',
expect.any(Error),
)
})
it('FE-WSCORE-005: with no joined trips onopen sends nothing and skips the refetch', async () => {
const refetch = vi.fn((_tripId: string) => {})
setRefetchCallback(refetch)
const sock = await openSocket()
sock.onopen!()
expect(sock.send).not.toHaveBeenCalled()
expect(refetch).not.toHaveBeenCalled()
})
})
describe('websocket > connection lifecycle', () => {
it('FE-WSCORE-006: connect() is a no-op while a socket is still CONNECTING', async () => {
const sock = await openSocket()
sock.readyState = MockWebSocket.CONNECTING
connect()
await vi.advanceTimersByTimeAsync(0)
expect(MockWebSocket.instances).toHaveLength(1)
})
it('FE-WSCORE-007: connect() cancels a pending reconnect timer', async () => {
server.use(http.post('/api/auth/ws-token', () => new HttpResponse(null, { status: 503 })))
connect()
await vi.advanceTimersByTimeAsync(0)
expect(MockWebSocket.instances).toHaveLength(0)
// A retry is now armed; connect() must clear it and dial immediately.
server.use(http.post('/api/auth/ws-token', () => HttpResponse.json({ token: 'fresh' })))
connect()
await vi.advanceTimersByTimeAsync(0)
expect(MockWebSocket.instances).toHaveLength(1)
// The cancelled timer must not fire a second dial afterwards.
await vi.advanceTimersByTimeAsync(5000)
expect(MockWebSocket.instances).toHaveLength(1)
})
it('FE-WSCORE-008: a duplicate close does not stack a second timer or skip a backoff step', async () => {
const sock = await openSocket()
// Every further token fetch fails, so each retry attempt is countable.
let attempts = 0
server.use(http.post('/api/auth/ws-token', () => {
attempts++
return new HttpResponse(null, { status: 503 })
}))
// A browser can deliver close twice (after onerror); the second must be ignored.
sock.onclose!()
sock.onclose!()
await vi.advanceTimersByTimeAsync(1001)
await vi.advanceTimersByTimeAsync(0)
expect(attempts, 'first retry fires after the 1s delay').toBe(1)
// Backoff advanced once (1s → 2s), not twice, so the next retry lands at 2s.
await vi.advanceTimersByTimeAsync(2001)
await vi.advanceTimersByTimeAsync(0)
expect(attempts, 'second retry fires after the doubled 2s delay').toBe(2)
expect(MockWebSocket.instances).toHaveLength(1)
})
it('FE-WSCORE-009: a failing ws-token fetch schedules a retry instead of throwing', async () => {
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('offline'))
connect()
await vi.advanceTimersByTimeAsync(0)
expect(MockWebSocket.instances).toHaveLength(0)
vi.mocked(globalThis.fetch).mockResolvedValue(
new Response(JSON.stringify({ token: 'back-online' }), {
status: 200, headers: { 'Content-Type': 'application/json' },
}),
)
await vi.advanceTimersByTimeAsync(1001)
await vi.advanceTimersByTimeAsync(0)
expect(MockWebSocket.instances).toHaveLength(1)
expect(lastSocket().url).toContain('token=back-online')
})
it('FE-WSCORE-010: the socket URL uses ws:// on http and wss:// on https', async () => {
const httpSock = await openSocket()
expect(httpSock.url.startsWith('ws://')).toBe(true)
disconnect()
MockWebSocket.instances = []
Object.defineProperty(window, 'location', {
writable: true, configurable: true,
value: {
protocol: 'https:',
host: 'trip.example',
origin: 'https://trip.example',
href: 'https://trip.example/dashboard',
pathname: '/dashboard',
},
})
const secure = await openSocket()
expect(secure.url).toBe('wss://trip.example/ws?token=ws-tok')
})
it('FE-WSCORE-011: disconnect() detaches onclose so no reconnect is armed', async () => {
const sock = await openSocket()
disconnect()
expect(sock.onclose).toBeNull()
expect(sock.close).toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(5000)
expect(MockWebSocket.instances).toHaveLength(1)
})
it('FE-WSCORE-012: onerror is inert — the reconnect is driven by onclose', async () => {
const sock = await openSocket()
expect(() => sock.onerror!()).not.toThrow()
expect(MockWebSocket.instances).toHaveLength(1)
sock.onclose!()
await vi.advanceTimersByTimeAsync(1001)
await vi.advanceTimersByTimeAsync(0)
expect(MockWebSocket.instances).toHaveLength(2)
})
})
-55
View File
@@ -9,15 +9,6 @@ let reconnectDelay = 1000
const MAX_RECONNECT_DELAY = 30000
const listeners = new Set<WebSocketListener>()
const activeTrips = new Set<string>()
/**
* Studio books this tab has open, so presence survives a reconnect.
*
* Same reasoning as `activeTrips`: the server forgets the room when the socket
* dies, and without rejoining, everyone else keeps showing a pointer for
* somebody who is no longer there.
*/
const activeBooks = new Set<string>()
let shouldReconnect = false
let refetchCallback: RefetchCallback | null = null
let mySocketId: string | null = null
@@ -29,12 +20,6 @@ export function getSocketId(): string | null {
return mySocketId
}
/** Trip ids the app currently has open (joined). Used to re-hydrate the active
* trip's store after the network comes back via the `online` event. */
export function getActiveTrips(): string[] {
return Array.from(activeTrips)
}
export function setRefetchCallback(fn: RefetchCallback | null): void {
refetchCallback = fn
}
@@ -119,11 +104,6 @@ async function connectInternal(_isReconnect = false): Promise<void> {
socket.onopen = () => {
reconnectDelay = 1000
activeBooks.forEach(journeyId => {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'book:join', journeyId }))
}
})
if (activeTrips.size > 0) {
activeTrips.forEach(tripId => {
if (socket && socket.readyState === WebSocket.OPEN) {
@@ -180,7 +160,6 @@ export function disconnect(): void {
reconnectTimer = null
}
activeTrips.clear()
activeBooks.clear()
if (socket) {
socket.onclose = null
socket.close()
@@ -202,40 +181,6 @@ export function leaveTrip(tripId: number | string): void {
}
}
/**
* Open a Studio book — presence and pointers, for as long as the editor is up.
*/
export function joinBook(journeyId: number | string): void {
activeBooks.add(String(journeyId))
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'book:join', journeyId: String(journeyId) }))
}
}
export function leaveBook(journeyId: number | string): void {
activeBooks.delete(String(journeyId))
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'book:leave', journeyId: String(journeyId) }))
}
}
/**
* Where this tab's pointer is, in millimetres on the spread.
*
* Dropped silently when the socket is not open. A pointer is worth nothing a
* moment later, so there is nothing to queue and nothing to retry — the next
* one is along in a tenth of a second.
*/
export function sendBookCursor(
journeyId: number | string,
spreadIndex: number,
x: number | null,
y: number | null,
): void {
if (!socket || socket.readyState !== WebSocket.OPEN) return
socket.send(JSON.stringify({ type: 'book:cursor', journeyId: String(journeyId), spreadIndex, x, y }))
}
export function addListener(fn: WebSocketListener): void {
listeners.add(fn)
}
-104
View File
@@ -1,104 +0,0 @@
import type { TrekWsEventName } from '@trek/shared'
/**
* Client-side handling policy for every event in the shared WS registry
* (`TREK_WS_EVENTS` in @trek/shared). Together with the tripStore lookups
* (DEXIE_WRITERS / STATE_APPLIERS in store/slices/remoteEventHandler.ts),
* these lists partition the registry exactly — the registry-parity test
* fails if a registry event is missing from all of them, or listed twice.
* A new server event therefore forces an explicit client decision (handle
* it, or add it here) instead of being dropped by a silent `default:`.
*/
/**
* Events consumed by dedicated listeners outside the tripStore reducer.
* Every entry names real handling code — if that code is removed, remove
* the entry (the event then needs a new home or an IGNORED_WS_EVENTS slot).
*/
export const HANDLED_OUTSIDE_TRIP_STORE = [
// Collab — Collab/MCollab components + useTripWebSocket's collabFileSync
'collab:note:created',
'collab:note:updated',
'collab:note:deleted',
'collab:poll:created',
'collab:poll:voted',
'collab:poll:closed',
'collab:poll:deleted',
'collab:message:created',
'collab:message:reacted',
'collab:message:deleted',
// In-app notifications — hooks/useInAppNotificationListener
'notification:new',
'notification:updated',
// Collections — pages/collections/useCollections ('collections:' prefix listener)
'collections:updated',
'collections:accepted',
'collections:declined',
'collections:left',
'collections:deleted',
'collections:cancelled',
'collections:removed',
'collections:invite',
// Vacay — pages/vacay/useVacay
'vacay:update',
'vacay:settings',
'vacay:accepted',
'vacay:declined',
'vacay:cancelled',
'vacay:dissolved',
'vacay:invite',
'vacay:share',
'vacay:share-removed',
'vacay:shared-update',
// Journey — pages/journeyDetail/useJourneyDetail ('journey:' prefix listener)
'journey:trip:synced',
'journey:entry:created',
'journey:entry:updated',
'journey:entry:deleted',
'journey:entries:reordered',
'journey:contributor:changed',
// Studio book — components/Studio/useBookStore (its own listener: a client
// with nothing outstanding takes the new version, one with unsaved edits
// deliberately does not and conflicts on its next save instead)
'journey:book:saved',
// Studio presence — components/Studio/useBookPresence (its own listener:
// who has the book open, and where their pointers are)
'journey:book:peers',
'journey:book:cursor',
// Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener)
'import:progress',
'import:done',
'import:error',
] as const satisfies readonly TrekWsEventName[]
/**
* Events the client deliberately does not act on today (state of the world
* when the registry landed — every one of these was already dropped by the
* old silent `default:` branches). Removing an entry means the event is now
* handled somewhere; ADDING an entry is a product decision that a new server
* event should have no client reaction — never add one just to silence the
* registry-parity test.
*/
export const IGNORED_WS_EVENTS = [
'assignment:participants',
'packing:reordered',
'packing:bag-created',
'packing:bag-updated',
'packing:bag-deleted',
'packing:bag-members-updated',
'packing:assignees',
'packing:template-applied',
'todo:assignees',
'budget:settlement-created',
'budget:settlement-updated',
'budget:settlement-deleted',
'reservation:positions',
// Accommodations live in page-local planner state; the client refetches
// them off trip:updated date changes, never off these events.
'accommodation:created',
'accommodation:updated',
'accommodation:deleted',
'trip:deleted',
'member:added',
'member:removed',
] as const satisfies readonly TrekWsEventName[]
@@ -1,7 +1,7 @@
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-025
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-011
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { delay, http, HttpResponse } from 'msw';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore';
@@ -21,45 +21,6 @@ function buildAddon(overrides = {}) {
};
}
function addonsRoute(addons: ReturnType<typeof buildAddon>[]) {
return http.get('/api/admin/addons', () => HttpResponse.json({ addons }));
}
function llmAddon(config: Record<string, unknown> = {}) {
return buildAddon({
id: 'llm_parsing',
name: 'AI Parsing',
description: 'Extract bookings from files',
icon: 'Sparkles',
type: 'integration',
enabled: true,
config,
});
}
function modelsRoute(names: string[], seen?: (string | null)[]) {
return http.get('/api/admin/llm/local/models', ({ request }) => {
seen?.push(new URL(request.url).searchParams.get('baseUrl'));
return HttpResponse.json({ models: names.map(name => ({ name, size: 1 })) });
});
}
/** The pill toggle of a top-level addon row. */
function addonToggle(name: string): HTMLElement {
const row = screen.getByText(name).closest('.px-6.py-4') as HTMLElement;
return within(row).getByRole('button');
}
/** The pill toggle of an indented sub-row (bag tracking, collab feature, photo provider). */
function subToggle(label: string): HTMLElement {
const row = screen.getByText(label).closest('.flex.items-center.gap-4') as HTMLElement;
return within(row).getByRole('button');
}
function isOn(toggle: HTMLElement): boolean {
return toggle.style.background === 'var(--text-primary)';
}
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
@@ -268,360 +229,4 @@ describe('AddonManager', () => {
expect(() => render(<AddonManager />)).not.toThrow();
await screen.findByText('Mystery Addon');
});
it('FE-ADMIN-ADDON-012: a failing load toasts the addon error and shows the empty state', async () => {
server.use(http.get('/api/admin/addons', () => HttpResponse.error()));
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Failed to update addon');
expect(screen.getByText('No addons available')).toBeInTheDocument();
});
it('FE-ADMIN-ADDON-013: dark mode swaps the wordmark in the header', async () => {
seedStore(useSettingsStore, { settings: { dark_mode: 'dark' } });
render(<AddonManager />);
await screen.findByText('No addons available');
expect(screen.getByAltText('TREK')).toHaveAttribute('src', '/text-light.svg');
});
it('FE-ADMIN-ADDON-014: photo-flavoured trip addons are hidden from the trip section', async () => {
server.use(addonsRoute([
buildAddon({ id: 'photos', name: 'Memories', icon: 'Image' }),
buildAddon({ id: 'gallery', name: 'Trip Photos', icon: 'Puzzle', description: 'Share your photo stream' }),
buildAddon({ id: 'todo', name: 'Todo List' }),
]));
render(<AddonManager />);
await screen.findByText('Todo List');
expect(screen.queryByText('Memories')).not.toBeInTheDocument();
expect(screen.queryByText('Trip Photos')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-015: provider sub-rows carry their vendor icons and toggle state', async () => {
server.use(addonsRoute([
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: true }),
buildAddon({ id: 'synologyphotos', name: 'Synology Photos', description: 'NAS photos', type: 'photo_provider', enabled: false }),
buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: false }),
]));
render(<AddonManager />);
await screen.findByText('Immich');
// immich and synologyphotos ship a vendor glyph, unsplash does not
const immichRow = screen.getByText('Immich').closest('.flex.items-center.gap-4') as HTMLElement;
expect(immichRow.querySelector('svg')).toBeInTheDocument();
const synologyRow = screen.getByText('Synology Photos').closest('.flex.items-center.gap-4') as HTMLElement;
expect(synologyRow.querySelector('svg')).toBeInTheDocument();
const unsplashRow = screen.getByText('Unsplash').closest('.flex.items-center.gap-4') as HTMLElement;
expect(unsplashRow.querySelector('svg')).not.toBeInTheDocument();
expect(isOn(subToggle('Immich'))).toBe(true);
expect(isOn(subToggle('Unsplash'))).toBe(false);
});
it('FE-ADMIN-ADDON-016: toggling a photo provider persists it and refreshes the global addons', async () => {
const user = userEvent.setup();
let body: unknown = null;
server.use(
addonsRoute([
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: false }),
]),
http.put('/api/admin/addons/immich', async ({ request }) => {
body = await request.json();
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Immich');
await user.click(subToggle('Immich'));
await waitFor(() => expect(body).toEqual({ enabled: true }));
await screen.findByText('Addon updated');
expect(isOn(subToggle('Immich'))).toBe(true);
});
it('FE-ADMIN-ADDON-017: a failing photo-provider toggle rolls the sub-row back', async () => {
const user = userEvent.setup();
server.use(
addonsRoute([
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: true }),
]),
http.put('/api/admin/addons/unsplash', () => HttpResponse.error()),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Unsplash');
await user.click(subToggle('Unsplash'));
await screen.findByText('Failed to update addon');
await waitFor(() => expect(isOn(subToggle('Unsplash'))).toBe(true));
});
it('FE-ADMIN-ADDON-018: the collab sub-features render their state and report the toggled key', async () => {
const user = userEvent.setup();
const onToggleCollabFeature = vi.fn();
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
render(
<AddonManager
collabFeatures={{ chat: true, notes: false, polls: false, whatsnext: true }}
onToggleCollabFeature={onToggleCollabFeature}
/>,
);
await screen.findByText('Chat');
expect(screen.getByText('Notes')).toBeInTheDocument();
expect(screen.getByText('Polls')).toBeInTheDocument();
expect(screen.getByText("What's Next")).toBeInTheDocument();
expect(isOn(subToggle('Chat'))).toBe(true);
expect(isOn(subToggle('Notes'))).toBe(false);
await user.click(subToggle('Polls'));
expect(onToggleCollabFeature).toHaveBeenCalledWith('polls');
});
it('FE-ADMIN-ADDON-019: collab sub-features stay hidden without the handler props', async () => {
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
render(<AddonManager />);
await screen.findByText('Collab');
expect(screen.queryByText('Polls')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-020: a disabled AI-parsing addon renders the row without its config block', async () => {
server.use(addonsRoute([{ ...llmAddon({ provider: 'local' }), enabled: false }]));
render(<AddonManager />);
await screen.findByText('AI Parsing');
expect(screen.getByText('Extract bookings from files')).toBeInTheDocument();
expect(screen.queryByText('Connection')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-021: the local provider lists installed models and a chip fills the model field', async () => {
const user = userEvent.setup();
const urls: (string | null)[] = [];
server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3:8b', 'llama3:8b'], urls));
render(<AddonManager />);
await screen.findByText('Installed on the server');
await screen.findByRole('button', { name: 'llama3:8b' });
expect(urls[0]).toBe('http://localhost:11434/v1');
await user.click(screen.getByRole('button', { name: 'llama3:8b' }));
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('llama3:8b');
// qwen3:8b is already installed, so the recommended row offers "Use" instead of "Pull"
await user.click(screen.getByRole('button', { name: 'Use' }));
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b');
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
});
it('FE-ADMIN-ADDON-022: an unreachable Ollama shows the error and Refresh retries', async () => {
const user = userEvent.setup();
let calls = 0;
server.use(
addonsRoute([llmAddon({ provider: 'local' })]),
http.get('/api/admin/llm/local/models', () => {
calls += 1;
return calls === 1
? HttpResponse.json({ error: 'down' }, { status: 500 })
: HttpResponse.json({ models: [] });
}),
);
render(<AddonManager />);
await screen.findByText(/Request failed with status code 500/);
await user.click(screen.getByRole('button', { name: 'Refresh' }));
await screen.findByText('No models installed yet — pull one below.');
expect(calls).toBe(2);
});
it('FE-ADMIN-ADDON-023: switching providers swaps the base URL field, the model hint and the Ollama block', async () => {
const user = userEvent.setup();
const urls: (string | null)[] = [];
server.use(addonsRoute([llmAddon({ provider: 'local', apiKey: '••••••••' })]), modelsRoute([], urls));
render(<AddonManager />);
await screen.findByText('Installed on the server');
expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument();
// A hand-typed base URL is used for the next lookup on blur
await user.type(screen.getByPlaceholderText('http://localhost:11434/v1'), 'http://ollama.lan:11434/v1');
await user.tab();
await waitFor(() => expect(urls).toContain('http://ollama.lan:11434/v1'));
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
expect(screen.getByPlaceholderText('https://api.openai.com/v1')).toBeInTheDocument();
expect(screen.getByPlaceholderText('gpt-4o')).toBeInTheDocument();
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
expect(screen.queryByPlaceholderText('https://api.openai.com/v1')).not.toBeInTheDocument();
expect(screen.getByPlaceholderText('claude-opus-4-8')).toBeInTheDocument();
expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument();
});
it('FE-ADMIN-ADDON-024: pulling a model streams progress and then selects it', async () => {
const user = userEvent.setup();
let pulled: unknown = null;
let modelCalls = 0;
server.use(
addonsRoute([llmAddon({ provider: 'local' })]),
http.get('/api/admin/llm/local/models', () => {
modelCalls += 1;
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3:8b', size: 1 }] });
}),
http.post('/api/admin/llm/local/pull', async ({ request }) => {
pulled = await request.json();
await delay(150);
return new HttpResponse(
'{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n',
{ headers: { 'Content-Type': 'application/x-ndjson' } },
);
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('No models installed yet — pull one below.');
await user.click(screen.getByRole('button', { name: 'Pull' }));
await screen.findByText('Pulling…');
expect(screen.getByText('starting…')).toBeInTheDocument();
await screen.findByText('Model pulled');
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3:8b' });
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b');
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
});
it('FE-ADMIN-ADDON-025: a failing pull surfaces the server error and saving reports both outcomes', async () => {
const user = userEvent.setup();
const bodies: unknown[] = [];
server.use(
addonsRoute([llmAddon({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', multimodal: true })]),
modelsRoute([]),
http.post('/api/admin/llm/local/pull', () => HttpResponse.json({ error: 'no disk space' }, { status: 500 })),
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
bodies.push(await request.json());
return bodies.length === 1 ? HttpResponse.json({ success: true }) : HttpResponse.error();
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('No models installed yet — pull one below.');
await user.click(screen.getByRole('button', { name: 'Pull' }));
await screen.findByText('no disk space');
expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled();
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Saved');
expect(bodies[0]).toEqual({
config: { provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', multimodal: true },
});
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Failed to save');
});
it('FE-ADMIN-ADDON-026: model and API key are editable and their hints follow the provider', async () => {
const user = userEvent.setup();
const bodies: unknown[] = [];
server.use(
addonsRoute([llmAddon({ provider: 'local' })]),
modelsRoute([]),
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Installed on the server');
expect(screen.getByPlaceholderText('(often not required)')).toBeInTheDocument();
await user.type(screen.getByPlaceholderText('select or pull below'), ' mistral:7b ');
await user.type(screen.getByPlaceholderText('(often not required)'), 'sk-live');
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
expect(screen.getByPlaceholderText('sk-…')).toHaveValue('sk-live');
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Saved');
// The model is trimmed before it is stored, the key is sent verbatim
expect(bodies[0]).toEqual({
config: { provider: 'openai', model: 'mistral:7b', baseUrl: '', apiKey: 'sk-live', multimodal: false },
});
});
it('FE-ADMIN-ADDON-029: switching to Anthropic clears a stale base URL before saving', async () => {
const user = userEvent.setup();
const bodies: unknown[] = [];
server.use(
addonsRoute([llmAddon({ provider: 'local', model: '', baseUrl: 'http://ollama.lan:11434/v1', apiKey: '', multimodal: false })]),
modelsRoute([]),
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Installed on the server');
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
await user.type(screen.getByPlaceholderText('claude-opus-4-8'), 'claude-haiku-4-5-20251001');
await user.type(screen.getByPlaceholderText('sk-…'), 'sk-ant-live');
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Saved');
// The stale local base URL must not ride along to Anthropic — it would hijack the endpoint.
expect(bodies[0]).toEqual({
config: { provider: 'anthropic', model: 'claude-haiku-4-5-20251001', baseUrl: '', apiKey: 'sk-ant-live', multimodal: false },
});
});
it('FE-ADMIN-ADDON-027: an error frame in the pull stream aborts the pull and is reported', async () => {
const user = userEvent.setup();
server.use(
addonsRoute([llmAddon({ provider: 'local' })]),
modelsRoute([]),
http.post('/api/admin/llm/local/pull', () => new HttpResponse(
'{"status":"pulling manifest"}\n{"error":"manifest not found"}\n',
{ headers: { 'Content-Type': 'application/x-ndjson' } },
)),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('No models installed yet — pull one below.');
await user.click(screen.getByRole('button', { name: 'Pull' }));
await screen.findByText('manifest not found');
expect(screen.queryByText('Model pulled')).not.toBeInTheDocument();
await waitFor(() => expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled());
expect(screen.queryByText('Pulling…')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-028: blurring the base URL under a cloud provider queries no local models', async () => {
const user = userEvent.setup();
const urls: (string | null)[] = [];
server.use(addonsRoute([llmAddon({ provider: 'openai' })]), modelsRoute([], urls));
render(<AddonManager />);
await screen.findByText('Connection');
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
await user.type(screen.getByPlaceholderText('https://api.openai.com/v1'), 'https://proxy.local/v1');
await user.tab();
await waitFor(() => expect(screen.getByDisplayValue('https://proxy.local/v1')).toBeInTheDocument());
expect(urls).toHaveLength(0);
});
});
+5 -230
View File
@@ -1,14 +1,13 @@
import { useEffect, useState, type ComponentType } from 'react'
import { useEffect, useState } from 'react'
import { adminApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { useToast } from '../shared/Toast'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane } from 'lucide-react'
const ICON_MAP = {
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark,
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane,
}
function ImmichIcon({ size = 14 }: { size?: number }) {
@@ -27,7 +26,7 @@ function SynologyIcon({ size = 14 }: { size?: number }) {
)
}
const PROVIDER_ICONS: Record<string, ComponentType<{ size?: number }>> = {
const PROVIDER_ICONS: Record<string, React.FC<{ size?: number }>> = {
immich: ImmichIcon,
synologyphotos: SynologyIcon,
}
@@ -299,12 +298,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
</span>
</div>
{integrationAddons.map(addon => (
<div key={addon.id}>
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
{addon.id === 'llm_parsing' && addon.enabled && (
<LlmParsingConfig addon={addon} />
)}
</div>
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
))}
</div>
)}
@@ -315,225 +309,6 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
)
}
const MASKED = '••••••••'
const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1'
/** Curated models the local extractor is tuned for, pullable via Ollama. The router drives
* one model per document via Ollama's grammar-constrained `format`; "thinking" is disabled
* automatically, so the Qwen3 family works without any tuning. A host only needs one. */
const RECOMMENDED_MODELS: { id: string; label: string; note: string; recommended: boolean; vision: boolean }[] = [
{ id: 'qwen3:8b', label: 'Qwen3 — 8B', note: 'Recommended · best extraction quality & speed on CPU (thinking auto-disabled) · Apache-2.0', recommended: true, vision: false },
]
/**
* Instance-wide AI-parsing config. When set, applies to the whole instance and
* overrides per-user config (see server llmConfig.ts). The API key is masked on
* read; an unchanged mask is treated as a no-op by the server. For the local
* provider, it also lists installed Ollama models and can pull NuExtract models.
*/
function LlmParsingConfig({ addon }: { addon: Addon }) {
const toast = useToast()
const cfg = (addon.config ?? {}) as Record<string, unknown>
const [provider, setProvider] = useState<string>((cfg.provider as string) ?? 'local')
const [model, setModel] = useState<string>((cfg.model as string) ?? '')
const [baseUrl, setBaseUrl] = useState<string>((cfg.baseUrl as string) ?? '')
const [apiKey, setApiKey] = useState<string>((cfg.apiKey as string) ?? '')
const [saving, setSaving] = useState(false)
// Local-provider model management.
const [installed, setInstalled] = useState<string[]>([])
const [modelsErr, setModelsErr] = useState('')
const [loadingModels, setLoadingModels] = useState(false)
const [pulling, setPulling] = useState<string | null>(null)
const [pullPct, setPullPct] = useState(0)
const [pullStatus, setPullStatus] = useState('')
const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL
const isInstalled = (id: string) => installed.some(n => n === id || n.startsWith(id + ':') || n.startsWith(id))
const loadModels = async () => {
if (provider !== 'local') return
setLoadingModels(true)
setModelsErr('')
try {
const res = await adminApi.llmLocalModels(effectiveUrl)
setInstalled(res.models.map(m => m.name))
} catch (e: unknown) {
setModelsErr(e instanceof Error ? e.message : 'Could not reach the local LLM server')
setInstalled([])
} finally {
setLoadingModels(false)
}
}
// Load installed models when the local provider is active.
useEffect(() => {
if (provider === 'local') loadModels()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider])
const pull = async (id: string) => {
if (pulling) return
setPulling(id)
setPullPct(0)
setPullStatus('starting…')
try {
await adminApi.llmLocalPull(effectiveUrl, id, (p) => {
if (p.error) throw new Error(p.error)
if (p.status) setPullStatus(p.status)
if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100))
})
toast.success('Model pulled')
setModel(id)
await loadModels()
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : 'Pull failed')
} finally {
setPulling(null)
setPullPct(0)
setPullStatus('')
}
}
const save = async () => {
setSaving(true)
try {
// Send the masked sentinel unchanged so the server keeps the stored key.
await adminApi.updateAddon(addon.id, { config: { provider, model: model.trim(), baseUrl: provider === 'anthropic' ? '' : baseUrl.trim(), apiKey, multimodal: cfg.multimodal === true } })
toast.success('Saved')
} catch {
toast.error('Failed to save')
} finally {
setSaving(false)
}
}
const fieldCls = 'w-full rounded-lg border border-edge-secondary bg-surface px-3 py-2 text-sm text-content placeholder:text-content-faint transition-colors focus:border-edge focus:outline-none'
const labelCls = 'mb-1.5 block text-xs font-medium text-content-secondary'
const sectionCls = 'text-[11px] font-semibold uppercase tracking-wide text-content-faint'
const providerOptions = [
{ value: 'local', label: 'Local · OpenAI-compatible', icon: <Server size={14} />, badge: 'Ollama' },
{ value: 'openai', label: 'OpenAI', icon: <Cloud size={14} /> },
{ value: 'anthropic', label: 'Anthropic', icon: <Sparkles size={14} /> },
]
return (
<div className="border-b border-edge-secondary bg-surface-secondary py-5 pr-6 pl-[70px]">
<div className="max-w-2xl space-y-6">
<p className="text-xs text-content-faint">
Set instance-wide config (applies to all users). Leave blank to let each user configure their own provider.
</p>
{/* Connection */}
<section className="space-y-3">
<div className={sectionCls}>Connection</div>
<div>
<span className={labelCls}>Provider</span>
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
</div>
{provider !== 'anthropic' && (
<label className="block">
<span className={labelCls}>Base URL</span>
<input type="url" autoComplete="off" className={fieldCls} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} />
</label>
)}
<label className="block">
<span className={labelCls}>API key</span>
<input type="password" className={fieldCls} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
</label>
{provider === 'anthropic' && (
<p className="text-xs text-content-faint">Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.</p>
)}
</section>
{/* Model */}
<section className="space-y-3">
<div className={sectionCls}>Model</div>
<label className="block">
<input autoComplete="off" className={fieldCls} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
</label>
{/* Local model management (Ollama) */}
{provider === 'local' && (
<div className="space-y-3 rounded-lg border border-edge-secondary bg-surface p-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-content-secondary">Installed on the server</span>
<button onClick={loadModels} disabled={loadingModels} className="text-xs text-content-muted underline disabled:opacity-60">
{loadingModels ? 'Loading…' : 'Refresh'}
</button>
</div>
{modelsErr && <p className="text-xs text-rose-600">{modelsErr}</p>}
{!modelsErr && installed.length === 0 && !loadingModels && (
<p className="text-xs text-content-faint">No models installed yet — pull one below.</p>
)}
{installed.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{installed.map(name => (
<button
key={name}
title={name}
onClick={() => setModel(name)}
className={`max-w-full truncate rounded-full border px-2.5 py-1 text-xs transition-colors ${model === name ? 'border-transparent bg-accent text-accent-text' : 'border-edge-secondary text-content-secondary hover:border-edge'}`}
>
{name}
</button>
))}
</div>
)}
<div className="border-t border-edge-secondary pt-3">
<div className="mb-2 text-xs font-medium text-content-secondary">Pull a recommended model</div>
<div className="space-y-1">
{RECOMMENDED_MODELS.map(m => {
const installedHere = isInstalled(m.id)
const isPulling = pulling === m.id
const active = model === m.id
return (
<div key={m.id} className={`flex items-center gap-3 rounded-lg border px-3 py-2 transition-colors ${active ? 'border-edge-secondary bg-surface-secondary' : 'border-transparent'}`}>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm text-content">{m.label}</span>
{m.recommended && (
<span className="rounded-md bg-[rgba(16,185,129,0.15)] px-1.5 py-px text-[10px] font-semibold text-emerald-600">Recommended</span>
)}
</div>
<div className="text-xs text-content-faint">{m.note}</div>
{isPulling && (
<div className="mt-1.5">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
<div className="h-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
</div>
<div className="mt-0.5 text-[10px] text-content-faint">{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
</div>
)}
</div>
{installedHere ? (
<button onClick={() => setModel(m.id)} disabled={active} className={`shrink-0 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${active ? 'bg-surface-tertiary text-content-muted' : 'border border-edge-secondary text-content-secondary hover:border-edge'}`}>
{active ? 'Selected' : 'Use'}
</button>
) : (
<button onClick={() => pull(m.id)} disabled={!!pulling} className="shrink-0 rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-accent-text disabled:opacity-60">
{isPulling ? 'Pulling…' : 'Pull'}
</button>
)}
</div>
)
})}
</div>
</div>
</div>
)}
</section>
<button onClick={save} disabled={saving} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-text transition-opacity disabled:opacity-60">
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
)
}
interface AddonRowProps {
addon: Addon
onToggle: (addon: Addon) => void
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -310,292 +310,4 @@ describe('BackupPanel', () => {
expect(screen.getByRole('button', { name: /^save$/i })).not.toBeDisabled()
})
})
// BKP-015: List request fails
it('FE-ADMIN-BKP-015: a failing list request toasts and keeps the empty state', async () => {
server.use(http.get('/api/backup/list', () => HttpResponse.error()))
render(<><ToastContainer /><BackupPanel /></>)
expect(await screen.findByText('Failed to load backups')).toBeInTheDocument()
expect(screen.getByText('No backups yet')).toBeInTheDocument()
})
// BKP-016: Create fails
it('FE-ADMIN-BKP-016: a failing create toasts the error and re-enables the button', async () => {
const user = userEvent.setup()
server.use(http.post('/api/backup/create', () => HttpResponse.error()))
render(<><ToastContainer /><BackupPanel /></>)
await screen.findByText('backup-2025-01-15.zip')
await user.click(screen.getByTitle('Create Backup'))
expect(await screen.findByText('Failed to create backup')).toBeInTheDocument()
await waitFor(() => expect(screen.getByTitle('Create Backup')).toBeEnabled())
})
// BKP-017: Restore fails
it('FE-ADMIN-BKP-017: a failing restore surfaces the server message and clears the spinner', async () => {
const user = userEvent.setup()
server.use(
http.post('/api/backup/restore/:filename', () =>
HttpResponse.json({ error: 'archive is corrupt' }, { status: 400 }),
),
)
render(<><ToastContainer /><BackupPanel /></>)
await screen.findByText('backup-2025-01-15.zip')
await user.click(screen.getAllByText('Restore')[0])
await user.click(await screen.findByText('Yes, restore'))
expect(await screen.findByText('archive is corrupt')).toBeInTheDocument()
await waitFor(() => expect(screen.getAllByText('Restore')[0].closest('button')).toBeEnabled())
})
// BKP-018: Upload & restore happy path
it('FE-ADMIN-BKP-018: picking a file opens the modal and uploads it on confirm', async () => {
const user = userEvent.setup()
let uploaded = false
server.use(
http.post('/api/backup/upload-restore', () => {
uploaded = true
return HttpResponse.json({ success: true })
}),
)
render(<><ToastContainer /><BackupPanel /></>)
await screen.findByText('backup-2025-01-15.zip')
const reloadMock = vi.fn()
vi.stubGlobal('location', { ...window.location, reload: reloadMock })
const input = document.querySelector('input[type="file"]') as HTMLInputElement
await user.upload(input, new File(['zip'], 'restore-me.zip', { type: 'application/zip' }))
expect(await screen.findByText('Restore Backup?')).toBeInTheDocument()
expect(screen.getByText('restore-me.zip')).toBeInTheDocument()
// The picked file is cleared from the input so the same file can be chosen again
expect(input.value).toBe('')
await user.click(screen.getByText('Yes, restore'))
await waitFor(() => expect(uploaded).toBe(true))
expect(await screen.findByText('Backup restored. Page will reload…')).toBeInTheDocument()
vi.unstubAllGlobals()
})
// BKP-019: Upload & restore failure
it('FE-ADMIN-BKP-019: a failing upload restore toasts and re-enables the upload button', async () => {
const user = userEvent.setup()
server.use(
http.post('/api/backup/upload-restore', () => HttpResponse.json({ error: 'not a backup' }, { status: 400 })),
)
render(<><ToastContainer /><BackupPanel /></>)
await screen.findByText('backup-2025-01-15.zip')
const input = document.querySelector('input[type="file"]') as HTMLInputElement
await user.upload(input, new File(['zip'], 'broken.zip', { type: 'application/zip' }))
await user.click(await screen.findByText('Yes, restore'))
expect(await screen.findByText('not a backup')).toBeInTheDocument()
await waitFor(() => expect(screen.getByTitle('Upload Backup')).toBeEnabled())
})
// BKP-020: Upload button forwards the click to the hidden file input
it('FE-ADMIN-BKP-020: the Upload button opens the hidden file picker', async () => {
const user = userEvent.setup()
render(<BackupPanel />)
await screen.findByText('backup-2025-01-15.zip')
const input = document.querySelector('input[type="file"]') as HTMLInputElement
const clickSpy = vi.spyOn(input, 'click').mockImplementation(() => {})
await user.click(screen.getByTitle('Upload Backup'))
expect(clickSpy).toHaveBeenCalled()
})
// BKP-021: Delete declined / failing
it('FE-ADMIN-BKP-021: declining the confirm keeps the backup, a failing delete toasts', async () => {
const user = userEvent.setup()
let deleteCalls = 0
server.use(
http.delete('/api/backup/:filename', () => {
deleteCalls += 1
return HttpResponse.json({ error: 'file is locked' }, { status: 500 })
}),
)
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
render(<><ToastContainer /><BackupPanel /></>)
await screen.findByText('backup-2025-01-15.zip')
const trashBtn = Array.from(document.querySelectorAll('button')).find(
b => b.querySelector('svg.lucide-trash2'),
) as HTMLElement
await user.click(trashBtn)
expect(deleteCalls).toBe(0)
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
confirmSpy.mockReturnValue(true)
await user.click(trashBtn)
expect(await screen.findByText('Failed to delete')).toBeInTheDocument()
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
})
// BKP-022: Auto settings save fails
it('FE-ADMIN-BKP-022: a failing auto-settings save toasts and keeps the form dirty', async () => {
const user = userEvent.setup()
server.use(http.put('/api/backup/auto-settings', () => HttpResponse.error()))
render(<><ToastContainer /><BackupPanel /></>)
await screen.findByText('Enable auto-backup')
await user.click(getToggleButton())
await user.click(screen.getByRole('button', { name: /^save$/i }))
expect(await screen.findByText('Failed to save settings')).toBeInTheDocument()
await waitFor(() => expect(screen.getByRole('button', { name: /^save$/i })).toBeEnabled())
})
// BKP-023: Size/date fallbacks
it('FE-ADMIN-BKP-023: missing size and date render as a dash, kilobytes are formatted', async () => {
server.use(
http.get('/api/backup/list', () =>
HttpResponse.json({
backups: [
{ filename: 'empty.zip', created_at: null, size: 0 },
{ filename: 'small.zip', created_at: '2025-03-01T08:00:00Z', size: 5120 },
],
}),
),
)
render(<BackupPanel />)
await screen.findByText('empty.zip')
expect(screen.getAllByText('-')).toHaveLength(2)
expect(screen.getByText('5.0 KB')).toBeInTheDocument()
})
// BKP-024: Invalid server timezone
it('FE-ADMIN-BKP-024: an unusable server timezone falls back to the raw timestamp', async () => {
server.use(
http.get('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
timezone: 'Not/AZone',
}),
),
)
render(<BackupPanel />)
await screen.findByText('backup-2025-01-15.zip')
await waitFor(() => expect(screen.getByText('2025-01-15T10:00:00Z')).toBeInTheDocument())
})
// BKP-025: 12h hour picker
it('FE-ADMIN-BKP-025: the hour picker uses AM/PM labels for 12h users and stores the pick', async () => {
const user = userEvent.setup()
seedStore(useSettingsStore, { settings: { time_format: '12h' } } as any)
let saved: Record<string, unknown> | null = null
server.use(
http.get('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: true, interval: 'daily', keep_days: 7, hour: 0, day_of_week: 0, day_of_month: 1 },
timezone: 'UTC',
}),
),
http.put('/api/backup/auto-settings', async ({ request }) => {
saved = await request.json() as Record<string, unknown>
return HttpResponse.json({ settings: saved })
}),
)
render(<><ToastContainer /><BackupPanel /></>)
await screen.findByText('Run at hour')
expect(screen.getByText('Server local time (12h format) (Timezone: UTC)')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '12:00 AM' }))
await user.click(await screen.findByRole('button', { name: '2:00 PM' }))
await user.click(screen.getByRole('button', { name: /^save$/i }))
await waitFor(() => expect(saved).toMatchObject({ hour: 14 }))
})
// BKP-026: Monthly interval
it('FE-ADMIN-BKP-026: the monthly interval offers a day-of-month picker', async () => {
const user = userEvent.setup()
let saved: Record<string, unknown> | null = null
server.use(
http.get('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: true, interval: 'monthly', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
timezone: '',
}),
),
http.put('/api/backup/auto-settings', async ({ request }) => {
saved = await request.json() as Record<string, unknown>
return HttpResponse.json({ settings: saved })
}),
)
render(<><ToastContainer /><BackupPanel /></>)
await screen.findByText('Day of month')
// No timezone from the server → the hint carries no timezone suffix
expect(screen.getByText('Server local time (24h format)')).toBeInTheDocument()
expect(screen.queryByText('Sun')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '1' }))
await user.click(await screen.findByRole('button', { name: '15' }))
await user.click(screen.getByRole('button', { name: /^save$/i }))
await waitFor(() => expect(saved).toMatchObject({ day_of_month: 15 }))
})
// BKP-027: Day of week + retention
it('FE-ADMIN-BKP-027: day-of-week and retention picks are stored together', async () => {
const user = userEvent.setup()
let saved: Record<string, unknown> | null = null
server.use(
http.get('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: true, interval: 'weekly', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
timezone: 'UTC',
}),
),
http.put('/api/backup/auto-settings', async ({ request }) => {
saved = await request.json() as Record<string, unknown>
return HttpResponse.json({ settings: saved })
}),
)
render(<><ToastContainer /><BackupPanel /></>)
await screen.findByText('Day of week')
await user.click(screen.getByText('Fri'))
await user.click(screen.getByText('Keep forever'))
await user.click(screen.getByRole('button', { name: /^save$/i }))
await waitFor(() => expect(saved).toMatchObject({ day_of_week: 5, keep_days: 0 }))
})
// BKP-028: Download failure
it('FE-ADMIN-BKP-028: a failing download toasts the download error', async () => {
const user = userEvent.setup()
server.use(http.get('/api/backup/download/:filename', () => HttpResponse.error()))
render(<><ToastContainer /><BackupPanel /></>)
await screen.findByText('backup-2025-01-15.zip')
await user.click(screen.getByText('Download'))
expect(await screen.findByText('Download failed')).toBeInTheDocument()
})
// BKP-029: Confirm button hover styling
it('FE-ADMIN-BKP-029: the destructive confirm button darkens on hover', async () => {
const user = userEvent.setup()
render(<BackupPanel />)
await screen.findByText('backup-2025-01-15.zip')
await user.click(screen.getAllByText('Restore')[0])
const confirmBtn = await screen.findByText('Yes, restore')
fireEvent.mouseEnter(confirmBtn)
expect(confirmBtn.style.background).toBe('rgb(185, 28, 28)')
fireEvent.mouseLeave(confirmBtn)
expect(confirmBtn.style.background).toBe('rgb(220, 38, 38)')
})
})
+6 -6
View File
@@ -473,10 +473,10 @@ export default function BackupPanel() {
<AlertTriangle size={20} className="text-white" />
</div>
<div>
<h3 className="text-white" style={{ margin: 0, fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700 }}>
<h3 className="text-white" style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>
{t('backup.restoreConfirmTitle')}
</h3>
<p className="text-[rgba(255,255,255,0.8)]" style={{ margin: '2px 0 0', fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
<p className="text-[rgba(255,255,255,0.8)]" style={{ margin: '2px 0 0', fontSize: 12 }}>
{restoreConfirm.filename}
</p>
</div>
@@ -484,11 +484,11 @@ export default function BackupPanel() {
{/* Body */}
<div style={{ padding: '20px 24px' }}>
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.6, margin: 0 }}>
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
{t('backup.restoreWarning')}
</p>
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: 1.5 }}
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
className="bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800"
>
{t('backup.restoreTip')}
@@ -500,14 +500,14 @@ export default function BackupPanel() {
<button
onClick={() => setRestoreConfirm(null)}
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('common.cancel')}
</button>
<button
onClick={executeRestore}
className="bg-[#dc2626] text-white"
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'}
onMouseLeave={e => e.currentTarget.style.background = '#dc2626'}
>
@@ -1,5 +1,5 @@
// FE-COMP-CAT-001 to FE-COMP-CAT-020
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render';
// FE-COMP-CAT-001 to FE-COMP-CAT-012
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
@@ -156,148 +156,4 @@ describe('CategoryManager', () => {
await user.click(screen.getByText('Cancel'));
expect(screen.queryByPlaceholderText('Category name')).not.toBeInTheDocument();
});
it('FE-COMP-CAT-013: a failing list request toasts and falls back to the empty state', async () => {
server.use(http.get('/api/categories', () => HttpResponse.error()));
render(<><ToastContainer /><CategoryManager /></>);
expect(await screen.findByText('Failed to load categories')).toBeInTheDocument();
expect(screen.getByText('No categories yet')).toBeInTheDocument();
});
it('FE-COMP-CAT-014: editing a category sends a PUT and replaces the row', async () => {
const user = userEvent.setup();
let body: Record<string, unknown> | null = null;
server.use(
http.get('/api/categories', () =>
HttpResponse.json({ categories: [buildCategory({ id: 5, name: 'Hotels', color: '#6366f1', icon: 'MapPin' })] })
),
http.put('/api/categories/5', async ({ request }) => {
body = await request.json() as Record<string, unknown>;
return HttpResponse.json({ category: buildCategory({ id: 5, name: 'Lodging', color: '#ef4444', icon: 'BedDouble' }) });
}),
);
render(<><ToastContainer /><CategoryManager /></>);
await screen.findByText('Hotels');
await user.click(screen.getAllByRole('button').filter(b => !b.textContent?.includes('New Category'))[0]);
const nameInput = screen.getByDisplayValue('Hotels');
await user.clear(nameInput);
await user.type(nameInput, 'Lodging');
await user.click(screen.getByTitle('Hotel'));
await user.click(screen.getByText('Update'));
expect(await screen.findByText('Category updated')).toBeInTheDocument();
expect(body).toEqual({ name: 'Lodging', color: '#6366f1', icon: 'BedDouble' });
expect(screen.getByText('Lodging')).toBeInTheDocument();
});
it('FE-COMP-CAT-015: a failing save surfaces the server message', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/categories', () => HttpResponse.json({ error: 'name already taken' }, { status: 409 })),
);
render(<><ToastContainer /><CategoryManager /></>);
await screen.findByText('New Category');
await user.click(screen.getByText('New Category'));
await user.type(screen.getByPlaceholderText('Category name'), 'Parks');
await user.click(screen.getByText('Create'));
expect(await screen.findByText('name already taken')).toBeInTheDocument();
// The form stays open so the name can be corrected
expect(screen.getByDisplayValue('Parks')).toBeInTheDocument();
});
it('FE-COMP-CAT-016: declining the delete confirm keeps the category', async () => {
const user = userEvent.setup();
let deleteCalled = false;
server.use(
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })),
http.delete('/api/categories/9', () => { deleteCalled = true; return HttpResponse.json({ success: true }); }),
);
vi.spyOn(window, 'confirm').mockReturnValue(false);
render(<CategoryManager />);
await screen.findByText('Parks');
const actionBtns = screen.getAllByRole('button').filter(b => !b.textContent?.includes('New Category'));
await user.click(actionBtns[1]);
expect(deleteCalled).toBe(false);
expect(screen.getByText('Parks')).toBeInTheDocument();
vi.restoreAllMocks();
});
it('FE-COMP-CAT-017: a failing delete toasts and keeps the row', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })),
http.delete('/api/categories/9', () => HttpResponse.json({ error: 'category in use' }, { status: 409 })),
);
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<><ToastContainer /><CategoryManager /></>);
await screen.findByText('Parks');
const actionBtns = screen.getAllByRole('button').filter(b => !b.textContent?.includes('New Category'));
await user.click(actionBtns[1]);
expect(await screen.findByText('category in use')).toBeInTheDocument();
expect(screen.getByText('Parks')).toBeInTheDocument();
vi.restoreAllMocks();
});
it('FE-COMP-CAT-018: picking an icon and a preset colour updates the live preview', async () => {
const user = userEvent.setup();
render(<CategoryManager />);
await screen.findByText('New Category');
await user.click(screen.getByText('New Category'));
// Empty name → the preview falls back to the generic label
expect(screen.getByText('Category')).toBeInTheDocument();
await user.type(screen.getByPlaceholderText('Category name'), 'Beach day');
await user.click(screen.getByTitle('Beach'));
const preview = screen.getByText('Beach day');
expect(preview).toHaveStyle({ color: '#6366f1' });
await user.click(document.querySelectorAll('button[style*="background-color: rgb(239, 68, 68)"]')[0]);
expect(screen.getByText('Beach day')).toHaveStyle({ color: '#ef4444' });
});
it('FE-COMP-CAT-019: the custom colour swatch opens the native picker and adopts its value', async () => {
const user = userEvent.setup();
render(<CategoryManager />);
await screen.findByText('New Category');
await user.click(screen.getByText('New Category'));
const colorInput = document.querySelector('input[type="color"]') as HTMLInputElement;
const clickSpy = vi.spyOn(colorInput, 'click').mockImplementation(() => {});
await user.click(screen.getByTitle('Choose custom color'));
expect(clickSpy).toHaveBeenCalled();
fireEvent.change(colorInput, { target: { value: '#123456' } });
await waitFor(() => expect(screen.getByText('Category')).toHaveStyle({ color: '#123456' }));
// A non-preset colour fills the custom swatch instead of showing the pipette
expect(screen.getByTitle('Choose custom color')).toHaveStyle({ backgroundColor: '#123456' });
vi.restoreAllMocks();
});
it('FE-COMP-CAT-020: starting an edit closes the create form', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 3, name: 'Hotels' })] })),
);
render(<CategoryManager />);
await screen.findByText('Hotels');
await user.click(screen.getByText('New Category'));
expect(screen.getByPlaceholderText('Category name')).toHaveValue('');
const row = screen.getByText('Hotels').closest('.p-3') as HTMLElement;
await user.click(within(row).getAllByRole('button')[0]);
// Only the inline edit form remains, pre-filled with the row's name
expect(screen.getAllByPlaceholderText('Category name')).toHaveLength(1);
expect(screen.getByDisplayValue('Hotels')).toBeInTheDocument();
});
});
@@ -56,8 +56,8 @@ export default function CategoryManager() {
setEditingId(null)
}
// The Save button carries disabled={… || !form.name.trim()}, so the name is set here.
const handleSave = async () => {
if (!form.name.trim()) { toast.error(t('categories.toast.nameRequired')); return }
setIsSaving(true)
try {
if (editingId) {
@@ -1,422 +0,0 @@
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-025
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildAdmin } from '../../../tests/helpers/factories';
import { useAuthStore } from '../../store/authStore';
import { ToastContainer } from '../shared/Toast';
import DefaultUserSettingsTab from './DefaultUserSettingsTab';
// The tile preview would pull Leaflet into jsdom; the panel only needs it to render.
vi.mock('../Map/MapView', () => ({
MapView: ({ tileUrl }: { tileUrl?: string }) => <div data-testid="map-preview" data-tile={tileUrl} />,
}));
const MAPBOX_STANDARD = 'mapbox://styles/mapbox/standard';
const MAPBOX_DARK = 'mapbox://styles/mapbox/dark-v11';
const MAPBOX_NAV_NIGHT = 'mapbox://styles/mapbox/navigation-night-v1';
const OFM_LIBERTY = 'https://tiles.openfreemap.org/styles/liberty';
const TILE_PLACEHOLDER = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
/** Stateful stand-in for the admin defaults endpoint: PUT merges, null deletes. */
function stubDefaults(initial: Record<string, unknown> = {}) {
const state: Record<string, unknown> = { ...initial };
const puts: Record<string, unknown>[] = [];
server.use(
http.get('/api/admin/default-user-settings', () => HttpResponse.json(state)),
http.put('/api/admin/default-user-settings', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
puts.push(body);
for (const [key, value] of Object.entries(body)) {
if (value === null) delete state[key];
else state[key] = value;
}
return HttpResponse.json({ ...state });
}),
);
return { puts, state };
}
function withToast() {
return render(<><ToastContainer /><DefaultUserSettingsTab /></>);
}
/** The selected option button is the one drawn with the strong border token. */
function isActive(button: HTMLElement): boolean {
return (button.style.border || '').includes('var(--text-primary)');
}
/**
* The reset link sits inside the field's own <label>; because a button is a labelable
* element the wrapping label becomes its accessible name, so it is queried positionally.
*/
function resetLink(label: string): HTMLElement {
const el = screen.getAllByText(label).find(node => node.tagName === 'LABEL');
if (!el) throw new Error(`no label found for ${label}`);
return within(el).getByRole('button');
}
function hasResetLink(label: string): boolean {
const el = screen.getAllByText(label).find(node => node.tagName === 'LABEL');
return !!el && within(el).queryByRole('button') !== null;
}
/** Opens a CustomSelect by its trigger label and picks an option from the portal. */
async function pickFromSelect(user: ReturnType<typeof userEvent.setup>, trigger: string, option: string) {
await user.click(screen.getByRole('button', { name: trigger }));
const choices = await screen.findAllByRole('button', { name: option });
await user.click(choices[choices.length - 1]);
}
describe('DefaultUserSettingsTab', () => {
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
stubDefaults();
});
it('FE-ADMIN-DUS-001: shows the loading placeholder until the defaults arrive', async () => {
render(<DefaultUserSettingsTab />);
expect(screen.getByText('Loading…')).toBeInTheDocument();
expect(await screen.findByText('Default User Settings')).toBeInTheDocument();
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
});
it('FE-ADMIN-DUS-002: renders every field with no reset links while nothing is set', async () => {
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
for (const name of ['Light', 'Dark', 'Auto', '°C Celsius', 'km Metric', '24h (14:30)', 'On', 'Off']) {
expect(isActive(screen.getByRole('button', { name }))).toBe(false);
}
for (const label of ['Color Mode', 'Temperature Unit', 'Distance Unit', 'Time Format', 'Display currency', 'Map Template']) {
expect(hasResetLink(label)).toBe(false);
}
expect(screen.getByTestId('map-preview')).toBeInTheDocument();
});
it('FE-ADMIN-DUS-003: a failing load still renders the panel with built-in defaults', async () => {
server.use(http.get('/api/admin/default-user-settings', () => HttpResponse.json({}, { status: 500 })));
render(<DefaultUserSettingsTab />);
expect(await screen.findByText('Default User Settings')).toBeInTheDocument();
expect(hasResetLink('Map engine')).toBe(false);
expect(isActive(screen.getByRole('button', { name: 'Standard (free)' }))).toBe(true);
});
it('FE-ADMIN-DUS-004: picking a colour mode saves it and confirms with a toast', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults();
withToast();
await screen.findByText('Default User Settings');
await user.click(screen.getByRole('button', { name: 'Dark' }));
expect(await screen.findByText('Default saved')).toBeInTheDocument();
expect(puts).toEqual([{ dark_mode: 'dark' }]);
await waitFor(() => expect(isActive(screen.getByRole('button', { name: 'Dark' }))).toBe(true));
expect(resetLink('Color Mode')).toBeInTheDocument();
});
it('FE-ADMIN-DUS-005: a legacy boolean dark_mode still highlights the matching option', async () => {
stubDefaults({ dark_mode: true });
const { unmount } = render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
expect(isActive(screen.getByRole('button', { name: 'Dark' }))).toBe(true);
expect(isActive(screen.getByRole('button', { name: 'Light' }))).toBe(false);
unmount();
stubDefaults({ dark_mode: false });
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
expect(isActive(screen.getByRole('button', { name: 'Light' }))).toBe(true);
expect(isActive(screen.getByRole('button', { name: 'Auto' }))).toBe(false);
});
it('FE-ADMIN-DUS-006: unit and time-format options each save their own key', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults();
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
await user.click(screen.getByRole('button', { name: '°F Fahrenheit' }));
await waitFor(() => expect(resetLink('Temperature Unit')).toBeInTheDocument());
await user.click(screen.getByRole('button', { name: 'mi Imperial' }));
await waitFor(() => expect(resetLink('Distance Unit')).toBeInTheDocument());
await user.click(screen.getByRole('button', { name: '12h (2:30 PM)' }));
await waitFor(() => expect(puts).toEqual([
{ temperature_unit: 'fahrenheit' },
{ distance_unit: 'imperial' },
{ time_format: '12h' },
]));
});
it('FE-ADMIN-DUS-007: a set default gets a reset link that clears it server-side', async () => {
const user = userEvent.setup();
const { puts, state } = stubDefaults({ temperature_unit: 'celsius' });
withToast();
await screen.findByText('Default User Settings');
expect(isActive(screen.getByRole('button', { name: '°C Celsius' }))).toBe(true);
await user.click(resetLink('Temperature Unit'));
expect(await screen.findByText('Reset to built-in default')).toBeInTheDocument();
expect(puts).toEqual([{ temperature_unit: null }]);
expect(state.temperature_unit).toBeUndefined();
await waitFor(() => expect(hasResetLink('Temperature Unit')).toBe(false));
});
it('FE-ADMIN-DUS-008: the currency picker saves the chosen code and can be reset', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ default_currency: 'USD' });
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
await pickFromSelect(user, 'USD $', 'EUR €');
await waitFor(() => expect(puts).toEqual([{ default_currency: 'EUR' }]));
await waitFor(() => expect(screen.getByRole('button', { name: 'EUR €' })).toBeInTheDocument());
await user.click(resetLink('Display currency'));
await waitFor(() => expect(puts).toHaveLength(2));
expect(puts[1]).toEqual({ default_currency: null });
});
it('FE-ADMIN-DUS-009: the blur-booking-codes options save booleans', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults();
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
await user.click(screen.getByRole('button', { name: 'On' }));
await waitFor(() => expect(isActive(screen.getByRole('button', { name: 'On' }))).toBe(true));
await user.click(screen.getByRole('button', { name: 'Off' }));
await waitFor(() => expect(puts).toEqual([
{ blur_booking_codes: true },
{ blur_booking_codes: false },
]));
});
it('FE-ADMIN-DUS-010: the tile preset dropdown fills the URL field and hands it to the preview', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults();
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
await pickFromSelect(user, 'Select template...', 'CartoDB Dark');
const url = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
await waitFor(() => expect(puts).toEqual([{ map_tile_url: url }]));
expect(screen.getByPlaceholderText(TILE_PLACEHOLDER)).toHaveValue(url);
expect(screen.getByTestId('map-preview')).toHaveAttribute('data-tile', url);
});
it('FE-ADMIN-DUS-011: a hand-typed tile URL is saved on blur', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults();
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
const input = screen.getByPlaceholderText(TILE_PLACEHOLDER);
// userEvent reads {...} as key descriptors, so the placeholders are omitted here
await user.type(input, 'https://tiles.example.org/tile.png');
fireEvent.blur(input);
await waitFor(() => expect(puts).toEqual([{ map_tile_url: 'https://tiles.example.org/tile.png' }]));
expect(screen.getByTestId('map-preview')).toHaveAttribute('data-tile', 'https://tiles.example.org/tile.png');
});
it('FE-ADMIN-DUS-012: resetting the tile URL clears the input too', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ map_tile_url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' });
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
expect(screen.getByRole('button', { name: 'OpenStreetMap DE' })).toBeInTheDocument();
await user.click(resetLink('Map Template'));
await waitFor(() => expect(puts).toEqual([{ map_tile_url: null }]));
await waitFor(() => expect(screen.getByPlaceholderText(TILE_PLACEHOLDER)).toHaveValue(''));
});
it('FE-ADMIN-DUS-013: leaflet hides the GL-only token and style fields', async () => {
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
expect(isActive(screen.getByRole('button', { name: 'Standard (free)' }))).toBe(true);
expect(screen.queryByText('Map style')).not.toBeInTheDocument();
expect(screen.queryByText('Shared Mapbox token')).not.toBeInTheDocument();
});
it('FE-ADMIN-DUS-014: switching to Mapbox stores the provider with its own style slot', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults();
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
await user.click(screen.getByRole('button', { name: 'Mapbox (3D)' }));
await waitFor(() => expect(puts).toEqual([{ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_STANDARD }]));
expect(await screen.findByText('Shared Mapbox token')).toBeInTheDocument();
expect(screen.getByDisplayValue(MAPBOX_STANDARD)).toBeInTheDocument();
});
it('FE-ADMIN-DUS-015: switching to MapLibre stores the OpenFreeMap default and hides the token field', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults();
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
await user.click(screen.getByRole('button', { name: 'MapLibre (OpenFreeMap)' }));
await waitFor(() => expect(puts).toEqual([{ map_provider: 'maplibre-gl', maplibre_style: OFM_LIBERTY }]));
expect(await screen.findByText('Map style')).toBeInTheDocument();
expect(screen.queryByText('Shared Mapbox token')).not.toBeInTheDocument();
expect(screen.getByDisplayValue(OFM_LIBERTY)).toBeInTheDocument();
});
it('FE-ADMIN-DUS-016: switching back to the standard engine only stores the provider', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_STANDARD });
render(<DefaultUserSettingsTab />);
await screen.findByText('Map style');
await user.click(screen.getByRole('button', { name: 'Standard (free)' }));
await waitFor(() => expect(puts).toEqual([{ map_provider: 'leaflet' }]));
await waitFor(() => expect(screen.queryByText('Map style')).not.toBeInTheDocument());
});
it('FE-ADMIN-DUS-017: a Mapbox default holding an OpenFreeMap style falls back to the Mapbox standard', async () => {
stubDefaults({ map_provider: 'mapbox-gl', mapbox_style: OFM_LIBERTY });
render(<DefaultUserSettingsTab />);
await screen.findByText('Map style');
expect(screen.getByDisplayValue(MAPBOX_STANDARD)).toBeInTheDocument();
});
it('FE-ADMIN-DUS-018: a stored Mapbox style survives while the standard engine is active', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ map_provider: 'leaflet', mapbox_style: MAPBOX_DARK });
render(<DefaultUserSettingsTab />);
await screen.findByText('Default User Settings');
expect(screen.queryByText('Map style')).not.toBeInTheDocument();
// Switching to Mapbox re-uses the stored slot instead of resetting it
await user.click(screen.getByRole('button', { name: 'Mapbox (3D)' }));
await waitFor(() => expect(puts).toEqual([{ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_DARK }]));
expect(screen.getByDisplayValue(MAPBOX_DARK)).toBeInTheDocument();
});
it('FE-ADMIN-DUS-019: the shared Mapbox token is stored on blur and cleared by its reset link', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_access_token: 'pk.old' });
render(<DefaultUserSettingsTab />);
await screen.findByText('Shared Mapbox token');
const input = screen.getByPlaceholderText('pk.eyJ…');
expect(input).toHaveValue('pk.old');
await user.clear(input);
await user.type(input, 'pk.new');
fireEvent.blur(input);
await waitFor(() => expect(puts).toEqual([{ mapbox_access_token: 'pk.new' }]));
// Clicking the reset link also blurs the field again, so only the last PUT is checked
await user.click(resetLink('Shared Mapbox token'));
await waitFor(() => expect(puts[puts.length - 1]).toEqual({ mapbox_access_token: null }));
await waitFor(() => expect(screen.getByPlaceholderText('pk.eyJ…')).toHaveValue(''));
});
it('FE-ADMIN-DUS-020: a hand-typed MapLibre style is normalised to OpenFreeMap on blur', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ map_provider: 'maplibre-gl', maplibre_style: OFM_LIBERTY });
render(<DefaultUserSettingsTab />);
await screen.findByText('Map style');
const input = screen.getByDisplayValue(OFM_LIBERTY);
await user.clear(input);
await user.type(input, 'https://example.com/custom.json');
fireEvent.blur(input);
await waitFor(() => expect(puts).toEqual([{ maplibre_style: OFM_LIBERTY }]));
expect(input).toHaveValue(OFM_LIBERTY);
});
it('FE-ADMIN-DUS-021: the style dropdown writes the picked preset into the active provider slot', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_STANDARD });
render(<DefaultUserSettingsTab />);
await screen.findByText('Map style');
await pickFromSelect(user, 'Mapbox Standard', 'Navigation Night');
await waitFor(() => expect(puts).toEqual([{ mapbox_style: MAPBOX_NAV_NIGHT }]));
expect(screen.getByDisplayValue(MAPBOX_NAV_NIGHT)).toBeInTheDocument();
});
it('FE-ADMIN-DUS-022: resetting the style restores the provider default in the field', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_DARK });
render(<DefaultUserSettingsTab />);
await screen.findByText('Map style');
await user.click(resetLink('Map style'));
await waitFor(() => expect(puts).toEqual([{ mapbox_style: null }]));
await waitFor(() => expect(screen.getByDisplayValue(MAPBOX_STANDARD)).toBeInTheDocument());
});
it('FE-ADMIN-DUS-023: the Mapbox 3D and quality options start on their built-in defaults and save their own keys', async () => {
const user = userEvent.setup();
const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_STANDARD });
render(<DefaultUserSettingsTab />);
await screen.findByText('3D buildings & terrain');
// 3D defaults to on, quality mode to off when neither is stored
const threeD = within(screen.getByText('3D buildings & terrain').closest('div') as HTMLElement);
expect(isActive(threeD.getByRole('button', { name: 'On' }))).toBe(true);
const quality = within(screen.getByText('High-quality mode').closest('div') as HTMLElement);
expect(isActive(quality.getByRole('button', { name: 'Off' }))).toBe(true);
await user.click(threeD.getByRole('button', { name: 'Off' }));
await waitFor(() => expect(puts).toEqual([{ mapbox_3d_enabled: false }]));
await user.click(quality.getByRole('button', { name: 'On' }));
await waitFor(() => expect(puts).toHaveLength(2));
expect(puts[1]).toEqual({ mapbox_quality_mode: true });
});
it('FE-ADMIN-DUS-024: a rejected save surfaces the request error instead of a success toast', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/default-user-settings', () => HttpResponse.json({})),
http.put('/api/admin/default-user-settings', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
);
withToast();
await screen.findByText('Default User Settings');
await user.click(screen.getByRole('button', { name: 'Dark' }));
expect(await screen.findByText(/Request failed with status code 500/)).toBeInTheDocument();
expect(screen.queryByText('Default saved')).not.toBeInTheDocument();
});
it('FE-ADMIN-DUS-025: a rejected reset surfaces the request error', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/default-user-settings', () => HttpResponse.json({ time_format: '12h' })),
http.put('/api/admin/default-user-settings', () => HttpResponse.json({ error: 'nope' }, { status: 503 })),
);
withToast();
await screen.findByText('Default User Settings');
await user.click(resetLink('Time Format'));
expect(await screen.findByText(/Request failed with status code 503/)).toBeInTheDocument();
expect(screen.queryByText('Reset to built-in default')).not.toBeInTheDocument();
});
});
@@ -6,22 +6,10 @@ import { useToast } from '../shared/Toast'
import Section from '../Settings/Section'
import CustomSelect from '../shared/CustomSelect'
import { MapView } from '../Map/MapView'
import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants'
import type { DistanceUnit, Place } from '../../types'
import { normalizeTileUrl } from '../../utils/tileUrl'
import {
MAPBOX_DEFAULT_STYLE,
defaultStyleForProvider,
getStylePresets,
isOpenFreeMapStyle,
normalizeStyleForProvider,
styleSettingKey,
type GlMapProvider,
} from '../Map/glProviders'
import { useAuthStore } from '../../store/authStore'
import type { Place } from '../../types'
const MAP_PRESETS = [
{ name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' },
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
@@ -30,31 +18,25 @@ const MAP_PRESETS = [
type Defaults = {
temperature_unit?: string
distance_unit?: DistanceUnit
dark_mode?: string | boolean
time_format?: string
default_currency?: string
blur_booking_codes?: boolean
map_tile_url?: string
map_provider?: string
mapbox_access_token?: string
mapbox_style?: string
maplibre_style?: string
mapbox_3d_enabled?: boolean
mapbox_quality_mode?: boolean
}
type MapProvider = 'leaflet' | GlMapProvider
function normalizeProvider(value: unknown): MapProvider {
return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet'
}
/** Only the GL providers keep a style — Leaflet is handled by its callers. */
function styleForProvider(provider: GlMapProvider, style?: string | null): string {
if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE
return normalizeStyleForProvider(provider, style)
}
const MAPBOX_STYLE_PRESETS = [
{ name: 'Standard', url: 'mapbox://styles/mapbox/standard' },
{ name: 'Streets', url: 'mapbox://styles/mapbox/streets-v12' },
{ name: 'Outdoors', url: 'mapbox://styles/mapbox/outdoors-v12' },
{ name: 'Light', url: 'mapbox://styles/mapbox/light-v11' },
{ name: 'Dark', url: 'mapbox://styles/mapbox/dark-v11' },
{ name: 'Satellite Streets', url: 'mapbox://styles/mapbox/satellite-streets-v12' },
]
function OptionRow({
label,
@@ -91,7 +73,7 @@ function OptionButton({
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
border: active ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: active ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
@@ -109,17 +91,15 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
const [defaults, setDefaults] = useState<Defaults>({})
const [loaded, setLoaded] = useState(false)
const [mapTileUrl, setMapTileUrl] = useState('')
const managed = useAuthStore((s) => s.managed)
const [mapboxToken, setMapboxToken] = useState('')
const [mapboxStyle, setMapboxStyle] = useState('')
useEffect(() => {
adminApi.getDefaultUserSettings().then((data: Defaults) => {
const provider = normalizeProvider(data.map_provider)
setDefaults(data)
setMapTileUrl(normalizeTileUrl(data.map_tile_url || ''))
setMapTileUrl(data.map_tile_url || '')
setMapboxToken(data.mapbox_access_token || '')
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
setMapboxStyle(data.mapbox_style || '')
setLoaded(true)
}).catch(() => setLoaded(true))
}, [])
@@ -140,10 +120,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
setDefaults(updated)
if (key === 'map_tile_url') setMapTileUrl('')
if (key === 'mapbox_access_token') setMapboxToken('')
if (key === 'mapbox_style' || key === 'maplibre_style') {
const provider = normalizeProvider(defaults.map_provider)
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
}
if (key === 'mapbox_style') setMapboxStyle('')
toast.success(t('admin.defaultSettings.reset'))
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.error'))
@@ -189,24 +166,10 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
}], [])
if (!loaded) {
return <p className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontStyle: 'italic', padding: 16 }}>Loading…</p>
return <p className="text-content-faint" style={{ fontSize: 12, fontStyle: 'italic', padding: 16 }}>Loading…</p>
}
const darkMode = defaults.dark_mode
const mapProvider = normalizeProvider(defaults.map_provider)
const glStylePresets = mapProvider === 'leaflet' ? [] : getStylePresets(mapProvider)
const styleKey: keyof Defaults = mapProvider === 'maplibre-gl' ? 'maplibre_style' : 'mapbox_style'
const saveMapProvider = (nextProvider: MapProvider) => {
const patch: Partial<Defaults> = { map_provider: nextProvider }
if (nextProvider !== 'leaflet') {
// Load + save the new provider's own style slot so the other provider's style is kept.
const slot = nextProvider === 'maplibre-gl' ? defaults.maplibre_style : defaults.mapbox_style
const nextStyle = styleForProvider(nextProvider, slot)
setMapboxStyle(nextStyle)
patch[styleSettingKey(nextProvider)] = nextStyle
}
save(patch)
}
return (
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
@@ -247,22 +210,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
))}
</OptionRow>
{/* Distance */}
<OptionRow label={<>{t('settings.distance')} <ResetButton field="distance_unit" /></>}>
{([
{ value: 'metric', label: 'km Metric' },
{ value: 'imperial', label: 'mi Imperial' },
] as const).map(opt => (
<OptionButton
key={opt.value}
active={defaults.distance_unit === opt.value}
onClick={() => save({ distance_unit: opt.value })}
>
{opt.label}
</OptionButton>
))}
</OptionRow>
{/* Time Format */}
<OptionRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
{([
@@ -279,23 +226,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
))}
</OptionRow>
{/* Default Currency */}
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('settings.currency')} <ResetButton field="default_currency" />
</label>
<CustomSelect
value={defaults.default_currency || ''}
onChange={(value: string) => { if (value) save({ default_currency: value }) }}
placeholder={t('settings.currency')}
searchable
options={currenciesWith(defaults.default_currency).map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
size="sm"
style={{ maxWidth: 240 }}
/>
<p className="text-xs mt-1 text-content-faint">{t('settings.currencyHint')}</p>
</div>
{/* Blur Booking Codes */}
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
{([
@@ -331,7 +261,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
value={mapTileUrl}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
onBlur={() => save({ map_tile_url: mapTileUrl })}
placeholder="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<p className="text-xs mt-1 text-content-faint">{t('settings.mapDefaultHint')}</p>
@@ -367,23 +297,19 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
{([
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
{ value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') },
] as const).map(opt => (
<OptionButton
key={opt.value}
active={mapProvider === opt.value}
onClick={() => saveMapProvider(opt.value)}
active={(defaults.map_provider || 'leaflet') === opt.value}
onClick={() => save({ map_provider: opt.value })}
>
{opt.label}
</OptionButton>
))}
</OptionRow>
{mapProvider !== 'leaflet' && (
{defaults.map_provider === 'mapbox-gl' && (
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 18 }}>
{/* The token comes with the instance on a managed install, injected when the
settings are read. A field here would only let somebody save a worse one. */}
{mapProvider === 'mapbox-gl' && !managed && (
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('admin.defaultSettings.mapboxToken')}
@@ -401,18 +327,17 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
/>
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.mapboxTokenHint')}</p>
</div>
)}
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('admin.defaultSettings.mapboxStyle')}
<ResetButton field={styleKey} />
<ResetButton field="mapbox_style" />
</label>
<CustomSelect
value={mapboxStyle}
onChange={(value: string) => { if (value) { setMapboxStyle(value); save({ [styleKey]: value }) } }}
onChange={(value: string) => { if (value) { setMapboxStyle(value); save({ mapbox_style: value }) } }}
placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')}
options={glStylePresets.map(p => ({ value: p.url, label: p.name }))}
options={MAPBOX_STYLE_PRESETS.map(p => ({ value: p.url, label: p.name }))}
size="sm"
style={{ marginBottom: 8 }}
/>
@@ -420,18 +345,12 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
type="text"
value={mapboxStyle}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)}
onBlur={() => {
const nextStyle = normalizeStyleForProvider(mapProvider, mapboxStyle)
setMapboxStyle(nextStyle)
save({ [styleKey]: nextStyle })
}}
placeholder={defaultStyleForProvider(mapProvider)}
onBlur={() => save({ mapbox_style: mapboxStyle })}
placeholder="mapbox://styles/mapbox/standard"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
{mapProvider === 'mapbox-gl' && (
<>
<OptionRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
{([
{ value: true, label: t('settings.on') || 'On' },
@@ -453,8 +372,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</OptionButton>
))}
</OptionRow>
</>
)}
</div>
)}
</div>
@@ -1,5 +1,5 @@
// FE-ADMIN-DEVNOTIF-001 to FE-ADMIN-DEVNOTIF-016
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
// FE-ADMIN-DEVNOTIF-001 to FE-ADMIN-DEVNOTIF-010
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
@@ -110,20 +110,7 @@ describe('DevNotificationsPanel', () => {
});
});
it('FE-ADMIN-DEVNOTIF-008: the server error field is what the toast shows', async () => {
server.use(
http.post('/api/admin/dev/test-notification', () =>
HttpResponse.json({ error: 'No channel configured' }, { status: 500 }),
),
);
const user = userEvent.setup();
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing');
await user.click(screen.getByText('Simple → Me').closest('button')!);
await screen.findByText('No channel configured');
});
it('FE-ADMIN-DEVNOTIF-008b: a failure without an error field falls back to the generic text', async () => {
it('FE-ADMIN-DEVNOTIF-008: error toast shown on API failure', async () => {
server.use(
http.post('/api/admin/dev/test-notification', () =>
HttpResponse.json({ message: 'Server error' }, { status: 500 }),
@@ -133,7 +120,7 @@ describe('DevNotificationsPanel', () => {
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing');
await user.click(screen.getByText('Simple → Me').closest('button')!);
await screen.findByText('Failed');
await screen.findByText(/failed|error/i);
});
it('FE-ADMIN-DEVNOTIF-009: changing trip selector updates payload targetId', async () => {
@@ -170,141 +157,4 @@ describe('DevNotificationsPanel', () => {
await screen.findByText('User-Scoped Events');
expect(screen.queryByText('Trip-Scoped Events')).not.toBeInTheDocument();
});
it('FE-ADMIN-DEVNOTIF-011: the remaining self/admin type buttons each fire their own event', async () => {
const bodies: Record<string, unknown>[] = [];
server.use(
http.post('/api/admin/dev/test-notification', async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>);
return HttpResponse.json({ ok: true });
}),
);
const user = userEvent.setup();
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing');
await user.click(screen.getByText('Boolean → Me').closest('button')!);
await screen.findByText('Sent: boolean-me');
await user.click(screen.getByText('Navigate → Me').closest('button')!);
await screen.findByText('Sent: navigate-me');
await user.click(screen.getByText('Simple → All Admins').closest('button')!);
await screen.findByText('Sent: simple-admins');
await user.click(screen.getByText('version_available').closest('button')!);
await screen.findByText('Sent: version_available');
expect(bodies[0]).toMatchObject({
event: 'test_boolean',
scope: 'user',
targetId: ADMIN_USER.id,
inApp: {
type: 'boolean',
positiveCallback: { action: 'test_approve', payload: {} },
negativeCallback: { action: 'test_deny', payload: {} },
},
});
expect(bodies[1]).toMatchObject({ event: 'test_navigate', scope: 'user', targetId: ADMIN_USER.id });
expect(bodies[2]).toMatchObject({ event: 'test_simple', scope: 'admin', targetId: 0 });
expect(bodies[3]).toMatchObject({ event: 'version_available', scope: 'admin', targetId: 0, params: { version: '9.9.9-test' } });
});
it('FE-ADMIN-DEVNOTIF-012: every trip-scoped button carries the selected trip and the actor', async () => {
const bodies: Record<string, unknown>[] = [];
server.use(
http.post('/api/admin/dev/test-notification', async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>);
return HttpResponse.json({ ok: true });
}),
);
const user = userEvent.setup();
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Trip-Scoped Events');
const [tripSelect] = screen.getAllByRole('combobox');
const tripId = Number((tripSelect as HTMLSelectElement).value);
for (const label of ['trip_reminder', 'photos_shared', 'collab_message', 'packing_tagged']) {
await user.click(screen.getByText(label).closest('button')!);
await screen.findByText(`Sent: ${label}`);
}
expect(bodies.map(b => b.event)).toEqual(['trip_reminder', 'photos_shared', 'collab_message', 'packing_tagged']);
for (const body of bodies) {
expect(body.scope).toBe('trip');
expect(body.targetId).toBe(tripId);
expect(body.params).toMatchObject({ trip: 'Paris Adventure', tripId: String(tripId) });
}
expect(bodies[1].params).toMatchObject({ actor: 'testadmin', count: '5' });
expect(bodies[2].params).toMatchObject({ preview: 'This is a test message preview.' });
expect(bodies[3].params).toMatchObject({ category: 'Clothing' });
});
it('FE-ADMIN-DEVNOTIF-013: user-scoped events target the picked recipient', async () => {
const bodies: Record<string, unknown>[] = [];
server.use(
http.post('/api/admin/dev/test-notification', async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>);
return HttpResponse.json({ ok: true });
}),
);
const user = userEvent.setup();
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('User-Scoped Events');
const userSelect = screen.getAllByRole('combobox')[1] as HTMLSelectElement;
const aliceOption = Array.from(userSelect.querySelectorAll('option')).find(
o => (o.textContent ?? '').includes('alice'),
)!;
await user.selectOptions(userSelect, aliceOption.value);
const aliceId = Number(aliceOption.value);
await user.click(screen.getByText('trip_invite').closest('button')!);
await screen.findByText(`Sent: trip_invite-${aliceId}`);
await user.click(screen.getByText('vacay_invite').closest('button')!);
await screen.findByText(`Sent: vacay_invite-${aliceId}`);
expect(bodies[0]).toMatchObject({
event: 'trip_invite',
scope: 'user',
targetId: aliceId,
params: { actor: 'testadmin', invitee: 'alice@example.com' },
});
expect(bodies[1]).toMatchObject({
event: 'vacay_invite',
scope: 'user',
targetId: aliceId,
params: { actor: 'testadmin', planId: '1' },
});
});
it('FE-ADMIN-DEVNOTIF-014: the User-Scoped section is hidden when no users come back', async () => {
server.use(http.get('/api/admin/users', () => HttpResponse.json({ users: [] })));
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Trip-Scoped Events');
expect(screen.queryByText('User-Scoped Events')).not.toBeInTheDocument();
});
it('FE-ADMIN-DEVNOTIF-015: failing lookups leave both scoped sections out without crashing', async () => {
server.use(
http.get('/api/trips', () => HttpResponse.error()),
http.get('/api/admin/users', () => HttpResponse.error()),
);
render(<><ToastContainer /><DevNotificationsPanel /></>);
expect(await screen.findByText('Type Testing')).toBeInTheDocument();
await waitFor(() => expect(screen.queryByText('Trip-Scoped Events')).not.toBeInTheDocument());
expect(screen.queryByText('User-Scoped Events')).not.toBeInTheDocument();
expect(screen.getByText('Admin-Scoped Events')).toBeInTheDocument();
});
it('FE-ADMIN-DEVNOTIF-016: hovering a trigger paints and restores its background', async () => {
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing');
const btn = screen.getByText('Simple → Me').closest('button')!;
fireEvent.mouseEnter(btn);
expect(btn.style.background).toBe('var(--bg-hover)');
fireEvent.mouseLeave(btn);
expect(btn.style.background).toBe('var(--bg-card)');
});
});
@@ -1,6 +1,5 @@
import React, { useState, useEffect } from 'react'
import { adminApi, tripsApi } from '../../api/client'
import { getApiErrorMessage } from '../../utils/apiError'
import { useAuthStore } from '../../store/authStore'
import { useToast } from '../shared/Toast'
import {
@@ -47,8 +46,8 @@ export default function DevNotificationsPanel(): React.ReactElement {
try {
await adminApi.sendTestNotification(payload)
toast.success(`Sent: ${label}`)
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, 'Failed'))
} catch (err: any) {
toast.error(err.message || 'Failed')
} finally {
setSending(null)
}
+224 -349
View File
@@ -1,187 +1,146 @@
import {
BookOpen,
Bug,
Calendar,
ChevronDown,
ChevronUp,
Coffee,
ExternalLink,
Heart,
Lightbulb,
Loader2,
Tag,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import apiClient from '../../api/client';
import { getLocaleForLanguage, useTranslation } from '../../i18n';
import { useState, useEffect } from 'react'
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee, Bug, Lightbulb, BookOpen } from 'lucide-react'
import { getLocaleForLanguage, useTranslation } from '../../i18n'
import apiClient from '../../api/client'
const REPO = 'liketrek/TREK';
const PER_PAGE = 10;
const REPO = 'mauriceboe/TREK'
const PER_PAGE = 10
interface GithubRelease {
id: number;
prerelease: boolean;
tag_name: string;
name: string | null;
body: string | null;
published_at: string | null;
created_at: string;
author: { login: string } | null;
[key: string]: unknown;
id: number
prerelease: boolean
tag_name: string
name: string | null
body: string | null
published_at: string | null
created_at: string
author: { login: string } | null
[key: string]: unknown
}
export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) {
const { t, language } = useTranslation();
const [releases, setReleases] = useState<GithubRelease[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState<Record<number, boolean>>({});
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const { t, language } = useTranslation()
const [releases, setReleases] = useState<GithubRelease[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const fetchReleases = async (pageNum = 1, append = false) => {
try {
const res = await apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } });
const data = Array.isArray(res.data) ? res.data : [];
setReleases((prev) => (append ? [...prev, ...data] : data));
setHasMore(data.length === PER_PAGE);
const res = await apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } })
const data = Array.isArray(res.data) ? res.data : []
setReleases(prev => append ? [...prev, ...data] : data)
setHasMore(data.length === PER_PAGE)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Unknown error');
setError(err instanceof Error ? err.message : 'Unknown error')
}
};
}
useEffect(() => {
setLoading(true);
fetchReleases(1).finally(() => setLoading(false));
}, []);
setLoading(true)
fetchReleases(1).finally(() => setLoading(false))
}, [])
const handleLoadMore = async () => {
const next = page + 1;
setLoadingMore(true);
await fetchReleases(next, true);
setPage(next);
setLoadingMore(false);
};
const next = page + 1
setLoadingMore(true)
await fetchReleases(next, true)
setPage(next)
setLoadingMore(false)
}
const toggleExpand = (id) => {
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
};
setExpanded(prev => ({ ...prev, [id]: !prev[id] }))
}
const formatDate = (dateStr) => {
const d = new Date(dateStr);
return d.toLocaleDateString(getLocaleForLanguage(language), { day: 'numeric', month: 'short', year: 'numeric' });
};
const d = new Date(dateStr)
return d.toLocaleDateString(getLocaleForLanguage(language), { day: 'numeric', month: 'short', year: 'numeric' })
}
// Simple markdown-to-html for release notes (handles headers, bold, lists, links)
const renderBody = (body) => {
if (!body) return null;
const lines = body.split('\n');
const elements = [];
let listItems = [];
if (!body) return null
const lines = body.split('\n')
const elements = []
let listItems = []
const flushList = () => {
if (listItems.length > 0) {
elements.push(
<ul key={`ul-${elements.length}`} className="my-2 space-y-1">
<ul key={`ul-${elements.length}`} className="space-y-1 my-2">
{listItems.map((item, i) => (
<li key={i} className="flex gap-2 text-xs text-content-muted">
<span
className="mt-1.5 h-1 w-1 flex-shrink-0 rounded-full"
style={{ background: 'var(--text-faint)' }}
/>
<span className="mt-1.5 w-1 h-1 rounded-full flex-shrink-0" style={{ background: 'var(--text-faint)' }} />
<span dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
</li>
))}
</ul>
);
listItems = [];
)
listItems = []
}
};
}
const escapeHtml = (str) =>
str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const escapeHtml = (str) => str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
const inlineFormat = (text) => {
return escapeHtml(text)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(
/`(.+?)`/g,
'<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>'
)
.replace(/`(.+?)`/g, '<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#';
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">${label}</a>`;
});
};
const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#'
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">${label}</a>`
})
}
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
flushList();
continue;
}
const trimmed = line.trim()
if (!trimmed) { flushList(); continue }
if (trimmed.startsWith('### ')) {
flushList();
flushList()
elements.push(
<h4 key={elements.length} className="mb-1 mt-3 text-xs font-semibold text-content">
<h4 key={elements.length} className="text-xs font-semibold mt-3 mb-1 text-content">
{trimmed.slice(4)}
</h4>
);
)
} else if (trimmed.startsWith('## ')) {
flushList();
flushList()
elements.push(
<h3 key={elements.length} className="mb-1 mt-3 text-sm font-semibold text-content">
<h3 key={elements.length} className="text-sm font-semibold mt-3 mb-1 text-content">
{trimmed.slice(3)}
</h3>
);
)
} else if (/^[-*] /.test(trimmed)) {
listItems.push(trimmed.slice(2));
listItems.push(trimmed.slice(2))
} else {
flushList();
flushList()
elements.push(
<p
key={elements.length}
className="my-1 text-xs text-content-muted"
<p key={elements.length} className="text-xs my-1 text-content-muted"
dangerouslySetInnerHTML={{ __html: inlineFormat(trimmed) }}
/>
);
)
}
}
flushList();
return elements;
};
flushList()
return elements
}
return (
<div className="space-y-3">
{/* Support cards */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<a
href="https://ko-fi.com/mauriceboe"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#ff5e5b';
e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div
className="bg-[#ff5e5b15]"
style={{
width: 40,
height: 40,
borderRadius: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<div className="bg-[#ff5e5b15]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Coffee size={20} className="text-[#ff5e5b]" />
</div>
<div>
@@ -194,28 +153,11 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
href="https://buymeacoffee.com/mauriceboe"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#ffdd00';
e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div
className="bg-[#ffdd0015]"
style={{
width: 40,
height: 40,
borderRadius: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<div className="bg-[#ffdd0015]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Heart size={20} className="text-[#ffdd00]" />
</div>
<div>
@@ -228,31 +170,12 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
href="https://discord.gg/NhZBDSd4qW"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#5865F2';
e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div
className="bg-[#5865F215]"
style={{
width: 40,
height: 40,
borderRadius: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
<div className="bg-[#5865F215]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
</div>
<div>
<div className="text-sm font-semibold text-content">Discord</div>
@@ -262,33 +185,16 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
</a>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<a
href="https://github.com/liketrek/TREK/issues/new?template=bug_report.yml"
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#ef4444';
e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div
className="bg-[#ef444415]"
style={{
width: 40,
height: 40,
borderRadius: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<div className="bg-[#ef444415]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Bug size={20} className="text-[#ef4444]" />
</div>
<div>
@@ -298,31 +204,14 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a>
<a
href="https://github.com/liketrek/TREK/discussions/new?category=feature-requests"
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#f59e0b';
e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div
className="bg-[#f59e0b15]"
style={{
width: 40,
height: 40,
borderRadius: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<div className="bg-[#f59e0b15]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Lightbulb size={20} className="text-[#f59e0b]" />
</div>
<div>
@@ -332,31 +221,14 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a>
<a
href="https://github.com/liketrek/TREK/wiki"
href="https://github.com/mauriceboe/TREK/wiki"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#6366f1';
e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div
className="bg-[#6366f115]"
style={{
width: 40,
height: 40,
borderRadius: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<div className="bg-[#6366f115]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<BookOpen size={20} className="text-[#6366f1]" />
</div>
<div>
@@ -369,134 +241,137 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
{/* Loading / Error / Releases */}
{loading ? (
<div className="overflow-hidden rounded-xl border border-edge bg-surface-card">
<div className="flex items-center justify-center p-8">
<Loader2 className="h-6 w-6 animate-spin text-content-muted" />
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
<div className="p-8 flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-content-muted" />
</div>
</div>
) : error ? (
<div className="overflow-hidden rounded-xl border border-edge bg-surface-card">
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
<div className="p-6 text-center">
<p className="text-sm text-content-muted">{t('admin.github.error')}</p>
<p className="mt-1 text-xs text-content-faint">{error}</p>
<p className="text-xs mt-1 text-content-faint">{error}</p>
</div>
</div>
) : (
<div className="overflow-hidden rounded-xl border border-edge bg-surface-card">
<div className="flex items-center justify-between border-b border-edge-secondary px-5 py-4">
<div>
<h2 className="font-semibold text-content">{t('admin.github.title')}</h2>
<p className="mt-0.5 text-xs text-content-faint">{t('admin.github.subtitle').replace('{repo}', REPO)}</p>
</div>
<a
href={`https://github.com/${REPO}/releases`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-lg bg-surface-secondary px-3 py-1.5 text-xs font-medium text-content-muted transition-colors"
>
<ExternalLink size={12} />
GitHub
</a>
</div>
{/* Timeline */}
<div className="px-5 py-4">
<div className="relative">
{/* Timeline line */}
<div
className="absolute bottom-3 left-[11px] top-3 w-px"
style={{ background: 'var(--border-primary)' }}
/>
<div className="space-y-0">
{(isPrerelease ? releases : releases.filter((r) => !r.prerelease)).map((release, idx) => {
const isLatest = idx === 0;
const isExpanded = expanded[release.id];
return (
<div key={release.id} className="relative pb-5 pl-8">
{/* Timeline dot */}
<div
className="absolute left-0 top-1 flex h-[23px] w-[23px] items-center justify-center rounded-full border-2"
style={{
background: isLatest ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: isLatest ? 'var(--text-primary)' : 'var(--border-primary)',
}}
>
<Tag size={10} style={{ color: isLatest ? 'var(--bg-card)' : 'var(--text-faint)' }} />
</div>
{/* Release content */}
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-content">{release.tag_name}</span>
{isLatest && (
<span className="rounded-full bg-[rgba(34,197,94,0.12)] px-2 py-0.5 text-[10px] font-semibold text-[#16a34a]">
{t('admin.github.latest')}
</span>
)}
{release.prerelease && (
<span className="rounded-full bg-[rgba(245,158,11,0.12)] px-2 py-0.5 text-[10px] font-semibold text-[#d97706]">
{t('admin.github.prerelease')}
</span>
)}
</div>
{release.name && release.name !== release.tag_name && (
<p className="mt-0.5 text-xs font-medium text-content-muted">{release.name}</p>
)}
<div className="mt-1 flex items-center gap-3">
<span className="flex items-center gap-1 text-[11px] text-content-faint">
<Calendar size={10} />
{formatDate(release.published_at || release.created_at)}
</span>
{release.author && (
<span className="text-[11px] text-content-faint">
{t('admin.github.by')} {release.author.login}
</span>
)}
</div>
{/* Expandable body */}
{release.body && (
<div className="mt-2">
<button
onClick={() => toggleExpand(release.id)}
className="flex items-center gap-1 text-[11px] font-medium text-content-muted transition-colors"
>
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
</button>
{isExpanded && (
<div className="mt-2 rounded-lg bg-surface-secondary p-3">{renderBody(release.body)}</div>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
{/* Load more */}
{hasMore && (
<div className="pt-2 text-center">
<button
onClick={handleLoadMore}
disabled={loadingMore}
className="inline-flex items-center gap-2 rounded-lg bg-surface-secondary px-4 py-2 text-xs font-medium text-content-muted transition-colors"
>
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
</button>
</div>
)}
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
<div className="px-5 py-4 border-b flex items-center justify-between border-edge-secondary">
<div>
<h2 className="font-semibold text-content">{t('admin.github.title')}</h2>
<p className="text-xs mt-0.5 text-content-faint">{t('admin.github.subtitle').replace('{repo}', REPO)}</p>
</div>
<a
href={`https://github.com/${REPO}/releases`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-surface-secondary text-content-muted"
>
<ExternalLink size={12} />
GitHub
</a>
</div>
{/* Timeline */}
<div className="px-5 py-4">
<div className="relative">
{/* Timeline line */}
<div className="absolute left-[11px] top-3 bottom-3 w-px" style={{ background: 'var(--border-primary)' }} />
<div className="space-y-0">
{(isPrerelease ? releases : releases.filter(r => !r.prerelease)).map((release, idx) => {
const isLatest = idx === 0
const isExpanded = expanded[release.id]
return (
<div key={release.id} className="relative pl-8 pb-5">
{/* Timeline dot */}
<div
className="absolute left-0 top-1 w-[23px] h-[23px] rounded-full flex items-center justify-center border-2"
style={{
background: isLatest ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: isLatest ? 'var(--text-primary)' : 'var(--border-primary)',
}}
>
<Tag size={10} style={{ color: isLatest ? 'var(--bg-card)' : 'var(--text-faint)' }} />
</div>
{/* Release content */}
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-content">
{release.tag_name}
</span>
{isLatest && (
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-[rgba(34,197,94,0.12)] text-[#16a34a]">
{t('admin.github.latest')}
</span>
)}
{release.prerelease && (
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-[rgba(245,158,11,0.12)] text-[#d97706]">
{t('admin.github.prerelease')}
</span>
)}
</div>
{release.name && release.name !== release.tag_name && (
<p className="text-xs font-medium mt-0.5 text-content-muted">
{release.name}
</p>
)}
<div className="flex items-center gap-3 mt-1">
<span className="flex items-center gap-1 text-[11px] text-content-faint">
<Calendar size={10} />
{formatDate(release.published_at || release.created_at)}
</span>
{release.author && (
<span className="text-[11px] text-content-faint">
{t('admin.github.by')} {release.author.login}
</span>
)}
</div>
{/* Expandable body */}
{release.body && (
<div className="mt-2">
<button
onClick={() => toggleExpand(release.id)}
className="flex items-center gap-1 text-[11px] font-medium transition-colors text-content-muted"
>
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
</button>
{isExpanded && (
<div className="mt-2 p-3 rounded-lg bg-surface-secondary">
{renderBody(release.body)}
</div>
)}
</div>
)}
</div>
</div>
)
})}
</div>
</div>
{/* Load more */}
{hasMore && (
<div className="text-center pt-2">
<button
onClick={handleLoadMore}
disabled={loadingMore}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-medium transition-colors bg-surface-secondary text-content-muted"
>
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
</button>
</div>
)}
</div>
</div>
)}
</div>
);
)
}
@@ -1,5 +1,5 @@
// FE-ADMIN-PKG-001 to FE-ADMIN-PKG-032
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
// FE-ADMIN-PKG-001 to FE-ADMIN-PKG-020
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
@@ -18,23 +18,6 @@ beforeEach(() => {
resetAllStores();
});
/** Template rows carry [chevron, edit, delete]; category headers [add item, edit, delete]. */
function rowButtons(name: string, selector: string): HTMLElement[] {
const row = screen.getByText(name).closest(selector) as HTMLElement;
return within(row).getAllByRole('button');
}
const templateButtons = (name: string) => rowButtons(name, '.px-5.py-3');
const categoryButtons = (name: string) => rowButtons(name, '.bg-slate-50');
const itemButtons = (name: string) => rowButtons(name, '.group');
/** Expands the single fixture template and waits for its content. */
async function expandBeachTrip(user: ReturnType<typeof userEvent.setup>, firstChild: string) {
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText(firstChild);
}
describe('PackingTemplateManager', () => {
it('FE-ADMIN-PKG-001: shows loading spinner on mount', async () => {
server.use(
@@ -525,296 +508,4 @@ describe('PackingTemplateManager', () => {
expect(screen.queryByPlaceholderText('Template name (e.g. Beach Holiday)')).not.toBeInTheDocument()
);
});
it('FE-ADMIN-PKG-021: a failing template list toasts and shows the empty state', async () => {
server.use(http.get('/api/admin/packing-templates', () => HttpResponse.error()));
render(<><ToastContainer /><PackingTemplateManager /></>);
expect(await screen.findByText('Failed to load templates')).toBeInTheDocument();
expect(screen.getByText('No templates created yet')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-022: a failing expand toasts and leaves the template without content', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.error()),
);
render(<><ToastContainer /><PackingTemplateManager /></>);
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
expect(await screen.findByText('Failed to load templates')).toBeInTheDocument();
expect(screen.getByText('Add category')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-023: an empty name is not submitted and a failing create toasts', async () => {
const user = userEvent.setup();
let posts = 0;
server.use(
http.post('/api/admin/packing-templates', () => {
posts += 1;
return HttpResponse.json({ error: 'nope' }, { status: 500 });
}),
);
render(<><ToastContainer /><PackingTemplateManager /></>);
await screen.findByText('No templates created yet');
await user.click(screen.getByRole('button', { name: /new template/i }));
const input = screen.getByPlaceholderText('Template name (e.g. Beach Holiday)');
await user.type(input, ' {Enter}');
expect(posts).toBe(0);
await user.clear(input);
await user.type(input, 'Ski trip{Enter}');
expect(await screen.findByText('Failed to create template')).toBeInTheDocument();
expect(posts).toBe(1);
});
it('FE-ADMIN-PKG-024: deleting the expanded template collapses it, a failing delete toasts', async () => {
const user = userEvent.setup();
let calls = 0;
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1] })),
http.delete('/api/admin/packing-templates/1', () => {
calls += 1;
return calls === 1
? HttpResponse.json({ error: 'in use' }, { status: 500 })
: HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><PackingTemplateManager /></>);
await expandBeachTrip(user, 'Clothing');
await user.click(templateButtons('Beach Trip')[2]);
expect(await screen.findByText('Failed to delete template')).toBeInTheDocument();
expect(screen.getByText('Clothing')).toBeInTheDocument();
await user.click(templateButtons('Beach Trip')[2]);
await screen.findByText('Template deleted');
await waitFor(() => expect(screen.queryByText('Clothing')).not.toBeInTheDocument());
expect(screen.getByText('No templates created yet')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-025: a blank rename closes the editor, a failing rename toasts, blur commits', async () => {
const user = userEvent.setup();
let puts = 0;
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.put('/api/admin/packing-templates/1', () => {
puts += 1;
return HttpResponse.json({ error: 'nope' }, { status: 500 });
}),
);
render(<><ToastContainer /><PackingTemplateManager /></>);
await screen.findByText('Beach Trip');
await user.click(templateButtons('Beach Trip')[1]);
await user.clear(screen.getByDisplayValue('Beach Trip'));
await user.type(screen.getByRole('textbox'), '{Enter}');
await waitFor(() => expect(screen.getByText('Beach Trip')).toBeInTheDocument());
expect(puts).toBe(0);
// Blurring the field commits the pending name — here the request fails
await user.click(templateButtons('Beach Trip')[1]);
const input = screen.getByDisplayValue('Beach Trip');
await user.clear(input);
await user.type(input, 'Winter');
await user.tab();
expect(await screen.findByText('Failed to save')).toBeInTheDocument();
expect(puts).toBe(1);
});
it('FE-ADMIN-PKG-026: a blank category is not posted, a failing add toasts and X cancels', async () => {
const user = userEvent.setup();
let posts = 0;
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [], items: [] })),
http.post('/api/admin/packing-templates/1/categories', () => {
posts += 1;
return HttpResponse.json({ error: 'nope' }, { status: 500 });
}),
);
render(<><ToastContainer /><PackingTemplateManager /></>);
await expandBeachTrip(user, 'Add category');
await user.click(screen.getByText('Add category'));
const catInput = screen.getByPlaceholderText('Category name (e.g. Clothing)');
await user.type(catInput, ' {Enter}');
expect(posts).toBe(0);
await user.clear(catInput);
await user.type(catInput, 'Electronics{Enter}');
expect(await screen.findByText('Failed to save')).toBeInTheDocument();
const cancel = within(catInput.parentElement as HTMLElement).getAllByRole('button')[1];
await user.click(cancel);
await waitFor(() =>
expect(screen.queryByPlaceholderText('Category name (e.g. Clothing)')).not.toBeInTheDocument(),
);
});
it('FE-ADMIN-PKG-027: a blank category rename closes the editor and a failing rename toasts', async () => {
const user = userEvent.setup();
let puts = 0;
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [] })),
http.put('/api/admin/packing-templates/1/categories/10', () => {
puts += 1;
return HttpResponse.json({ error: 'nope' }, { status: 500 });
}),
);
render(<><ToastContainer /><PackingTemplateManager /></>);
await expandBeachTrip(user, 'Clothing');
await user.click(categoryButtons('Clothing')[1]);
await user.clear(screen.getByDisplayValue('Clothing'));
await user.tab();
await waitFor(() => expect(screen.getByText('Clothing')).toBeInTheDocument());
expect(puts).toBe(0);
await user.click(categoryButtons('Clothing')[1]);
const catInput = screen.getByDisplayValue('Clothing');
await user.clear(catInput);
await user.type(catInput, 'Shoes{Enter}');
expect(await screen.findByText('Failed to save')).toBeInTheDocument();
expect(puts).toBe(1);
});
it('FE-ADMIN-PKG-028: a failing category delete toasts and keeps the category', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1] })),
http.delete('/api/admin/packing-templates/1/categories/10', () => HttpResponse.error()),
);
render(<><ToastContainer /><PackingTemplateManager /></>);
await expandBeachTrip(user, 'Clothing');
await user.click(categoryButtons('Clothing')[2]);
expect(await screen.findByText('Failed to delete category')).toBeInTheDocument();
expect(screen.getByText('Clothing')).toBeInTheDocument();
expect(screen.getByText('T-shirt')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-029: the add-item button posts the item, a failing add toasts and X closes the row', async () => {
const user = userEvent.setup();
let posts = 0;
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [] })),
http.post('/api/admin/packing-templates/1/categories/10/items', () => {
posts += 1;
return posts === 1
? HttpResponse.json({ item: { id: 102, category_id: 10, name: 'Sandals', sort_order: 0 } })
: HttpResponse.json({ error: 'nope' }, { status: 500 });
}),
);
render(<><ToastContainer /><PackingTemplateManager /></>);
await expandBeachTrip(user, 'Clothing');
await user.click(categoryButtons('Clothing')[0]);
const itemInput = screen.getByPlaceholderText('Item name');
const addRow = itemInput.parentElement as HTMLElement;
expect(within(addRow).getAllByRole('button')[0]).toBeDisabled();
await user.type(itemInput, 'Sandals');
await user.click(within(addRow).getAllByRole('button')[0]);
await screen.findByText('Sandals');
await user.type(screen.getByPlaceholderText('Item name'), 'Towel');
await user.click(within(addRow).getAllByRole('button')[0]);
expect(await screen.findByText('Failed to save')).toBeInTheDocument();
await user.click(within(addRow).getAllByRole('button')[1]);
await waitFor(() => expect(screen.queryByPlaceholderText('Item name')).not.toBeInTheDocument());
});
it('FE-ADMIN-PKG-030: the item editor commits on the check button, cancels on X and ignores a blank name', async () => {
const user = userEvent.setup();
let puts = 0;
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1] })),
http.put('/api/admin/packing-templates/1/items/100', () => {
puts += 1;
return puts === 1
? HttpResponse.json({ success: true })
: HttpResponse.json({ error: 'nope' }, { status: 500 });
}),
);
render(<><ToastContainer /><PackingTemplateManager /></>);
await expandBeachTrip(user, 'T-shirt');
// A blank name just closes the editor
await user.click(itemButtons('T-shirt')[0]);
const blank = screen.getByDisplayValue('T-shirt');
await user.clear(blank);
await user.click(within(blank.parentElement as HTMLElement).getAllByRole('button')[0]);
await waitFor(() => expect(screen.getByText('T-shirt')).toBeInTheDocument());
expect(puts).toBe(0);
// X discards the pending name
await user.click(itemButtons('T-shirt')[0]);
const editing = screen.getByDisplayValue('T-shirt');
await user.clear(editing);
await user.type(editing, 'Discarded');
await user.click(within(editing.parentElement as HTMLElement).getAllByRole('button')[1]);
await waitFor(() => expect(screen.getByText('T-shirt')).toBeInTheDocument());
expect(puts).toBe(0);
// The check button commits
await user.click(itemButtons('T-shirt')[0]);
const editing2 = screen.getByDisplayValue('T-shirt');
await user.clear(editing2);
await user.type(editing2, 'Tank Top');
await user.click(within(editing2.parentElement as HTMLElement).getAllByRole('button')[0]);
await screen.findByText('Tank Top');
expect(puts).toBe(1);
// A failing rename keeps the editor open and toasts
await user.click(itemButtons('Tank Top')[0]);
const editing3 = screen.getByDisplayValue('Tank Top');
await user.clear(editing3);
await user.type(editing3, 'Vest{Enter}');
expect(await screen.findByText('Failed to save')).toBeInTheDocument();
expect(screen.getByDisplayValue('Vest')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-031: a failing item delete toasts and keeps the item', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1] })),
http.delete('/api/admin/packing-templates/1/items/100', () => HttpResponse.error()),
);
render(<><ToastContainer /><PackingTemplateManager /></>);
await expandBeachTrip(user, 'T-shirt');
await user.click(itemButtons('T-shirt')[1]);
expect(await screen.findByText('Failed to delete item')).toBeInTheDocument();
expect(screen.getByText('T-shirt')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-032: the chevron button expands and collapses the template', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1] })),
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
await user.click(templateButtons('Beach Trip')[0]);
await screen.findByText('Clothing');
await user.click(templateButtons('Beach Trip')[0]);
await waitFor(() => expect(screen.queryByText('Clothing')).not.toBeInTheDocument());
});
});
@@ -115,13 +115,12 @@ export default function PackingTemplateManager() {
await adminApi.deleteTemplateCategory(expandedId, catId)
setCategories(prev => prev.filter(c => c.id !== catId))
setItems(prev => prev.filter(i => i.category_id !== catId))
} catch { toast.error(t('admin.packingTemplates.deleteCategoryError')) }
} catch { toast.error(t('admin.packingTemplates.deleteError')) }
}
// Item CRUD
const handleAddItem = async (catId: number) => {
// The name is already guaranteed non-empty by the button and the Enter handler.
if (!expandedId) return
if (!newItemName.trim() || !expandedId) return
try {
const data = await adminApi.addTemplateItem(expandedId, catId, { name: newItemName.trim() })
setItems(prev => [...prev, data.item])
@@ -144,7 +143,7 @@ export default function PackingTemplateManager() {
try {
await adminApi.deleteTemplateItem(expandedId, itemId)
setItems(prev => prev.filter(i => i.id !== itemId))
} catch { toast.error(t('admin.packingTemplates.deleteItemError')) }
} catch { toast.error(t('admin.packingTemplates.deleteError')) }
}
const inputStyle = 'w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent outline-none'
File diff suppressed because it is too large Load Diff
@@ -1,779 +0,0 @@
import React, { useEffect, useRef, useState } from 'react'
import { Activity, FolderTree, HardDrive } from 'lucide-react'
import {
STORAGE_CATEGORIES,
type StorageBackend,
type StorageCategory,
type StorageConfig,
type StorageMigrationStatus,
type StorageTestResponse,
} from '@trek/shared'
import { useTranslation } from '../../../i18n'
import { formatBytes } from '../../../utils/formatBytes'
import { relativeTime } from '../../../utils/relativeTime'
import ConfirmDialog from '../../shared/ConfirmDialog'
import CustomSelect from '../../shared/CustomSelect'
import { useToast } from '../../shared/Toast'
import Section from '../../Settings/Section'
import BackendForm, { type BackendFormMirrorProps } from './BackendForm'
import {
CACHE_CATEGORIES,
adoptedMirrorFor,
computeMigrationCandidates,
effectiveCategoryMap,
foldBackends,
mirrorProbeTargets,
primaryNameOf,
removeBackend,
removeBackendAndMirrors,
renameBackendRefs,
replicaCandidates,
replicaOfPrimaries,
settingsDocumentOf,
setMirrorTargets,
stripCategories,
upsertBackend,
usageByBackend,
type FoldedBackendRow,
type MigrationCandidate,
} from './storageModel'
import { useStorageAdmin } from './useStorageAdmin'
/** Display-name mapper for joined category lists — the raw id renders only in the badge. */
const categoryNames = (t: (key: string) => string, ids: readonly string[]): string =>
ids.map((id) => t(`storage.category.${id}`)).join(', ')
function TestResult({ result, failedLabel, okLabel }: {
result: StorageTestResponse | undefined
failedLabel: string
okLabel: string
}): React.ReactElement | null {
if (result === undefined) return null
return (
<div className="mt-2 space-y-0.5">
<p className="text-xs font-semibold text-content">{result.ok ? okLabel : failedLabel}</p>
{result.targets.map((target) => (
<p key={target.name} className="text-xs text-content-faint">
{target.ok ? '✓' : '✗'} {target.name}
{target.error ? ` — ${target.error}` : ''}
</p>
))}
</div>
)
}
const LINK_BUTTON_STYLE: React.CSSProperties = { background: 'none', border: 'none', cursor: 'pointer' }
export default function AdminStoragePanel(): React.ReactElement {
const { t, locale } = useTranslation()
const toast = useToast()
const admin = useStorageAdmin(t('common.error'), t('storage.saveConflict'))
const [editing, setEditing] = useState<{
initial: StorageBackend | null
originalName: string | null
mirror: BackendFormMirrorProps
} | null>(null)
const [confirmRemove, setConfirmRemove] = useState<{ name: string; degenerate: boolean } | null>(null)
const [syncPrompt, setSyncPrompt] = useState<string | null>(null)
// Open/closed only — never a snapshot of candidates. Storing the candidate
// array here would go stale the moment the operator edits a category while
// the dialog is open; the render below and moveAndSave both recompute
// computeMigrationCandidates(draft, state) fresh instead.
const [migratePromptOpen, setMigratePromptOpen] = useState(false)
const [migrationQueue, setMigrationQueue] = useState<MigrationCandidate[]>([])
// Set by `save` right before it calls admin.save(): the pre-save mirror
// target count per row name. Consumed (and cleared) by the effect below the
// first time `admin.state` changes afterward — never touched otherwise, so
// unrelated state changes (the backfill poll included) are no-ops here.
const pendingPromptCheck = useRef<Map<string, number> | null>(null)
// Synchronous single-flight lock for the queue effect below: `admin.state`
// only reflects a just-started migration once startMigration's awaited
// refreshState() resolves, so `setMigrationQueue(rest)` re-firing the
// effect synchronously (still against the pre-POST `admin.state`) would
// otherwise dequeue and POST the next candidate before the server has
// confirmed the first — a ref (not state) so the guard is visible on that
// very next synchronous re-render, not just after a state-driven one.
const migrationStartInFlight = useRef(false)
useEffect(() => {
if (!pendingPromptCheck.current || !admin.state) return
const before = pendingPromptCheck.current
pendingPromptCheck.current = null
const { rows: afterRows } = foldBackends(admin.state, settingsDocumentOf(admin.state))
const grown = afterRows.find((r) => r.mirrorTargets.length > (before.get(r.name) ?? 0))
if (grown) setSyncPrompt(grown.name)
}, [admin.state])
// Queued category migrations run strictly sequentially: once a slot opens
// (no migration currently running), dequeue the next candidate and start
// it. Depends on admin.state so a poll landing a terminal status re-fires
// this without any user interaction. The in-flight lock closes the race
// where setMigrationQueue(rest) re-fires this effect synchronously while
// admin.state still shows the pre-POST world — without it, the next
// candidate could dequeue and POST before the first is server-confirmed.
// Also waits out a running BACKFILL — the server's one-storage-job-at-a-time
// rule spans backfills and migrations alike, so starting while one runs
// would 409, and the queued candidate would be lost (no retry).
//
// A failed startMigration POST would otherwise strand `rest`: it was
// already dequeued into local state before the POST, so on failure nothing
// ever changes `migrationQueue` or `admin.state` again — the effect never
// re-fires and the remaining candidates sit invisibly forever, silently
// never migrated. On failure the whole remaining queue is explicitly
// cleared (never retried — the operator re-triggers via Save), named in a
// toast, and admin.state is refreshed so the panel reflects whatever the
// failed attempt's category ended up as server-side.
useEffect(() => {
if (migrationQueue.length === 0 || !admin.state) return
if (migrationStartInFlight.current) return
if (admin.state.migrations.some((m) => m.status === 'running')) return
if (admin.state.backfills.some((b) => b.status === 'running')) return
const [next, ...rest] = migrationQueue
migrationStartInFlight.current = true
setMigrationQueue(rest)
void admin.startMigration(next!.category, next!.toWire).then((error) => {
migrationStartInFlight.current = false
if (error) {
toast.error(error)
if (rest.length > 0) {
toast.error(t('storage.migrate.queueDropped', { categories: categoryNames(t, rest.map((c) => c.category)) }))
setMigrationQueue([])
}
void admin.refreshState().catch(() => {})
}
})
}, [migrationQueue, admin.state])
if (admin.loading) {
return <p className="text-sm italic p-4 text-content-faint">{t('storage.loading')}</p>
}
if (!admin.state || !admin.draft) {
return (
<div role="alert" className="rounded-xl border p-4 text-sm border-edge bg-surface-card text-content">
{admin.loadError || t('common.error')}
</div>
)
}
const { state, draft } = admin
// Pure/cheap — recomputed every render so the migrate-prompt dialog (below)
// never shows a stale candidate set while it's open (fix: migration prompt
// staleness). moveAndSave recomputes independently at confirm time rather
// than reading this render-scoped value, since the click handler and the
// render that produced it are not guaranteed to be the same one.
const migrationCandidates = computeMigrationCandidates(draft, state)
// Duplicate pre-check needs EVERY wire name (hidden mirror names included);
// mirror-target candidates are the visible primaries only.
const backendNames = [...new Set([...state.backends.map((b) => b.name), ...draft.backends.map((b) => b.name)])]
const { rows, degenerate } = foldBackends(state, draft)
const effective = effectiveCategoryMap(state, draft)
const usageSums = usageByBackend(state, draft)
const startEdit = (row: FoldedBackendRow) => {
// Editing a built-in creates a settings override row bearing its name —
// the first-class relocation path (merge-by-name).
setEditing({
initial: row.backend,
originalName: row.name,
mirror: {
candidates: replicaCandidates(rows, row.name, row.mirrorTargets),
initialTargets: row.mirrorTargets,
},
})
}
const commitBackend = (backend: StorageBackend, mirrorTargets?: string[]) => {
const renamedFrom =
editing?.originalName && editing.originalName !== backend.name ? editing.originalName : null
// Widened to StorageConfig: these edit helpers are version-blind (they
// return plain StorageConfig), and admin.setDraft re-attaches the
// draft's own `version` regardless of what shape it's handed.
let next: StorageConfig | null = draft
if (renamedFrom) next = renameBackendRefs(removeBackend(next, renamedFrom), renamedFrom, backend.name)
next = upsertBackend(next, backend)
if (mirrorTargets !== undefined) next = setMirrorTargets(state, next, backend.name, mirrorTargets)
admin.setDraft(next)
setEditing(null)
}
const removeMessage = (name: string, isDegenerate: boolean): string => {
const row = rows.find((r) => r.name === name)
const assigned = isDegenerate
? degenerate.find((d) => d.backend.name === name)?.categories ?? []
: row?.categories ?? []
const usedAsReplicaBy = isDegenerate ? [] : replicaOfPrimaries(draft, name)
return [
t('storage.remove.body', { name }),
assigned.length > 0 ? t('storage.remove.stillAssigned', { categories: categoryNames(t, assigned) }) : '',
usedAsReplicaBy.length > 0 ? t('storage.remove.usedAsReplicaBy', { primaries: usedAsReplicaBy.join(', ') }) : '',
]
.filter(Boolean)
.join(' ')
}
const setCategory = (category: StorageCategory, primaryName: string) => {
// Picking a mirrored primary routes through its adopted mirror silently.
const target = adoptedMirrorFor(draft, primaryName)?.name ?? primaryName
// The admin state's category record is exhaustive by schema contract.
const stateEntry = state.categories[category]!
const categories = { ...draft.categories }
if (stateEntry.source === 'default' && target === stateEntry.backend) {
delete categories[category] // back to the default → no longer settings-owned
} else {
categories[category] = target
}
admin.setDraft({ ...draft, categories })
}
// Snapshot the LAST-CONFIRMED (pre-save `state`'s own fold, not the
// in-progress `draft`) mirror target counts; the effect above compares
// them against the fresh post-save state to detect newly-added
// replicas. Folding `draft` here would already include the unsaved
// edit and mask the very growth this is meant to detect.
const snapshotMirrorTargets = (): Map<string, number> => {
const { rows: beforeRows } = foldBackends(state, settingsDocumentOf(state))
return new Map(beforeRows.map((r) => [r.name, r.mirrorTargets.length]))
}
const doPlainSave = async () => {
pendingPromptCheck.current = snapshotMirrorTargets()
if (await admin.save()) toast.success(t('storage.saved'))
else pendingPromptCheck.current = null
}
// Recomputes the candidate set at confirm time — never trusts whatever was
// true when the dialog opened. The operator can edit categories (or a
// background poll can land a fresher usage scan) while the dialog is on
// screen; the recomputed set is what actually drives both stripCategories
// (what gets reverted from the PUT body) and the queue (what gets POSTed),
// so the two can never diverge from what the dialog most recently showed.
const moveAndSave = async () => {
const candidates = computeMigrationCandidates(draft, state)
pendingPromptCheck.current = snapshotMirrorTargets()
const ok = await admin.save(stripCategories(draft, state, candidates.map((c) => c.category)))
if (ok) {
setMigrationQueue(candidates)
toast.success(t('storage.saved'))
} else {
pendingPromptCheck.current = null
}
setMigratePromptOpen(false)
}
const routeOnlySave = async () => {
setMigratePromptOpen(false)
await doPlainSave()
}
const save = async () => {
if (migrationCandidates.length > 0) {
setMigratePromptOpen(true)
return
}
await doPlainSave()
}
const handleCancelMigration = async (m: StorageMigrationStatus) => {
const error = await admin.cancelMigration(m.category)
if (error) toast.error(error)
}
const testResultFor = (key: string): StorageTestResponse | 'running' | undefined => admin.testResults[key]
const handleRefreshStats = async () => {
const error = await admin.refreshStats()
if (error) toast.error(error)
}
const handleStartBackfill = async (row: FoldedBackendRow) => {
if (!row.mirrorName) return
setSyncPrompt((prev) => (prev === row.name ? null : prev))
const error = await admin.startBackfill(row.mirrorName)
if (error) toast.error(error)
}
const handleCancelBackfill = async (row: FoldedBackendRow) => {
if (!row.mirrorName) return
const error = await admin.cancelBackfill(row.mirrorName)
if (error) toast.error(error)
}
return (
<div>
{state.configError && (
<div
role="alert"
className="rounded-xl border p-3 mb-4 text-sm"
style={{ borderColor: 'var(--warning)', background: 'var(--warning-soft)', color: 'var(--text-primary)' }}
>
{t('storage.configError.banner', { error: state.configError })}
</div>
)}
<Section title={t('storage.health.title')} icon={Activity}>
{state.health.replicaFailures.length === 0 ? (
<p className="text-sm text-content-faint">{t('storage.health.allClear')}</p>
) : (
<ul className="space-y-1">
{state.health.replicaFailures.map((failure) => (
<li key={`${failure.backend}-${failure.key}-${failure.at}`} className="text-sm text-content">
{t('storage.health.failureLine', {
op: failure.op,
key: failure.key,
backend: failure.backend,
error: failure.error,
})}
<span className="text-content-faint"> · {relativeTime(failure.at, locale)}</span>
</li>
))}
</ul>
)}
{state.seedFilePresent && <p className="text-xs mt-2 text-content-faint">{t('storage.health.seedFile')}</p>}
</Section>
<Section title={t('storage.backends.title')} icon={HardDrive}>
<p className="text-sm text-content-faint" style={{ marginTop: -8 }}>{t('storage.description')}</p>
<p className="text-xs mb-2 text-content-faint">
{state.usage
? t('storage.usage.computed', { age: relativeTime(state.usage.computedAt, locale) })
: t('storage.usage.never')}
{' '}
<button className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={handleRefreshStats}>
{state.usage ? t('storage.usage.refresh') : t('storage.usage.compute')}
</button>
</p>
<div className="space-y-3">
{rows.map((row) => {
const resultKey = row.mirrorName ?? row.name
const result = testResultFor(resultKey)
// row.mirrorName only exists when foldBackends adopted a draft mirror
// for this row, so the draft lookup below cannot miss.
const testCandidate = row.mirrorName
? draft.backends.find((b) => b.name === row.mirrorName)!
: row.backend
const rowUsage = usageSums?.[row.name]
const backfill = row.mirrorName ? state.backfills.find((b) => b.backend === row.mirrorName) : undefined
return (
<div key={row.name} data-testid={`storage-backend-${row.name}`} className="rounded-xl border p-4 border-edge-secondary">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-content">{row.name}</span>
<span className="text-xs px-2 py-0.5 rounded-full border border-edge text-content-secondary">
{t(`storage.type.${row.type}`)}
</span>
<span className="text-xs px-2 py-0.5 rounded-full border border-edge text-content-faint">
{t(`storage.source.${row.source}`)}
</span>
<span className="flex-1" />
<button
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() =>
row.mirrorName
? admin.testMirror(resultKey, mirrorProbeTargets(draft, state, testCandidate))
: admin.test(testCandidate)
}
>
{t('storage.actions.test')}
</button>
{row.source !== 'env' && (
<button className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => startEdit(row)}>
{t('storage.actions.edit')}
</button>
)}
{row.source === 'settings' && (
<button className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => setConfirmRemove({ name: row.name, degenerate: false })}>
{t('storage.actions.remove')}
</button>
)}
</div>
<p className="text-xs mt-1 text-content-faint">
{row.categories.length > 0
? t('storage.backends.usedBy', { categories: categoryNames(t, row.categories) })
: t('storage.backends.unused')}
</p>
{rowUsage && (
<p className="text-xs mt-1 text-content-faint">
{t('storage.usage.line', { objects: String(rowUsage.objects), size: formatBytes(rowUsage.bytes) })}
{row.name === 'uploads-local' && state.usage!.legacyPhotos.objects > 0
? ` (${t('storage.usage.legacyNote')})`
: ''}
</p>
)}
{row.mirrorTargets.length > 0 && (
<p className="text-xs mt-1 text-content-faint">
{t('storage.mirror.mirroredTo', { targets: row.mirrorTargets.join(', ') })}
</p>
)}
{row.replicaOf.length > 0 && (
<p className="text-xs mt-1 text-content-faint">
{t('storage.mirror.replicaOf', { primaries: row.replicaOf.join(', ') })}
</p>
)}
{row.source === 'env' && <p className="text-xs mt-1 text-content-faint">{t('storage.backends.envReadOnly')}</p>}
{result === 'running' ? (
<p className="text-xs mt-2 text-content-faint">{t('storage.test.running')}</p>
) : (
<TestResult
result={result as StorageTestResponse | undefined}
okLabel={t('storage.test.ok')}
failedLabel={t('storage.test.failed')}
/>
)}
{row.mirrorTargets.length > 0 && (
<div className="mt-2">
{backfill?.status === 'running' ? (
<>
<p className="text-xs text-content-faint">
{t('storage.sync.running', { done: String(backfill.done), total: String(backfill.total) })}
</p>
<p className="text-xs text-content-faint">
{t('storage.sync.counts', {
copied: String(backfill.copied),
skipped: String(backfill.skipped),
failed: String(backfill.failed),
})}
</p>
<button
className="text-xs underline text-content-secondary mt-1"
style={LINK_BUTTON_STYLE}
onClick={() => handleCancelBackfill(row)}
>
{t('storage.sync.cancel')}
</button>
</>
) : (
<>
{/* Terminal statuses render ALONGSIDE the button (not instead-of) —
a completed/cancelled/errored backfill must stay re-runnable
without a page reload. */}
{backfill?.status === 'done' && (
<p className="text-xs text-content-faint">
{t('storage.sync.done', {
copied: String(backfill.copied),
deleted: String(backfill.deleted),
failed: String(backfill.failed),
})}
</p>
)}
{backfill?.status === 'cancelled' && (
<p className="text-xs text-content-faint">{t('storage.sync.cancelled')}</p>
)}
{backfill?.status === 'error' && (
<p className="text-xs text-content-faint">
{t('storage.sync.error', { error: backfill.error ?? '' })}
</p>
)}
{syncPrompt !== row.name && (
<button
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => handleStartBackfill(row)}
>
{t('storage.sync.now')}
</button>
)}
</>
)}
</div>
)}
{syncPrompt === row.name && (
<div className="mt-2 rounded-lg border p-2 border-edge-secondary">
<p className="text-xs text-content">{t('storage.sync.prompt')}</p>
<div className="flex items-center gap-3 mt-1">
<button
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => handleStartBackfill(row)}
>
{t('storage.sync.now')}
</button>
<button
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => setSyncPrompt(null)}
>
{t('storage.sync.dismiss')}
</button>
</div>
</div>
)}
</div>
)
})}
{degenerate.map(({ backend, reason }) => {
const result = testResultFor(backend.name)
const primary = backend.type === 'mirror' ? backend.options.primary : ''
return (
<div key={backend.name} data-testid={`storage-backend-${backend.name}`} className="rounded-xl border p-4 border-edge-secondary">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-content">{backend.name}</span>
<span className="text-xs px-2 py-0.5 rounded-full border border-edge text-content-secondary">
{t('storage.type.mirror')}
</span>
<span className="flex-1" />
<button className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => admin.test(backend)}>
{t('storage.actions.test')}
</button>
<button className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => setConfirmRemove({ name: backend.name, degenerate: true })}>
{t('storage.actions.remove')}
</button>
</div>
<p className="text-xs mt-1 text-content-faint">{t(`storage.mirror.degenerate.${reason}`, { primary })}</p>
{result === 'running' ? (
<p className="text-xs mt-2 text-content-faint">{t('storage.test.running')}</p>
) : (
<TestResult
result={result as StorageTestResponse | undefined}
okLabel={t('storage.test.ok')}
failedLabel={t('storage.test.failed')}
/>
)}
</div>
)
})}
</div>
{state.migrations.length > 0 && (
<div className="space-y-3 mt-3">
{state.migrations.map((m) => (
<div
key={m.category}
data-testid={`storage-migration-${m.category}`}
className="rounded-xl border p-4 border-edge-secondary"
>
<p className="text-sm font-semibold text-content">{t(`storage.category.${m.category}`)}</p>
{m.status === 'running' ? (
<>
<p className="text-xs mt-1 text-content-faint">
{t('storage.migrate.running', {
category: t(`storage.category.${m.category}`),
done: String(m.done),
total: String(m.total),
})}
</p>
<button
className="text-xs underline text-content-secondary mt-1"
style={LINK_BUTTON_STYLE}
onClick={() => void handleCancelMigration(m)}
>
{t('storage.migrate.cancel')}
</button>
</>
) : m.status === 'done' ? (
<>
<p className="text-xs mt-1 text-content-faint">
{t('storage.migrate.done', { copied: String(m.copied), skipped: String(m.skipped) })}
</p>
{m.failed > 0 && (
<p className="text-xs mt-1 text-content-faint">
{t('storage.migrate.doneFailures', { failed: String(m.failed) })}
</p>
)}
{m.reclaimable && (
<p className="text-xs mt-1 text-content-faint">
{t('storage.migrate.reclaimable', {
objects: String(m.reclaimable.objects),
size: formatBytes(m.reclaimable.bytes),
from: m.from,
})}
</p>
)}
</>
) : m.status === 'failed' ? (
<p className="text-xs mt-1 text-content-faint">
{t('storage.migrate.failed', { error: m.error ?? '' })}
</p>
) : (
<p className="text-xs mt-1 text-content-faint">{t('storage.migrate.cancelled')}</p>
)}
</div>
))}
</div>
)}
{editing ? (
<BackendForm
initial={editing.initial}
backendNames={backendNames}
mirror={editing.mirror}
onCommit={commitBackend}
onCancel={() => setEditing(null)}
/>
) : (
<button
onClick={() =>
setEditing({
initial: null,
originalName: null,
mirror: { candidates: replicaCandidates(rows, null), initialTargets: [] },
})
}
style={{
padding: '8px 20px', borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit',
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
border: '2px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)',
}}
>
{t('storage.backends.add')}
</button>
)}
</Section>
<Section title={t('storage.categories.title')} icon={FolderTree}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4">
{STORAGE_CATEGORIES.map((category) => {
// The admin state's category record is exhaustive by schema contract.
const stateEntry = state.categories[category]!
const selectedPrimary = primaryNameOf(state, draft, effective[category])
const changed = selectedPrimary !== primaryNameOf(state, draft, stateEntry.backend)
const viaMirror = effective[category] !== selectedPrimary
return (
<div key={category} data-testid={`storage-category-${category}`}>
<label className="block text-sm font-medium mb-1 text-content-secondary">
{t(`storage.category.${category}`)}
<span className="ml-2 px-1.5 py-0.5 rounded border font-mono font-normal text-xs border-edge text-content-faint">
{category}
</span>
</label>
<p className="text-xs mb-1.5 text-content-faint">{t(`storage.categoryDesc.${category}`)}</p>
{state.usage?.categories[category] && (
<p className="text-xs mb-1.5 text-content-faint">
{t('storage.usage.line', {
objects: String(state.usage.categories[category]!.objects),
size: formatBytes(state.usage.categories[category]!.bytes),
})}
</p>
)}
<CustomSelect
value={selectedPrimary}
onChange={(value) => setCategory(category, String(value))}
options={rows.map((row) => ({
value: row.name,
label:
stateEntry.source === 'default' && row.name === stateEntry.backend
? `${row.name} (${t('storage.categories.default')})`
: row.name,
}))}
size="sm"
style={{ maxWidth: 320 }}
/>
{changed && (
<p role="alert" className="text-xs mt-1 text-content-faint">
{t('storage.categories.reassignWarning')}
</p>
)}
{viaMirror && CACHE_CATEGORIES.includes(category) && (
<p role="note" className="text-xs mt-1 text-content-faint">
{t('storage.mirror.cacheWarning')}
</p>
)}
</div>
)
})}
</div>
</Section>
<div className="flex items-center gap-3">
<button
onClick={save}
disabled={!admin.dirty || admin.saving}
style={{
padding: '10px 20px', borderRadius: 10, cursor: admin.dirty && !admin.saving ? 'pointer' : 'default',
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600,
border: '2px solid var(--text-primary)', background: 'var(--bg-hover)', color: 'var(--text-primary)',
opacity: admin.dirty && !admin.saving ? 1 : 0.5,
}}
>
{t('storage.save')}
</button>
{admin.dirty && <span className="text-xs text-content-faint">{t('storage.unsaved')}</span>}
</div>
{migrationQueue.length > 0 && (
<p className="text-xs mt-2 text-content-faint">
{t('storage.migrate.queued', { categories: categoryNames(t, migrationQueue.map((c) => c.category)) })}
</p>
)}
{migratePromptOpen && (
<div className="rounded-lg border px-3 py-2 mt-2 border-edge bg-surface-secondary" role="alertdialog">
<p className="text-sm text-content">{t('storage.migrate.promptTitle')}</p>
{migrationCandidates.map((c) => (
<p key={c.category} className="text-xs mt-1 text-content-secondary">
{c.objects === null
? t('storage.migrate.promptLineUnknown', {
category: t(`storage.category.${c.category}`),
from: c.from,
to: c.to,
})
: t('storage.migrate.promptLine', {
category: t(`storage.category.${c.category}`),
objects: String(c.objects),
size: formatBytes(c.bytes ?? 0),
from: c.from,
to: c.to,
})}
</p>
))}
<div className="flex items-center gap-3 mt-2">
<button
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => void moveAndSave()}
>
{t('storage.migrate.move')}
</button>
<button
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => void routeOnlySave()}
>
{t('storage.migrate.routeOnly')}
</button>
<button
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => setMigratePromptOpen(false)}
>
{t('storage.migrate.promptCancel')}
</button>
</div>
</div>
)}
{admin.saveError && (
<div role="alert" className="text-sm mt-2 text-content">
<p>{admin.saveError}</p>
{/* A 409 leaves the draft pinned to a version the server has moved
past; "save again" can never clear it, so the banner offers the
one action that can — see useStorageAdmin.discardDraft. */}
{admin.saveConflict && (
<button
className="text-xs underline text-content-secondary mt-1"
style={LINK_BUTTON_STYLE}
onClick={() => void admin.discardDraft()}
>
{t('storage.discardAndReload')}
</button>
)}
</div>
)}
<ConfirmDialog
isOpen={confirmRemove !== null}
onClose={() => setConfirmRemove(null)}
onConfirm={() => {
if (confirmRemove) {
admin.setDraft(
confirmRemove.degenerate
? removeBackend(draft, confirmRemove.name)
: removeBackendAndMirrors(state, draft, confirmRemove.name),
)
}
setConfirmRemove(null)
}}
title={t('storage.remove.title')}
message={confirmRemove ? removeMessage(confirmRemove.name, confirmRemove.degenerate) : ''}
confirmLabel={t('storage.remove.title')}
/>
</div>
)
}
@@ -1,155 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { MASKED_SETTING_VALUE, type StorageBackend } from '@trek/shared';
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
import BackendForm from './BackendForm';
const NAMES = ['uploads-local', 'backups-local', 'off-box'];
type FormProps = Parameters<typeof BackendForm>[0];
function renderForm(overrides: Partial<FormProps> = {}) {
const onCommit = vi.fn();
const onCancel = vi.fn();
render(
<BackendForm
initial={null}
backendNames={NAMES}
onCommit={onCommit}
onCancel={onCancel}
{...overrides}
/>,
);
return { onCommit, onCancel };
}
const S3_INITIAL: StorageBackend = {
name: 'off-box',
type: 's3',
options: {
endpoint: 'http://127.0.0.1:9000', bucket: 'trek', accessKeyId: 'ak',
secretAccessKey: MASKED_SETTING_VALUE, region: 'us-east-1', keyPrefix: '', retries: 1, timeoutMs: 30000,
},
};
describe('BackendForm', () => {
it('FE-ADMIN-STORF-001: local renders the root path field from the registry', () => {
renderForm();
expect(screen.getByLabelText(/Root directory/)).toBeInTheDocument();
expect(screen.queryByLabelText(/Bucket/)).not.toBeInTheDocument();
});
it('FE-ADMIN-STORF-002: s3 renders every field by kind — password secret, number inputs, defaultValue placeholders', () => {
renderForm({ initial: S3_INITIAL });
expect(screen.getByLabelText(/Endpoint URL/)).toHaveAttribute('type', 'text');
expect(screen.getByLabelText(/Secret access key/)).toHaveAttribute('type', 'password');
expect(screen.getByLabelText(/Region/)).toHaveAttribute('placeholder', 'us-east-1');
expect(screen.getByLabelText(/Retries/)).toHaveAttribute('type', 'number');
expect(screen.getByLabelText(/Retries/)).toHaveAttribute('placeholder', '1');
expect(screen.getByLabelText(/Timeout \(ms\)/)).toHaveAttribute('placeholder', '30000');
});
it('FE-ADMIN-STORF-004: the mask echoes back through commit untouched (no-op by contract)', () => {
const { onCommit } = renderForm({ initial: S3_INITIAL });
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
expect(onCommit).toHaveBeenCalledWith(
expect.objectContaining({
name: 'off-box',
type: 's3',
options: expect.objectContaining({ secretAccessKey: MASKED_SETTING_VALUE, retries: 1, timeoutMs: 30000 }),
}),
);
});
it('FE-ADMIN-STORF-005: a typed plaintext secret keeps Apply offered (no encryption-key gate)', () => {
renderForm({ initial: S3_INITIAL });
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/Secret access key/), { target: { value: 'sk-new' } });
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('FE-ADMIN-STORF-006: Apply stays disabled until name and every required field are filled', () => {
const { onCommit } = renderForm();
const apply = screen.getByRole('button', { name: 'Apply' });
expect(apply).toBeDisabled();
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'nas' } });
expect(apply).toBeDisabled(); // root still empty
fireEvent.change(screen.getByLabelText(/Root directory/), { target: { value: '/mnt/nas' } });
expect(apply).toBeEnabled();
fireEvent.click(apply);
expect(onCommit).toHaveBeenCalledWith({ name: 'nas', type: 'local', options: { root: '/mnt/nas' } });
});
it('FE-ADMIN-STORF-007: a duplicate name on a NEW backend warns and blocks Apply', () => {
renderForm();
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'off-box' } });
fireEvent.change(screen.getByLabelText(/Root directory/), { target: { value: '/x' } });
expect(screen.getByText(/A backend named off-box already exists/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
});
it('FE-ADMIN-STORF-008: empty optional fields are omitted so the shared schema defaults apply', () => {
const { onCommit } = renderForm();
// Switch to s3 via the type select (CustomSelect renders options into a portal).
fireEvent.click(screen.getByText('Local'));
fireEvent.click(screen.getByText('S3'));
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'fresh' } });
fireEvent.change(screen.getByLabelText(/Endpoint URL/), { target: { value: 'http://127.0.0.1:9000' } });
fireEvent.change(screen.getByLabelText(/Bucket/), { target: { value: 'trek' } });
fireEvent.change(screen.getByLabelText(/Access key ID/), { target: { value: 'ak' } });
fireEvent.change(screen.getByLabelText(/Secret access key/), { target: { value: 'sk' } });
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
const committed = onCommit.mock.calls[0]![0] as StorageBackend;
expect(committed.options).toEqual({
endpoint: 'http://127.0.0.1:9000', bucket: 'trek', accessKeyId: 'ak', secretAccessKey: 'sk',
});
expect(committed.options).not.toHaveProperty('region');
});
it('FE-ADMIN-STORF-009: renaming onto another existing backend warns and blocks Apply', () => {
renderForm({ initial: S3_INITIAL });
expect(screen.getByRole('button', { name: 'Apply' })).toBeEnabled();
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'uploads-local' } });
expect(screen.getByText(/A backend named uploads-local already exists/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
// Keeping the original name stays allowed.
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'off-box' } });
expect(screen.getByRole('button', { name: 'Apply' })).toBeEnabled();
});
it('FE-ADMIN-STORF-010: the type select no longer offers Mirror', () => {
renderForm();
fireEvent.click(screen.getByText('Local')); // open the type select
expect(screen.queryByText('Mirror')).not.toBeInTheDocument();
expect(screen.getByText('S3')).toBeInTheDocument();
});
it('FE-ADMIN-STORF-011: the mirror composer renders candidates minus self and commits targets', () => {
const { onCommit } = renderForm({
initial: S3_INITIAL,
mirror: { candidates: ['uploads-local', 'backups-local', 'off-box'], initialTargets: ['backups-local'] },
});
expect(screen.getByText('Mirror targets')).toBeInTheDocument();
const boxes = screen.getAllByRole('checkbox');
expect(boxes).toHaveLength(2); // off-box (self) excluded
expect(screen.getByRole('checkbox', { name: 'backups-local' })).toBeChecked();
expect(screen.getByRole('note').textContent).toContain('slows every upload');
fireEvent.click(screen.getByRole('checkbox', { name: 'uploads-local' }));
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
expect(onCommit).toHaveBeenCalledWith(
expect.objectContaining({ name: 'off-box', type: 's3' }),
['backups-local', 'uploads-local'],
);
});
it('FE-ADMIN-STORF-012: unchecking every target commits an empty array (dissolve) and hides the latency note', () => {
const { onCommit } = renderForm({
initial: S3_INITIAL,
mirror: { candidates: ['uploads-local'], initialTargets: ['uploads-local'] },
});
fireEvent.click(screen.getByRole('checkbox', { name: 'uploads-local' }));
expect(screen.queryByRole('note')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
expect(onCommit).toHaveBeenCalledWith(expect.objectContaining({ name: 'off-box' }), []);
});
});
@@ -1,264 +0,0 @@
import React, { useState } from 'react'
import {
STORAGE_BACKEND_TYPES,
STORAGE_BACKEND_TYPE_IDS,
type StorageBackend,
type StorageBackendFieldDef,
type StorageBackendTypeId,
} from '@trek/shared'
import { useTranslation } from '../../../i18n'
import CustomSelect from '../../shared/CustomSelect'
type FieldValues = Record<string, string | string[]>
function valuesOf(backend: StorageBackend | null): FieldValues {
if (!backend) return {}
const values: FieldValues = {}
for (const [key, value] of Object.entries(backend.options)) {
values[key] = Array.isArray(value) ? value : String(value)
}
return values
}
export interface BackendFormMirrorProps {
/** Selectable replica targets (the form excludes its own name at render). */
candidates: string[]
initialTargets: string[]
}
interface BackendFormProps {
/** null = new backend */
initial: StorageBackend | null
/** Every defined backend name — mirror-target options and the duplicate pre-check. */
backendNames: string[]
/**
* Present on non-mirror backends: renders the Mirror-targets composer
* (replicas-on-primary — panel-supplied chrome, never a registry field).
* When present, onCommit's second argument is always an array.
*/
mirror?: BackendFormMirrorProps
onCommit: (backend: StorageBackend, mirrorTargets?: string[]) => void
onCancel: () => void
}
const LABEL_CLASS = 'block text-sm font-medium mb-1.5 text-content-secondary'
const INPUT_CLASS =
'mt-1.5 w-full px-3 py-2 border rounded-lg text-sm border-edge bg-surface-card text-content'
/**
* Renders whatever STORAGE_BACKEND_TYPES declares, by field kind. The raw
* `mirror` type is hidden from the type select — mirrors are composed via the
* Mirror-targets prop block and synthesized by the panels (the ref-kind
* renderers below stay for future registry types).
*/
export default function BackendForm({
initial,
backendNames,
mirror,
onCommit,
onCancel,
}: BackendFormProps): React.ReactElement {
const { t } = useTranslation()
const [type, setType] = useState<StorageBackendTypeId>(initial?.type ?? 'local')
const [name, setName] = useState(initial?.name ?? '')
const [values, setValues] = useState<FieldValues>(() => valuesOf(initial))
const [targets, setTargets] = useState<string[]>(mirror?.initialTargets ?? [])
const fields = STORAGE_BACKEND_TYPES[type].fields as readonly StorageBackendFieldDef[]
const refOptions = backendNames.filter((candidate) => candidate !== name.trim())
const setValue = (key: string, value: string | string[]) =>
setValues((prev) => ({ ...prev, [key]: value }))
const filled = (field: StorageBackendFieldDef): boolean => {
const value = values[field.key]
if (field.kind === 'backend-ref-list') return Array.isArray(value) && value.length > 0
return typeof value === 'string' && value.trim() !== ''
}
const duplicate = name.trim() !== (initial?.name ?? '') && backendNames.includes(name.trim())
const canApply =
name.trim() !== '' && !duplicate && fields.every((f) => !f.required || filled(f))
const apply = () => {
const options: Record<string, unknown> = {}
for (const field of fields) {
const value = values[field.key]
if (field.kind === 'backend-ref-list') {
options[field.key] = Array.isArray(value) ? value : []
continue
}
const text = typeof value === 'string' ? value : ''
if (text === '' && !field.required) continue // omitted → the shared schema default applies
options[field.key] = field.kind === 'number' ? Number(text) : text
}
// The options were built from the same field defs the schema is generated
// from — this is the same sanctioned cast storageModel.asWireBackend makes.
const payload = { name: name.trim(), type, options } as StorageBackend
// Arity matters: the landed tests pin single-argument calls when no mirror
// block is supplied (toHaveBeenCalledWith treats a trailing undefined as a
// mismatch), so the second argument exists only when the composer does.
if (mirror) onCommit(payload, targets)
else onCommit(payload)
}
const renderField = (field: StorageBackendFieldDef): React.ReactElement => {
const value = values[field.key]
if (field.kind === 'backend-ref') {
return (
<div key={field.key}>
<span className={LABEL_CLASS}>{t(field.labelKey)}</span>
<CustomSelect
value={typeof value === 'string' ? value : ''}
onChange={(next) => setValue(field.key, String(next))}
options={refOptions.map((candidate) => ({ value: candidate, label: candidate }))}
placeholder={t(field.labelKey)}
size="sm"
/>
</div>
)
}
if (field.kind === 'backend-ref-list') {
const selected = Array.isArray(value) ? value : []
return (
<div key={field.key}>
<span className={LABEL_CLASS}>{t(field.labelKey)}</span>
<div className="space-y-1">
{refOptions.map((candidate) => (
<label key={candidate} className="flex items-center gap-2 text-sm text-content">
<input
type="checkbox"
checked={selected.includes(candidate)}
onChange={(e) =>
setValue(
field.key,
e.target.checked
? [...selected, candidate]
: selected.filter((existing) => existing !== candidate),
)
}
/>
{candidate}
</label>
))}
</div>
</div>
)
}
const inputType = field.kind === 'secret' ? 'password' : field.kind === 'number' ? 'number' : 'text'
return (
<label key={field.key} className={LABEL_CLASS}>
{t(field.labelKey)}
<input
type={inputType}
value={typeof value === 'string' ? value : ''}
onChange={(e) => setValue(field.key, e.target.value)}
placeholder={field.defaultValue !== undefined ? String(field.defaultValue) : ''}
spellCheck={false}
autoComplete="off"
className={INPUT_CLASS}
/>
{field.helpKey && <span className="block text-xs mt-1 font-normal text-content-faint">{t(field.helpKey)}</span>}
</label>
)
}
return (
<div className="rounded-xl border p-4 space-y-4 border-edge bg-surface-card">
<p className="text-sm font-semibold text-content">
{initial ? t('storage.form.editTitle') : t('storage.form.addTitle')}
</p>
<label className={LABEL_CLASS}>
{t('storage.form.name')}
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
spellCheck={false}
autoComplete="off"
className={INPUT_CLASS}
/>
</label>
{duplicate && (
<p className="text-xs text-content-faint" role="alert">
{t('storage.form.duplicateName', { name: name.trim() })}
</p>
)}
<div>
<span className={LABEL_CLASS}>{t('storage.form.type')}</span>
<CustomSelect
value={type}
onChange={(next) => {
setType(next as StorageBackendTypeId)
setValues({}) // a different type has different fields
}}
options={STORAGE_BACKEND_TYPE_IDS.filter((id) => id !== 'mirror').map((id) => ({
value: id,
label: t(`storage.type.${id}`),
}))}
size="sm"
disabled={initial !== null}
/>
</div>
{fields.map(renderField)}
{mirror && (
<div>
<span className={LABEL_CLASS}>{t('storage.mirror.targets')}</span>
<p className="text-xs mb-1 text-content-faint">{t('storage.mirror.targetsHelp')}</p>
<div className="space-y-1">
{mirror.candidates
.filter((candidate) => candidate !== name.trim())
.map((candidate) => (
<label key={candidate} className="flex items-center gap-2 text-sm text-content">
<input
type="checkbox"
checked={targets.includes(candidate)}
onChange={(e) =>
setTargets(
e.target.checked
? [...targets, candidate]
: targets.filter((existing) => existing !== candidate),
)
}
/>
{candidate}
</label>
))}
</div>
{targets.length > 0 && (
<p className="text-xs mt-1 text-content-faint" role="note">
{t('storage.mirror.latencyNote')}
</p>
)}
</div>
)}
<div className="flex items-center gap-3">
<button
onClick={apply}
disabled={!canApply}
style={{
padding: '8px 20px', borderRadius: 10, cursor: canApply ? 'pointer' : 'default',
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600,
border: '2px solid var(--text-primary)', background: 'var(--bg-hover)',
color: 'var(--text-primary)', opacity: canApply ? 1 : 0.5,
}}
>
{t('storage.form.apply')}
</button>
<button
onClick={onCancel}
style={{
padding: '8px 20px', borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit',
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
border: '2px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)',
}}
>
{t('storage.form.cancel')}
</button>
</div>
</div>
)
}
@@ -1,407 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
MASKED_SETTING_VALUE,
STORAGE_CATEGORIES,
type StorageAdminState,
type StorageBackend,
type StorageCategory,
type StorageConfig,
} from '@trek/shared';
import {
categoriesPointingAt,
computeMigrationCandidates,
foldBackends,
mirrorProbeTargets,
mirrorsReferencing,
primaryNameOf,
removeBackend,
removeBackendAndMirrors,
renameBackendRefs,
replicaCandidates,
replicaOfPrimaries,
setMirrorTargets,
settingsDocumentOf,
stripCategories,
upsertBackend,
usageByBackend,
} from './storageModel';
const STATE: StorageAdminState = {
backends: [
{ name: 'uploads-local', type: 'local', source: 'built-in', options: { root: '/data/uploads' }, categories: ['files'] },
{ name: 'place-photos-local', type: 'local', source: 'env', options: { root: '/photos' }, categories: ['places'] },
{
name: 'off-box',
type: 's3',
source: 'settings',
options: {
endpoint: 'http://127.0.0.1:9000', bucket: 'trek', accessKeyId: 'ak',
secretAccessKey: MASKED_SETTING_VALUE, region: 'us-east-1', keyPrefix: '', retries: 1, timeoutMs: 30000,
},
categories: ['covers'],
},
],
categories: {
files: { backend: 'uploads-local', source: 'default' },
journey: { backend: 'uploads-local', source: 'default' },
covers: { backend: 'off-box', source: 'settings' },
avatars: { backend: 'uploads-local', source: 'default' },
places: { backend: 'place-photos-local', source: 'default' },
'photos-google': { backend: 'place-photos-local', source: 'default' },
'photos-trek': { backend: 'uploads-local', source: 'default' },
backups: { backend: 'backups-local', source: 'default' },
},
health: { replicaFailures: [] },
seedFilePresent: false,
usage: null,
backfills: [],
migrations: [],
version: 5,
configError: null,
};
describe('settingsDocumentOf', () => {
it('FE-ADMIN-STORM-001: carries ONLY settings-sourced backends and categories (the PUT contract)', () => {
const doc = settingsDocumentOf(STATE);
expect(doc.backends.map((b) => b.name)).toEqual(['off-box']);
expect(doc.categories).toEqual({ covers: 'off-box' });
});
it('FE-ADMIN-STORM-001b: carries version from state (audit #7 — the PUT body\'s optimistic-concurrency check)', () => {
expect(settingsDocumentOf(STATE).version).toBe(5);
expect(settingsDocumentOf({ ...STATE, version: 12 }).version).toBe(12);
});
});
describe('remove pre-check helpers', () => {
const draft: StorageConfig = {
backends: [
{ name: 'nas', type: 'local', options: { root: '/mnt/nas' } },
{ name: 'mir', type: 'mirror', options: { primary: 'backups-local', replicas: ['nas'] } },
],
categories: { covers: 'nas', backups: 'mir' },
};
it('FE-ADMIN-STORM-002: lists categories pointing at a backend', () => {
expect(categoriesPointingAt(draft, 'nas')).toEqual(['covers']);
expect(categoriesPointingAt(draft, 'unused')).toEqual([]);
});
it('FE-ADMIN-STORM-003: lists mirrors referencing a backend (primary or replica)', () => {
expect(mirrorsReferencing(draft, 'nas')).toEqual(['mir']);
expect(mirrorsReferencing(draft, 'backups-local')).toEqual(['mir']);
expect(mirrorsReferencing(draft, 'unused')).toEqual([]);
});
});
describe('draft edits', () => {
const draft: StorageConfig = {
backends: [{ name: 'nas', type: 'local', options: { root: '/mnt/nas' } }],
categories: {},
};
it('FE-ADMIN-STORM-005: upsert replaces by name in place, appends new ones, never mutates', () => {
const edited = upsertBackend(draft, { name: 'nas', type: 'local', options: { root: '/mnt/nas2' } });
expect(edited.backends).toEqual([{ name: 'nas', type: 'local', options: { root: '/mnt/nas2' } }]);
const added = upsertBackend(draft, { name: 'other', type: 'local', options: { root: '/o' } });
expect(added.backends.map((b) => b.name)).toEqual(['nas', 'other']);
expect(draft.backends).toHaveLength(1); // untouched
});
it('FE-ADMIN-STORM-006: removeBackend filters by name', () => {
expect(removeBackend(draft, 'nas').backends).toEqual([]);
expect(removeBackend(draft, 'ghost').backends).toHaveLength(1);
});
});
// Hoisted to module scope (not just the mirror-fold describe below) so the
// usageByBackend suite can reuse the same mirrored fixture the brief's test
// snippet assumes is in scope.
const MIRRORED_STATE: StorageAdminState = {
...STATE,
backends: [
...STATE.backends,
{
name: 'mirror', type: 'mirror', source: 'settings',
options: { primary: 'backups-local', replicas: ['off-box'] }, categories: ['backups'],
},
{ name: 'backups-local', type: 'local', source: 'built-in', options: { root: '/data/backups' }, categories: [] },
],
categories: { ...STATE.categories, backups: { backend: 'mirror', source: 'settings' } },
};
const mirroredDraft = (): StorageConfig => settingsDocumentOf(MIRRORED_STATE);
describe('mirror fold/synthesize (replicas-on-primary)', () => {
it('FE-ADMIN-STORM-007: foldBackends hides the mirror, decorates primary and replica, unions categories', () => {
const { rows, degenerate } = foldBackends(MIRRORED_STATE, mirroredDraft());
expect(degenerate).toEqual([]);
expect(rows.map((r) => r.name)).not.toContain('mirror');
const primary = rows.find((r) => r.name === 'backups-local')!;
expect(primary.mirrorTargets).toEqual(['off-box']);
expect(primary.mirrorName).toBe('mirror');
expect(primary.categories).toContain('backups'); // via the mirror
const replica = rows.find((r) => r.name === 'off-box')!;
expect(replica.replicaOf).toEqual(['backups-local']);
expect(replica.categories).toContain('covers'); // its own direct assignment survives
});
it('FE-ADMIN-STORM-008: setMirrorTargets synthesizes a mirror and reroutes ALL effective categories of the primary, defaults included', () => {
const draft = settingsDocumentOf(STATE); // no mirror yet; backups is default → backups-local
const next = setMirrorTargets(STATE, draft, 'backups-local', ['off-box']);
const mirror = next.backends.find((b) => b.type === 'mirror')!;
expect(mirror.name).toBe('backups-local-mirror');
expect(mirror.options).toEqual({ primary: 'backups-local', replicas: ['off-box'] });
expect(next.categories.backups).toBe('backups-local-mirror'); // default-sourced category rewritten
expect(next.categories.covers).toBe('off-box'); // categories of OTHER backends untouched
});
it('FE-ADMIN-STORM-009: setMirrorTargets adopts a foreign-named mirror in place', () => {
const next = setMirrorTargets(MIRRORED_STATE, mirroredDraft(), 'backups-local', ['off-box', 'uploads-local']);
const mirrors = next.backends.filter((b) => b.type === 'mirror');
expect(mirrors).toHaveLength(1);
expect(mirrors[0]!.name).toBe('mirror'); // adopted, not renamed
expect(mirrors[0]!.options).toEqual({ primary: 'backups-local', replicas: ['off-box', 'uploads-local'] });
});
it('FE-ADMIN-STORM-010: empty targets dissolve the mirror and revert default-sourced categories', () => {
// Build the synthesized state locally: default backups routed via a fresh mirror…
const withMirror = setMirrorTargets(STATE, settingsDocumentOf(STATE), 'backups-local', ['off-box']);
// …then dissolve it: the backups entry must DROP (state says its default is backups-local).
const dissolved = setMirrorTargets(STATE, withMirror, 'backups-local', []);
expect(dissolved.backends.some((b) => b.type === 'mirror')).toBe(false);
expect(dissolved.categories).not.toHaveProperty('backups');
// Against MIRRORED_STATE (backups already settings-sourced at the mirror), dissolve re-points instead of dropping.
const repointed = setMirrorTargets(MIRRORED_STATE, mirroredDraft(), 'backups-local', []);
expect(repointed.categories.backups).toBe('backups-local');
});
it('FE-ADMIN-STORM-011: degenerate mirrors are classified, never hidden', () => {
const draft: StorageConfig = {
backends: [
{ name: 'off-box', type: 's3', options: STATE.backends[2]!.options } as StorageBackend,
{ name: 'm1', type: 'mirror', options: { primary: 'uploads-local', replicas: ['off-box'] } },
{ name: 'm2', type: 'mirror', options: { primary: 'uploads-local', replicas: ['off-box'] } },
{ name: 'm-env', type: 'mirror', options: { primary: 'place-photos-local', replicas: ['off-box'] } },
{ name: 'm-ghost', type: 'mirror', options: { primary: 'nope', replicas: [] } },
],
categories: {},
};
const { rows, degenerate } = foldBackends(STATE, draft);
expect(rows.find((r) => r.name === 'uploads-local')!.mirrorName).toBe('m1'); // first wins
expect(degenerate.map((d) => [d.backend.name, d.reason])).toEqual([
['m2', 'duplicate-mirror'],
['m-env', 'env-primary'],
['m-ghost', 'missing-primary'],
]);
});
it('FE-ADMIN-STORM-012: renameBackendRefs rewrites mirror primaries, replicas, and category entries', () => {
const draft: StorageConfig = {
backends: [
{ name: 'nas', type: 'local', options: { root: '/mnt/nas' } },
{ name: 'm', type: 'mirror', options: { primary: 'nas', replicas: ['nas'] } },
],
categories: { covers: 'nas' },
};
const renamed = renameBackendRefs(draft, 'nas', 'nas2');
const mirror = renamed.backends.find((b) => b.type === 'mirror')!;
expect(mirror.options).toEqual({ primary: 'nas2', replicas: ['nas2'] });
expect(renamed.categories.covers).toBe('nas2');
expect(renamed.backends[0]!.name).toBe('nas'); // the backend row itself is the caller's job
});
it('FE-ADMIN-STORM-013: removeBackendAndMirrors cascades; replicaOfPrimaries phrases in primary names', () => {
const draft = mirroredDraft();
expect(replicaOfPrimaries(draft, 'off-box')).toEqual(['backups-local']);
const removed = removeBackendAndMirrors(MIRRORED_STATE, draft, 'backups-local');
expect(removed.backends.some((b) => b.name === 'mirror')).toBe(false);
expect(removed.backends.some((b) => b.name === 'backups-local')).toBe(false);
});
it('FE-ADMIN-STORM-015: removing a replica strips it from remaining mirrors; a mirror stripped empty dissolves', () => {
// Single-replica mirror: stripping off-box empties it → dissolve + re-point
// (backups is settings-sourced at the mirror in MIRRORED_STATE, so it re-points, not drops).
const dissolved = removeBackendAndMirrors(MIRRORED_STATE, mirroredDraft(), 'off-box');
expect(dissolved.backends.some((b) => b.name === 'off-box')).toBe(false);
expect(dissolved.backends.some((b) => b.type === 'mirror')).toBe(false);
expect(dissolved.categories.backups).toBe('backups-local');
// Multi-replica mirror: stripping one target keeps the mirror alive with the rest.
const twoTargets = setMirrorTargets(MIRRORED_STATE, mirroredDraft(), 'backups-local', ['off-box', 'uploads-local']);
const survived = removeBackendAndMirrors(MIRRORED_STATE, twoTargets, 'off-box');
const mirror = survived.backends.find((b) => b.type === 'mirror')!;
expect(mirror.options).toEqual({ primary: 'backups-local', replicas: ['uploads-local'] });
expect(survived.categories.backups).toBe('mirror'); // still routed via the surviving mirror
});
it('FE-ADMIN-STORM-014: primaryNameOf maps mirror names (draft or state) to their primary; others pass through', () => {
expect(primaryNameOf(MIRRORED_STATE, mirroredDraft(), 'mirror')).toBe('backups-local');
expect(primaryNameOf(MIRRORED_STATE, { backends: [], categories: {} }, 'mirror')).toBe('backups-local'); // state fallback
expect(primaryNameOf(STATE, settingsDocumentOf(STATE), 'uploads-local')).toBe('uploads-local');
});
});
describe('replicaCandidates', () => {
it('FE-ADMIN-STORM-020: offers only rows that serve no category, never the row being edited', () => {
const { rows } = foldBackends(MIRRORED_STATE, mirroredDraft());
// uploads-local/place-photos-local serve categories directly;
// backups-local serves backups through the mirror it is the primary of;
// off-box carries a direct `covers` assignment. Nothing is offerable.
expect(replicaCandidates(rows, 'backups-local')).toEqual([]);
});
it('FE-ADMIN-STORM-021: keeps an already-selected target listed so it can be unchecked', () => {
const { rows } = foldBackends(MIRRORED_STATE, mirroredDraft());
expect(replicaCandidates(rows, 'backups-local', ['off-box'])).toEqual(['off-box']);
});
it('FE-ADMIN-STORM-022: an unassigned row is offered', () => {
const state: StorageAdminState = {
...MIRRORED_STATE,
backends: [
...MIRRORED_STATE.backends,
{ name: 'cold-store', type: 'local', source: 'settings', options: { root: '/data/cold' }, categories: [] },
],
};
const { rows } = foldBackends(state, settingsDocumentOf(state));
expect(replicaCandidates(rows, 'backups-local')).toEqual(['cold-store']);
});
});
describe('usageByBackend', () => {
it('FE-ADMIN-STORM-016: sums categories onto their effective backend rows, mirrors onto the primary, legacy onto uploads-local', () => {
const usage = {
computedAt: 1,
categories: Object.fromEntries(
STORAGE_CATEGORIES.map((c) => [c, { objects: 1, bytes: 10 }]),
) as Record<StorageCategory, { objects: number; bytes: number }>,
legacyPhotos: { objects: 5, bytes: 50 },
};
const state = { ...MIRRORED_STATE, usage };
const sums = usageByBackend(state, mirroredDraft())!;
// backups routes via the mirror → attributed to backups-local (the primary row).
expect(sums['backups-local']).toEqual({ objects: 1, bytes: 10 });
// uploads-local serves 4 direct categories (files, journey, avatars, photos-trek —
// this fixture's `places` and `photos-google` default to the env-sourced
// place-photos-local backend, not uploads-local) + legacy photos.
expect(sums['uploads-local']).toEqual({ objects: 4 + 5, bytes: 40 + 50 });
// places + photos-google both default to place-photos-local.
expect(sums['place-photos-local']).toEqual({ objects: 2, bytes: 20 });
// covers routes to off-box directly.
expect(sums['off-box']).toEqual({ objects: 1, bytes: 10 });
});
it('FE-ADMIN-STORM-017: null usage yields null', () => {
expect(usageByBackend({ ...STATE, usage: null }, settingsDocumentOf(STATE))).toBeNull();
});
});
describe('computeMigrationCandidates', () => {
it('FE-ADMIN-STORM-018: lists changed categories with usage counts; zero-object ones are excluded; null usage keeps them with null counts', () => {
const state: StorageAdminState = {
...STATE,
usage: {
computedAt: 1,
categories: {
files: { objects: 3, bytes: 300 },
journey: { objects: 0, bytes: 0 },
} as Record<StorageCategory, { objects: number; bytes: number }>,
legacyPhotos: { objects: 0, bytes: 0 },
},
};
const base = settingsDocumentOf(state);
const draft: StorageConfig = {
...base,
categories: { ...base.categories, files: 'off-box', journey: 'off-box', avatars: 'off-box' },
};
const candidates = computeMigrationCandidates(draft, state);
expect(candidates).toEqual([
{ category: 'files', from: 'uploads-local', to: 'off-box', toWire: 'off-box', objects: 3, bytes: 300 },
{ category: 'avatars', from: 'uploads-local', to: 'off-box', toWire: 'off-box', objects: null, bytes: null },
]);
});
it('FE-ADMIN-STORM-018b: reassigning onto a mirrored primary yields toWire = the mirror wire name while `to` stays the display primary', () => {
const state: StorageAdminState = {
...STATE,
backends: [
...STATE.backends,
{ name: 'nas', type: 'local', source: 'settings', options: { root: '/mnt/nas' }, categories: [] },
{
name: 'off-box-mirror',
type: 'mirror',
source: 'settings',
options: { primary: 'off-box', replicas: ['nas'] },
categories: [],
},
],
usage: {
computedAt: 1,
categories: { files: { objects: 3, bytes: 300 } } as Record<StorageCategory, { objects: number; bytes: number }>,
legacyPhotos: { objects: 0, bytes: 0 },
},
};
const base = settingsDocumentOf(state);
// Reassign 'files' to the mirrored primary's WIRE name — the same value
// setCategory() writes into the draft when the operator picks a mirrored
// primary in the UI (adoptedMirrorFor(draft, 'off-box')?.name).
const draft: StorageConfig = { ...base, categories: { ...base.categories, files: 'off-box-mirror' } };
const candidates = computeMigrationCandidates(draft, state);
expect(candidates).toEqual([
{ category: 'files', from: 'uploads-local', to: 'off-box', toWire: 'off-box-mirror', objects: 3, bytes: 300 },
]);
});
it('FE-ADMIN-STORM-019: stripCategories restores the saved assignment for exactly the named categories', () => {
const base = settingsDocumentOf(STATE);
const draft: StorageConfig = {
...base,
categories: { ...base.categories, files: 'off-box', covers: 'uploads-local' },
};
const strippedFiles = stripCategories(draft, STATE, ['files']);
expect(strippedFiles.categories.files).toBeUndefined(); // default-sourced: saved value is "no override"
expect(strippedFiles.categories.covers).toBe('uploads-local'); // untouched — not in the strip list
const strippedCovers = stripCategories(draft, STATE, ['covers']);
expect(strippedCovers.categories.covers).toBe('off-box'); // settings-sourced saved value restored explicitly
expect(strippedCovers.categories.files).toBe('off-box'); // untouched — not in the strip list
});
});
describe('mirrorProbeTargets', () => {
it('FE-ADMIN-STORM-020: a non-mirror candidate passes through unchanged', () => {
const local: StorageBackend = { name: 'uploads-local', type: 'local', options: { root: '/data/uploads' } };
expect(mirrorProbeTargets(settingsDocumentOf(STATE), STATE, local)).toEqual([local]);
});
it('FE-ADMIN-STORM-021: expands primary + replicas — draft override wins per name, falling back to state for names the draft never touched', () => {
const mirror: StorageBackend = {
name: 'mirror', type: 'mirror',
options: { primary: 'uploads-local', replicas: ['off-box'] },
};
// The draft carries an EDITED off-box (unsaved endpoint change) but never
// touches uploads-local (a built-in, never draft-owned).
const editedOffBox = {
name: 'off-box', type: 's3',
options: { ...(STATE.backends[2]!.options as Record<string, unknown>), endpoint: 'http://edited:9000' },
} as StorageBackend;
const draft: StorageConfig = { backends: [editedOffBox, mirror], categories: {} };
const targets = mirrorProbeTargets(draft, STATE, mirror);
expect(targets.map((t) => t.name)).toEqual(['uploads-local', 'off-box']);
// Primary: no draft entry — resolved from state (the live built-in).
expect(targets[0]).toEqual({ name: 'uploads-local', type: 'local', options: { root: '/data/uploads' } });
// Replica: draft override wins — the unsaved edit, not the saved options.
expect((targets[1]!.options as Record<string, unknown>).endpoint).toBe('http://edited:9000');
});
it('FE-ADMIN-STORM-022: a name resolving to neither draft nor state is dropped, not thrown', () => {
const mirror: StorageBackend = {
name: 'mirror', type: 'mirror',
options: { primary: 'uploads-local', replicas: ['ghost'] },
};
const targets = mirrorProbeTargets(settingsDocumentOf(STATE), STATE, mirror);
expect(targets.map((t) => t.name)).toEqual(['uploads-local']);
});
});
@@ -1,440 +0,0 @@
import {
STORAGE_CATEGORIES,
type StorageAdminState,
type StorageBackend,
type StorageCategory,
type StorageConfig,
type StorageConfigPut,
} from '@trek/shared'
/** One entry of the effective world (GET state), as the panels render it. */
export type StateBackend = StorageAdminState['backends'][number]
/**
* The settings-owned document the PUT carries: settings-sourced backends and
* settings-sourced category assignments ONLY — built-ins and env backends are
* never in the body unless the operator overrides one by name (in which case
* the override already reports source 'settings'). Carries `version` from
* the state it was built at (audit #7 — optimistic concurrency): the hook
* re-attaches this same version on every subsequent local edit, so a PUT
* always submits the version the operator's form was loaded at, not
* whatever the server currently holds.
*/
export function settingsDocumentOf(state: StorageAdminState): StorageConfigPut {
return {
backends: state.backends.filter((b) => b.source === 'settings').map(asWireBackend),
categories: Object.fromEntries(
Object.entries(state.categories)
.filter(([, entry]) => entry.source === 'settings')
.map(([category, entry]) => [category, entry.backend]),
) as StorageConfig['categories'],
version: state.version,
}
}
/**
* State options are already schema-shaped (the wire contract guarantees it;
* secret fields carry the mask, which the server unmasks by name on PUT/test) —
* the cast re-attaches the discriminated-union type the loose state record dropped.
*/
export function asWireBackend(backend: Pick<StateBackend, 'name' | 'type' | 'options'>): StorageBackend {
return { name: backend.name, type: backend.type, options: backend.options } as StorageBackend
}
/** Draft categories pointing at a backend name (the friendly remove pre-check). */
export function categoriesPointingAt(draft: StorageConfig, name: string): StorageCategory[] {
return (Object.entries(draft.categories) as Array<[StorageCategory, string]>)
.filter(([, backend]) => backend === name)
.map(([category]) => category)
}
/** Draft mirrors referencing a backend name as primary or replica (remove pre-check). */
export function mirrorsReferencing(draft: StorageConfig, name: string): string[] {
return draft.backends
.filter((b) => b.type === 'mirror' && (b.options.primary === name || b.options.replicas.includes(name)))
.map((b) => b.name)
}
/** Replace by name in place, or append. Never mutates. */
export function upsertBackend(draft: StorageConfig, backend: StorageBackend): StorageConfig {
const exists = draft.backends.some((b) => b.name === backend.name)
return {
...draft,
backends: exists
? draft.backends.map((b) => (b.name === backend.name ? backend : b))
: [...draft.backends, backend],
}
}
export function removeBackend(draft: StorageConfig, name: string): StorageConfig {
return { ...draft, backends: draft.backends.filter((b) => b.name !== name) }
}
// ── Mirror fold/synthesize (replicas-on-primary spec) ─────────────────────────
// The draft stays the wire document; folding is a view rule, synthesis a set
// of pure draft operations. Mirror NAMES never reach the UI — every helper
// that renders phrases mirrors as their primary.
type MirrorBackend = Extract<StorageBackend, { type: 'mirror' }>
const isMirror = (b: StorageBackend): b is MirrorBackend => b.type === 'mirror'
export interface FoldedBackendRow {
name: string
type: 'local' | 's3'
source: 'built-in' | 'env' | 'settings'
backend: StorageBackend
/** Direct assignments ∪ assignments routed via the adopted mirror. */
categories: StorageCategory[]
/** Adopted mirror's replicas — [] when unmirrored. */
mirrorTargets: string[]
/** Adopted mirror's wire name — needed by draft ops, hidden from the UI. */
mirrorName: string | null
/** Primaries whose adopted mirror lists this backend as a replica. */
replicaOf: string[]
}
export interface DegenerateMirror {
backend: StorageBackend
reason: 'duplicate-mirror' | 'env-primary' | 'missing-primary'
categories: StorageCategory[]
}
/**
* Re-fetchable content — replicating it is usually wasteful. photos-google is
* the Google photo cache (place-photo-cache.service.ts); places is mostly
* provider-derived imagery.
*/
export const CACHE_CATEGORIES: readonly StorageCategory[] = ['photos-google', 'places']
/** The configurable categories resolved draft-over-state (draft entries win; defaults fill the rest). */
export function effectiveCategoryMap(state: StorageAdminState, draft: StorageConfig): Record<StorageCategory, string> {
const map = {} as Record<StorageCategory, string>
for (const category of STORAGE_CATEGORIES) {
// The admin state's category record is exhaustive by schema contract.
map[category] = draft.categories[category] ?? state.categories[category]!.backend
}
return map
}
/** First draft mirror wrapping the named primary — the one the UI manages. */
export function adoptedMirrorFor(draft: StorageConfig, primaryName: string): MirrorBackend | undefined {
return draft.backends.filter(isMirror).find((m) => m.options.primary === primaryName)
}
/**
* Client-side mirror expansion for the test probe (audit fix — the server's
* `testBackend` resolves a mirror candidate's primary/replicas against its
* OWN live snapshot by name, so probing a mirrored row with unsaved draft
* edits to those targets would silently test the SAVED options instead of
* what's on the form). Draft overrides win per name, falling back to state
* (built-in/env); a name that resolves to neither is dropped — that
* primary/replica no longer exists, and the row already renders as
* degenerate in that case.
*/
export function mirrorProbeTargets(
draft: StorageConfig,
state: StorageAdminState,
mirror: StorageBackend,
): StorageBackend[] {
if (mirror.type !== 'mirror') return [mirror]
const names = [mirror.options.primary, ...mirror.options.replicas]
return names
.map((name): StorageBackend | null => {
const draftMatch = draft.backends.find((b) => b.name === name)
if (draftMatch) return draftMatch
const stateMatch = state.backends.find((b) => b.name === name)
return stateMatch ? asWireBackend(stateMatch) : null
})
.filter((b): b is StorageBackend => b !== null)
}
export function foldBackends(
state: StorageAdminState,
draft: StorageConfig,
): { rows: FoldedBackendRow[]; degenerate: DegenerateMirror[] } {
const draftNames = draft.backends.map((b) => b.name)
const effective = effectiveCategoryMap(state, draft)
const categoriesOf = (name: string): StorageCategory[] =>
STORAGE_CATEGORIES.filter((category) => effective[category] === name)
// Base (non-mirror) rows, slice-2 precedence: built-ins/env from the state
// unless a draft row overrides the name; settings rows from the draft.
const base: Array<Pick<FoldedBackendRow, 'name' | 'type' | 'source' | 'backend'>> = []
for (const b of state.backends) {
if (b.type === 'mirror') continue // mirrors fold below; the draft owns them
if (draftNames.includes(b.name)) continue
if (b.source === 'settings') continue // removed from the draft, pending save
base.push({ name: b.name, type: b.type, source: b.source, backend: asWireBackend(b) })
}
for (const b of draft.backends) {
if (isMirror(b)) continue
base.push({ name: b.name, type: b.type, source: 'settings', backend: b })
}
// Adoption: the first mirror per editable primary; the rest are degenerate —
// rendered unfolded with a reason, never silently dropped.
const adopted = new Map<string, MirrorBackend>()
const degenerate: DegenerateMirror[] = []
for (const mirror of draft.backends.filter(isMirror)) {
const primary = base.find((row) => row.name === mirror.options.primary)
if (!primary) degenerate.push({ backend: mirror, reason: 'missing-primary', categories: categoriesOf(mirror.name) })
else if (primary.source === 'env') degenerate.push({ backend: mirror, reason: 'env-primary', categories: categoriesOf(mirror.name) })
else if (adopted.has(primary.name)) degenerate.push({ backend: mirror, reason: 'duplicate-mirror', categories: categoriesOf(mirror.name) })
else adopted.set(primary.name, mirror)
}
const rows = base.map((row): FoldedBackendRow => {
const mirror = adopted.get(row.name)
return {
...row,
categories: [...categoriesOf(row.name), ...(mirror ? categoriesOf(mirror.name) : [])],
mirrorTargets: mirror ? [...mirror.options.replicas] : [],
mirrorName: mirror?.name ?? null,
replicaOf: [...adopted.values()]
.filter((m) => m.options.replicas.includes(row.name))
.map((m) => m.options.primary),
}
})
return { rows, degenerate }
}
/**
* The rows offerable as mirror replicas (the "Mirror targets" picker).
*
* A backend that already serves a category — directly, or as the primary of a
* mirror a category routes to, which is exactly what `row.categories` unions —
* can never also be a replica: the mirror's sync sweep makes each replica
* MATCH the primary, deleting whatever the primary doesn't hold, and `backups`
* sweeps the replica's entire root. The server refuses such a config outright
* (storage-registry.service.ts, assertNoSharedReplicas); the picker simply
* never offers one, so the operator can't walk into the refusal.
*
* Targets already selected on this mirror stay listed regardless — they must
* remain visible to be unchecked (and a config that predates this rule is
* repaired here, not hidden).
*/
export function replicaCandidates(
rows: FoldedBackendRow[],
selfName: string | null,
currentTargets: readonly string[] = [],
): string[] {
return rows
.filter((row) => row.name !== selfName)
.filter((row) => row.categories.length === 0 || currentTargets.includes(row.name))
.map((row) => row.name)
}
function uniqueMirrorName(state: StorageAdminState, draft: StorageConfig, primaryName: string): string {
const taken = new Set([...draft.backends.map((b) => b.name), ...state.backends.map((b) => b.name)])
const base = `${primaryName}-mirror`
if (!taken.has(base)) return base
for (let n = 2; ; n++) {
if (!taken.has(`${base}-${n}`)) return `${base}-${n}`
}
}
/**
* targets non-empty: update the adopted mirror in place (or synthesize one)
* and route every category that effectively resolves to the primary —
* default-sourced included — through it. targets empty: dissolve, re-pointing
* the mirror's categories at the primary and dropping entries that revert to
* their built-in default.
*/
export function setMirrorTargets(
state: StorageAdminState,
draft: StorageConfig,
primaryName: string,
targets: string[],
): StorageConfig {
const adopted = adoptedMirrorFor(draft, primaryName)
if (targets.length === 0) {
if (!adopted) return draft
const categories = { ...draft.categories }
for (const category of STORAGE_CATEGORIES) {
if (categories[category] !== adopted.name) continue
const stateEntry = state.categories[category]! // exhaustive by schema contract
if (stateEntry.source === 'default' && stateEntry.backend === primaryName) delete categories[category]
else categories[category] = primaryName
}
return { backends: draft.backends.filter((b) => b.name !== adopted.name), categories }
}
const effective = effectiveCategoryMap(state, draft)
const mirror: StorageBackend = {
name: adopted?.name ?? uniqueMirrorName(state, draft, primaryName),
type: 'mirror',
options: { primary: primaryName, replicas: [...targets] },
}
const backends = adopted
? draft.backends.map((b) => (b.name === adopted.name ? mirror : b))
: [...draft.backends, mirror]
const categories = { ...draft.categories }
for (const category of STORAGE_CATEGORIES) {
if (effective[category] === primaryName) categories[category] = mirror.name
}
return { backends, categories }
}
/** Rewrite every reference to a renamed backend: mirror primaries, replicas, category entries. */
export function renameBackendRefs(draft: StorageConfig, oldName: string, newName: string): StorageConfig {
return {
backends: draft.backends.map((b) => {
if (!isMirror(b)) return b
return {
...b,
options: {
primary: b.options.primary === oldName ? newName : b.options.primary,
replicas: b.options.replicas.map((r) => (r === oldName ? newName : r)),
},
}
}),
categories: Object.fromEntries(
Object.entries(draft.categories).map(([category, backend]) => [category, backend === oldName ? newName : backend]),
) as StorageConfig['categories'],
}
}
/**
* Remove a backend, every mirror wrapping it as primary, and every replica
* reference to it in the remaining mirrors — a mirror stripped of its last
* replica dissolves (with the same category re-pointing as an explicit
* dissolve), so the draft never carries a phantom reference the composer
* cannot render or repair.
*/
export function removeBackendAndMirrors(
state: StorageAdminState,
draft: StorageConfig,
name: string,
): StorageConfig {
const stripped = draft.backends
.filter((b) => b.name !== name && !(isMirror(b) && b.options.primary === name))
.map((b) =>
isMirror(b) && b.options.replicas.includes(name)
? { ...b, options: { ...b.options, replicas: b.options.replicas.filter((r) => r !== name) } }
: b,
)
let next: StorageConfig = { ...draft, backends: stripped }
for (const mirror of stripped.filter(isMirror)) {
if (mirror.options.replicas.length > 0) continue
if (adoptedMirrorFor(next, mirror.options.primary)?.name === mirror.name) {
next = setMirrorTargets(state, next, mirror.options.primary, [])
} else {
// A degenerate duplicate stripped empty: drop it and re-point its categories.
const categories = { ...next.categories }
for (const category of STORAGE_CATEGORIES) {
if (categories[category] === mirror.name) categories[category] = mirror.options.primary
}
next = { backends: next.backends.filter((b) => b.name !== mirror.name), categories }
}
}
return next
}
/** Primaries whose mirrors use the named backend as a replica — pre-check copy stays in primary names. */
export function replicaOfPrimaries(draft: StorageConfig, name: string): string[] {
return draft.backends
.filter(isMirror)
.filter((m) => m.options.replicas.includes(name))
.map((m) => m.options.primary)
}
/** Map a mirror name (draft first, then state) to its primary; anything else passes through. */
export function primaryNameOf(state: StorageAdminState, draft: StorageConfig, backendName: string): string {
const draftMirror = draft.backends.filter(isMirror).find((m) => m.name === backendName)
if (draftMirror) return draftMirror.options.primary
const stateMirror = state.backends.find((b) => b.name === backendName && b.type === 'mirror')
if (stateMirror) return String((stateMirror.options as Record<string, unknown>).primary)
return backendName
}
/**
* Usage sums per VISIBLE backend row: each category's numbers land on the row
* that serves it (a mirror-routed category on the mirror's PRIMARY row), and
* the legacy photos directory lands on uploads-local — the only backend the
* served-legacy `photos` category ever resolves to.
*/
export function usageByBackend(
state: StorageAdminState,
draft: StorageConfig,
): Record<string, { objects: number; bytes: number }> | null {
if (!state.usage) return null
const sums: Record<string, { objects: number; bytes: number }> = {}
const add = (row: string, entry: { objects: number; bytes: number }) => {
const current = (sums[row] ??= { objects: 0, bytes: 0 })
current.objects += entry.objects
current.bytes += entry.bytes
}
const effective = effectiveCategoryMap(state, draft)
for (const category of STORAGE_CATEGORIES) {
const entry = state.usage.categories[category]
if (entry) add(primaryNameOf(state, draft, effective[category]), entry)
}
// The served-legacy `photos` directory always resolves to uploads-local —
// hardcoded, not derived, because it is not one of the configurable categories.
add(primaryNameOf(state, draft, 'uploads-local'), state.usage.legacyPhotos)
return sums
}
// ── Category migration (copy → flip → delta sweep) ────────────────────────────
/** One reassigned category the operator might want to move existing objects for. */
export interface MigrationCandidate {
category: StorageCategory
from: string
/** Display primary — never a hidden mirror name; used by the prompt lines and comparisons. */
to: string
/**
* The RAW effective draft backend name for this category — possibly a
* mirror's wire name when `to` is a mirrored primary. The migration POST
* must carry this, not `to`: posting the bare primary would silently drop
* replication for a category that was just routed onto a mirror.
*/
toWire: string
/** null when usage was never scanned — the prompt still fires, with an unknown-size line. */
objects: number | null
bytes: number | null
}
/**
* Every category whose effective (draft-over-state) PRIMARY backend differs
* from what's currently saved — normalized through `primaryNameOf` so simply
* wrapping (or unwrapping) a mirror around the same primary never counts as
* a candidate; mirroring is the existing sync/backfill flow, not a category
* migration. Zero-object categories (usage present, 0 objects) are excluded —
* nothing to move; null usage (never scanned) keeps the category as a
* candidate with null counts, per spec ("unknown still prompts").
*/
export function computeMigrationCandidates(draft: StorageConfig, state: StorageAdminState): MigrationCandidate[] {
const effective = effectiveCategoryMap(state, draft)
const candidates: MigrationCandidate[] = []
for (const category of STORAGE_CATEGORIES) {
const from = primaryNameOf(state, draft, state.categories[category]!.backend) // exhaustive by schema contract
const toWire = effective[category]
const to = primaryNameOf(state, draft, toWire)
if (from === to) continue
const usage = state.usage?.categories[category]
candidates.push({ category, from, to, toWire, objects: usage ? usage.objects : null, bytes: usage ? usage.bytes : null })
}
return candidates.filter((c) => c.objects !== 0)
}
/**
* Revert exactly the named categories in `draft` back to what's currently
* saved in `saved` — same default/settings convention as the rest of this
* file (drop the override when the saved entry is default-sourced, write the
* explicit backend name otherwise). Every other draft edit is untouched.
*/
export function stripCategories(
draft: StorageConfig,
saved: StorageAdminState,
categories: StorageCategory[],
): StorageConfig {
const next = { ...draft.categories }
for (const category of categories) {
const savedEntry = saved.categories[category]! // exhaustive by schema contract
if (savedEntry.source === 'default') delete next[category]
else next[category] = savedEntry.backend
}
return { ...draft, categories: next }
}
@@ -1,349 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { StorageAdminState, StorageBackend, StorageConfig, StorageConfigPut, StorageTestResponse } from '@trek/shared'
import { adminApi } from '../../../api/client'
import { getApiErrorMessage, type ApiError } from '../../../types'
import { settingsDocumentOf } from './storageModel'
export type StorageTestResults = Record<string, StorageTestResponse | 'running'>
/** 50ms under vitest so poll tests run on real timers (MSW + fake timers don't mix). */
export const BACKFILL_POLL_MS = import.meta.env.MODE === 'test' ? 50 : 5000
/** True for a 409 response — the version this draft was built at is no longer current (audit #7). */
function isConflictError(err: unknown): boolean {
return typeof err === 'object' && err !== null && 'response' in err && (err as ApiError).response?.status === 409
}
export interface StorageAdmin {
state: StorageAdminState | null
/** The settings-owned document (the PUT body), with local edits layered in. Carries the version the draft was built at. */
draft: StorageConfigPut | null
dirty: boolean
loading: boolean
loadError: string | null
/** Server 400s/409s land here VERBATIM (400) or as the distinct conflict copy (409) — long registry messages outlive a toast. */
saveError: string | null
/** True while `saveError` is the 409 copy — the panels render the discard-and-reload escape next to it. */
saveConflict: boolean
saving: boolean
testResults: StorageTestResults
setDraft: (next: StorageConfig) => void
/** PUTs `overrideDraft ?? draft` — an override lets a caller strip categories from the wire body without staging them (and marking dirty) via setDraft. */
save: (overrideDraft?: StorageConfig) => Promise<boolean>
test: (backend: StorageBackend) => Promise<void>
/**
* Probe each target individually (client-expanded mirror primary +
* replicas — see `mirrorProbeTargets`) and merge the results into one
* `StorageTestResponse` under `key` (the row's mirror wire name). Never
* sends the mirror stub itself — each call probes a concrete backend.
*/
testMirror: (key: string, targets: StorageBackend[]) => Promise<void>
/** null on success; the verbatim server error otherwise. */
startBackfill: (mirrorName: string) => Promise<string | null>
cancelBackfill: (mirrorName: string) => Promise<string | null>
startMigration: (category: string, to: string) => Promise<string | null>
cancelMigration: (category: string) => Promise<string | null>
refreshStats: () => Promise<string | null>
/** Re-GETs admin state (poll-safe — see the implementation comment below). Exposed for the panel's failed-queue recovery path. */
refreshState: () => Promise<void>
/**
* The 409's only exit: throw the local edits away and reload the saved
* settings (fresh GET → fresh draft → fresh version), clearing dirty and
* saveError. See the implementation comment for why refreshState can't do
* this itself.
*/
discardDraft: () => Promise<void>
}
/**
* Shared by the desktop and phone storage panels (DefaultUserSettingsTab
* model: self-contained, adminApi directly, deliberately no offline core —
* hoster-level config is online-only).
*/
export function useStorageAdmin(genericError: string, conflictError: string): StorageAdmin {
const [state, setState] = useState<StorageAdminState | null>(null)
const [draft, setDraftState] = useState<StorageConfigPut | null>(null)
const [dirty, setDirty] = useState(false)
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [saveError, setSaveError] = useState<string | null>(null)
const [saveConflict, setSaveConflict] = useState(false)
const [saving, setSaving] = useState(false)
const [testResults, setTestResults] = useState<StorageTestResults>({})
// Read imperatively from the poll effect/refreshState so a stale closure
// never re-derives the draft out from under an in-flight edit.
const dirtyRef = useRef(dirty)
useEffect(() => {
dirtyRef.current = dirty
}, [dirty])
// Read imperatively at refreshState's resolve time so a poll GET that was
// in flight when a save started never applies after the save's own state.
const savingRef = useRef(saving)
useEffect(() => {
savingRef.current = saving
}, [saving])
// Bumped by every deliberate state application (initial load, save
// success). A refreshState() call captures the seq before its GET and
// drops the response if the seq moved on — i.e. a save applied its own
// fresher state — while that GET was in flight.
const stateSeq = useRef(0)
const applyState = useCallback((next: StorageAdminState) => {
stateSeq.current += 1
setState(next)
setDraftState(settingsDocumentOf(next))
setDirty(false)
setSaveError(null)
setSaveConflict(false)
}, [])
useEffect(() => {
let cancelled = false
adminApi
.getStorage()
.then((data) => {
if (!cancelled) applyState(data)
})
.catch((err: unknown) => {
if (!cancelled) setLoadError(getApiErrorMessage(err, genericError))
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [applyState, genericError])
// Re-attaches the CURRENT draft's version onto whatever shape the caller
// hands in (the storageModel edit helpers are version-blind — they operate
// on plain StorageConfig — so this is the one place that keeps `version`
// pinned to what the operator's form was loaded at, no matter how many
// local edits run in between).
const setDraft = useCallback((next: StorageConfig) => {
setDraftState((prev) => ({ ...next, version: prev?.version ?? 0 }))
setDirty(true)
}, [])
// Poll-safe refresh: replaces `state` unconditionally, but only re-derives
// `draft` when the operator has no unsaved edits in flight — never touches
// `dirty`/`saveError`, so a running poll cannot clobber a dirty draft or
// mask a pending save error.
//
// Race guard: a poll GET issued before a save's PUT resolves can resolve
// AFTER the save applied its own (fresher) response, overwriting it with
// the pre-save world. Capture the state-application sequence number before
// the await and drop the response if it moved on, or if a save is
// in-flight/just landed (read via a ref so it's current at resolve time).
//
// Declared before `save` (which calls it on a 409) — a hook's `const`s
// must exist before another callback's dependency array references them.
const refreshState = useCallback(async (): Promise<void> => {
const seq = stateSeq.current
const next = await adminApi.getStorage()
if (stateSeq.current !== seq || savingRef.current) return
setState(next)
if (!dirtyRef.current) setDraftState(settingsDocumentOf(next))
}, [])
const save = useCallback(
async (overrideDraft?: StorageConfig): Promise<boolean> => {
if (!draft) return false
// Always the DRAFT's own version, even for an overrideDraft (a variant
// of the same draft with categories stripped, per stripCategories) —
// both share the same version basis: what the operator's form was
// loaded/refreshed at, never re-read from `state` here (that would
// defeat the whole check — see setDraft's re-attach comment above).
const body: StorageConfigPut = { ...(overrideDraft ?? draft), version: draft.version }
setSaving(true)
setSaveError(null)
setSaveConflict(false)
try {
// The response is the fresh effective world, never the request echo.
applyState(await adminApi.updateStorage(body))
return true
} catch (err: unknown) {
if (isConflictError(err)) {
// Something else (another admin's save, or a category migration's
// flip) wrote since this draft's version was captured. Refresh
// `state` so the operator sees the fresh world, but the dirty
// draft itself is left untouched for review (dirty is never
// cleared here) — refreshState() already respects dirtyRef.
setSaveError(conflictError)
setSaveConflict(true)
void refreshState().catch(() => {})
} else {
setSaveError(getApiErrorMessage(err, genericError))
}
return false
} finally {
setSaving(false)
}
},
[draft, applyState, genericError, conflictError, refreshState],
)
/**
* Conflict recovery (final-review fix): after a 409 the dirty draft keeps
* the version it was built at. refreshState deliberately leaves a dirty
* draft's version alone — bumping it there would resurrect audit #7, where
* the background poll floats a mid-edit draft past a migration flip it
* never carried — so nothing else can ever move it, and every retry 409s
* again. The operator's way out is to say so explicitly: discard the local
* edits, reload the saved settings, and start over from the fresh version.
* A fresh GET (not the possibly-dropped post-409 refresh) guarantees that
* version is current, and applyState clears dirty/saveError with it.
*/
const discardDraft = useCallback(async (): Promise<void> => {
try {
applyState(await adminApi.getStorage())
} catch (err: unknown) {
setSaveError(getApiErrorMessage(err, genericError))
setSaveConflict(false)
}
}, [applyState, genericError])
const test = useCallback(
async (backend: StorageBackend): Promise<void> => {
setTestResults((prev) => ({ ...prev, [backend.name]: 'running' }))
try {
const result = await adminApi.testStorageBackend(backend)
setTestResults((prev) => ({ ...prev, [backend.name]: result }))
} catch (err: unknown) {
const error = getApiErrorMessage(err, genericError)
setTestResults((prev) => ({
...prev,
[backend.name]: { ok: false, targets: [{ name: backend.name, ok: false, error }] },
}))
}
},
[genericError],
)
const testMirror = useCallback(
async (key: string, targets: StorageBackend[]): Promise<void> => {
setTestResults((prev) => ({ ...prev, [key]: 'running' }))
const settled = await Promise.all(
targets.map(async (target) => {
try {
const result = await adminApi.testStorageBackend(target)
return result.targets[0] ?? { name: target.name, ok: result.ok }
} catch (err: unknown) {
return { name: target.name, ok: false, error: getApiErrorMessage(err, genericError) }
}
}),
)
setTestResults((prev) => ({ ...prev, [key]: { ok: settled.every((r) => r.ok), targets: settled } }))
},
[genericError],
)
// While any backfill or category migration is running, poll GET state so
// progress/counts advance without the operator refreshing the page.
useEffect(() => {
if (
!state?.backfills.some((b) => b.status === 'running') &&
!state?.migrations.some((m) => m.status === 'running')
)
return
const iv = setInterval(() => {
// A transient poll failure is retried on the next tick — it must never
// surface as a toast or interrupt the operator's in-progress edit.
void refreshState().catch(() => {})
}, BACKFILL_POLL_MS)
return () => clearInterval(iv)
}, [state, refreshState])
const startBackfill = useCallback(
async (mirrorName: string): Promise<string | null> => {
try {
await adminApi.startStorageBackfill(mirrorName)
void refreshState().catch(() => {})
return null
} catch (err: unknown) {
return getApiErrorMessage(err, genericError)
}
},
[refreshState, genericError],
)
const cancelBackfill = useCallback(
async (mirrorName: string): Promise<string | null> => {
try {
await adminApi.cancelStorageBackfill(mirrorName)
void refreshState().catch(() => {})
return null
} catch (err: unknown) {
return getApiErrorMessage(err, genericError)
}
},
[refreshState, genericError],
)
const startMigration = useCallback(
async (category: string, to: string): Promise<string | null> => {
try {
await adminApi.startStorageMigration(category, to)
// Awaited (unlike cancelMigration/startBackfill/cancelBackfill): the
// queue effect's in-flight lock releases as soon as this promise
// resolves, so admin.state must already reflect the just-started
// migration by then — otherwise the next queued candidate can
// dequeue and POST before the server has confirmed this one.
await refreshState().catch(() => {})
return null
} catch (err: unknown) {
return getApiErrorMessage(err, genericError)
}
},
[refreshState, genericError],
)
const cancelMigration = useCallback(
async (category: string): Promise<string | null> => {
try {
await adminApi.cancelStorageMigration(category)
void refreshState().catch(() => {})
return null
} catch (err: unknown) {
return getApiErrorMessage(err, genericError)
}
},
[refreshState, genericError],
)
const refreshStats = useCallback(async (): Promise<string | null> => {
try {
const usage = await adminApi.refreshStorageStats()
setState((prev) => (prev ? { ...prev, usage } : prev))
return null
} catch (err: unknown) {
return getApiErrorMessage(err, genericError)
}
}, [genericError])
return {
state,
draft,
dirty,
loading,
loadError,
saveError,
saveConflict,
saving,
testResults,
setDraft,
save,
test,
testMirror,
startBackfill,
cancelBackfill,
startMigration,
cancelMigration,
refreshStats,
refreshState,
discardDraft,
}
}
@@ -1,247 +0,0 @@
// FE-W4BGT-001 to FE-W4BGT-020
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { screen, act, waitFor } from '@testing-library/react'
import { render, fireEvent } from '../../../tests/helpers/render'
import { reservationsApi, healthApi } from '../../api/client'
import { addListener } from '../../api/websocket'
import { useBackgroundTasksStore, type BackgroundImportTask } from '../../store/backgroundTasksStore'
import BackgroundTasksWidget from './BackgroundTasksWidget'
const navigate = vi.fn()
vi.mock('react-router', async () => {
const actual = await vi.importActual<typeof import('react-router')>('react-router')
return { ...actual, useNavigate: () => navigate }
})
vi.mock('../../api/websocket', () => ({ addListener: vi.fn(), removeListener: vi.fn() }))
vi.mock('../../api/client', () => ({
reservationsApi: { importJobStatus: vi.fn(), importBookingAsync: vi.fn() },
healthApi: { features: vi.fn() },
}))
vi.mock('../../db/offlineDb', () => ({ saveImportFiles: vi.fn(() => Promise.resolve()) }))
const task = (overrides: Partial<BackgroundImportTask> = {}): BackgroundImportTask => ({
id: 'j1', tripId: 't1', label: 'voucher.pdf', status: 'done', done: 0, total: 1, items: [], warnings: [],
...overrides,
})
type WsHandler = (e: Record<string, unknown>) => void
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(healthApi.features).mockReturnValue(new Promise(() => {}))
vi.mocked(reservationsApi.importJobStatus).mockReturnValue(new Promise(() => {}))
useBackgroundTasksStore.setState({ tasks: [] })
})
afterEach(() => {
vi.useRealTimers()
})
describe('BackgroundTasksWidget — rendering', () => {
it('FE-W4BGT-001: renders nothing without tasks', () => {
const { container, baseElement } = render(<BackgroundTasksWidget />)
expect(container).toBeEmptyDOMElement()
expect(baseElement.querySelectorAll('button')).toHaveLength(0)
})
it('FE-W4BGT-002: a running job shows the spinner, the parsing note and no close button', () => {
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', done: 1, total: 3 })] })
const { baseElement } = render(<BackgroundTasksWidget />)
expect(screen.getByText('voucher.pdf')).toBeInTheDocument()
expect(screen.getByText(/· 1\/3$/)).toBeInTheDocument()
expect(baseElement.querySelector('.animate-spin')).not.toBeNull()
expect(screen.queryByLabelText('Close')).toBeNull()
})
it('FE-W4BGT-003: a single-file job omits the counter', () => {
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', done: 0, total: 1 })] })
render(<BackgroundTasksWidget />)
expect(screen.queryByText(/·/)).toBeNull()
})
it('FE-W4BGT-004: a restored done job without items still reads as parsing', () => {
useBackgroundTasksStore.setState({ tasks: [task({ status: 'done', items: undefined })] })
const { baseElement } = render(<BackgroundTasksWidget />)
expect(baseElement.querySelector('.animate-spin')).not.toBeNull()
expect(screen.getByLabelText('Close')).toBeInTheDocument()
})
it('FE-W4BGT-005: a finished job with items offers the review action', () => {
useBackgroundTasksStore.setState({ tasks: [task({ items: [{ id: 1 }] as never })] })
render(<BackgroundTasksWidget />)
fireEvent.click(screen.getByRole('button', { name: 'Import' }))
expect(useBackgroundTasksStore.getState().tasks[0].reviewRequested).toBe(true)
expect(navigate).toHaveBeenCalledWith('/trips/t1')
})
it('FE-W4BGT-006: a failed job shows the error message', () => {
useBackgroundTasksStore.setState({ tasks: [task({ status: 'error', error: 'AI quota exhausted' })] })
render(<BackgroundTasksWidget />)
expect(screen.getByText('AI quota exhausted')).toBeInTheDocument()
})
it('FE-W4BGT-007: the close button drops the card', () => {
useBackgroundTasksStore.setState({ tasks: [task()] })
render(<BackgroundTasksWidget />)
fireEvent.click(screen.getByLabelText('Close'))
expect(useBackgroundTasksStore.getState().tasks).toHaveLength(0)
})
})
describe('BackgroundTasksWidget — websocket', () => {
it('FE-W4BGT-008: import:progress updates the running card', () => {
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', total: 4 })] })
render(<BackgroundTasksWidget />)
const handler = vi.mocked(addListener).mock.calls[0][0] as WsHandler
act(() => { handler({ type: 'import:progress', jobId: 'j1', tripId: 't1', done: 2, total: 4 }) })
expect(screen.getByText(/· 2\/4$/)).toBeInTheDocument()
})
it('FE-W4BGT-009: import:done attaches the parsed items', () => {
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] })
render(<BackgroundTasksWidget />)
const handler = vi.mocked(addListener).mock.calls[0][0] as WsHandler
act(() => { handler({ type: 'import:done', jobId: 'j1', tripId: 't1', result: { items: [{ id: 1 }], warnings: [] } }) })
expect(screen.getByRole('button', { name: 'Import' })).toBeInTheDocument()
})
it('FE-W4BGT-010: import:error surfaces the message', () => {
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running' })] })
render(<BackgroundTasksWidget />)
const handler = vi.mocked(addListener).mock.calls[0][0] as WsHandler
act(() => { handler({ type: 'import:error', jobId: 'j1', tripId: 't1', message: 'boom' }) })
expect(screen.getByText('boom')).toBeInTheDocument()
})
it('FE-W4BGT-011: unrelated events and events without a job id are ignored', () => {
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', total: 4 })] })
render(<BackgroundTasksWidget />)
const handler = vi.mocked(addListener).mock.calls[0][0] as WsHandler
act(() => {
handler({ type: 'place:updated', jobId: 'j1' })
handler({ type: 'import:progress', done: 3, total: 4 })
handler({ done: 3 })
})
expect(screen.getByText(/· 0\/4$/)).toBeInTheDocument()
})
})
describe('BackgroundTasksWidget — rehydrate', () => {
it('FE-W4BGT-012: a restored job that the server finished gets its items back', async () => {
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] })
vi.mocked(reservationsApi.importJobStatus).mockResolvedValue({
status: 'done', done: 1, total: 1, result: { items: [{ id: 1 }], warnings: [] },
} as never)
render(<BackgroundTasksWidget />)
expect(await screen.findByRole('button', { name: 'Import' })).toBeInTheDocument()
expect(reservationsApi.importJobStatus).toHaveBeenCalledWith('t1', 'j1')
})
it('FE-W4BGT-013: a restored job the server reports as failed shows the error', async () => {
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] })
vi.mocked(reservationsApi.importJobStatus).mockResolvedValue({ status: 'error', error: 'expired', done: 0, total: 1 } as never)
render(<BackgroundTasksWidget />)
expect(await screen.findByText('expired')).toBeInTheDocument()
})
it('FE-W4BGT-014: a restored job the server has dropped is removed', async () => {
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] })
vi.mocked(reservationsApi.importJobStatus).mockRejectedValue({ response: { status: 404 } })
render(<BackgroundTasksWidget />)
await waitFor(() => expect(useBackgroundTasksStore.getState().tasks).toHaveLength(0))
})
it('FE-W4BGT-015: a non-404 failure keeps the card', async () => {
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] })
vi.mocked(reservationsApi.importJobStatus).mockRejectedValue({ response: { status: 500 } })
render(<BackgroundTasksWidget />)
await waitFor(() => expect(reservationsApi.importJobStatus).toHaveBeenCalled())
expect(useBackgroundTasksStore.getState().tasks).toHaveLength(1)
})
})
describe('BackgroundTasksWidget — AI retry', () => {
const withFiles = () => task({
items: [], sourceFiles: [new File(['%PDF'], 'voucher.pdf', { type: 'application/pdf' })],
})
it('FE-W4BGT-016: offers the AI retry only when the feature is on', async () => {
vi.mocked(healthApi.features).mockResolvedValue({ aiParsing: true } as never)
useBackgroundTasksStore.setState({ tasks: [withFiles()] })
render(<BackgroundTasksWidget />)
expect(await screen.findByRole('button', { name: /AI/i })).toBeInTheDocument()
})
it('FE-W4BGT-017: hides the retry when the feature probe fails', async () => {
vi.mocked(healthApi.features).mockRejectedValue(new Error('down'))
useBackgroundTasksStore.setState({ tasks: [withFiles()] })
render(<BackgroundTasksWidget />)
await waitFor(() => expect(healthApi.features).toHaveBeenCalled())
expect(screen.queryByRole('button', { name: /AI/i })).toBeNull()
})
it('FE-W4BGT-018: hides the retry on a job that already ran with force-ai', async () => {
vi.mocked(healthApi.features).mockResolvedValue({ aiParsing: true } as never)
useBackgroundTasksStore.setState({ tasks: [task({ items: [], mode: 'force-ai', sourceFiles: [new File([''], 'a.pdf')] })] })
render(<BackgroundTasksWidget />)
await waitFor(() => expect(healthApi.features).toHaveBeenCalled())
expect(screen.queryByRole('button', { name: /AI/i })).toBeNull()
})
it('FE-W4BGT-019: retrying swaps the card for the new force-ai job', async () => {
vi.mocked(healthApi.features).mockResolvedValue({ aiParsing: true } as never)
vi.mocked(reservationsApi.importBookingAsync).mockResolvedValue({ jobId: 'j2' } as never)
useBackgroundTasksStore.setState({ tasks: [withFiles()] })
render(<BackgroundTasksWidget />)
fireEvent.click(await screen.findByRole('button', { name: /AI/i }))
await waitFor(() => {
const tasks = useBackgroundTasksStore.getState().tasks
expect(tasks).toHaveLength(1)
expect(tasks[0]).toMatchObject({ id: 'j2', mode: 'force-ai', tripId: 't1' })
})
})
it('FE-W4BGT-020: a refused retry surfaces the server error on the original card', async () => {
vi.mocked(healthApi.features).mockResolvedValue({ aiParsing: true } as never)
vi.mocked(reservationsApi.importBookingAsync).mockRejectedValue({ response: { data: { error: 'No model configured' } } })
useBackgroundTasksStore.setState({ tasks: [withFiles()] })
render(<BackgroundTasksWidget />)
fireEvent.click(await screen.findByRole('button', { name: /AI/i }))
expect(await screen.findByText('No model configured')).toBeInTheDocument()
})
})
@@ -1,153 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { render } from '../../../tests/helpers/render'
import { reservationsApi, healthApi } from '../../api/client'
import { saveImportFiles } from '../../db/offlineDb'
import { useBackgroundTasksStore, type BackgroundImportTask } from '../../store/backgroundTasksStore'
import BackgroundTasksWidget from './BackgroundTasksWidget'
vi.mock('../../api/websocket', () => ({ addListener: vi.fn(), removeListener: vi.fn() }))
vi.mock('../../api/client', () => ({
// Keep the rehydrate/poll backstops pending so the seeded state is what renders.
reservationsApi: { importJobStatus: vi.fn(() => new Promise(() => {})), importBookingAsync: vi.fn() },
healthApi: { features: vi.fn() },
}))
vi.mock('../../db/offlineDb', () => ({ saveImportFiles: vi.fn(() => Promise.resolve()) }))
const task = (overrides: Partial<BackgroundImportTask> = {}): BackgroundImportTask => ({
id: 'j1',
tripId: 't1',
label: 'voucher.pdf',
status: 'done',
done: 0,
total: 1,
items: [],
warnings: [],
...overrides,
})
const pdf = () => new File(['%PDF'], 'voucher.pdf', { type: 'application/pdf' })
beforeEach(() => {
vi.clearAllMocks()
// Like the poll backstop above: leave the feature probe pending so tests that don't care
// about the AI retry render the same widget they did before the button existed.
vi.mocked(healthApi.features).mockReturnValue(new Promise(() => {}))
vi.mocked(saveImportFiles).mockResolvedValue(undefined)
useBackgroundTasksStore.setState({ tasks: [] })
})
describe('BackgroundTasksWidget', () => {
it('shows the warnings when a finished job produced no items', () => {
const warning = 'voucher.pdf: AI parsing failed — LLM request failed (400): response_format unsupported'
useBackgroundTasksStore.setState({ tasks: [task({ warnings: [warning] })] })
render(<BackgroundTasksWidget />)
expect(screen.getByText('No reservations could be extracted from the uploaded files.')).toBeInTheDocument()
expect(screen.getByText(warning)).toBeInTheDocument()
})
it('shows only the empty-preview note when there are no warnings', () => {
useBackgroundTasksStore.setState({ tasks: [task()] })
render(<BackgroundTasksWidget />)
expect(screen.getByText('No reservations could be extracted from the uploaded files.')).toBeInTheDocument()
expect(screen.queryByText(/AI parsing failed/)).not.toBeInTheDocument()
})
describe('AI retry', () => {
it('offers the retry on an empty result once the addon reports AI parsing', async () => {
vi.mocked(healthApi.features).mockResolvedValue({ bookingImport: true, aiParsing: true })
useBackgroundTasksStore.setState({ tasks: [task({ sourceFiles: [pdf()] })] })
render(<BackgroundTasksWidget />)
expect(await screen.findByRole('button', { name: 'Try AI parsing' })).toBeInTheDocument()
})
it('stays hidden when the addon is off, the files are gone, or the run was already force-ai', async () => {
vi.mocked(healthApi.features).mockResolvedValue({ bookingImport: true, aiParsing: false })
useBackgroundTasksStore.setState({ tasks: [task({ sourceFiles: [pdf()] })] })
const { unmount } = render(<BackgroundTasksWidget />)
await waitFor(() => expect(healthApi.features).toHaveBeenCalled())
expect(screen.queryByRole('button', { name: 'Try AI parsing' })).not.toBeInTheDocument()
unmount()
vi.mocked(healthApi.features).mockResolvedValue({ bookingImport: true, aiParsing: true })
// Rehydrated from storage: sourceFiles can't survive a reload, so there is nothing to resend.
useBackgroundTasksStore.setState({ tasks: [task()] })
const withoutFiles = render(<BackgroundTasksWidget />)
await waitFor(() => expect(healthApi.features).toHaveBeenCalledTimes(2))
expect(screen.queryByRole('button', { name: 'Try AI parsing' })).not.toBeInTheDocument()
withoutFiles.unmount()
useBackgroundTasksStore.setState({ tasks: [task({ sourceFiles: [pdf()], mode: 'force-ai' })] })
render(<BackgroundTasksWidget />)
await waitFor(() => expect(healthApi.features).toHaveBeenCalledTimes(3))
expect(screen.queryByRole('button', { name: 'Try AI parsing' })).not.toBeInTheDocument()
})
it('re-submits the files with force-ai, keeps them for the review and replaces the task', async () => {
const file = pdf()
vi.mocked(healthApi.features).mockResolvedValue({ bookingImport: true, aiParsing: true })
vi.mocked(reservationsApi.importBookingAsync).mockResolvedValue({ jobId: 'j2' })
useBackgroundTasksStore.setState({ tasks: [task({ sourceFiles: [file] })] })
render(<BackgroundTasksWidget />)
await userEvent.click(await screen.findByRole('button', { name: 'Try AI parsing' }))
expect(reservationsApi.importBookingAsync).toHaveBeenCalledWith('t1', [file], 'force-ai')
// Without this the reviewed bookings lose their source document after a reload.
await waitFor(() => expect(saveImportFiles).toHaveBeenCalledWith('j2', [file]))
await waitFor(() => {
const tasks = useBackgroundTasksStore.getState().tasks
expect(tasks).toHaveLength(1)
expect(tasks[0]).toMatchObject({ id: 'j2', tripId: 't1', status: 'running', mode: 'force-ai' })
})
})
it('keeps the task and surfaces the server error when the retry is rejected', async () => {
vi.mocked(healthApi.features).mockResolvedValue({ bookingImport: true, aiParsing: true })
vi.mocked(reservationsApi.importBookingAsync).mockRejectedValue({ response: { data: { error: 'No AI model configured' } } })
useBackgroundTasksStore.setState({ tasks: [task({ sourceFiles: [pdf()] })] })
render(<BackgroundTasksWidget />)
await userEvent.click(await screen.findByRole('button', { name: 'Try AI parsing' }))
expect(await screen.findByText('No AI model configured')).toBeInTheDocument()
const tasks = useBackgroundTasksStore.getState().tasks
expect(tasks).toHaveLength(1)
expect(tasks[0]).toMatchObject({ id: 'j1', status: 'error' })
expect(saveImportFiles).not.toHaveBeenCalled()
})
it('ignores a second click while the first retry is still in flight', async () => {
vi.mocked(healthApi.features).mockResolvedValue({ bookingImport: true, aiParsing: true })
// Never settles: the retry stays in flight for the whole test.
vi.mocked(reservationsApi.importBookingAsync).mockReturnValue(new Promise<{ jobId: string }>(() => {}))
useBackgroundTasksStore.setState({ tasks: [task({ sourceFiles: [pdf()] })] })
render(<BackgroundTasksWidget />)
const button = await screen.findByRole('button', { name: 'Try AI parsing' })
await userEvent.click(button)
await waitFor(() => expect(button).toBeDisabled())
await userEvent.click(button)
expect(reservationsApi.importBookingAsync).toHaveBeenCalledTimes(1)
})
})
/*
* Warnings used to appear only when nothing at all was found, so a partly
* understood import dropped whatever it could not place without a word — a
* station the geocoder could not locate, say (#1969), whose endpoint then
* disappeared on save.
*/
it('shows the warnings on a job that did produce items', () => {
const warning = 'voucher.pdf: could not locate "Curbside Pickup Counter 7" — set it manually before saving'
useBackgroundTasksStore.setState({
tasks: [task({ items: [{ id: 1 } as never], warnings: [warning] })],
})
render(<BackgroundTasksWidget />)
expect(screen.getByText(new RegExp('Curbside Pickup Counter 7'))).toBeInTheDocument()
// and the import button is still there — the job did find something
expect(screen.getByRole('button', { name: /import/i })).toBeInTheDocument()
})
})
@@ -1,223 +0,0 @@
import { createPortal } from 'react-dom'
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router'
import { Loader2, CheckCircle2, AlertCircle, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { addListener, removeListener } from '../../api/websocket'
import { reservationsApi, healthApi } from '../../api/client'
import { saveImportFiles } from '../../db/offlineDb'
import { useBackgroundTasksStore, type BackgroundImportTask } from '../../store/backgroundTasksStore'
/**
* Global, route-independent widget (bottom-right) that tracks background booking
* imports. Mounted once at the app root so it survives navigation. It listens to the
* user's WebSocket for import:progress / import:done / import:error and reflects each
* job; a finished job offers a "review" action that takes the user to the trip, where
* the per-item review flow opens. Polls running jobs as a backstop for missed pushes.
*/
export default function BackgroundTasksWidget() {
const { t } = useTranslation()
const navigate = useNavigate()
const tasks = useBackgroundTasksStore((s) => s.tasks)
const setProgress = useBackgroundTasksStore((s) => s.setProgress)
const setDone = useBackgroundTasksStore((s) => s.setDone)
const setError = useBackgroundTasksStore((s) => s.setError)
const requestReview = useBackgroundTasksStore((s) => s.requestReview)
const dismiss = useBackgroundTasksStore((s) => s.dismiss)
const addTask = useBackgroundTasksStore((s) => s.addTask)
const [aiParsing, setAiParsing] = useState(false)
useEffect(() => {
healthApi.features().then((f) => setAiParsing(!!f.aiParsing)).catch(() => setAiParsing(false))
}, [])
// Re-runs the same files with force-ai: the LLM sees every file, kitinerary is skipped.
const [retrying, setRetrying] = useState<string | null>(null)
const retryWithAi = async (task: BackgroundImportTask) => {
const files = task.sourceFiles
if (!files || files.length === 0 || retrying === task.id) return
setRetrying(task.id)
try {
const { jobId } = await reservationsApi.importBookingAsync(task.tripId, files, 'force-ai')
// Same as the modal's first submit: the review attaches each source document to the
// booking it created, and only IndexedDB survives a reload mid-parse.
await saveImportFiles(jobId, files)
dismiss(task.id)
addTask({ id: jobId, tripId: task.tripId, label: task.label, total: files.length, files, mode: 'force-ai' })
} catch (err) {
// 409 when the addon is enabled but this user has no model configured.
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error
setError(task.id, task.tripId, message ?? t('reservations.import.error'))
} finally {
setRetrying(null)
}
}
// On (re)load, reconcile tasks restored from localStorage with the server: a parse
// that was still running when the page reloaded must keep its widget, so re-fetch each
// job's real status (and its parsed items) once. A job the server has since dropped
// (404, expired) is removed so no stale card lingers.
const didRehydrate = useRef(false)
useEffect(() => {
if (didRehydrate.current) return
didRehydrate.current = true
const restored = useBackgroundTasksStore.getState().tasks
for (const task of restored) {
reservationsApi
.importJobStatus(task.tripId, task.id)
.then((s) => {
if (s.status === 'done') setDone(task.id, task.tripId, (s.result?.items ?? []) as never, s.result?.warnings ?? [])
else if (s.status === 'error') setError(task.id, task.tripId, s.error ?? 'error')
else setProgress(task.id, task.tripId, s.done, s.total)
})
.catch((err: { response?: { status?: number } }) => {
if (err?.response?.status === 404) dismiss(task.id)
})
}
// run once on mount against whatever was rehydrated from storage
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Server pushes import:* to the user on whatever page they're on.
useEffect(() => {
const handler = (e: Record<string, unknown>) => {
const type = typeof e.type === 'string' ? e.type : ''
if (!type.startsWith('import:')) return
const id = String(e.jobId ?? '')
const tripId = String(e.tripId ?? '')
if (!id) return
if (type === 'import:progress') setProgress(id, tripId, Number(e.done ?? 0), Number(e.total ?? 1))
else if (type === 'import:done') {
const result = e.result as { items?: unknown[]; warnings?: string[] } | undefined
setDone(id, tripId, (result?.items ?? []) as never, result?.warnings ?? [])
} else if (type === 'import:error') setError(id, tripId, String(e.message ?? 'error'))
}
addListener(handler)
return () => removeListener(handler)
}, [setProgress, setDone, setError])
// Backstop: poll jobs whose state we still need — running ones (in case a WebSocket push
// was missed) and a restored 'done' task whose items haven't been re-fetched yet (so a
// failed one-shot rehydrate self-heals instead of getting stuck on "preview empty").
useEffect(() => {
const pending = tasks.filter((task) => task.status === 'running' || (task.status === 'done' && task.items === undefined))
if (pending.length === 0) return
const iv = setInterval(() => {
for (const task of pending) {
reservationsApi
.importJobStatus(task.tripId, task.id)
.then((s) => {
if (s.status === 'done') setDone(task.id, task.tripId, (s.result?.items ?? []) as never, s.result?.warnings ?? [])
else if (s.status === 'error') setError(task.id, task.tripId, s.error ?? 'error')
else setProgress(task.id, task.tripId, s.done, s.total)
})
.catch(() => {})
}
}, 5000)
return () => clearInterval(iv)
}, [tasks, setProgress, setDone, setError])
if (tasks.length === 0) return null
const review = (task: BackgroundImportTask) => {
requestReview(task.id)
navigate(`/trips/${task.tripId}`)
}
return createPortal(
<div
style={{ position: 'fixed', right: 16, bottom: 16, zIndex: 50000, display: 'flex', flexDirection: 'column', gap: 8, width: 380, maxWidth: 'calc(100vw - 32px)', fontFamily: 'var(--font-system)' }}
>
{tasks.map((task) => (
<div
key={task.id}
className="bg-surface-card"
style={{ borderRadius: 12, border: '1px solid var(--border-primary)', boxShadow: '0 8px 24px rgba(0,0,0,0.18)', padding: '11px 13px', backdropFilter: 'blur(8px)', display: 'flex', gap: 10, alignItems: 'flex-start' }}
>
<div style={{ flexShrink: 0, marginTop: 1 }}>
{(task.status === 'running' || (task.status === 'done' && task.items === undefined)) && <Loader2 size={16} className="animate-spin" color="var(--accent)" />}
{task.status === 'done' && task.items !== undefined && <CheckCircle2 size={16} color="#10b981" />}
{task.status === 'error' && <AlertCircle size={16} color="#ef4444" />}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{task.label}
</div>
{task.status === 'running' && (
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 1 }}>
{t('reservations.import.parsing')}
{task.total > 1 ? ` · ${task.done}/${task.total}` : ''}
</div>
)}
{task.status === 'done' && (
task.items === undefined ? (
// Restored from a reload; items are being re-fetched (see the poll backstop).
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 1 }}>{t('reservations.import.parsing')}</div>
) : task.items.length > 0 ? (
<div>
<button
onClick={() => review(task)}
className="bg-accent text-accent-text"
style={{ marginTop: 4, border: 'none', borderRadius: 8, padding: '4px 12px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('common.import')}
</button>
{/* A partly-understood import warns too. Warnings used to be
shown only when nothing at all was found, so anything the
parse could not place — a station it could not locate, say
(#1969) — was dropped without a word on exactly the imports
that did produce something. */}
{(task.warnings?.length ?? 0) > 0 && (
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#b45309', marginTop: 3, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: 96, overflowY: 'auto' }}>
{task.warnings!.join('\n')}
</div>
)}
</div>
) : (
<div>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 1 }}>
{t('reservations.import.previewEmpty')}
{(task.warnings?.length ?? 0) > 0 && (
<div style={{ color: '#b45309', marginTop: 3, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: 96, overflowY: 'auto' }}>
{task.warnings!.join('\n')}
</div>
)}
</div>
{aiParsing && task.mode !== 'force-ai' && task.sourceFiles && task.sourceFiles.length > 0 && (
<button
onClick={() => retryWithAi(task)}
disabled={retrying === task.id}
className="bg-surface-tertiary text-content"
style={{ marginTop: 4, border: 'none', borderRadius: 8, padding: '4px 12px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: retrying === task.id ? 'default' : 'pointer', opacity: retrying === task.id ? 0.6 : 1, fontFamily: 'inherit' }}
>
{t('reservations.import.tryAi')}
</button>
)}
</div>
)
)}
{task.status === 'error' && (
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#b91c1c', marginTop: 1, whiteSpace: 'pre-wrap' }}>{task.error}</div>
)}
</div>
{task.status !== 'running' && (
<button
onClick={() => dismiss(task.id)}
className="bg-transparent text-content-faint"
style={{ flexShrink: 0, border: 'none', cursor: 'pointer', padding: 2, borderRadius: 6, display: 'flex', alignItems: 'center' }}
aria-label={t('common.close')}
>
<X size={13} />
</button>
)}
</div>
))}
</div>,
document.body
)
}
@@ -1,72 +1,23 @@
// The full set of currencies the Frankfurter v2 FX API supports (archived codes
// excluded), so every selectable currency actually converts. Regenerate from
// `GET https://api.frankfurter.dev/v2/currencies?expand=providers` (iso_code +
// symbol) if the provider's list changes. See issue #1470.
export const CURRENCIES = [
'AED', 'AFN', 'ALL', 'AMD', 'ANG', 'AOA', 'ARS', 'AUD', 'AWG', 'AZN',
'BAM', 'BBD', 'BDT', 'BHD', 'BIF', 'BMD', 'BND', 'BOB', 'BRL', 'BSD',
'BTN', 'BWP', 'BYN', 'BZD', 'CAD', 'CDF', 'CHF', 'CLP', 'CNH', 'CNY',
'COP', 'CRC', 'CUP', 'CVE', 'CZK', 'DJF', 'DKK', 'DOP', 'DZD', 'EGP',
'ERN', 'ETB', 'EUR', 'FJD', 'FKP', 'GBP', 'GEL', 'GGP', 'GHS', 'GIP',
'GMD', 'GNF', 'GTQ', 'GYD', 'HKD', 'HNL', 'HTG', 'HUF', 'IDR', 'ILS',
'IMP', 'INR', 'IQD', 'IRR', 'ISK', 'JEP', 'JMD', 'JOD', 'JPY', 'KES',
'KGS', 'KHR', 'KMF', 'KPW', 'KRW', 'KWD', 'KYD', 'KZT', 'LAK', 'LBP',
'LKR', 'LRD', 'LSL', 'LYD', 'MAD', 'MDL', 'MGA', 'MKD', 'MMK', 'MNT',
'MOP', 'MRO', 'MRU', 'MUR', 'MVR', 'MWK', 'MXN', 'MYR', 'MZN', 'NAD',
'NGN', 'NIO', 'NOK', 'NPR', 'NZD', 'OMR', 'PAB', 'PEN', 'PGK', 'PHP',
'PKR', 'PLN', 'PYG', 'QAR', 'RON', 'RSD', 'RUB', 'RWF', 'SAR', 'SBD',
'SCR', 'SDG', 'SEK', 'SGD', 'SHP', 'SLE', 'SOS', 'SRD', 'SSP', 'STN',
'SVC', 'SYP', 'SZL', 'THB', 'TJS', 'TMT', 'TND', 'TOP', 'TRY', 'TTD',
'TWD', 'TZS', 'UAH', 'UGX', 'USD', 'UYU', 'UZS', 'VES', 'VND', 'VUV',
'WST', 'XAF', 'XAG', 'XAU', 'XCD', 'XCG', 'XDR', 'XOF', 'XPD', 'XPF',
'XPT', 'YER', 'ZAR', 'ZMW', 'ZWG',
'EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK',
'TRY', 'THB', 'AUD', 'CAD', 'NZD', 'BRL', 'MXN', 'INR', 'IDR', 'MYR',
'PHP', 'SGD', 'KRW', 'CNY', 'HKD', 'TWD', 'ZAR', 'AED', 'SAR', 'ILS',
'EGP', 'MAD', 'HUF', 'RON', 'BGN', 'HRK', 'ISK', 'RUB', 'UAH', 'BDT',
'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS',
]
export const SYMBOLS: Record<string, string> = {
AED: 'د.إ', AFN: '؋', ALL: 'L', AMD: '֏', ANG: 'ƒ',
AOA: 'Kz', ARS: '$', AUD: '$', AWG: 'ƒ', AZN: '₼',
BAM: 'КМ', BBD: '$', BDT: '৳', BHD: 'د.ب', BIF: 'Fr',
BMD: '$', BND: '$', BOB: 'Bs.', BRL: 'R$', BSD: '$',
BTN: 'Nu.', BWP: 'P', BYN: 'Br', BZD: '$', CAD: '$',
CDF: 'Fr', CHF: 'CHF', CLP: '$', CNH: '¥', CNY: '¥',
COP: '$', CRC: '₡', CUP: '$', CVE: '$', CZK: 'Kč',
DJF: 'Fdj', DKK: 'kr.', DOP: '$', DZD: 'د.ج', EGP: 'ج.م',
ERN: 'Nfk', ETB: 'Br', EUR: '€', FJD: '$', FKP: '£',
GBP: '£', GEL: '₾', GGP: '£', GHS: '₵', GIP: '£',
GMD: 'D', GNF: 'Fr', GTQ: 'Q', GYD: '$', HKD: '$',
HNL: 'L', HTG: 'G', HUF: 'Ft', IDR: 'Rp', ILS: '₪',
IMP: '£', INR: '₹', IQD: 'ع.د', IRR: '﷼', ISK: 'kr.',
JEP: '£', JMD: '$', JOD: 'د.ا', JPY: '¥', KES: 'KSh',
KGS: 'som', KHR: '៛', KMF: 'Fr', KPW: '₩', KRW: '₩',
KWD: 'د.ك', KYD: '$', KZT: '₸', LAK: '₭', LBP: 'ل.ل',
LKR: '₨', LRD: '$', LSL: 'L', LYD: 'ل.د', MAD: 'د.م.',
MDL: 'L', MGA: 'Ar', MKD: 'ден', MMK: 'K', MNT: '₮',
MOP: 'P', MRO: 'UM', MRU: 'UM', MUR: '₨', MVR: 'MVR',
MWK: 'MK', MXN: '$', MYR: 'RM', MZN: 'MTn', NAD: '$',
NGN: '₦', NIO: 'C$', NOK: 'kr', NPR: 'Rs.', NZD: '$',
OMR: 'ر.ع.', PAB: 'B/.', PEN: 'S/', PGK: 'K', PHP: '₱',
PKR: '₨', PLN: 'zł', PYG: '₲', QAR: 'ر.ق', RON: 'Lei',
RSD: 'RSD', RUB: '₽', RWF: 'FRw', SAR: 'ر.س', SBD: '$',
SCR: '₨', SDG: '£', SEK: 'kr', SGD: '$', SHP: '£',
SLE: 'Le', SOS: 'Sh', SRD: '$', SSP: '£', STN: 'Db',
SVC: '₡', SYP: '£S', SZL: 'E', THB: '฿', TJS: 'ЅМ',
TMT: 'm', TND: 'د.ت', TOP: 'T$', TRY: '₺', TTD: '$',
TWD: '$', TZS: 'Sh', UAH: '₴', UGX: 'USh', USD: '$',
UYU: '$U', UZS: 'so\'m', VES: 'Bs', VND: '₫', VUV: 'Vt',
WST: 'T', XAF: 'CFA', XAG: 'oz t', XAU: 'oz t', XCD: '$',
XCG: 'Cg', XDR: 'SDR', XOF: 'Fr', XPD: 'oz t', XPF: 'Fr',
XPT: 'oz t', YER: '﷼', ZAR: 'R', ZMW: 'K', ZWG: 'ZiG',
EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', CZK: 'Kč', PLN: 'zł',
SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$',
NZD: 'NZ$', BRL: 'R$', MXN: 'MX$', INR: '₹', IDR: 'Rp', MYR: 'RM',
PHP: '₱', SGD: 'S$', KRW: '₩', CNY: '¥', HKD: 'HK$', TWD: 'NT$',
ZAR: 'R', AED: 'د.إ', SAR: '﷼', ILS: '₪', EGP: 'E£', MAD: 'MAD',
HUF: 'Ft', RON: 'lei', BGN: 'лв', HRK: 'kn', ISK: 'kr', RUB: '₽',
UAH: '₴', BDT: '৳', LKR: 'Rs', VND: '₫', CLP: 'CL$', COP: 'CO$',
PEN: 'S/.', ARS: 'AR$',
}
// Keep a currency the user already saved selectable even after it leaves the
// supported set (e.g. archived BGN/HRK), so opening an existing item or settings
// row doesn't silently blank the field and wipe the value on the next save.
export function currenciesWith(current?: string | null): readonly string[] {
const cur = (current || '').toUpperCase()
return cur && !CURRENCIES.includes(cur) ? [...CURRENCIES, cur] : CURRENCIES
}
export const PIE_COLORS =['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7']
export const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7']
export const SPLIT_COLORS = [
{ solid: '#6366f1', gradient: 'linear-gradient(135deg, #6366f1, #8b5cf6)' },

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