Compare commits

...

276 Commits

Author SHA1 Message Date
Konstantinos Thermos d8541a53e2 fix(llm): retry OpenAI import with max_completion_tokens
Newer OpenAI models (gpt-5.x) reject max_tokens with a 400 and demand
max_completion_tokens, so AI booking import failed and no reservations
were created. Send max_tokens first (understood by the classic chat API
and every local server), and only on a 400 naming max_completion_tokens
resend the whole request with that parameter instead — mirroring the
existing json_schema->json_object fallback beside it. Detection is by
API response, not a model-name allowlist.

Fixes #1760
2026-08-04 14:14:42 +03:00
Maurice 446bbdc38f fix(client): stop shared items counting towards the recipient bag weight
An item shared through the sharing tier is visible to its recipients but
carried by its owner. The bag totals filtered on bag_id alone, so every
recipient saw the owner's weight added to their own load — which is exactly
the situation that tier exists for (one person brings the first-aid kit).

Filtering by owner_id, as the issue suggests, would have broken more than it
fixed: owner_id is stamped on every item including Common ones, so the group
pool would have shrunk to "only what I entered myself" even on trips where
nobody shares anything. The predicate keeps the Common pool intact and drops
only private items owned by someone else.

Closes #1767
2026-08-03 21:59:34 +02:00
Maurice d7d7fac339 feat(i18n): translate the bag weight limit strings 2026-08-03 21:40:02 +02:00
Maurice e25a1276dc feat(client): let people actually type a bag weight limit
The column, the contract, the API route and its tests have all been there
since v2.9.0 — there was simply no field to enter a limit in, so the only
way to record one was to put it in the bag name, where nothing can act on
it. Both the desktop bag card and the mobile bag row now take a limit in
kilograms (how airlines state them) and store it in grams. Emptying the
field clears the limit instead of writing a zero.

While adding it: the fill-bar formula existed in three places, and the one
in the bag modal had been copied without the limit, so a limit set on a
window narrower than xl did nothing. It now lives once in
packingListPanel.helpers as bagFillPct, next to itemWeight.
2026-08-03 21:40:02 +02:00
Maurice 628f4e78cd fix(server): let a copied packing item keep its weight
createItem never accepted weight_grams or bag_id — not in its signature, not
in the INSERT — so cloneItem had nothing to pass on and every copy arrived
empty. For a group trip that meant re-entering every weight by hand for each
traveller, which is what people were doing.

The bag needs more care than the weight. Bags belong to a trip, but they can
be assigned to a person, so inheriting the original bag would drop your copy
into somebody else luggage and inflate their total. A copy keeps the bag only
when nobody owns it or when the copier is one of its members.
2026-08-03 21:40:02 +02:00
Maurice 4e090411fe fix(server): stop a failed notification import from crashing the run
The fire-and-forget notification sends guard the send() call but not the
dynamic import around it, so a rejected import escapes as an unhandled
rejection. In CI that surfaced as an EnvironmentTeardownError from vacay
after the suite had already passed, failing the run with every test green.

trips, packing and collections already catch on the import chain; collab and
the two vacay sends were missing it.
2026-08-03 20:21:18 +02:00
Maurice 8ab50dcbbb docs(wiki): explain visited versus planned countries in the atlas
The page still described the old behaviour, where every trip counted as a
visit regardless of its dates.
2026-08-03 20:21:18 +02:00
Maurice 6019b3ed4e feat(client): carry the planned/visited split into the mobile atlas
The mobile screen shares useAtlas, so it inherits the switch state; it gets
its own compact toggle next to the bucket-list button. Search now labels a
planned country as such instead of calling it visited, the stats card hangs
the planned count off the country total, and the suggestion list keeps
places you have actually been at the top.
2026-08-03 20:21:18 +02:00
Maurice 558f348881 feat(client): let the atlas hide countries you have not reached yet
Planned countries are off the map by default and come back through a switch
above the globe, drawn dashed so they never read as somewhere you have been.
The switch only appears once there is something planned to show, and it
remembers its state.

Three things that needed care:

The colour palette is built from the visited countries alone. It assigns
colours by index, so letting planned ones join would reshuffle the whole map
every time the switch is flipped.

countryMap decides both the tooltip and what a click does, so it is built
from the visible countries — a hidden country has to fall through to the
mark-as-visited branch or it becomes unclickable.

The region layer read `data` without listing it as a dependency, which meant
region colours already went stale on their own; adding the switch would have
made that visible.

Five copies of the optimistic mark-as-visited update have moved into
withCountryMarkedVisited, which also promotes a planned country instead of
adding it twice. Antarctica joins the continent row when someone has been.
2026-08-03 20:21:18 +02:00
Maurice 97f6a43333 feat(i18n): translate the planned-countries strings
Also adds atlas.antarctica, which CONTINENT_MAP has always known about but
no locale ever had a word for.
2026-08-03 20:21:18 +02:00
Maurice d9778ed7c0 fix(server): keep the passport card in step with the atlas
getTravelStats derives its countries independently of the atlas service, so
without the same date filter the dashboard would have kept counting trips
the map no longer treats as visited — the drift #1490 closed.

Manually marked countries and the hidden-country tombstones are untouched.
2026-08-03 20:21:18 +02:00
Maurice 269bd9140f feat(server): count only started trips as visited in the atlas
Every country now carries the status of the strongest source it came from:
a country reached by both a past and a future trip is visited, one that only
appears in upcoming trips is planned. Marking a country by hand still wins
over everything, since that is a statement of fact rather than a date.

totalCountries drops to the visited ones and totalCountriesPlanned sits
beside it; continents and mostVisited follow the same rule. countries[]
stays a superset of what it returned before, so an older client keeps
painting the map exactly as it did.

The endpoint query has to select trip_id to know which trip a leg belongs
to, which makes its DISTINCT per trip — the coordinates are collapsed again
in JS so the point-in-polygon lookup still runs once per location.
2026-08-03 20:21:18 +02:00
Maurice ddba45ab3e feat(shared): tell visited countries apart from planned ones
The atlas counted every trip as a visit, so a country you had merely booked
a flight to was stamped the same as one you came back from.

tripVisitStatus buckets a trip by its dates: already started (or running)
counts as visited, starting in the future is planned, and a trip with no
dates at all is an idea that must not reach the visit stats. The comparison
is UTC, matching the dashboard and journey lifecycles.

Requested in discussion #1048.
2026-08-03 20:21:18 +02:00
jubnl c42aea4171 Merge remote-tracking branch 'origin/dev' into dev 2026-08-02 15:52:22 +02:00
jubnl 414d967223 docs: regenerate migration-graph after the admin fold
Records the fold and corrects two long-carried errors: there is no admin MCP
surface (the '11 MCP consumers' figure predated the Phase-0 addons extraction)
and systemNotices/conditions.ts was never a consumer. The memories/admin edge is
documented in the right direction for the first time. journeyService and
oauthService now head the frontier.
2026-08-02 15:08:02 +02:00
jubnl 2ac6c76086 chore: move admin backend to proper DI
The 851-line services/adminService folds into the wrapper AdminService over
DatabaseService plus injected Settings/Addons/Passkey/Packing/Auth/Permissions/
Notifications services; the auth, notifications and permissions bridge imports
all become injections, PERMISSION_ACTIONS stays a plain const, and the
mcp/sessionManager deep import keeps its anti-cycle comment. Ahead of the fold
the 11 packing-template functions move to PackingService, which already owned all
three template tables — resolving the admin-2 residual with no bridge. The pure
and module-scoped half moves to admin.helpers.ts, the version cache staying
module-scoped so admin.bridge's instance and the container singleton share one
GitHub fetch. A 1-export admin.bridge.ts serves scheduler.ts's cron, the only
out-of-container consumer. Recipe steps 3 and 4 are no-ops: no src/mcp/tools/
admin.ts has ever existed and the plugin host never imported the domain.

Four lines are non-verbatim, all path re-anchoring one directory deeper, both
resolved paths verified against the emitted dist layout.

admin.dto.ts clears all twenty AdminController allow-list entries, retiring the
three 'enabled must be a boolean' checks plus 'permissions object required' and
'Object body required' for the pipe envelope. The module e2e goes DI-native: the
3-method whole-module mock dies, 6 cases become 15 over real SQL.

Quirk fixes on top, each pinned: the three places toggles go fail-closed with an
append-only backfill migration; the template item routes honour :templateId;
updateUser rejects an empty username/email; createInvite 404s an unresolvable
trip_id; listOAuthSessions guards its scopes parse; GitHub fetches gain a timeout
and size cap and failures cache briefly; updateOidcSettings/updateAddon/updateUser
become transactional; admin.oauth_session.revoke is renamed to match its
siblings; two mutations gain their missing audit rows; getAuditLog drops the
_parse_error sentinel.

Tests move with IDs preserved (ADMIN-SVC-001..069 incl. the pre-existing 029/030
gap and duplicated 069, VNOTIF-001..007), plus ADMIN-BR-001 and ADMIN-SVC-070..076.
2026-08-02 15:07:55 +02:00
jubnl ee13b6325b feat(shared): extend the admin request contracts
Twelve schemas covering every AdminController body, replacing the four that were
imported nowhere. Deliberately permissive wherever AdminService already owns a
bespoke 400 ('Invalid role', 'Name is required', 'Username, email and password
are required'), so those errors survive byte-identically: role is z.string() not
an enum, template name and the user-create fields stay optional. savePermissions
keeps z.unknown() values so unknown levels still land in the 200 response's
skipped list; addon config and the notification-preference matrix stay records so
per-addon keys and plugin channels are not stripped; devTestNotification's inApp
accepts the object form the dev panel actually sends.
2026-08-02 15:07:23 +02:00
Johann Bauer ad7d81fd00 fix(i18n): use mode-agnostic German departure translation (#1765)
'Abflug'/'Abflugzeit' are flight-specific, but the reservations.departureDate/
departureTime keys are shared across all transport types in TransportModal
(flight and train waypoints). Switched to 'Abreise'/'Abreisezeit', which read
naturally for any mode of transport.
2026-08-02 14:44:15 +02:00
jubnl aec2d8cd87 docs: regenerate migration-graph after the notifications fold
Import-scan regeneration: notificationService and the folded
inAppNotifications leave the graph, the notifications cluster is consumed as
plain infra by the DI service, and the unblock cascade over-delivered —
repointing memories' sends erased that cluster's last domain edge, so BOTH
adminService and journeyService join the ready frontier (adminService heads
the order). Adds the dated correction bullet and the 'Quirks fixed after the
notifications fold' section (fixed vs deliberately-preserved).
2026-08-02 11:36:46 +02:00
jubnl 00447c9043 feat(server): validate notification request bodies
notifications.dto.ts wraps the @trek/shared request schemas via createZodDto
and types all five @Body() params; the five NotificationsController entries
leave the body-contract allow-list (ratchet gate enforced). Contract catch:
the client sends server/token: null on test-ntfy to mean 'use the saved
value', so testNtfyRequestSchema gains .nullable() there (spec pinned). The
inline response-enum and url-type checks die — malformed bodies now get the
pipe's standard envelope; valid bodies behave byte-identically. Two
integration cases pinning pre-ratchet tolerances (flat notify_* preferences
body, body-less test-smtp POST) update to the current wire contract.
2026-08-02 11:36:36 +02:00
jubnl d203d8cccd chore: move notifications backend to proper DI
The send() dispatcher (services/notificationService.ts) and the in-app store
(services/inAppNotifications.ts) fold together into the DI-native
NotificationsService over DatabaseService + RealtimeService; the prefs
matrix, transports, channel registry and inAppNotificationActions stay plain
infra modules. A 1-export notifications.bridge covers scheduler, legacy
adminService/memories and the six lazy fire-and-forget sends; AdminController
and the plugin RPC host (24th factory dep) inject. The 5-tool registrar +
notifications-in-app resource move to notifications.mcp.ts. Tests move with
case IDs preserved (NSVC-*, INOTIF-*, + NSVC-020 bridge pin); ~18 suites
repoint path mocks/warm-ups; the module e2e goes DI-native.

Also carries the verified-quirk fixes that would normally trail as a
separate fix(server) commit (file-overlap with the fold): toUtcIso on all
three created_at paths (INOTIF-013/014), respond claims the response CAS
before running the action handler with release-on-failure (INOTIF-011/012),
synology's fire-and-forget send gains .catch, the dispatch-failure log
unwraps Error messages (NSVC-021), and the MCP resource description drops
its false 'unread first' claim.
2026-08-02 11:36:06 +02:00
jubnl c814653f4e fix(server): tidy passkey quirks after the DI fold 2026-08-02 10:28:22 +02:00
jubnl 74be789f22 docs: regenerate migration-graph after the passkey fold 2026-08-02 10:20:38 +02:00
jubnl 5d4b2abd26 feat(server): validate passkey request bodies 2026-08-02 10:16:10 +02:00
jubnl e65f851446 chore: move passkey backend to proper DI 2026-08-02 10:10:33 +02:00
jubnl f40c66da6b fix(server): tidy oidc quirks after the DI fold 2026-08-01 18:36:07 +02:00
jubnl 5da98d0fb9 docs: regenerate migration-graph after the oidc fold 2026-08-01 18:25:51 +02:00
jubnl ec0709741a chore: move oidc backend to proper DI 2026-08-01 18:25:28 +02:00
jubnl d892b61a59 fix(server): tidy auth quirks after the DI fold
Verified defects the relocation carried byte-for-byte, each pinned by a
regression test (AUTH-DB-089..093):

- getTravelStats kept dropping lat/lng of exactly 0 (equator / prime
  meridian) through its falsy check — now explicit != null.
- registerUser's INSERT user -> invite used_count bump -> joinTripAsMember
  ran statement-by-statement; the whole signup now runs in
  db.transaction() so a mid-sequence throw rolls everything back.
- verifyMfaLogin's backup-code burn and last_login/login_count bump now
  commit as one atomic pair.
- deleteMcpToken's revokeUserSessions call is best-effort (try/catch),
  matching the changePassword/resetPassword revocations.
- updateApiKeys' current! non-null assertions became current?. ?? null —
  a user row deleted mid-request degrades to a 0-row UPDATE, not a
  TypeError/500.

Deliberately preserved (documented in migration-graph.md): the two
divergent "Password authentication is disabled" strings and enableMfa's
15-min pending-secret TTL after a wrong code.
2026-08-01 17:46:54 +02:00
jubnl 9bc31e6a1c docs: regenerate migration-graph after the auth fold
Re-ran the three-pattern import scan over server/src: authService row
deleted, thirteen helper rows updated (nine lose their last legacy
importer), oidcService/passkeyService join the ready frontier as
auth.bridge repoints, adminService is blocked on notificationService
alone, atlas.bridge's death recorded, dependency-honest order step 5
ticked, a post-auth-fold corrections entry added (bridge tax held exactly;
the reverse cycle-break, the in-container module fan-out and path-mock rot
are now tracked shapes), and the preserved auth quirks listed as trailing
fix(server) candidates.
2026-08-01 17:40:25 +02:00
jubnl fb87202683 feat(server): validate auth request bodies
DTO ratchet for the auth domain (collections sibling-commit precedent):

- shared/src/auth/auth.schema.ts gains the six missing request schemas —
  mapsKey (nullable: the client clears with an explicit null), apiKeys and
  settings (per-key partials, nullable key values), appSettings (open
  record — the server's ADMIN_SETTINGS_KEYS allow-list and its bespoke
  lockout/self-MFA 400s stay service-owned), mfaDisable, resourceToken
  (the 'Invalid purpose' 400 stays service-owned) — plus spec cases.
- New nest/auth/auth.dto.ts createZodDto wrappers; all 14 raw @Body()
  params typed (9 AuthController + 5 AuthPublicController), validated by
  the global ZodValidationPipe.
- All 14 auth entries removed from body-contract-allow-list.ts
  (PasskeyController's 4 stay until passkeyService migrates).
- auth.e2e mirrors the production pipe and pins the { error: 'field: ...' }
  envelope on a shapeless login body; auth.controller.ts drops its
  duplicated avatarDir const in favour of the auth.helpers export.
2026-08-01 17:40:16 +02:00
jubnl 45564b0a8a chore: move auth backend to proper DI
Fold the 1497-line services/authService.ts into the DI-native nest/auth
domain (Wave-5 chain opener, unblocked by the atlas fold):

- Pure crypto half (backup-code hash/match/generate, stripUserForClient,
  key masking, the import-time DUMMY_PASSWORD_HASH timing equaliser and
  avatarDir mkdir — documented parity exceptions) moves verbatim to the
  plain module nest/auth/auth.helpers.ts.
- DB half folds into AuthService over DatabaseService + injected
  PermissionsService (ex permissions.bridge) + AtlasService (ex
  atlas.bridge, deleted on schedule). mfaSetupPending and the per-email
  reset throttle stay module-scoped so the bridge and container instances
  share them; avatarDir and require('../../../package.json') re-anchor one
  directory deeper — with the two injection swaps, the only non-verbatim
  lines. Controller-facing method surface unchanged.
- New 8-export auth.bridge.ts for out-of-container consumers: mcp/index.ts
  token verification, the journey/notifications/transports registrars'
  isDemoUser, and the still-legacy adminService/oidcService/passkeyService.
- 15 domain *.mcp.ts demo guards now inject AuthService (their modules
  import AuthModule); atlas.mcp.ts alone stays on the bridge because
  AuthService injects AtlasService and the reverse module edge would close
  an AuthModule<->AtlasModule cycle (places.mcp precedent). OidcService and
  PasskeyEnabledGuard inject AuthService too.
- Tests move with case IDs preserved: authService.test.ts ->
  auth.helpers.test.ts, authServiceDb.test.ts -> auth.service.test.ts
  (+ AUTH-BR-001..007 bridge delegation, + AUTH-DB-050..088 pinning the
  previously untested getAppConfig/login/MFA/reset/admin-settings branches
  now under the src/nest coverage gate). auth.e2e converts DI-native (real
  bcrypt, real audit rows); oidc.e2e swaps its dead path mock for an
  instance spy; websocket/integration suites repoint to bridge/helpers.

Quirks preserved byte-for-byte (0-coord drop in getTravelStats,
un-transactioned registerUser/verifyMfaLogin multi-writes, unguarded
revokeUserSessions in deleteMcpToken, current! assertions in
updateApiKeys) — fixes land separately.
2026-08-01 17:39:59 +02:00
jubnl 464fe5ff77 docs: regenerate migration-graph after the atlas fold 2026-08-01 15:50:46 +02:00
jubnl ae3ba18b9e chore: move atlas backend to proper DI
Fold the 1612-line services/atlasService.ts into the DI-native AtlasService:
the stats aggregation, visited countries/regions with the #1490 tombstone and
cascade logic, and the bucket-list CRUD become methods over the injected
DatabaseService. The ~750-line pure-geo half — the bundled admin0/admin1
boundary stores with their #1576 streaming builders, the point-in-polygon
indexes, Nominatim geocoding with its shared throttle, and every module-scoped
cache including the import-time unref'd cleanup interval — moves verbatim to
the plain module atlas-geo.ts so the caches stay process-global across the
container instance, the bridge instance and test helpers (assetPath
re-anchored one directory deeper, the only non-verbatim line).

The 10-tool mcp/tools/atlas.ts registrar plus all four atlas resources in
mcp/resources.ts move onto the decorator registry as atlas.mcp.ts — the when:
atlas-addon gate is parity here (legacy tools and resources both gated; the
REST controller deliberately does not). resources.test.ts retires with its
last two cases, which move to tools-atlas-expanded.test.ts. The plugin RPC
host swaps its 9 atlas imports for the injected AtlasService (23rd constructor
dep); a minimal 2-export atlas.bridge.ts serves the one legacy consumer,
authService.getTravelStats, and dies when authService migrates.

Adopts atlas.dto.ts over the existing shared schemas and clears all three
AtlasController entries from body-contract-allow-list.ts: the hand-rolled
'name and country_code are required' 400 becomes the ZodValidationPipe
envelope (todo/places trade) while the whitespace-only bucket name keeps its
legacy 'Name is required' trim guard.

Fixes the verified defects the relocation had faithfully carried, each with a
regression test (ATLAS-SVC-031..036 plus two MCP casing cases): the four
multi-statement mark/unmark writes now run in db.transaction() (the region
cascade nests as a savepoint), the trip-less countryPlaces early return
honours manually_marked, the '|| null' bindings that dropped lat/lng 0 and
empty-string notes on bucket update become '?? null', the mutating bucket SQL
is user-scoped, and the MCP region/country-places tools uppercase their codes
to match REST.

Quirks deliberately preserved: the detached GET-path place_regions backfill
writes, getStats' two divergent return shapes, the import-time cache-cleanup
interval, getCountryFromAddress's unvalidated bare 2-letter codes, and the
whitespace-only-name silent no-op on bucket update.

Tests move with their case IDs: atlasService.test.ts becomes
unit/nest/atlas.service.test.ts (pure-geo imports from atlas-geo, DB calls
through the constructed service, plus bridge-delegation cases), the atlas e2e
converts to the DI-native pattern (real SQL against the full temp-db schema),
and the plugin-host path mock becomes a constructor stub.
2026-08-01 15:50:36 +02:00
jubnl c78134831f fix(server): tidy collections quirks after the DI fold 2026-08-01 14:03:05 +02:00
jubnl 79c20c07c8 docs: regenerate migration-graph after the collections fold 2026-08-01 13:54:36 +02:00
jubnl d11b7bb054 feat(server): validate collections reorder and delete-many bodies 2026-08-01 13:54:36 +02:00
jubnl cab49ea511 chore: move collections backend to proper DI 2026-08-01 13:54:19 +02:00
jubnl 4f8300e2ba fix(client): fall back to scheduled stop times in transit reservations 2026-08-01 12:49:22 +02:00
jubnl 2b03f9a024 fix(server): tidy transit itinerary quirks after the DI relocation 2026-08-01 12:49:22 +02:00
jubnl e80ae79b77 chore: move transitItineraryService into nest/transit as pure helpers 2026-08-01 12:45:35 +02:00
jubnl 57cc676e8a test(uploads): pin the uploads subdir list across Dockerfile and boot
The set of uploads subdirectories is hand-mirrored between the image build
and the boot-time mkdir in server/src/index.ts, and drift is invisible
until a user whose bind-mounted uploads dir is not writable by node hits an
EACCES on the first upload to the missing dir (#1762).

Parse both lists and assert they match, with a guard case so the regexes
cannot silently match nothing and pass vacuously.
2026-07-31 15:41:02 +02:00
jubnl 1fb6d19f6f fix(uploads): pre-create journey and places upload dirs
Both were created lazily on the first upload, which needs write permission
on the uploads mount point itself at request time. On hosts whose
bind-mounted uploads dir is not writable by the runtime user, every feature
writing into an already-existing subdir keeps working while journey photo
and place image uploads fail with EACCES (#1762). Creating them in the
image and at boot turns that into one loud startup failure instead of a
stray 500 on upload.

Also drop the recursive chown over all of /app from the final layer: it
copied up every inode it touched and duplicated node_modules, dist and the
client bundle into a 518 MB layer. The chown now rides the layer that
installs node_modules, later copies carry --chown=node:node, and the last
layer chowns only the paths it creates. Everything under /app stays
node-owned; image size 2.36 GB -> 1.68 GB.
2026-07-31 15:40:24 +02:00
jubnl f02f255016 fix(places): cap the Naver list-import response like the Google one
The place fold capped the Google list-import body at 8 MB but left the Naver
importer reading its pages unbounded — same attacker-influenced URL (the folder
id), and worse in practice because that path is a pager that buffers a fresh
body on every iteration.

fetchPage now checks the declared content-length before reading and the actual
length after, returning the existing 'Failed to fetch list from Naver Maps' 502
on either. Reading the body as text and parsing it explicitly (rather than
apiRes.json()) is what makes the post-read check possible; the malformed-payload
branch keeps its 'Invalid list data received from Naver Maps' 400 because the
JSON.parse stays inside the same try.

The post-read check is the one that matters for a hostile server: a chunked
response carries no content-length, so the declared check alone is evadable.
Covered for both providers now — PLACE-SVC-073b (Google, chunked) and
PLACE-SVC-074..077 (Naver: declared, chunked, malformed, happy path).

The five Naver fetch mocks in the integration suite move from json() to text().
2026-07-30 17:53:48 +02:00
jubnl 4f0f80ad69 chore: move place backend to proper DI
Fold the 1029-line services/placeService.ts into the DI-native PlacesService:
the CRUD + ratings SQL, the GPX/KML/KMZ importers and the Google/Naver list
importers become methods over the injected DatabaseService, byte-identical
statements, COALESCE semantics and the If-Match conflict protocol (#1135).
The pure half — frozen XML parsers, the KMZ unpacker, the dedup predicates,
the Google hex-id parsers, reclaimPhotoCache — moves to places.helpers.ts
(maps.helpers precedent).

The 10-tool mcp/tools/places.ts registrar plus the trek://trips/{tripId}/places
resource move onto the decorator registry as places.mcp.ts. search_place travels
with them because its gate is places:read, not the read-only geo group, and now
injects MapsService. TripsService, DaysMcp, BookingImportService and the plugin
RPC host (PluginHostDepsFactory's 21st constructor dep) all inject PlacesService,
so the domain needs no bridge at all. The two assignments.bridge imports that
places.mcp.ts keeps are a deliberate cycle break: AssignmentsModule imports
DaysModule and DaysModule now imports PlacesModule for days.mcp.ts's place
creation, so injecting AssignmentsService there would close
DaysModule -> PlacesModule -> AssignmentsModule -> DaysModule. Same seam
reservations.mcp.ts uses, same trade trips.bridge.ts documents.

Also folds services/placeEnrichment.ts in — its DB/websocket/Maps half becomes
PlacesService methods over the injected DatabaseService/RealtimeService/
MapsService, its pure match selector joins places.helpers.ts — which retires
nest/maps/maps.bridge.ts with its last consumer.

Adopts places.dto.ts for the seven grandfathered body contracts and clears every
PlacesController entry from body-contract-allow-list.ts. Two consequences worth
knowing: the bespoke 400s 'Place name is required', 'ids must be an array of
numbers' and 'URL is required' are now the ZodValidationPipe envelope, and
because a pipe runs before the handler a malformed body 400s ahead of the
trip-access 404 it used to follow — the same trade the todo and trips migrations
took. placeBulkUpdateRequestSchema.ids drops .min(1) so the endpoint's
empty-list short-circuit stays reachable.

Fixes four defects the relocation had faithfully carried, each with a
failing-first regression test:

- create dropped lat/lng of exactly 0, so a place on the equator or the prime
  meridian lost its coordinates (`x || null` cannot tell absent from zero).
- duration_minutes: 0 was unsettable — replaced by the 60-minute default on
  create, and read as "absent" by COALESCE on update.
- the journey delete hook fired on unscoped ids: DELETE /:id, POST /bulk-delete
  and the delete_place MCP tool could detach another trip's journey entries for
  an id they then refused. All three scope first via the new
  PlacesService.scopedIds, mirroring the guard PluginHostDepsFactory already had.
  bulk_delete_places also moves its hook ahead of the DELETE —
  journey_entries.source_place_id is ON DELETE SET NULL, so running it afterwards
  left the entries as orphans.
- the place search treated % and _ as LIKE wildcards; a bare % returned the whole
  trip. The three clauses now carry ESCAPE with the term escaped.

Plus hardening on the Google list fetch: the response is capped at 8 MB
(declared content-length and post-read length, the transit.service precedent)
and its JSON.parse is guarded, so a malformed provider payload produces the
existing 'Invalid list data received from Google Maps' 400 instead of throwing.

Quirks deliberately preserved: the non-COALESCE route_color (#776),
currency/transport_mode staying unclearable, importGpx returning null for a file
that parses but yields no usable geometry, update skipping the If-Match check
when the stored updated_at is null, and every string-valued `x || null`.

Tests move with their case IDs: placeService.test.ts and the wrapper suite merge
into unit/nest/places.service.test.ts, kmzUnpack into places.helpers.test.ts,
placeEnrichment into places.enrichment.test.ts. The places e2e converts to the
DI-native pattern (real SQL against temp-db DDL), tools-places.test.ts is kept
and extended so it exercises the decorator path through the test registry, and
the plugin-host and booking-import path mocks become constructor stubs.
2026-07-30 17:07:12 +02:00
xthephreakx 57017b8aed fix(navbar): prevent centred tab pill overlapping actions on tablet (#1747)
The desktop navbar's centred tab pill is absolutely positioned, so on the
md-range (tablet, ~768-1024px) its right edge slid underneath the right-hand
action cluster (notification bell + user menu) once the tab text labels made it
wide enough.

Hide the tab text labels below the lg breakpoint (icon-only on tablet, icon +
label on desktop ≥1024px), which keeps the pill narrow enough to clear the
actions. The icons gain title/aria-label so they stay accessible without text.

Co-authored-by: phreaky <phreakydev@gmail.com>
2026-07-29 22:09:40 +02:00
jubnl cd67074e0b Merge remote-tracking branch 'origin/dev' into dev 2026-07-29 21:40:35 +02:00
jubnl 4e0ac74fcb chore(deps): bump @modelcontextprotocol/sdk to ^1.30.0
Security fixes in the MCP SDK.
2026-07-29 21:40:05 +02:00
jubnl c7760cb823 docs: regenerate the legacy-services dependency graph after the transit fold 2026-07-29 21:40:05 +02:00
jubnl f4bf7cf3dc chore: move transit backend to proper DI
Includes the quirk-repair pass (upstream timeout + size cap, LRU cache, NaN-duration guard) that maps landed as a sibling fix commit.
2026-07-29 21:39:51 +02:00
Maurice 129f952db4 fix(pdf): stop a flowing day stranding its header at the foot of a page
Keeping a stay whole moved its cards to the next sheet, and the header bar
stayed behind on the old one — empty, above a stretch of white, with the
same day printed again overhead. The repeat itself is the continuation
marker from #1471; what was wrong is that there was nothing left to
continue from.

break-after on the thead and break-before on the tbody are both ignored by
Chromium here, measured rather than assumed. Holding the whole day together
is what works: a day that fits moves as one, and a day too long for a page
still breaks and still repeats its header. Scoped to the flowing layout,
since a day that starts its own page can never strand one.
2026-07-29 21:32:39 +02:00
Maurice eb817dea65 fix(pdf): keep a stay whole across a page edge
An accommodation could be cut in half by a page break — the check-in time
at the foot of one sheet, the hotel name and address at the head of the
next, under a repeated day header that read like a duplicate day. Place and
note cards have carried break-inside: avoid all along; this one never did.

It could always happen to a day that overflowed its page. Letting the days
flow means content meets a page edge far more often, which is how it turned
up. Printing the same trip through Chromium: two torn stays before, none
after.
2026-07-29 21:32:39 +02:00
Maurice 5dbb2db3ee fix(planner): keep the PDF tooltip inside the window
The export button is the one toolbar button with a hand-rolled tooltip
instead of the shared one. It was anchored with `right: 0` and never
wrapped, so its label grew leftwards out of the day plan — the leftmost
pane — and off the window.

The shared tooltip is portalled and clamped to the viewport, which is what
every neighbouring button already uses. The hover state it needed was
threaded down from DayPlanSidebar and has no other reader, so it goes too.
2026-07-29 21:32:39 +02:00
Maurice be5b48532d fix(pdf): dress the layout switch like the rest of the app
The preview shipped a bare checkbox, which is not what a toggle looks like
anywhere else in TREK. The overlay is imperative DOM, so the switch from
Settings/ToggleSwitch cannot be rendered into it — as static markup it would
carry no behaviour — but its geometry and its tokens can be, and the control
now reads as the same one.
2026-07-29 21:32:39 +02:00
Maurice 03be438e0b feat(pdf): make the page break between days optional
Every day started a new page, so a plan of short days printed one sheet per
handful of lines and looked nothing like the view it was exported from.

The preview now carries the choice, next to Save as PDF, and remembers it.
The two layouts differ by a single class on <body>, so ticking the box
re-lays the open preview out immediately instead of rebuilding the document
and re-fetching every photo.

Breaking per day stays the default — this only adds the other option.
2026-07-29 21:32:39 +02:00
Maurice dc4ccb2304 chore(i18n): add the PDF page break label 2026-07-29 21:32:39 +02:00
jubnl a7cb4eb880 chore: point MCP.md to the wiki 2026-07-29 21:24:57 +02:00
jubnl 09f7cc4811 fix(maps): repair the quirks preserved by the DI fold 2026-07-29 20:27:30 +02:00
jubnl 74e1e444c5 docs: regenerate the legacy-services dependency graph after the maps fold 2026-07-29 20:21:10 +02:00
jubnl 4706112516 feat(server): adopt zod DTOs for the maps body contracts 2026-07-29 20:21:09 +02:00
jubnl 2e40c19249 chore: move maps backend to proper DI 2026-07-29 20:20:57 +02:00
Maurice 397f504d01 fix(planner): reach the day reorder popup with a finger too
The popup is a modal, so it portals out of the sidebar and misses the
opt-in its rows sit inside everywhere else. Its rows already carried a bare
draggable attribute, which on a tablet meant a long press selected the row
label and nothing moved.
2026-07-29 19:15:57 +02:00
Maurice edea67ff7e fix(planner): let tablets drag places into the day plan (#1616)
Dragging a place from the right pane to the left one did nothing on an
iPad or a Samsung tablet — the row was selected as text instead. Phones
never reach this layout, they get the mobile shell below md, so the coarse
pointer check in dragDisabled was only ever switching tablets off.

It was also doing that for a reason that had gone away. What swallowed the
scroll gesture in #1432 was the drag-drop-touch polyfill, and that has been
confined to hybrid laptops since. Width is the gate that still means
something: below lg the plan and the places are separate tabs, so there is
nothing to drag between.

So touch is no longer a reason to disable the drag, and the planner arms
the long-press bridge instead where the pointer is coarse. Rows get
-webkit-touch-callout and user-select off there too, otherwise the press
that starts the drag brings up the selection first. The isTouch prop had no
other use and is gone, along with a comment claiming arrow reorder buttons
take over on touch — there are none.
2026-07-29 19:15:57 +02:00
Maurice e7ccbcdf86 feat(planner): add a long-press touch drag bridge
A finger never starts an HTML5 drag on Android, and the answer used to be
drag-drop-touch: a document-wide polyfill that turned every touch over a
draggable element into a drag. That cost the places list its scrolling
(#1432) and the map its one-finger pan (#1440), so it now only loads on
hybrid laptops.

This is the narrow version. It only watches touches that begin on a
draggable row inside a container that opted in, and it waits out a long
press before claiming the gesture, so a swipe is still a swipe. Once armed
it replays the real drag sequence on the element under the finger, which
leaves every existing drop handler working as it is.

Nothing is wired up to it yet.
2026-07-29 19:15:57 +02:00
Maurice 98ad2450a2 feat(db): make the SQLite journal mode configurable (#1675)
WAL was hardcoded, and on network-backed storage — Azure App Service Linux,
SMB/NFS volumes on NAS and PaaS — SQLite documents it as unsafe, because the
coordination runs through the -shm file and mmap. Operators had no way out.

TREK_DB_JOURNAL_MODE and TREK_DB_SYNCHRONOUS now set both pragmas, defaulting
to today's behaviour. The mode lives in the file header, so all three
processes that open travel.db read the same configuration — the server,
reset-admin.js and migrate-encryption.ts, which used to hard-set WAL and would
have silently reverted a deliberate DELETE setup on the first key rotation.

An invalid value falls back and warns rather than refusing to start, and the
mode that actually took effect is read back and logged, so an operator can see
whether the setting landed.
2026-07-29 17:02:56 +02:00
Maurice e62dceb4bb fix(backup): snapshot the database instead of archiving it live (#1675)
The scheduled backup archived travel.db directly. The archiver reads its
entries during finalize(), so a WAL checkpoint writing pages back mid-stream
tore the copy — and the -wal that would make it recoverable isn't in the zip.
The manual path was moved to VACUUM INTO for exactly this reason; the
scheduler never followed. It calls the same createBackup() now, which also
gets it the encryption key and plugin data it was missing, so a scheduled
backup is finally restorable onto another install.

The pre-rotation copy in migrate-encryption.ts had the same problem: a plain
file copy of a live WAL database, and it is the operator's only rollback if a
key rotation goes wrong.

Auto-backups now exclude the re-derivable photo caches, matching manual ones.
2026-07-29 17:02:56 +02:00
Maurice 14bc41f2cf chore(i18n): drop the unused map template placeholder key
Left behind by the tile URL change (#1733): all four call sites use the
.select variant, the plain key had no consumer.
2026-07-29 15:14:21 +02:00
Maurice f35c3f31e2 fix(transit): prefer displayName for the line identifier (#1715)
Transitous exposes routeShortName, which many operators — German long
distance among them — do not fill. displayName carries the identifier
passengers actually see, with routeShortName as the fallback.
2026-07-29 15:14:21 +02:00
Maurice 4f99834965 test(admin): stop the plugin sheet test racing its own Escape
MSheet binds the Escape listener in an effect, so a key fired the moment the
sheet content appears can land before the listener exists. Under a loaded
full-suite run that made the test fail on its own timing. It retries until
the sheet is gone rather than pressing once.
2026-07-29 15:14:21 +02:00
Maurice 8b7e2ddaa5 fix(places): evaluate open/closed in the place's timezone (#1680)
The ring was checked against the viewer's clock, so a trip in Europe planned
from Singapore showed the wrong state. It evaluates in the timezone of the
place now, from Google's structured opening periods rather than the localised
description text — parsing display strings breaks as soon as the account
language is not English. Midnight-spanning hours, closed days and 24/7 are
covered.
2026-07-29 15:14:21 +02:00
Maurice 724b52aa92 fix(plugins): broadcast itinerary.unassign to open sessions (#1705)
assign broadcast, unassign did not, so the place stayed on the Plan tab until
a manual refresh. It emits the same event as the REST route now. createPlace,
updatePlace and deletePlace in the same file had the identical gap.
2026-07-29 15:14:21 +02:00
Maurice aaef3e3e76 fix(reservations): honour the 24h time setting (#1725)
The 12h/24h rules lived in three copies and two of them ignored the setting.
They are one function now. splitReservationDateTime also truncated the
meridiem instead of converting it, so 3:00 PM became 3:00 — three in the
morning. Values already stored that way normalise when a form renders the
field, without changing the payload or the stored format.
2026-07-29 15:14:21 +02:00
Maurice 9ab9665e99 fix(llm-parse): decode .eml as MIME before extracting text (#1724)
Attachments were treated as ready-made HTML, but a real mail is multipart
with base64 or quoted-printable transfer encoding. What reached the model was
the plaintext headers followed by a wall of base64, truncated before any
content — which is why a sender name from the Subject line still showed up
while the booking never did.

The html part is preferred, plain text is the fallback, transfer encoding and
charset are decoded and entities resolved before the text goes out. No new
dependency. If no text part is found the previous raw path still runs.
2026-07-29 15:14:21 +02:00
Maurice 50600b74cd fix(maps): stop the photo endpoint answering 404 for photo-less places (#1727)
Opening one trip fires a 404 per place without a photo, 30 to 60 of them from
one IP in seconds. CrowdSec and fail2ban read that as scanning; the reporter
had three users banned within five minutes. A place having no photo is not an
error and no longer answers as one.

OSM places were also sent to Google as node:/way:/relation: ids, which can
only return 400 INVALID_ARGUMENT — a billed round-trip and about 1.3s per
place. getPlacePhoto already guarded coords: this way; the OSM prefixes now
get the same treatment, as does getPlaceDetailsExpanded, which had no guard.

The negative cache separates a place having no photo from a failed provider
call instead of expiring both after five minutes and replaying the storm.
2026-07-29 15:14:21 +02:00
Maurice 9ecca404c5 fix(map): drop the retired OpenStreetMap tile subdomains (#1733)
The a/b/c shards stopped being necessary in 2022 and d has since lost its
DNS record. The map never asked for d — Leaflet defaults to abc — but the
offline prefetcher rotated over a to d, which is where the failing requests
came from, and it cached most tiles under a host the map never requests.

Presets and the prefetcher use the apex host now. A URL already saved in
user_settings is rewritten where it is read and written back, so existing
instances heal without a migration touching what someone typed themselves.

The CSP only allowed https://*.tile.openstreetmap.org, and a wildcard host
never matches the apex domain, so the prefetch fetch would have been blocked.
The service worker rule had the same gap and would have stopped filling the
tile cache.
2026-07-29 15:14:21 +02:00
Maurice f013c4964c chore(client): set every coverage threshold to 85
Treats the gate as a floor rather than a target — the suite runs well above it,
and a PR that legitimately trades a few points should not have to move the
numbers to land.
2026-07-29 12:56:17 +02:00
Maurice 72a82b3c44 fix(client): work through the findings from the coverage pass
214 items came out of writing the tests. Real defects are corrected, dead code
is removed rather than covered, and the tests that pinned the old behaviour
move with them.

Also drops two agent coverage scratch files that slipped into the coverage PR
and adds the ignore rule that keeps them out.
2026-07-29 12:35:00 +02:00
Maurice a4bf815d24 test(import): read the upload body instead of parsing it as FormData
These are the only two test files that call request.formData() inside an MSW
handler, and they hold exactly the three tests that failed on CI while passing
locally. The body axios' XHR adapter produces under jsdom is one Node 24's
multipart parser rejects, so the handler threw before reading a field and the
component reported a failed upload. Reading the raw payload asserts the same
thing without depending on the parser.
2026-07-29 11:55:31 +02:00
Maurice 1d9fe9a3df test(import): stop the upload fixtures lying about their size
The helpers built a one-byte File and then redefined size to 100, so the
multipart body and the length it announced disagreed. Node 24 rejects that
while 22 lets it through, which is why only CI saw it: every test that
uploaded such a file and expected success failed, the one expecting an error
passed for the wrong reason, and the KML case with a truthful File was fine.
2026-07-29 11:55:31 +02:00
Maurice be0164eb91 ci: run the client gates alongside the suite instead of in front of it
The client suite is ~11 minutes now. With typecheck and lint sitting in front
of it in the same job, a single lint finding threw away the whole test signal
for that run — twice today. Both jobs pay the install and the shared build,
which is cheap next to running the two in series.
2026-07-29 11:55:31 +02:00
Maurice 16922766e5 test: give async assertions a realistic budget on CI
waitFor and findBy* default to 1s. That holds on a dev machine but not on a
4-core runner executing 527 test files in parallel forks, where three import
dialog tests timed out waiting for their upload to settle.
2026-07-29 11:55:31 +02:00
Maurice 99816bff4c test(costs): escape the BOM in the CSV export assertion
A literal U+FEFF inside a regex trips no-irregular-whitespace, which is an
error rather than a warning, so the client lint job failed. The escape is
equivalent.
2026-07-29 11:55:31 +02:00
Maurice 02ad6c3d16 test: close the remaining gaps and raise the thresholds
Pushes lines from 95.2% to 98.1% and branches from 85.2% to 89.9%, mostly
in the release-notes modal, the reservation overlay, the journey entry sheet
and the trip dialogs.
2026-07-29 11:55:31 +02:00
Maurice b80bd3992b test: pick up the rest 2026-07-29 11:55:31 +02:00
Maurice 1e66bcc701 test(pages): cover the page hooks, stores and the api client 2026-07-29 11:55:31 +02:00
Maurice bbb0f93af2 test(components): cover map, collections, settings, journey and admin 2026-07-29 11:55:31 +02:00
Maurice a3b263017f test(planner): cover the day plan sidebar and the planner dialogs 2026-07-29 11:55:31 +02:00
Maurice 12558fe9c4 test(mobile): cover admin, settings and the remaining screens 2026-07-29 11:55:31 +02:00
Maurice c071901e55 test(4-0): cover the KEINE ANGST show 2026-07-29 11:55:31 +02:00
Maurice c884ddf527 test(mobile): cover the trip screens 2026-07-29 11:55:31 +02:00
jubnl 287dceb532 chore: wire TripsService into the plugin RPC host and retire the legacy tripService
Swap the factory's six tripService imports for the injected TripsService; delete services/tripService.ts and the collab/vacay bridges with it; prune the days/budget bridges to their surviving exports; update the migration docs and regenerate migration-graph.md.
2026-07-29 01:53:54 +02:00
jubnl 530c7cb005 chore: move trip backend to proper DI
Fold the legacy tripService SQL into TripsService; port the trips MCP registrar/resources/trip-summary prompt to trips.mcp.ts and the share-link tools to share.mcp.ts (predicate access for the trips:delete/trips:share/canReadTrips gates, deprecation notice via the attach ctx); adopt trips.dto.ts and clear the seven allow-list entries; delete todo/share bridges with their last consumers. Includes the trailing quirk fixes: transactional deleteTrip/deleteGuest, owner display_name in listMembers.
2026-07-29 01:53:40 +02:00
jubnl a3c2cfa4b2 fix(shared): tighten WS payload schemas after the MCP drift fixes
assignment:moved requires newDayId again; assignment:reordered and
day:deleted drop their drift-union variants now that every emitter
sends the canonical shape.
2026-07-29 00:34:58 +02:00
jubnl 23486f841f fix(server): align MCP assignment/day WS payloads with the REST shapes
move_assignment omitted newDayId (moved assignments vanished from
collaborator views), reorder_day_assignments sent assignmentIds where
the client reads orderedIds (reorders emptied the day remotely), and
delete_day sent { id } where the client reads dayId (days never
disappeared). Registry drift findings from the WS contract work.
2026-07-29 00:34:53 +02:00
jubnl a8a5f7e611 chore: update sonar props 2026-07-29 00:24:26 +02:00
jubnl a2011dc991 feat(client): handle WS events against the shared registry
remoteEventHandler's two switches become enumerable lookups keyed by
TrekWsTripEventName (bodies unchanged); WebSocketEvent.type derives
from the registry. wsEventPolicy.ts makes the client's non-reactions
explicit (HANDLED_OUTSIDE_TRIP_STORE + IGNORED_WS_EVENTS) and the
registry-parity test turns an unclassified server event into a red
test instead of silence. Zero behavior change.
2026-07-29 00:17:47 +02:00
jubnl f66f8d8bd9 feat(server): type RealtimeService against the event registry
broadcast/broadcastToUser gain registry-typed overloads (event name
must be a TREK_WS_EVENTS key, payload must match its schema) with the
plugin: namespace as the deliberate escape hatch; the 13 service
wrappers forward the generic signature. Compile-time only — the
runtime pass-through and all call sites are unchanged.
2026-07-29 00:16:08 +02:00
jubnl c0baaeb898 feat(shared): WS event contract registry
TREK_WS_EVENTS: 65 trip-scoped + 29 user-scoped events with Zod
payload schemas transcribed from the emitting call sites; derived
name unions and payload types; plugin: namespace reserved as the
escape hatch. 8 payload-drift shapes modeled as unions and flagged.
2026-07-29 00:15:56 +02:00
jubnl a2861d40f3 feat(server): injectable RealtimeService over the ws singleton
Nest services inject RealtimeService (call-time delegation to the
websocket module, DatabaseService pattern) instead of importing
broadcast/broadcastToUser globals. Zero behavior change; test edits
are constructor-arg additions plus RealtimeModule in e2e harness
imports only.
2026-07-29 00:07:51 +02:00
jubnl 80f3958569 fix(server): silence expected-error noise in test stderr 2026-07-28 22:31:46 +02:00
jubnl c7dc38a5b9 fix(server): close the notification-send teardown race in tests 2026-07-28 22:31:40 +02:00
jubnl 65fb00d63b feat(server): type MCP access groups against SCOPES + boot gate 2026-07-28 20:56:43 +02:00
jubnl 59ea420f1d feat(nest-mcp): host-typed access groups + entry validation hook 2026-07-28 20:56:29 +02:00
jubnl ee2ea1d065 refactor(server): extract addon enablement into the addons domain 2026-07-28 20:22:01 +02:00
jubnl cafff003a3 refactor(server): move instance-URL resolution to app-config 2026-07-28 20:21:09 +02:00
Maurice 292f1b181c Document track colours in the wiki
Map-Features had no GPX section at all, so the tracks paragraph is new
rather than an addition.
2026-07-28 07:25:36 +02:00
Maurice 2b8b55aa8f Translate the track colour strings 2026-07-28 07:25:36 +02:00
Maurice 502f604ca2 Reach the track colour from the mobile sheet
MPlaceSheet had no relation to route_geometry at all, so the section is
new. It saves through the single-field patch the sheet already uses for
the image, which keeps it clear of the edit form and its prefill.

The browse list gets the same colour stroke as the desktop sidebar. It
matters more here: the mobile map is full-bleed with no sidebar next to
it, so without the stroke a coloured line has nothing to tie it to.
2026-07-28 07:25:36 +02:00
Maurice 3c575a06f4 Add the track colour picker to the place inspector
New shared component rather than a ninth copy of the swatch grid that is
already inline in eight places. Selection is drawn with var(--accent) so
it follows whatever accent the user runs.

The custom-colour cell listens for the native change event instead of
React's onChange: for a colour input React maps onChange to input, which
fires on every drag inside the OS picker — dozens of writes, each one a
409 candidate against the base-version header. The auto cell previews the
colour you would get back, not the one currently set, which is a
different value as soon as anything is picked.

The row sits next to the track stats block, not inside it: that block
returns null on unparsable geometry and the colour control has no
business disappearing with it. The elevation chart and the distance pin
follow the track colour too, otherwise the panel and the map read as two
unrelated objects.

In the places list the track icon becomes a stroke in the colour the line
is drawn in. The map has no legend of its own, so that stroke is the only
thing tying a line back to a row. Undo after deleting a track now
restores its geometry and colour instead of bringing back a bare point.
2026-07-28 07:25:36 +02:00
Maurice fcabb4c645 Draw tracks in their own colour on both map engines
The MapLibre layer was already data-driven, so it only needed the feature
property filled. Leaflet needed more work: the GPX polyline passed its
style as bare color/weight props, and react-leaflet only calls setStyle
when the pathOptions reference changes — a recoloured track would have
kept its mount-time colour until the next reload. Every other vector
layer in here already uses pathOptions.

A track with a colour gets a white casing so it survives satellite
imagery and dark basemaps. The casings live in their own pane below the
lines: Leaflet stacks paths in insertion order, so drawn inline they end
up over the line of a track that was added earlier. They are always
mounted and hidden by opacity, otherwise giving a track a colour remounts
the path into the wrong position.

Tracks are clickable now — 3.5px is not a target, so the hit area is a
transparent 14px line on top. bubblingMouseEvents is off on it: paths
bubble to the map by default (markers do not), and the map's own click
handler would clear the selection the track just made. The GL side needs
the mirror image of that, a guard so a click on a cluster bubble over a
track still belongs to the cluster.
2026-07-28 07:25:36 +02:00
Maurice 1b5317a817 Give GPX tracks their own colour (#776)
Imported tracks all render in the same blue: the GPX/KML importers never
assign a category, so category_color is null for every one of them and
several walks in the same area are impossible to tell apart.

Adds a nullable route_color on places, kept out of the COALESCE group in
updatePlace so an explicit null can reset a track back to inheriting its
category colour. The hex is validated in the Nest controller rather than
in the shared contract: the update body is an open record on purpose, and
tightening it would mean removing PlacesController.update from the
ratchet-only body-contract allow-list.

The importers now hand each new track a colour of its own, picked from
the palette entries the trip is not already using. That happens in the
Nest service over the injected DatabaseService — the colour assignment is
new logic, and new logic does not belong in the legacy place service (see
server/src/nest/README.md). The field itself is only threaded through the
existing update/create statements, which the proportionality rule covers.

Also fixes duplicateTrip, whose explicit column list has been silently
dropping route_geometry all along, so a duplicated trip lost its tracks.
2026-07-28 07:25:36 +02:00
jubnl 351b5fb4ec fix(server): fix the quirks preserved by the budget DI migration 2026-07-28 00:04:50 +02:00
jubnl 40f3103c04 docs: tick budgetService in migrate.md and refresh the migration graph 2026-07-27 23:48:23 +02:00
jubnl cf1b9e4c06 feat(server): validate budget request bodies with shared Zod DTOs 2026-07-27 23:48:23 +02:00
jubnl 7f34dadf70 chore: move budget backend to proper DI 2026-07-27 23:48:05 +02:00
jubnl b0041f34ba fix(server): fix the quirks preserved by the exchange-rates DI migration
- the Frankfurter fetch now carries an AbortSignal timeout (10s) and a 1 MB
  response-size cap, and the body is boundary-validated through a type guard
  instead of an as-cast
- fetch failures are logged instead of silently swallowed (callers still get
  the graceful null)
- the inflight-coalescing cleanup moved into .finally so a rejected fetch
  can no longer leak the key
- the dead convertWithRates export (zero call sites) is dropped from the
  service and the bridge

Kept on purpose: the beyond-TTL stale-cache fallback, the >1-keys
empty-response heuristic and the || falsy-coercion defaults.
2026-07-27 23:03:59 +02:00
jubnl 166fe798a7 docs: tick the exchangeRateService fold in migrate.md and refresh the migration graph 2026-07-27 22:58:14 +02:00
jubnl a3e6eb52a4 chore: fold exchangeRateService into the nest budget domain 2026-07-27 22:58:09 +02:00
jubnl 132eb2035f fix(server): fix the quirks preserved by the permissions and auditLog DI migrations
Permissions: stored levels are validated at load (corrupt/out-of-range rows
fall back to the default consistently across all readers), the load-error
swallow is narrowed to missing-table (anything else logs), and an
all-skipped save no longer opens a transaction or flushes the cache.
The module-scoped cache stays by design (shared DI+bridge invalidation),
and the checkPermission 'admin' case stays — it is reachable (correct deny
for non-admins on admin-level actions), not dead code.

Audit: data/logs creation is lazy (no import-time disk side effect; the
LOG_LEVEL freeze remains — tests/setup.ts timing contract), the log helpers
gate on a real severity threshold (error < warn < info < debug; prod
default 'info' unchanged), file-IO failures leave a console.error trace
instead of vanishing in bare catches, and resolveUserEmail treats only
null/undefined as anonymous so a real id 0 resolves via the DB.
2026-07-27 22:34:41 +02:00
jubnl 2944cd32a1 docs: tick permissions and auditLog in migrate.md and refresh the migration graph 2026-07-27 22:09:08 +02:00
jubnl 6ff936c613 chore: move auditLog backend to proper DI 2026-07-27 22:09:08 +02:00
jubnl 1d56de3897 chore: move permissions backend to proper DI 2026-07-27 22:08:37 +02:00
jubnl 55cb2148d4 fix(server): fix the quirks preserved by the day DI migration 2026-07-27 20:58:03 +02:00
jubnl c276e3a79a docs: tick dayService in migrate.md and refresh the migration graph 2026-07-27 20:57:18 +02:00
jubnl 2d1eec76c3 chore: move day backend to proper DI 2026-07-27 20:57:01 +02:00
jubnl e62628bf93 feat(server): adopt zod DTOs for the day and accommodation body contracts 2026-07-27 20:56:45 +02:00
jubnl d952eaf4d4 fix(server): fix the quirks preserved by the reservations DI migration
Two verified defects carried through the parity fold:

- The create-path accommodation metadata sync gated on the raw
  accommodation_id instead of the resolved one, so a hotel whose
  accommodation was just auto-created never received its metadata
  check-in/out times or confirmation. Now keyed off resolvedAccommodationId
  (RESV-SVC-006 re-pins the fixed behavior).
- The multi-statement writes ran outside transactions: create (accommodation
  insert + reservation insert + endpoint save + metadata sync), update
  (accommodation upsert + update + endpoint replace + sync), remove (the
  3-delete cascade), setReservationTravelers (delete + inserts) and
  resyncReservationDays now run in db.transaction() — a mid-write failure no
  longer leaves partial state (RESV-FIX-001/002 pin the rollbacks).

Left as-is on purpose: the truthy updatePositions dayId check, the
empty-string COALESCE keeps on title/status/type, the TEXT accommodation_id
normalization, the optional day_plan_position wire tolerance and the
swallowed notifyBookingChange catches — all contract or intentional
behavior, not defects.
2026-07-27 19:52:00 +02:00
jubnl c7dde2af2e docs: tick reservationService in migrate.md and refresh the migration graph
- nest/README.md: reservations joins the DI-native list; recipe history gains
  the residue-fold paragraph; next up is dayService per the dependency-honest
  order.
- migrate.md: wave-4 entry ticked, with the migration-graph correction borne
  out — reservationService imported neither budgetService nor dayService.
- migration-graph.md: regenerated for the post-fold import graph (row/node
  removed, frontier and order updated, dayService is the next pick).
- plugins/DI-MIGRATION.md: the factory's last plain-function reservation
  import is drained.
2026-07-27 19:47:09 +02:00
jubnl 203d67c45f chore: move reservations backend to proper DI
Folds the 626-line services/reservationService.ts into the DI-native
ReservationsService — byte-identical SQL, statuses, bodies, error strings
and broadcasts; the legacy file is deleted.

- reservations.mcp.ts: the 5 legacy tools (create/update/delete_reservation,
  reorder_reservations, link_hotel_accommodation) + the trip-reservations
  resource move to the decorator registry (identical names, schemas,
  annotations, error strings, broadcasts); the canWrite/canRead registration
  gates become declarative access markers. Legacy registrar and resource
  entries removed.
- reservations.bridge.ts: 9 legacy-named exports + 3 type re-exports for the
  outside-container consumers (tripService, airtrail import/sync, the
  still-legacy transit + transports registrars, transitItineraryService's
  type-only import).
- notifyBookingChange folds to the legacy (tripId, actorId) signature; the
  controller passes user.id and the plugin-host factory drops its last
  plain-function reservation import for a private method over the injected
  service (constructor unchanged).
- TripsService and BookingImportService inject ReservationsService instead of
  importing legacy functions.
- Tests: reservations.service.test.ts rewritten as a real-SQL suite
  (RESV-SVC-001..030, RES-TRAV-001..005 moved verbatim,
  RESV-BRIDGE-001..009 pinning the bridge); both e2e suites converted to the
  DI-native pattern; path mocks repointed to the bridge or constructor stubs;
  tools-reservations kept and extended with the moved resource cases.

Quirks preserved on purpose (parity first — see the follow-up fix commit):
the raw-accommodation_id metadata-sync gate, the un-transactioned
multi-statement writes, the truthy updatePositions dayId check, the
empty-string COALESCE keeps, and the TEXT accommodation_id normalization.
2026-07-27 19:47:01 +02:00
jubnl c27fc99220 feat(server): adopt zod DTOs for the reservation body contracts
ReservationsController's 4 grandfathered @Body() params now validate through
the global ZodValidationPipe (createZodDto over the existing @trek/shared
schemas); their allow-list entries are removed (the boot gate rejects stale
entries). The bespoke 400 bodies (Title is required / positions must be an
array / user_ids must be an array) become the pipe's field envelope, and
non-numeric traveler ids now 400 instead of being silently dropped.

reservationPositionsRequestSchema is loosened to the real wire contract:
day_plan_position is optional (the legacy route never validated position
items and an absent value binds NULL) — pinned by RESV-006.

AccommodationsController stays grandfathered until the dayService migration.
2026-07-27 19:45:41 +02:00
jubnl fbc773386f fix(server): fix the quirks preserved by the vacay DI migration 2026-07-27 18:39:53 +02:00
jubnl e3547ec43b docs: tick vacayService in migrate.md and refresh the migration graph 2026-07-27 18:39:52 +02:00
jubnl 588457710c chore: move vacay backend to proper DI 2026-07-27 18:39:52 +02:00
jubnl 29e89a5774 feat(server): adopt zod DTOs for the vacay body contracts 2026-07-27 18:39:47 +02:00
jubnl a8d6663dc7 fix(client): send multiple_choice from the desktop poll form 2026-07-27 00:27:12 +02:00
jubnl 07b8f1234c fix(server): fix the quirks preserved by the collab DI migration 2026-07-27 00:27:12 +02:00
jubnl 20882f47b7 docs: tick collabService in migrate.md and refresh the migration graph 2026-07-27 00:27:12 +02:00
jubnl 49197c5370 feat(server): adopt zod DTOs for the collab body contracts 2026-07-27 00:27:11 +02:00
jubnl c08c595b7e chore: move collab backend to proper DI 2026-07-27 00:27:11 +02:00
jubnl 470e4dc92c feat(shared): make collab note optional fields nullable in the request contracts 2026-07-27 00:27:06 +02:00
jubnl f69731b72b docs(server): chronicle the fileService DI migration 2026-07-26 23:19:32 +02:00
jubnl f3e5be78c9 chore: move files backend to proper DI
Folds the legacy services/fileService.ts into the DI-native FilesService
(injected DatabaseService, byte-identical SQL). Load-time constants move to
files.constants.ts; files.bridge.ts survives with a single export for the
request-time getAllowedExtensions read the module-scope multer configs need
outside DI. TripsService and PluginHostDepsFactory inject FilesService.

Also rides the nestjs-zod ratchet (FileUploadDto/FileUpdateDto/FileLinkDto
over the @trek/shared contracts, FilesController entries dropped from the
body-contract allow-list) and fixes the verified legacy quirks: file-link
insert failures now surface instead of being swallowed, updateFile coerces
an empty-string description to NULL like createFile, and formatFile's
misleading optional trip_id annotation is gone.
2026-07-26 23:19:26 +02:00
jubnl dd3c03ba0f feat(shared): add fileUploadRequestSchema for the files upload body 2026-07-26 23:19:04 +02:00
jubnl 4a7d9cd0be refactor(plugins): route injectable-class SQL through the injected DatabaseService
PluginRuntimeService's operatorEgressHosts/pruneErrorLog and PluginsService's
egressHostCount were module-level helpers on the raw db proxy; they fold into
their classes as methods over the injected service. (PluginHostDepsFactory's
equivalent conversion rode the settings commit — same files.) No injectable
class under src/nest imports the db global anymore; the remaining importers
are the documented module-level seams (bridges, platform shell,
plugin-host-state, signature-status, the bare plugin user-setting readers).
2026-07-26 22:24:24 +02:00
jubnl e0530680cb feat(server): adopt zod DTOs for the settings body contracts
SettingUpsertDto/SettingsBulkDto wrap the existing @trek/shared schemas; both
SettingsController entries drop off the body-contract allow-list (boot
ratchet). Per the todo precedent the dead bespoke 400 guards go: missing/empty
key and a non-record settings body now answer with the pipe envelope
({ error: 'key: …' } / { error: 'settings: …' }) instead of 'Key is required' /
'Settings object is required', and arrays for settings are rejected. The local
masked-value literal now imports MASKED_SETTING_VALUE from @trek/shared.
The settings e2e registers ZodValidationPipe and pins the DTO 400s plus the
bulk masked-sentinel skip.
2026-07-26 22:24:14 +02:00
jubnl cece6509b9 chore: move settings backend to proper DI
Folds the legacy services/settingsService SQL into the Nest SettingsService
(byte-identical statements; BEGIN/COMMIT blocks become db.transaction()).
AdminService and ShareService inject it; llm-config.resolver becomes the
injectable LlmConfigResolver (SettingsService + DatabaseService), injected by
LlmParseService and PluginHostDepsFactory. No MCP surface, no bridge.

Whole-file commit, so this also carries the post-migration quirk fixes that
live in the same files: null serializes as '' (a stored "null" leaked out of
getDecryptedUserSetting as the literal string), bulk upserts skip the masked
sentinel (mirroring the single-upsert no-op) and return the written count.
Unit suite moved to tests/unit/nest with SET-SVC-001..026 preserved,
027..030 pin the fixes; settings/admin e2e converted to DI-native.
2026-07-26 22:23:57 +02:00
jubnl c9a9df5310 fix(server): harden share links — transactional writes, TTL renewal on update, scoped shared queries 2026-07-26 21:25:47 +02:00
jubnl d572b1ec8d feat(server): adopt zod DTOs for the share-link body contract 2026-07-26 21:25:34 +02:00
jubnl 72c3edda1a chore: move share backend to proper DI 2026-07-26 21:24:57 +02:00
jubnl efd0e7fa98 fix(server): import MCP session revocation from sessionManager, not the mcp barrel 2026-07-26 20:34:21 +02:00
jubnl 41b51849b7 feat(server): adopt zod DTOs for the assignment body contracts 2026-07-26 20:34:06 +02:00
jubnl 164f5152be chore: move assignments backend to proper DI and harden its writes 2026-07-26 20:33:31 +02:00
jubnl 9650ad4597 feat(server): adopt zod DTOs for the trip-invite body contracts
New tripInviteLinkCreateRequestSchema in @trek/shared (single source of
truth), a createZodDto wrapper on the controller's raw @Body(), and the
TripInviteLinkController.create entry dropped from the body-contract
allow-list (the boot ratchet enforces it).

expires_in_days accepts number | digits-only string | null | absent — the
string branch is digits-only so garbage like "7abc" is a 400 instead of
silently parseInt-ing to 7; the empty string (blank form input, meaning no
expiry) stays accepted. The e2e registers ZodValidationPipe and pins both
the bounded-expiry and 400 paths.
2026-07-26 18:21:18 +02:00
jubnl cb0cd3300d chore: move trip-invite backend to proper DI
Fold the legacy tripInviteService SQL into the Nest TripInviteService over
the injected DatabaseService (byte-identical statements; the JS-side expiry
check and non-positive-means-no-expiry coercion preserved) and delete the
legacy module. No bridge, no MCP port and no plugin-host change needed —
nothing outside the container consumed this domain.

Also wraps the rotation's probe + write + re-select in db.transaction()
per the repo rule that multi-statement writes are never bare.

Unit suite moved to tests/unit/nest preserving TRIP-INVITE-001..006 and
adding 007 (expiry pinning); the e2e converted to the DI-native pattern
(real SQL against temp trips/trip_invite_tokens tables).
2026-07-26 18:21:04 +02:00
jubnl 3762d3306c feat(server): adopt zod DTOs for the day-note body contracts 2026-07-26 17:44:25 +02:00
jubnl 4e49e9c21e chore: move day-notes backend to proper DI 2026-07-26 17:44:12 +02:00
jubnl 7242812b27 fix(trips): scope the offline bundle's packing items to the viewer (#858) 2026-07-26 16:57:02 +02:00
jubnl 934848e114 fix(packing): correct legacy quirks kept during the DI migration 2026-07-26 16:56:18 +02:00
jubnl 5da2abf4f9 feat(server): adopt zod DTOs for the packing body contracts 2026-07-26 16:55:53 +02:00
jubnl f1960dc364 chore: move packing backend to proper DI 2026-07-26 16:55:20 +02:00
jubnl 67b3005d36 docs(plugins): record Option A as implemented, correct bridge/next-up drift 2026-07-26 15:29:00 +02:00
jubnl 811230fa5d refactor(plugins): wire PluginHostDepsFactory through DI, drop tags/categories bridges 2026-07-26 15:28:55 +02:00
jubnl 167db04d69 refactor(plugins): split create-rpc-host into an injectable factory + host-state module 2026-07-26 15:28:39 +02:00
jubnl 6c583c2f27 feat(server): adopt nestjs-zod with a fail-closed body-contract boot gate
- One global ZodValidationPipe (APP_PIPE, createZodValidationPipe) validates
  any @Body() typed with a createZodDto class; the custom exception factory
  keeps TREK's { error: 'field: message; ...' } envelope byte-identical.
- The 7 piped controllers (collections, places, vacay, todo, health,
  airtrail x2) now type bodies with colocated <domain>.dto.ts wrappers —
  the Zod schemas in @trek/shared stay the single source of truth.
- OpenAPI: the hand-rolled api-zod.ts enricher is replaced by nestjs-zod's
  cleanupOpenApiDoc; request bodies surface as $refs to named DTO schemas.
- validateBodyContracts() runs in buildApp() after init and refuses to boot
  when any POST/PUT/PATCH @Body() (including @Body('field') sub-reads) lacks
  a ZodDto metatype. The 175 pre-existing legacy handlers are grandfathered
  in body-contract-allow-list.ts; the list is ratchet-only — the gate also
  throws on stale entries, so migrating a domain forces their removal.
2026-07-26 14:55:13 +02:00
jubnl 8e6b3a424e style(shared): apply eslint --fix line wrapping to locale and schema files 2026-07-26 14:54:59 +02:00
jubnl 2fa075a076 fix(shared): align todo contracts with the wire format the client sends
The optional metadata fields (category, due_date, description,
assigned_user_id — plus priority on update) now accept explicit null: the
client clears fields by sending null, which the schemas rejected. checked
accepts the legacy 0/1 numeric form alongside boolean (the integration net
pins it). Drops the client-side casts that papered over the too-narrow
types.
2026-07-26 14:54:53 +02:00
jubnl ad7d349c85 fix(todo): wrap category-assignee replacement in a transaction
updateCategoryAssignees ran its DELETE + INSERT OR IGNORE loop unwrapped, so
a mid-loop failure could leave a category half-cleared. Also widens the item
params to the nullable shapes the wire contract actually sends (prep for the
todo body validation).
2026-07-26 14:54:38 +02:00
jubnl 79946cc711 chore: move todo backend to proper DI 2026-07-26 14:09:54 +02:00
Maurice b5ebffa115 fix(dashboard): keep a trip the hero fell back to in the grid
The hero picks the trip that is running, else the next one coming up, else
just trips[0] — and that pick was then removed from the grid below. The first
two cases are deliberate: the trip is already on screen, showing it again
right underneath would be noise. The trips[0] fallback is a different thing.
It fires precisely when nothing qualifies, so it borrows a trip that belongs
in the grid, and removing it there leaves the user with a hero and an empty
list. Someone whose trips are all finished sees "No trips yet" under a card
of the trip they just came back from.

Split the two: only a trip that earned the hero is taken out of the grid.
Undated trips were caught by this too, and worse — getTripStatus returns
null rather than 'past' for them, so a lone dateless trip appeared in no
tab at all.

The empty state was the second half of the report. It equated "planned is
empty" with "you have no trips" and told people to create their first one.
Gate it on there being no trip at all, which is the condition the mobile
dashboard already uses.

Mobile shares useDashboard, so it is fixed along with the desktop.

Closes #1706
2026-07-26 12:45:54 +02:00
jubnl 2ce40724d7 chore: move category backend to proper DI 2026-07-26 12:36:30 +02:00
Maurice c8369bd14b fix(plugin): clear brokered session state on logout
sessionStorage outlives a logout inside the same tab, so the keys stay readable
after the next user signs in. They are namespaced by user id, which stops the new
user's plugins from reading them, but the data is still sitting in the tab — the
same reason logout already drops the appearance snapshot, the SW caches and the
user-scoped offline DB. Purge them there too.

Three things around it, from reading the handlers next to the new ones:

- The inactive-plugin purge ran before the store was updated, so a sessionStorage
  failure cost us the plugin list we had just fetched. Commit first, purge after.
- The session handlers answered without checking requestId, unlike confirm and
  geolocation — an answer the frame can never match is worth skipping.
- The mock host threw plain Errors while the real bridge rejects with .code set,
  and checked the key before the trip scope where the host does the reverse, so a
  plugin's error handling could pass its tests and still break in the app.
2026-07-26 11:48:15 +02:00
geracobo 2068133de2 feat(plugins): add quota-limited session storage API 2026-07-26 11:48:15 +02:00
geracobo cc8e4d480e fix(plugins): corrected non-sequential test numbers for PluginFrame 2026-07-26 11:48:15 +02:00
geracobo 9d8d0b1653 feat(plugins): add session storage api for plugin bridge 2026-07-26 11:48:15 +02:00
Maurice 0fc04909a8 docs(plugin): spell out what blurBookingCodes does, and preview it
The context table listed the new field without saying what a plugin is meant to
do with it. It is a display hint — booking codes still arrive in full over
trek:invoke — and reading it as redaction is the one mistake worth preventing.

The dev preview hardcoded it to false, so the blurred path was the only host flag
an author could not try out; it gets a toggle like reduce motion and the rest.
2026-07-26 11:48:06 +02:00
geracobo 16793761be fix(plugin): include blur booking codes on the plugin context 2026-07-26 11:48:06 +02:00
Maurice c4f4fdd794 fix(booking-import): cover the AI retry and keep its source files
The widget's new healthApi.features() call left BackgroundTasksWidget.test.tsx
red — the api/client mock never exported healthApi, so both existing tests threw
on mount. The mock now carries it, pending by default like the poll backstop, and
the retry itself finally has tests.

Writing them turned up two gaps. The retry never called saveImportFiles, so the
files only lived in memory: reloading during the retry parse left the review with
no source document to attach to the bookings it creates, unlike every import
started from the modal. And the button stayed live while the request was in
flight, so a second click started a second force-ai job on the same files.
2026-07-26 11:47:59 +02:00
Xre0uS 6854436c24 feat(booking-import): retry a failed import with AI parsing 2026-07-26 11:47:59 +02:00
Maurice eae2ade740 fix(booking-import): only resolve leg days from local_date on a prefill
Resolving every waypoint day from endpoint.local_date also changed the edit path,
where it inverts the precedence the form uses everywhere else: local_date is
denormalised and the server only restamps it in the day_id branch of insertDay,
and only when reservation_time is set. Dragging a booking to another day rewrites
day_id/end_day_id and leaves the endpoints alone, so local_date points at the old
day — and reopening the form and pressing Update silently moved the booking back.

Gate the lookup on a prefill, where there is no saved day to lose, and the edit
path behaves exactly as it did before. Same change in the mobile sheet, which
shares the seeding code and would otherwise still show empty day selectors on an
imported booking.
2026-07-26 11:47:52 +02:00
Xre0uS 68ebc700cb fix(booking-import): pre-fill leg dates from endpoint local_date 2026-07-26 11:47:52 +02:00
jubnl 8f3ee53841 feat(mcp): Create nest wrapper for MCP for proper DI (#1708) 2026-07-26 11:42:47 +02:00
Maurice 828c1db489 fix(offline): start the tile prefetch once the app is idle
syncAll runs straight off the back of a successful login, while the app
is still mounting its first screen. Kicking off a bulk background
download at that exact moment is the worst possible timing even with the
prefetch throttled, so hold the tiles back until requestIdleCallback
says the browser has nothing better to do.

Files keep their old behaviour — they are a handful of requests, not
thousands.
2026-07-26 10:54:22 +02:00
Maurice 3c67afeade fix(offline): throttle the map tile prefetch
The prefetch dispatched every tile of a trip's bounding box in one
synchronous loop — up to MAX_TILES per trip. A trip spanning a few
countries enumerates thousands of tiles at zoom 10 alone, so logging in
fired ~8k no-cors requests back to back and kept them coming for half a
minute. All of them go through the Workbox fetch handler, which then has
to write a cache entry plus an expiration record per tile, so everything
the app itself requested queued up behind the burst and the UI just sat
there.

Run the queue through a small worker pool instead, and let it stop early
when the user goes offline or logs out.

Two things fall out of that:

- Tiles already in Cache Storage are now skipped before the request is
  made, so a resumed or repeated prefetch over a warm cache is nearly
  free.
- {s} has to be a pure function of the tile coordinates for that lookup
  to hit. The rotating counter gave the same tile a different host on
  every run, which also meant caching it up to four times.
2026-07-26 10:54:22 +02:00
Maurice a3e2ff9fa5 fix(mobile): offer the currency on the dashboard trip sheet
The mobile create/edit sheet never showed the currency, so every trip
started from the dashboard silently took the server default of EUR and
could only be corrected from the trip planner, which reuses TripFormModal
and does have the field.

Same control as the desktop form — searchable select over currenciesWith(),
gated on trip_edit like the rest of the sheet.
2026-07-25 22:04:03 +02:00
jubnl 53e884ad55 refactor(nest): migrate tagService into TagsService DI (pilot)
Move the tag SQL from src/services/tagService.ts into TagsService over an
injected DatabaseService (identical statements, #10b981 default, COALESCE
semantics). Non-Nest consumers (MCP tools, plugin RPC host) go through the
new tags.bridge.ts, which exports the legacy function names 1:1 from a
module-level instance over the shared db Proxy — the settled pattern for
the remaining legacy-service migrations (recipe documented in
src/nest/README.md). Tests are DI-native: direct service construction in
unit (with bridge delegation cases for the coverage gate) and
DatabaseModule + real SQL in e2e.
2026-07-25 18:36:17 +02:00
jubnl dd2c2d6668 refactor(nest): inject DatabaseService in db-using controllers 2026-07-25 18:15:15 +02:00
jubnl fbe200424d refactor(nest): inject DatabaseService in db-using services 2026-07-25 18:15:04 +02:00
jubnl def6b84938 refactor(nest): inject DatabaseService in access-check services and controllers 2026-07-25 18:14:53 +02:00
jubnl 718022c7ba feat(db): provide better-sqlite3 connection via DI token and complete DatabaseService surface 2026-07-25 18:14:37 +02:00
jubnl 7059b686ac Merge remote-tracking branch 'origin/dev' into dev 2026-07-25 17:02:55 +02:00
jubnl 45c1568e7c chore: correct user setting currency i18n 2026-07-25 17:02:32 +02:00
Maurice c7a67ed58a fix(build): align the musl sharp pins with the sharp version
The bump to sharp 0.35.0 left the @img/sharp-linuxmusl-* pins on 0.35.1,
so npm nested the 0.35.0 binary under node_modules/sharp while hoisting
libvips to the root. The nested .node then looks for libvips-cpp.so.8.18.3
next to itself, finds nothing, and the Alpine image build dies in the
client prebuild:

  Could not load the "sharp" module using the linuxmusl-x64 runtime
  ERR_DLOPEN_FAILED: Error loading shared library libvips-cpp.so.8.18.3

Neither docker workflow runs on push, so this only surfaces when a release
is cut. Pin both binaries to 0.35.0 so they hoist next to libvips again.
2026-07-25 16:46:29 +02:00
jubnl 8a46c56a96 test(client): dismiss the CTA-style support modal in e2e setup 2026-07-25 16:30:33 +02:00
jubnl e162da6dbe fix(server): resolve zh-TW default language, close lint gap on bracket env access 2026-07-25 16:19:15 +02:00
jubnl f539c2aab7 Merge remote-tracking branch 'origin/dev' into dev 2026-07-25 16:06:29 +02:00
jubnl 0f3a6f80b7 chore(server): forbid direct process.env access outside app-config 2026-07-25 16:05:26 +02:00
jubnl 3b31f4256b refactor(server): move language/session parsing into app-config, config.ts becomes keys module 2026-07-25 16:01:42 +02:00
jubnl fb9abdb76c refactor(server): non-DI islands read env via app-config 2026-07-25 15:58:02 +02:00
jubnl 7f795a2199 refactor(server): legacy services read env via app-config 2026-07-25 15:37:37 +02:00
jubnl d13758221e refactor(server): remaining nest domains read env via app-config 2026-07-25 15:29:13 +02:00
jubnl 9e87e09d03 refactor(server): auth, oidc and admin nest modules read env via app-config 2026-07-25 15:21:17 +02:00
jubnl a34ab288f5 refactor(server): bootstrap and middleware read config via app-config 2026-07-25 15:17:33 +02:00
jubnl e17f165e44 feat(server): add nest AppConfigModule bound to @nestjs/config 2026-07-25 15:12:38 +02:00
jubnl 48ba9092c3 feat(server): add app-config core with fail-fast env validation 2026-07-25 15:03:27 +02:00
Maurice 39d4dc2675 feat(vacay): thin the mobile person card down to the number you act on
Used, carried over, comp days and remaining all fit a desktop sidebar; on a
phone they collapsed into four scraps of 9px text fighting for one row. Only
the balance is left, as a pill tinted by how much of it there is — the rest is
still a tap away in the entitlement view.

The toolbar chip says "Company" rather than "Company Holiday": it sits beside
two other buttons and the building icon already carries the meaning. The
allowance stepper reads "Days" instead of "days / year", reusing the label the
desktop entitlement tile has always used — the year is on the pill above it.
2026-07-25 14:49:38 +02:00
Maurice bccf4284c4 i18n(login): drop the keys the feature tiles used
login.features.* and login.selfHosted across all 23 locales — nothing renders
them any more.
2026-07-25 14:49:38 +02:00
Maurice 8b6a80fce0 feat(login): rebuild the branding panel and the sign-in moment around it
The panel dropped the eight feature tiles and the self-hosted line — they read
as a spec sheet on a screen whose job is to get you in. What is left is the
mark, the tagline and one line of copy, centred over the map, each with a soft
dark halo so the dots cannot eat the type. The tagline and subtitle never wrap:
they scale with the panel instead, since it is a fixed share of the viewport.

The takeoff after sign-in used to tell a different story than the screen it
came from — cloud ellipses, a lone plane, a contrail. It now picks the panel up
and escalates it: the same map, every route departing at once, the logo landing
as the network completes, then a soft hand-off into the app.
2026-07-25 14:49:38 +02:00
Maurice 45ce365920 feat(login): a living world map behind the sign-in panel
Replaces the drifting plane silhouettes with the thing TREK actually does:
routes lighting up between cities across a dot map of the world. Arcs leave a
city, a light travels the curve, the destination lands, and the arc retires to
make room for the next.

The map is drawn once into an offscreen layer and blitted per frame, so only
the handful of live arcs cost anything — no shadowBlur in the frame path. The
same component covers the sign-in moment via `variant="takeoff"`, where the
network fires as one cascade and stays lit instead of cycling.

Reduced motion gets the map as a still frame.
2026-07-25 14:49:38 +02:00
Maurice 0d9032224d feat(login): bake TREK's coastlines into a dot grid for the sign-in screen
The login screen is unauthenticated, so it cannot call the Atlas endpoint the
rest of the app uses for country geometry. This is that same admin-0 bundle,
sampled along the coastlines onto a 300x150 grid and delta-encoded — 15 KB
instead of a 28 MB fetch nobody is allowed to make yet.
2026-07-25 14:49:38 +02:00
dependabot[bot] b955998905 chore(deps-dev): bump sharp from 0.33.5 to 0.35.0 (#1690)
* chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)

Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump sharp from 0.33.5 to 0.35.0

Bumps [sharp](https://github.com/lovell/sharp) from 0.33.5 to 0.35.0.
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.33.5...v0.35.0)

---
updated-dependencies:
- dependency-name: sharp
  dependency-version: 0.35.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-25 13:14:41 +02:00
dependabot[bot] 50c0864bce chore(deps): bump hono from 4.12.26 to 4.12.32 (#1692)
* chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)

Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Revert "chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)" (#1694)

This reverts commit 94736112cf.

* chore(deps): bump hono from 4.12.26 to 4.12.32

Bumps [hono](https://github.com/honojs/hono) from 4.12.26 to 4.12.32.
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.26...v4.12.32)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.12.32
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: jubnl <66769052+jubnl@users.noreply.github.com>
2026-07-25 13:12:58 +02:00
dependabot[bot] 60f888dc71 chore(deps): bump body-parser from 1.20.5 to 1.20.6 (#1693)
* chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)

Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump body-parser from 1.20.5 to 1.20.6

Bumps [body-parser](https://github.com/expressjs/body-parser) from 1.20.5 to 1.20.6.
- [Release notes](https://github.com/expressjs/body-parser/releases)
- [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/body-parser/compare/1.20.5...1.20.6)

---
updated-dependencies:
- dependency-name: body-parser
  dependency-version: 1.20.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-25 13:11:09 +02:00
dependabot[bot] 3b98721a64 chore(deps): bump shell-quote and concurrently (#1691)
* chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)

Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump shell-quote and concurrently

Bumps [shell-quote](https://github.com/ljharb/shell-quote) to 1.9.0 and updates ancestor dependency [concurrently](https://github.com/open-cli-tools/concurrently). These dependencies need to be updated together.


Updates `shell-quote` from 1.8.4 to 1.9.0
- [Changelog](https://github.com/ljharb/shell-quote/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/shell-quote/compare/v1.8.4...v1.9.0)

Updates `concurrently` from 10.0.3 to 10.0.4
- [Release notes](https://github.com/open-cli-tools/concurrently/releases)
- [Commits](https://github.com/open-cli-tools/concurrently/compare/v10.0.3...v10.0.4)

---
updated-dependencies:
- dependency-name: shell-quote
  dependency-version: 1.9.0
  dependency-type: indirect
- dependency-name: concurrently
  dependency-version: 10.0.4
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-25 13:09:45 +02:00
dependabot[bot] 8bf8da18f6 chore(deps-dev): bump postcss from 8.5.16 to 8.5.23 in /plugin-sdk (#1688)
* chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)

Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Revert "chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)" (#1694)

This reverts commit 94736112cf.

* chore(deps-dev): bump postcss from 8.5.16 to 8.5.23 in /plugin-sdk

Bumps [postcss](https://github.com/postcss/postcss) from 8.5.16 to 8.5.23.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.16...8.5.23)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.23
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: jubnl <66769052+jubnl@users.noreply.github.com>
2026-07-25 13:08:16 +02:00
dependabot[bot] 65913975d7 chore(deps): bump fast-uri from 3.1.2 to 3.1.4 (#1689)
* chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)

Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump fast-uri from 3.1.2 to 3.1.4

Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.2 to 3.1.4.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.2...v3.1.4)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-25 13:05:51 +02:00
jubnl f1489534bb chore: bump dompurify to 3.4.12 2026-07-25 13:02:27 +02:00
Maurice fbd2d17fa8 docs(vacay): document the leave year and the comp/half modifiers
The page still described Vacay as strictly January to December and never
mentioned half days at all, so both new features would have shipped
undocumented.
2026-07-25 12:02:16 +02:00
Maurice 3c28c7eaeb test(vacay): cover the leave-year window and the comp exclusion
Windows for all three year types, the fallbacks (anniversary without a hire
date, a day the month cannot have), usage counted across the calendar-year
boundary, carry-over chained over periods rather than years, deleteYear over a
shifted period, and the reads with and without a viewer.

For comp: excluded from usage, a half comp day still costing nothing,
converting a vacation day back, and comp_used reported on its own.
2026-07-25 12:02:16 +02:00
Maurice 59128566d8 i18n(vacay): translate the leave-year and comp keys 2026-07-25 12:02:16 +02:00
Maurice b37996ec39 feat(vacay): make the toolbar show the marker it places (#1074)
The half-day button carried a ½ glyph and comp/flex a clock, neither of which
appears anywhere on the calendar. Both now show what a click actually puts on
the day: the orange dot for a half day, a hatched disc in the selected person's
colour for comp.

The digit on an all-comp cell also needed help. The hatch lets the surface
through between its stripes, so a white number sank into it — a shadow carries
the contrast now and every logged day keeps the same digit colour. Mixed days
still have a solid segment under the number and get none.
2026-07-25 12:02:16 +02:00
Maurice f1d520bbc2 feat(vacay): show comp days in the stats and fold the card away (#1074)
The server has returned comp_used since the leave type landed, but nothing
showed it — comp days simply vanished from the sidebar. They now sit beside
the tiles rather than inside the used/left arithmetic, which is the point of
them.

Carry-over is labelled from the window the server counted that row over, not
from the viewer's own setting, so a fused plan with mixed leave years does not
tell one member their days came "from 2025" when they came from a period that
started in July.

The card also folds now. It is the longest thing in the sidebar once several
people are fused in, and the state belongs to the person looking at it, so it
lives in localStorage rather than on the plan.
2026-07-25 12:02:16 +02:00
Maurice d984e09473 feat(vacay): pick a leave year and roll the grid over with it (#737)
Calendar, fiscal (month + day) or from the hire date, per user — it sits with
the plan settings but belongs to the person, so fused members each keep their
own and the grid follows whoever is looking at it.

Both grids render twelve months from the window start with year rollover
instead of a fixed Jan–Dec, and the mobile month navigation moves through slots
of that window, so stepping past December lands in January of the same period
rather than jumping back to the start of the calendar year. The month effect
keys on the window's shape rather than the settings object, which the store
replaces with an equal copy on every refresh — otherwise any sync would throw
away the month the user had open.

Known edge for a start day past the 1st: the cards are month-aligned, so the
first days of the start month are drawn but count toward the previous period.
Counting stays day-exact.
2026-07-25 12:02:16 +02:00
Maurice e1c5daeccb feat(vacay): mirror the leave-year window in the client (#737)
The server owns the arithmetic entitlements are computed with, but the grid has
to render the same twelve months and the overlays have to filter against the
same range, so the window resolves on both sides. yearWindow.ts is that mirror,
including the Feb-29 clamp and the anniversary-without-a-hire-date fallback.

The store loads the settings before the year list picks a default — with a
shifted year the current period is not necessarily the current calendar year.
Holidays are fetched per calendar year the window touches and clipped back to
what the grid draws; a school break straddling New Year comes back from both
fetches, so markers are de-duplicated per day. The label is part of that
identity, otherwise two calendars left on the same colour collapse into one.
2026-07-25 12:02:16 +02:00
Maurice 9d93ed95bf feat(vacay): read and write the leave year over its own window (#737)
Adds GET/PUT /addons/vacay/year-settings and makes the reads window-aware, so
the backend from the previous commit is actually reachable.

Entries and shared calendars now load over the caller's window instead of a
date prefix — a shifted year spans two calendar years and the prefix silently
dropped the second half. Reads are month-aligned (the grid draws month cards),
while entitlement stays day-exact; the two coincide for every window starting
on the 1st, which is all of calendar. deleteYear clears entries per author over
that author's period, and company holidays only over the range every member
shares, so one member deleting a period cannot reach into another's live one.
getStats reports the window it counted, and getOwnPlan seeds the period today
falls into rather than the calendar year.

A start day the month cannot have (Feb 29 from a hire date, a stored Feb 31)
is clamped to one every year has — otherwise the boundary string matches no
date and the whole window slides. Anniversary before a hire date is entered
resolves to the calendar year instead of borrowing a month a previous fiscal
setting left behind.

MCP reads pass their user through; without one the range stays the plain
calendar year, so plan-scoped callers are unchanged.
2026-07-25 12:02:16 +02:00
Maurice 2672a7a741 test(vacay): catch the suites up with the comp/flex column
The kind column changed four things the tests still asserted the old way:
toggleEntry gained a parameter, the toolbar gained the comp modifier, shared
calendar rows carry kind, and a multi-person cell is now segment overlays
instead of one split gradient. splitBackground went with it — nothing but the
test still referenced it.

The toolbar tests picked the half-day button by index, which the new button
shifted; they select by name now so the next modifier does not break them.
2026-07-25 12:02:16 +02:00
Maurice c475e5b04c feat(vacay): comp/flex leave type (#1074) + configurable-year backend (#737)
Comp/Flex days (#1074) — complete. A second leave type that does not deduct from the vacation entitlement (flextime / overtime offset). New kind column on vacay_entries, excluded from usedDays via a CASE sum; toggleEntry carries kind through service/nest/store with change-detection; desktop and mobile get a comp toolbar toggle next to the half-day one (orthogonal, so a half comp day is free); comp segments render as a diagonal hatch of the person colour, per-segment for split days (Version B). comp_used is surfaced separately in getStats. Translated into all 23 locales.

Configurable vacation year (#737) — backend only. Per-user vacay_user_settings (calendar / fiscal / anniversary), resolveYearWindow, and usedDays / compUsedDays / getStats / carry-over now computed over the leave-year [start,end) window. 'calendar' resolves byte-identically to the old date-prefix behaviour, so nothing changes for existing users and the feature is inert until the settings UI lands. Frontend (settings selector + calendar window rollover), the settings endpoint, window-aware getEntries and holiday loading are still to do — see the PR description.
2026-07-25 12:02:16 +02:00
Konstantinos Thermos c61203e607 fix(reservations): restrict all booking dates to the trip span 2026-07-24 19:16:55 +02:00
Maurice 8769ed084e refactor(reservations): move desktop traveler assignment into the edit/create modals
The always-on card picker is gone — travelers are assigned in the booking and transport modals now, matching mobile. The card shows assigned travelers read-only as pills in the grey field box, TravelerChips is replaced by a shared TravelerPicker, and the toolbar filter gets gradient avatars with an accent ring and check badge.
2026-07-24 16:50:41 +02:00
Maurice 2a5c54c025 feat(reservations): traveler filter on the mobile bookings and transports tabs 2026-07-24 16:50:41 +02:00
Maurice bc06125d6e test(reservations): server + client coverage for traveler assignment 2026-07-24 16:50:41 +02:00
Maurice e89dae5528 feat(reservations): traveler filter + transit-card badge (desktop) and full mobile traveler UI (cards + sheet pickers) 2026-07-24 16:50:41 +02:00
Maurice 8d7753c2e1 i18n(reservations): translate the traveler keys into all locales 2026-07-24 16:50:41 +02:00
Maurice 781c693434 feat(reservations): traveler badge and inline member/guest picker on booking cards (desktop) 2026-07-24 16:50:41 +02:00
Maurice 21456b983e feat(reservations): assign trip members and guests to bookings — backend (schema, migration, travelers endpoint) 2026-07-24 16:50:41 +02:00
Maurice 9344d4508c chore(deps): regenerate lockfile so json5 is the only change and the peer markers stay intact 2026-07-24 12:42:38 +02:00
fbnlrz 9ad3507e57 fix(llm-parse): tolerate non-strict JSON from Gemini and similar models
Gemini reached through the OpenAI-compatible endpoint returns
JavaScript-object-literal output — single-quoted strings, unquoted keys,
trailing commas — which strict JSON.parse rejects. The reservation list
then came back empty and the booking-import UI showed nothing (#1638).

Add a shared parseLenientJson() that tries strict JSON first and falls
back to JSON5 (which accepts exactly that relaxed superset), and route
both the OpenAI-compatible and Ollama-native parse paths through it,
replacing the two identical strict parsers.
2026-07-24 12:42:38 +02:00
Maurice 791468e5b3 fix(airtrail): also treat an endpoint-count > 2 booking as a local multi-leg shape, mirroring the server so a locally-grown chain is not mislabelled removed 2026-07-24 12:28:49 +02:00
fbnlrz 3dc9ac6ac6 fix(airtrail): stop labelling a merged multi-leg import "removed in AirTrail"
A multi-leg flight imported "as one flight with a layover" is detached
from sync by design — AirTrail has no single flight to round-trip a
layover chain to, so it is created with sync_enabled=0 (#1535). The
reservations badge collapsed every sync_enabled=0 AirTrail row into "Not
synced" with the tooltip "This flight was removed in AirTrail", which is
false for a merged booking that was never removed (#1646).

Give the multi-leg case (metadata.legs.length > 1) its own state: show
the normal AirTrail badge with a truthful tooltip explaining it stays a
one-time import. A genuinely removed single-leg flight still shows "Not
synced". Adds reservations.airtrail.layoverHint across all locales.
2026-07-24 12:28:49 +02:00
Maurice 68b0c6bceb fix(settings): mask inherited encrypted admin defaults so llm_api_key never reaches the client in cleartext 2026-07-24 12:26:58 +02:00
fbnlrz 4b73fa80a9 fix(settings): let an empty user value fall back to the admin default
The admin "user defaults" merge kept any user value that was merely
present, so an empty string shadowed the system-wide default. The
client's Settings save always writes every field — a blank Mapbox token
included — so a user who ever opened Map Settings got '' instead of the
admin token, and clearing their own token could never restore the
fallback (#1634).

Treat an empty/null user value for a defaultable key as "not set" when an
admin default exists, so it falls through. Non-defaultable keys and keys
without an admin default keep their exact stored value.
2026-07-24 12:26:58 +02:00
yu 617ffae351 feat(journey): add mobile quick capture with location and weather 2026-07-24 12:06:29 +02:00
Maurice 60abcf6598 feat(dashboard): drop the play circle from the beacon, keep the copy and the badge 2026-07-24 11:49:11 +02:00
Maurice 51f8afdb60 feat(dashboard): retire the 4.0.0 moment after Aug 23 — viewable-until badge + a permanent dismiss X 2026-07-24 11:49:11 +02:00
Maurice 2cec06161d fix(dashboard): drop the frame shake — the boom and the shatter carry the climax on their own 2026-07-24 11:49:11 +02:00
Maurice 0834282bfd feat(dashboard): five signature text moments + widget-matched corners
- The finale condenses out of light: ~1000 particles rise from the world and
  gather into the anthem's letterforms before the crisp DOM text takes over;
  the language cascade waits for the word to settle.
- Replaced fear-act lines decay letter by letter — the words of fear fall
  apart instead of vanishing.
- The climax boom rocks the whole frame for half a second, in sync with the
  sub impact and the border shatter.
- Hope-act lines reveal sentence by sentence, in speech rhythm.
- The standing anthem breathes — a barely-there scale and glow pulse.
- Beacon corners now use the dashboard tool widgets' --r-xl radius.
2026-07-24 11:49:11 +02:00
Maurice ffda4de77d feat(dashboard): the anthem IS the ending + beacon final polish + navbar fix
The show now ends standing: KEINE ANGST with the full 23-language cascade
stays up over the lit world, music carrying on until the traveler closes it —
no credits hand-off. Skip jumps straight to that standing frame.

Beacon: dismiss × removed, copy block centered on the canvas, the title gets
its quiet jewelry treatment — ivory-to-gold fill, a traveling sheen and a slow
breathing glow.

The overlay now portals to document.body: the sticky dashboard sidebar forms
its own stacking context, so the fixed navbar (z-200 at root) used to float
above the show regardless of the overlay's z-index.
2026-07-24 11:49:11 +02:00
Maurice 1cf1a9ca42 fix(dashboard): end screen drops the inspiration credit and the close button — anthem, three lines, done 2026-07-24 11:49:11 +02:00
Maurice 406229e701 fix(dashboard): flash words become stamped title cards — chromatic fringes, micro-jitter, hard cut, no zoom 2026-07-24 11:49:11 +02:00
Maurice d2d8b65827 fix(dashboard): soften the pivot into 'but you have traveled' + audio review fixes
The transition out of the fear act was too brutal: the silence now arrives as
a fast pull-away with the hall tail ringing out instead of a 100ms kill, the
pivot line trades its impact boom for a slow warm A1 swell (the first calm
breath), and the line itself breathes in over 3.4s.

Audio review fixes: the wind's swell LFO moves to a series tremolo stage so
the release can actually silence it before the cut (it used to warble through
the silence and click on stop); the heartbeat's noise layer becomes the
intended high-passed click instead of a dull lowpassed thud; dispose() pulls
the master to zero before the deferred context close so leaving the show
cannot pop.
2026-07-24 11:49:11 +02:00
Maurice ba2582f9c8 feat(dashboard): beacon goes cinema-poster — centered copy, iconic play circle
Copy sits centered on the night-world canvas over a corner-pooling scrim; the
title grows monumental tracking, a gold sheen and a soft glow; the text CTA
becomes a slowly pulsing golden play circle that fills on hover. The version
eyebrow and the red live dot are gone — title, one line, play. Nothing else.
2026-07-24 11:49:11 +02:00
Maurice 920628d63c feat(dashboard): show polish — readable lines, gliding arcs, a real hall
- Lines get a layered soft glow plus a radial pool of darkness under the text
  zone, so they hold up over the burning border map without a hard box.
- Arc heads now interpolate fractionally along the curve with ease-out growth
  (the whole-segment quantization read as stutter), in the show and the beacon
  teaser alike.
- The score gains a convolution reverb with a procedurally generated impulse
  response; the fear act trades raw saws for an organic bed (sine sub,
  drifting fifth, breathing noise rumble, a semitone grind in the dread act),
  the shimmer runs through a feedback echo, impact tails bloom in the hall,
  the heartbeat stays close and dry.
2026-07-24 11:49:11 +02:00
Maurice 10bfad97da feat(dashboard): cinematic beacon — living arc teaser, sheen title, scrim
The beacon is now a dark cinema card: a small canvas paints a breathing
night-world with golden travel arcs on loop, colored glows drift over it, the
title carries a gold sheen sweep and the copy sits on a bottom scrim. Core
colors ride inline so no dashboard widget CSS can wash the card out again.
2026-07-24 11:49:11 +02:00
Maurice c541c158df fix(dashboard): keep the generic line up through the stats slot for travelers with no places yet 2026-07-24 11:49:11 +02:00
Maurice aec541c573 feat(dashboard): make the KEINE ANGST show hit harder
Visual: city lights now come alive in the opening and die one by one while the
borders burn; three staccato words strobe with the heartbeat before the cut;
at the climax the borders no longer fade — they shatter into drifting sparks.
The traveler's own places ignite gold in the release act, with their real
numbers in the line ('{p} places. {c} countries. And not once did the world
hurt you.'). All heavy geometry is decimated to a vertex budget and pre-baked
into offscreen layers, so the frame path is a handful of drawImage calls — the
per-frame border stroke that dropped the show to ~1 fps is gone.

Sound: heartbeat with a click transient, wind textures, a doubled noise riser,
cue-driven sub-bass impacts and a gliding D–A–f#m–E progression with a sub
root under the anthem instead of the static pad.

Also: dashboard FAB hidden behind the show, suspended-AudioContext recovery
(Safari), tab-hidden pause for clock and audio, aborted geo fetch on close,
valid beacon markup (dismiss as sibling button), RTL-aware beacon, real
reduced-motion static frame, and native-speaker fixes for nl/sv/br/fr/it/es/
tr/gr copy.
2026-07-24 11:49:11 +02:00
Maurice c6d96872ae feat(dashboard): 4.0.0 release moment — KEINE ANGST
A one-release statement against racism, hatred and fascism, told the way TREK
knows the world: through travel. A pulsing beacon in the dashboard sidebar
(desktop only, dismissible) opens a ~80s full-screen show — the world as a map
of dots whose borders burn red and then dissolve under a growing web of travel
connections, driven by a fully procedural Web Audio score (heartbeat, drone,
A-major release; no audio assets, no copyrighted material) and hard-cut lines
in all 23 TREK languages, closing on the anthem cascade and a credit to the
song that sparked it (Danger Dan — Keine Angst).

Self-contained in components/FourZero (own copy dictionary, no shared i18n
churn); wired into the dashboard with two marked lines. Removing the folder
plus those two lines retires the moment after the release.
2026-07-24 11:49:11 +02:00
dependabot[bot] eb0dfc83c1 chore(deps): bump hono from 4.12.26 to 4.12.31
Bumps [hono](https://github.com/honojs/hono) from 4.12.26 to 4.12.31.
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.26...v4.12.31)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.12.31
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 11:38:38 +02:00
Konstantinos Thermos e100c52676 fix(maps): localize map POI pin titles to the user's language 2026-07-24 11:36:30 +02:00
Konstantinos Thermos fce26618a1 fix(transport): guard non-finite dates in trip-map duration 2026-07-24 11:36:24 +02:00
Elmer 7bd9e08016 feat(vacay): add school holiday overlays (#1633)
* feat: add vacay school holiday overlays

* fix(vacay): remove SOH control character corruption in locale files

13 of 22 vacay locale files contained a 0x01 (SOH) control character
that corrupted the closing }; token. This broke the shared package
build (tsdown/rolldown PARSE_ERROR: Invalid Character) and cascaded
to all client tests failing to resolve @trek/shared.

Affected locales: ar, br, ca, cs, es, fr, gr, hu, id, it, ja, ko,
nl, pl, ru, sv, tr, uk, vi, zh, zh-TW

* fix(vacay): school-holiday migration order, i18n dupes, settings layout

- Move the school-holiday columns migration to the end of the migrations
  array. It sat before the #1435/#1281 migrations, which every existing DB
  had already run, so the runner skipped it and school_holidays_enabled /
  vacay_holiday_calendars.type were never created — PUT /addons/vacay/plan
  then 500'd with "no such column".
- Remove duplicate i18n keys in 13 locale vacay files (TS1117) that failed
  the shared + client typechecks.
- Move the school-holidays block into the right column, directly under
  public holidays, instead of full-width below everything.
- Add the school-holidays section to the mobile Vacay settings sheet.

* feat(vacay): modern school-holiday day styling + combined hover tooltip

- Replace the stacked 2px underlines with a single rounded accent band and a
  soft background wash on plain school-break days (desktop + mobile — mobile
  did not render school holidays at all before).
- Fold school holidays into the custom day tooltip under a divider instead of a
  separate native title, and show the tooltip on every set day (whole vacation
  days too), not only half days / shared calendars.

---------

Co-authored-by: Otto <uif86164@automotive-wan.com>
Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-07-23 22:48:42 +02:00
SimMesg20 4d3d16389e Feat/journey external picture provider rework (#1632)
* feat(journey): add contextual external photo browser

* fix(Journey) added missing language keys for new Journey feature

* fix(Journey) fixing missing gps requests aswell as added protection against duplicate entries
2026-07-23 21:35:12 +02:00
Maurice 8855b5581d feat(places): planned pool filter + hotel-transfer day routing (#1666)
* fix(dayplan): expose route tools on a hotel-to-hotel transfer day (#1297)

* feat(places): add a Planned filter to the places pool (sidebar, map, mobile)

* fix(places): stretch the mobile pool filter chips to fill the row
2026-07-23 21:28:00 +02:00
Maurice c5360be242 feat(dayplan): choose the travel mode per segment (#1281) (#1651)
* feat(dayplan): persist per-leg and per-day travel mode (#1281)

The day-plan route used one transport mode for the whole day, held only in
ephemeral state. Store it instead: leg_transport_mode on an assignment is
the mode of the leg leaving that stop (null = inherit the day default), and
days gains a default_transport_mode. Both nullable, so existing itineraries
are unchanged.

Adds PUT /assignments/:id/transport and PUT /days/:id/transport — their own
endpoints so setting the day default can't wipe notes/title the way the
general day update would — mirroring the assignment_time path.

* feat(dayplan): choose the travel mode per segment (#1281)

Each leg between two stops now routes with its own mode, so a day can mix
walking, driving and plugin-routed legs. Tap the connector between two
places to change that leg's mode (desktop popup / mobile sheet); the
whole-day Foot/Car picker sets the persisted day default and leaves
explicitly-set legs alone. The connector and map redraw in the chosen mode,
and each route segment is tagged with the mode it was drawn in.

New strings across all 23 locales.
2026-07-21 23:36:53 +02:00
Maurice ac723744ab feat(collections): add places by coordinates + sort A–Z (#1647)
* feat(collections): add places by coordinates and sort the list

Raised in #1640: collections behaved differently from trips — you
couldn't add a place by typing coordinates, and the list had no order
control.

The add-place modal and the place detail sheet now take an editable
address plus latitude/longitude, with a "lat, lng" paste shortcut, so a
place can be saved by GPS without searching. The place update path
learned to persist coordinates, so an existing one can be corrected
later.

A sort control in the filter row toggles between the saved order and
alphabetical by name. The add-place button moved into that row to match
the other controls, and the add-label button dropped its dashed one-off
styling. New strings land across all 23 locales.

* feat(collections): make the add-place button icon-only

Keep the filter row compact — the button carries just the plus icon (with
an aria-label/title), squared off to match the dropdown height.
2026-07-21 22:10:41 +02:00
Maurice 399e3573c1 feat(places): collaborative place ratings + collections MCP (#1435) (#1644)
Trip members can rate a place 1-5 stars; the place shows the average,
with a who-voted custom tooltip and a star-sort toggle in the places
sidebar. Saved collection places are ratable too (plus a min-rating
filter), and ratings travel with a place copied between a trip and a
collection — carrying only the votes of users who belong to both sides.

Adds a full Collections MCP surface (list/get, CRUD lists + places,
status, rate_collection_place, labels, sharing, copy-to-trip) under a
new collections:read/collections:write scope, plus rate_place for trip
places; rating aggregates flow through the existing place outputs.

Mobile trip + collection place sheets gain the rating row. New UI
strings translated across all 22 locales.
2026-07-21 21:00:26 +02:00
Konstantinos Thermos 88cb7f22dd fix(budget): match the add-expense input decimal separator to the list (#1645) 2026-07-21 20:36:47 +02:00
Maurice b8728078c4 feat(reservations): add a parking booking type (#1444) (#1642)
* feat(reservations): add a parking booking type (#1444)

* feat(reservations): parking type option, icon and color across the UI

* test(reservations): cover the parking booking type

* docs(reservations): document the parking booking type
2026-07-21 16:02:53 +02:00
Maurice 3678761a2d feat(places): custom place image (#1136) (#1641)
* feat(places): upload a custom place image on the server (#1136)

* i18n(places): custom place image labels

* feat(places): click a place thumbnail to set a custom image

* feat(places): custom place image on mobile place and collection sheets

* test(places): cover custom place and collection image upload

* docs(places): document custom place images

* fix(places): carry osm_id on the embedded place so day-plan thumbnails auto-fetch (#1136)

* feat(places): custom tooltip on the place image controls

* fix(places): show a custom place image on map markers, skip the redundant photo fetch (#1136)
2026-07-21 15:33:31 +02:00
Maurice 31f6ec1f47 feat(vacay): read-only calendar sharing (#444, #667) (#1637)
* feat(vacay): read-only calendar shares on the server (#444, #667)

* feat(vacay): shared calendars card and ring overlay on desktop

* feat(vacay): share sheet and calendar rings on mobile

* test(vacay): cover the calendar share flows

* docs(vacay): document read-only calendar sharing
2026-07-21 13:46:38 +02:00
Maurice 7528706d00 feat(vacay): half vacation days (#552) (#1631)
* feat(vacay): support half vacation days (#552)

Log a vacation day as a half day (0.5) as well as a full day and count it
as such against the entitlement. A "Half day" mode joins the calendar
toolbar on desktop and mobile; half days render as a diagonal half-fill in
the day cell, and the used/remaining totals carry the fraction.

Closes #552

* refactor(vacay): half-day as a per-person toggle with a corner badge

The diagonal half-fill collided with the two-person split — one person's
half day looked like two people off. Drop the diagonal: the cell fill keeps
showing who is off, a half day gets a small corner ½ badge, and a hover
tooltip lists who is off on that date and how much (only when a half day is
present). Logging moves from a third mode to a Half-day toggle that applies
to the selected person, on desktop and mobile.

* feat(vacay): mark half days with an orange corner dot

Swap the ½ badge for a small orange dot in the bottom-right corner, mirroring
the blue trip dot — quieter in the grid, and the hover tooltip still spells
out who is on a half day.

* fix(vacay): make the fraction migration idempotent

The crosswalk test replays the migration tail (rewind + re-run), which expects
every later migration to be idempotent. Guard the vacay_entries ADD COLUMN
behind a pragma_table_info check so the second run is a no-op. Also only set
dayVisual's `half` flag when true, so full days keep their exact
{ background, numColor } shape for the mobile day-model unit test.

* style(vacay): nudge the mobile day dots slightly inward

The blue trip dot and orange half-day dot sat right in the cell corner on
mobile; move both a touch toward the centre so they read cleaner.
2026-07-20 23:38:47 +02:00
Konstantinos Thermos a3cb783df7 fix(settings): retry settings load on reconnect so region prefs stop resetting (#1626) 2026-07-20 21:02:19 +02:00
Maurice b6a3cced43 Comprehensive mobile UI rewrite + desktop Vacay & Journey redesign (#1577)
* 3.3.0 (#1472)

* feat(plugins): grow the frame bridge — fill pages, confirm, openExternal, live context

- page/trip-page hosts pass fill: the frame pins to 100% height and ignores
  trek:resize, so a kit plugin's auto height report no longer collapses a full
  page into a floating island with dead space below (widgets keep self-sizing)
- trek:confirm renders the native ConfirmDialog host-side (the sandbox has no
  allow-modals) and answers trek:confirm:result; one at a time
- trek:openExternal opens validated http(s) URLs in a noopener tab — the
  sandbox has no allow-popups, so plugins simply couldn't link out before
- trek:notify accepts an optional duration, clamped to 1.5-15s
- context gains dir (rtl/ltr) and is re-pushed when locale or format settings
  change, not just on appearance mutations
- core events for the trip in view are forwarded as trek:event — names only,
  never payloads, mirroring the server-side events surface

* fix(plugins): move trip warnings out of the content area

The warning pills overlaid the top of every planner tab at full width, sitting
on the map and its toolbar. Now a warning from a plugin that owns a trip-page
tab renders as a compact chip in the navbar centre (click jumps to the tab; the
navbar centre is free on trip pages), and everything else floats above the
content at the bottom instead. Mobile keeps all warnings in the bottom overlay
since the desktop navbar isn't there. The trip tab's frame also opts into the
new fill mode.

* feat(plugin-sdk): 1.4.0 — motion library + new bridge helpers in the kit

Mirrors the host's animation vocabulary 1:1 into TREK_UI_CSS (menu/popover/
modal/backdrop/toast enters, drawer variant under 640px, page-enter, stagger,
skeleton shimmer, chart reveals) including the reduced-motion degrade to a
gentle fade. window.trek grows confirm(), openExternal(), onEvent() and a
notify duration, and applyContext now stamps lang/dir on the document so RTL
hosts get RTL plugin UIs.

* feat(plugins): surface registry download counts in browse

The registry now aggregates GitHub release download counts per plugin
(TREK-Plugins#18) as an entry-level downloadCount. Project it through
browse/detail and show it as a compact stat on the browse cards and in the
detail meta grid. Counts are raw asset downloads (updates and CI included),
so the UI says downloads, not installs.

* docs(plugins): document the grown bridge surface and motion classes

* fix(plugins): harden the new bridge paths

Review pass over the bridge additions:

- keep the unstable useToast() object out of the effect deps (ref instead) —
  it re-created the effect on every parent render, and with the new live
  repost that meant a trek:context flood into the frame
- reset loads/height/confirm state and key the iframe when a host swaps
  pluginId in place (tab bar, /plugins/:id) — the new plugin's document was
  refused as a 'navigated' frame and every kit promise hung
- confirm dialogs always lead with the host-controlled plugin name so a
  plugin can't dress its dialog up as a TREK system prompt; answer/refuse
  moved out of setState updaters (StrictMode ran them twice)
- Number.isFinite on the notify duration (NaN parked a sticky toast)
- don't forward other plugins' namespaced broadcasts as trek:event; a
  plugin's own plugin:{id}:* broadcasts now reach its frame though
- teach the SDK dev preview the confirm/openExternal contract so
  trek.confirm() resolves in /preview
- 999,950 downloads formats as 1M, not 1000k

* fix(planner): let plugin warning chips grow wider before truncating

The nav-centre chip capped at 340px, so a longer warning (e.g. the TREK x
Japan weather prompt) was ellipsised almost immediately. Scale it with the
viewport up to 520px so most messages read in full while still yielding on
narrow desktops.

* feat(plugins): sort the plugin browser by download count

Discover now honours the sort dropdown (it was always alphabetical) and adds
a 'Most downloads' option that ranks the registry by downloadCount. The sort
keys are scoped per tab — updates-first stays with Installed, most-downloads
with Discover — and snap back to name when the tab can't offer them.

* feat(plugin-sdk): auto-upgrade native <select> to a host-styled dropdown

A sandboxed plugin can't reach the host's components, and a native <select>
draws its popup from the OS — so plugin dropdowns never matched TREK. The design
kit now enhances every <select> into a keyboard-accessible listbox that uses the
kit tokens, keeping the real element as the value/form source (it still fires
change). Authors write a plain <select> and get the host look for free; opt a
field out with data-trek-native. validate warns when a plugin ships a <select>
without inlining the kit.

* feat(plugins): add issue url link

* feat(plugins): reservations write + cross-trip reads

- db:write:reservations -> reservations.create/update/delete, gated exactly like
  the REST/MCP path (reservation_edit + trip membership, acting user host-bound,
  no impersonation) and delegating to ReservationsService so the accommodation,
  budget-sync, booking-notification and reservation:* broadcasts match the web
  app 1:1 — a booking/flight/import plugin can finally write a reservation
- trips.listMine / reservations.listMine: enumerate every trip and booking the
  acting user can access (membership baked into listTrips, never a raw
  cross-tenant SELECT) — dashboards/aggregates were impossible before
- audit: derive auditability from METHOD_PERMISSION so a new capability method
  can't be added un-audited by omission
- typed ctx.reservations.* / ctx.trips.listMine, perm label (en/de), wiki

* feat(plugins): read scopes for journal, atlas, vacay and day notes

- db:read:journal / db:read:atlas / db:read:vacay expose the acting user's OWN
  journals / visited countries+regions / vacation plan across all their trips
  (user-scoped like costs.listMine, each gated on its addon being enabled),
  reusing the addon's existing readers
- db:read:daynotes -> daynotes.list(tripId, dayId), trip-scoped and
  membership-checked like the other trip reads
- typed ctx.journal / atlas / vacay / daynotes, perm labels (en/de), wiki,
  audit resource labels, tests

* feat(plugins): day notes write scope

- db:write:daynotes -> daynotes.create/update/delete, gated under the app's
  'day_edit' permission (like days) with the day verified to belong to the trip;
  reuses dayNoteService and broadcasts the same dayNote:* events so open
  sessions update live
- typed ctx.daynotes.create/update/delete, perm label (en/de), wiki, tests

* feat(plugins): run declared background jobs on a schedule

- plugins already declared jobs {id, schedule} but the cron was never wired. The
  host now schedules them: host-entry reports each job's schedule, the supervisor
  starts the jobs (node-cron) when the plugin goes active and stops them on
  kill/deactivate so nothing leaks
- opt-in via a new jobs:run permission — scheduled work runs with NO acting user
  (its trip reads stay refused; it can only use ctx.db and declared egress), so
  background execution is a distinct, admin-granted capability. Invalid crons are
  skipped and a throwing job can't break the host
- extracted a small, unit-tested scheduler (plugin-jobs.ts); perm label (en/de),
  wiki, tests

* feat(plugins): read scope for saved-place collections

- db:read:collections -> collections.listMine() / collections.get(id): the acting
  user's own collections (user-scoped, gated on the Collections addon), reusing
  collectionsService
- typed ctx.collections, perm label (en/de), wiki, audit resource labels, tests

* fix(plugins): translate new permission labels to all locales + cover the new wiring

- add the 8 new admin.plugins.perm.* labels (reservations/day-notes writes, the
  journal/atlas/vacay/day-notes/collections reads and jobs:run) to the remaining
  20 locales so the strict i18n key-parity test passes again
- cover the create-rpc-host reservation / day-note / cross-trip / addon-read deps
  (the real side-effect wiring the mocked rpc-host tests don't exercise) so the
  src/nest 80% branch-coverage gate holds

* feat(plugins): dev-link — hot-reload a local plugin against real data

Answers a plugin developer's ask: today you either get `trek-plugin-sdk dev`
(fast hot-reload but MOCK/fixture data) or the full build->pack->upload->activate
cycle (real data, no watcher). Neither gives "local dir + hot-reload + real data".

- POST /admin/plugins/link registers a plugin from a LOCAL built directory by
  symlinking it into the plugins volume — the loader already forks the resolved
  real path, so ZERO loader change — and registering it INACTIVE as `local:link`.
  Validates the manifest + refuses native binaries exactly like a sideload.
- POST /admin/plugins/:id/reload re-forks a linked plugin via the existing
  deactivate->activate primitive (same grants, no re-consent unless the manifest
  widened perms). A best-effort fs.watch auto-reloads on rebuild.
- It runs through the UNCHANGED capability RPC host: real, membership-gated data,
  acting user host-bound, no impersonation — code origin never touches the gate.
- Gated behind TREK_PLUGINS_DEV_LINK on top of admin + kill-switch, because a
  linked plugin bypasses the install-time signature model and, under `npm run
  dev`, the OS jail is off. Off by default; never reachable in production.
- discovery follows a symlinked <root>/<id>; uninstall/link never delete the
  author's source (link-safe removal for POSIX symlinks and Windows junctions).

* docs(plugins): document the dev-link real-data hot-reload workflow

Adds a "Test against a real instance's data (dev-link)" subsection next to the
mock-data SDK preview: TREK_PLUGINS_DEV_LINK, POST /link with a local built dir,
activate + consent, hot-reload via the file-watch / POST /:id/reload / Restart,
and the dev-only security caveats.

* feat(plugins): dev-link admin UI

- surface devLink (TREK_PLUGINS_DEV_LINK) in GET /admin/plugins so the panel shows
  the link form only where dev-link is enabled
- AdminPluginsPanel: a "Link a local plugin" form (path -> POST /link), a Dev-Link
  badge for source_repo=local:link, and adminApi.pluginLink/pluginReload
- fix: the plugin menu treated any non-local:upload source_repo as a GitHub repo,
  so a dev-linked plugin rendered github.com/local:link links — exclude local:link
- labels for the 6 new dev-link UI strings across all 22 locales

* docs(plugins): document the dev-link admin UI

The dev-link section showed only the curl call — surface the Admin → Plugins
"Link a local plugin" field (the primary path) and the Dev-link badge, with curl
kept as the scripting alternative.

* feat(plugins): enrich core events with { entity, entityId }

Subscribed plugins now learn WHICH entity changed, not just the event name — a
reservation/place/day/... id derived host-side from an explicit per-family
whitelist. Threaded through the six event hops WITHOUT touching actingUserId: the
handler still runs with no user, so the id is not dereferenceable (a trip read is
still refused; the id says what to react to, not what it contains). A non-entity id
can never surface — budget:member-paid-updated yields the itemId, never the userId
— and bulk/reorder/sub-entity payloads carry no id. The mapper is pure, synchronous
and never throws into the core broadcast. No new permission (reuses
events:subscribe); backend-only.

* feat(plugins): packing write scope with #858 privacy-scoped broadcasts

- db:write:packing -> packing.create/update/delete, gated under the app's
  'packing_edit' permission (like the REST path) with the host-bound acting user
  as owner; reuses packingService
- replicates the packing privacy model 1:1 (the controller/service helpers aren't
  exported): create/delete fan out to the item's viewers only (owner + recipients,
  or the whole room for a Common item); update runs the four public<->private
  transitions, dropping a freshly-privatized item from the room BEFORE re-adding it
  owner-only so it never leaks. A stale write is BAD_PARAMS with no broadcast
- typed ctx.packing.create/update/delete, perm label (22 locales), wiki, tests
  (rpc-host gating + the four transitions + owner-scoped delete)

* feat(plugins): tableContributor hook — host-rendered view columns/actions (backend)

The registry backend for plugin-contributed columns/actions in the native planner
views (the tabular-reservations use case), mirroring placeDetailProvider:
- hook:table-contributor + the tableContributor hook (getContributions(view,
  tripId, ctx) -> TableContribution[]), double-gated (implement + grant) like the
  other provider hooks
- GET /api/view-contributions/:view/:tripId — view whitelist + membership gate +
  per-provider timeout/fail-safe, plus the hardening the older provider hooks lack:
  every field is String-coerced + length-capped, kind/tone/target enum-whitelisted,
  per-provider counts capped (<=20 columns / <=10 actions), and a column url must be
  http/https/mailto (a javascript:/data: url is click-XSS into the native DOM)
- typed pluginsApi.viewContributions + the ViewContribution union, perm label
  (22 locales), wiki, hardening tests

* feat(plugins): render tableContributor columns/actions in the reservations view

The frontend for the tableContributor hook: a reusable PluginContributions layer
(usePluginViewContributions + PluginColumns/PluginActions) that renders the
host-normalized column/action leaves NATIVELY — a column is text/badge/link, an
action is a button that calls the plugin route or opens its sandboxed frame in a
modal (plugin markup only ever runs inside the opaque-origin iframe). Wired into the
reservations cards (both ReservationCard and TransitJourneyCard) as a strictly-
additive footer keyed by reservation id: zero change to a card when no plugin
contributes. Fetched once per view, fail-safe.

* docs(plugins): bring the permissions wikis current with this cycle

Plugin-Permissions.md was missing every permission added this cycle — add rows for
the read scopes (journal/atlas/vacay/daynotes/collections), the write scopes
(reservations/daynotes/packing, packing noting the #858 owner-scoping), jobs:run
and hook:table-contributor, and correct the events:subscribe row for the new
{ entity, entityId } hint. Add the jobs:run row to Plugin-Development.md too.

* fix(maps): stop quick one-finger pans zooming the map on mobile (#1440)

The global drag-drop-touch polyfill installs document-level touch listeners
on phones. On every single-finger touchend it records a timestamp, and if the
next touch starts within 500ms it synthesises a dblclick on the target, which
the map's default double-click-zoom turns into a zoom-in. Two quick one-finger
pans therefore zoomed instead of panning.

The polyfill only bridges HTML5 drag-and-drop to touch for planner reordering,
which is already disabled on mobile (#1432), so gate its import to viewports
>=1024px (the lg breakpoint useIsMobile uses). Removes the phantom-dblclick
source on phones while keeping touch DnD on large viewports; fixes both the
Leaflet and GL renderers.

* fix(feeds): emit TZID + VTIMEZONE so subscribed calendars respect time zones (#1453)

exportICS emitted timed DTSTART/DTEND as bare floating times (no Z, no TZID),
which iOS/Google Calendar render in the subscriber's local zone instead of the
zone TREK shows. Resolve an IANA zone per timed event — transport endpoints use
their stored timezone (departure drives DTSTART, arrival drives DTEND), while
assignments and hotel/restaurant reservations derive it from place coordinates
via tz-lookup — and attach TZID backed by a VTIMEZONE component. The all-trips
feed now carries deduped VTIMEZONE blocks so TZID references still resolve.

* feat(plugins): render tableContributor contributions in the places + day views

Extends the tableContributor frontend to all three planner views: hoist the shared
PluginCardFooter into PluginContributions, wire the places sidebar (keyed by place
id, rendered as a sibling after each row so the drag/scroll row stays untouched)
and the day panel (keyed by day id, guarded for a null day). Strictly additive +
fail-safe like the reservations view — nothing renders when no plugin contributes.

* fix(vacay): source holiday subdivisions from ISO 3166-2 so all states show

The state/region picker for public-holiday calendars was built from the
union of each holiday's counties for the current year, so a subdivision
only appeared if some holiday that year was tagged with it. States with
no state-specific holiday (e.g. US-WA, and AR/FL/NV/WY in 2026) silently
vanished, blocking calendar creation (#1456).

Source the full, correctly-named subdivision list per country from
ISO 3166-2 instead, merged with any nager county code ISO lacks. Only
region-partitioned countries get a picker, so nationwide-only countries
keep allowing a country-level calendar. No server change needed —
selecting a state already yields federal holidays via applyHolidayCalendars.

* fix(costs): settlements honor custom per-member splits (#1458)

calculateSettlement read each member's custom split amount but its query
never selected budget_item_members.amount, so hasCustomSplit was always
false and every settlement fell back to the equal split. Select bm.amount
so custom amounts drive the balances.

Also blank the Overview 'Per Person' / 'Per Person·Day' columns and CSV
for custom-split items, where a single averaged figure is meaningless.

* feat(plugins): read-convenience + todos + packing bags + tags + roster

A wave of small, high-value capabilities:
- weather:read (ctx.weather.get) — the host's cached forecast, tenant-free
- db:read:categories (ctx.categories.list) — the global place-category list
- db:read:tags / db:write:tags (ctx.tags) — the acting user's own tags, ownership
  re-checked before each write
- trips.members (ctx.trips.members) — the trip roster (id + display fields),
  membership-checked
- db:read:todos / db:write:todos (ctx.todos) — a trip's to-dos, gated by the app's
  packing_edit like the REST path, broadcasts todo:*
- packing bags on ctx.packing (listBags/createBag/updateBag/deleteBag/setBagMembers)
  under db:write:packing — no privacy, plain room broadcasts
perm labels (22 locales), both wikis, rpc-host gating + create-rpc-host wiring tests

* fix(dashboard): render next-trip boarding pass stats on Safari (#1459)

The boarding-pass bar carved its ticket-stub notches with a two-layer
radial-gradient mask composited via mask-composite: intersect (and legacy
-webkit-mask-composite: source-in). Safari mis-composites that multi-layer
path to fully transparent, hiding the entire stats bar while Chrome renders
it fine.

Split .hero-pass into an outer wrapper (left notch) and a .hero-pass-inner
glass panel (right notch), each carrying a single-layer mask so the
mask-composite path is never exercised. Renders identically across engines
and degrades safely where mask-image is unsupported.

* feat(plugins): write scopes for atlas, vacay, journal and collections

The write half of the user-scoped addon reads:
- db:write:atlas -> ctx.atlas.markCountry/unmarkCountry/markRegion/unmarkRegion +
  bucket-list create/delete. Every row is the acting user's own (visited_countries/
  visited_regions/bucket) — no trip scoping, no cross-tenant surface. Unblocks
  AirTrail-style two-way sync (#214)
- db:write:vacay -> ctx.vacay.toggleEntry/toggleCompanyHoliday. The plan is
  resolved HOST-SIDE from the acting user's active plan — a plugin can never name
  another plan, and toggleEntry only toggles the acting user's own PTO day
- db:write:journal -> ctx.journal.createEntry/updateEntry/deleteEntry, self-gated
  by journeyService.canEdit (owner/contributor) against the acting user
- db:write:collections -> ctx.collections.create/update/savePlace/copyToTrip/
  deletePlace, schema-validated; the service's per-collection role checks
  (assertAccess 404 / assertCanEdit 403) map onto RESOURCE_FORBIDDEN
All addon-gated, userless contexts refused, audited. Perm labels (22 locales),
both wikis, gating + wiring tests.

* fix(admin): name the Costs add-on consistently in the catalog

The budget add-on catalog entry still resolved to 'Budget' while the
feature is labeled 'Costs' everywhere else (trip tab, navbar). Align
admin.addons.catalog.budget.name with each locale's trip.tabs.budget
label. Closes #1464

* feat(plugins): file attach, collab content and gated member-add

- db:write:files -> ctx.files.create/createLink/update/softDelete under the app's
  separate file_upload/file_edit/file_delete rights. Content arrives as bounded
  base64 (10MB decoded cap, well under the app's 50MB), the extension is validated
  against the central blocklist BEFORE anything touches disk, and link targets
  must live on the same trip (findForeignLinkTarget). Broadcasts file:*
- db:write:collab -> ctx.collab.createNote/createPoll/votePoll/createMessage
  under collab_edit + the Collab addon, emitting the same collab:* events as the
  app; service-reported errors surface as BAD_PARAMS
- db:write:members -> ctx.trips.addMember. Adding a member GRANTS TRIP ACCESS, so
  it is deliberately its own permission behind the app's member_manage right
  (default: trip owner only) and never bundled with a lower-risk write; the acting
  user is recorded as the inviter, target must exist, owner/duplicate adds no-op
Perm labels (22 locales), both wikis, gating + wiring tests.

* fix(maps): honor check-in/out times for hotel bookend legs (#1465)

The day route drew the accommodation as the day's start/end whenever the
edge stop was a place, ignoring the morningIsSleptHere/eveningIsOvernight
provenance already computed by getDayBookendHotels. On a check-in day an
airport placed before check-in got a spurious hotel -> airport leg, and on
a check-out day a later "home" stop still got a home -> hotel return leg.

Add time-aware shouldDrawMorningLeg/shouldDrawEveningLeg helpers: the
morning leg is the home-base default on a check-in day but is dropped when
the first place is timed before check-in; the evening return leg is off on
a check-out day unless the last place is timed at/before check-out. Wire
them into the map polyline, the sidebar hotel connectors, and the Google
Maps export so all three stay consistent.

* feat(plugins): host-mediated notifications and LLM access

Two host-owned integration primitives — the plugin supplies intent, the host
owns the sensitive part:

- notify:send -> ctx.notify.send({title, body, link?, scope, targetId}). Delegates
  to notificationService.send with a new plugin_notification event (raw title/body
  carried as passthrough params), so recipient resolution, channel fan-out
  (bell inbox + email/ntfy/webhook) and per-user preferences all match core 1:1.
  Recipients are FORCED to the acting user (scope 'user', targetId === uid) or a
  trip they belong to (scope 'trip'); scope 'admin' refused; the in-app link must
  be a relative /path (open-redirect-safe). No arbitrary recipient, no impersonation.
  Users can mute plugin notifications like any other event.
- ai:invoke -> ctx.ai.complete(prompt) / ctx.ai.extract(text, jsonSchema). Runs the
  admin/user-configured provider via resolveLlmConfig + the existing extraction
  client under the acting user; the host holds the (encrypted) key, the plugin
  never sees it. Refused when no provider is configured; 20k-char caps. Output is
  DATA (complete -> {text}, extract -> {results}) and never auto-written, so
  prompt-injection can't reach a write without the plugin's own gated call.

plugin_notification wired through the shared NotificationEventKey + all 22 locales
(inbox passthrough + external channels). Perm labels (22 locales), both wikis,
gating + wiring tests.

* fix(budget): offer every Frankfurter-supported currency (#1470)

The cost currency picker was gated by a hardcoded 47-code list, so
currencies the app can actually convert (OMR, CRC, UGX, MKD, ALL, and
~115 more) couldn't be selected. Replace CURRENCIES/SYMBOLS with the full
set the Frankfurter v2 FX API supports (archived BGN/HRK dropped), unify
the dashboard offline fallback onto it, and teach currencyDecimals about
the newly reachable zero- and three-decimal currencies. A currenciesWith
helper keeps a previously saved (now-archived) selection selectable so it
isn't silently wiped.

* feat(plugins): tableContributor into the costs, packing and files views

Extends the shipped tableContributor hook to three more native views — no new
permission, no new attack surface: the same host-normalized, length-capped,
url-allowlisted (http/https/mailto), enum-bounded, fail-safe pipeline, just more
render sites.

- server: add costs/packing/files to the view-contributions whitelist
- client: widen the ViewName union + the api view type; render PluginCardFooter
  keyed by entityId in the budget category table (a colSpan footer row per item),
  the packing category group (footer after each item row, drag untouched) and the
  files list (footer after each row)

A currency plugin can now drop a converted-amount column onto a cost row, a
receipts plugin a 'view receipt' action onto a file, etc. Controller test asserts
the three new views are accepted; both wikis updated.

* fix(pdf): repeat day header on overflowing itinerary export pages (#1471)

* feat(plugins): map-marker provider hook — plugins can overlay trip-map markers

New declarative provider hook `mapMarkerProvider` (#587 "show bookings on map",
the single most-requested contribution class, with zero contribution point until
now):

- hook:map-marker-provider permission + MapMarkerProvider/MapMarkerContribution SDK
  types + HOOK_PERMISSION wiring
- GET /api/map-markers/:tripId (MapMarkersController) mirrors the view-contributions
  hardening: membership-gated, providers invoked host->plugin on a 5s timeout,
  fail-safe. Every field normalized server-side — coordinates range-checked
  (-90..90 / -180..180), strings String-coerced + length-capped, icon/tone enum-
  whitelisted, popup url http/https/mailto only (a javascript:/data: url would be
  click-XSS), marker count capped at 200 per plugin
- client: PluginMapMarkers layer renders the markers as plain Leaflet Marker+Popup
  inside the trip map; plugin JS NEVER runs on the map canvas, every value is
  host-vetted data. Threaded tripId through MapView; fail-safe fetch

Declarative-only by design, mirroring placeDetailProvider/tableContributor. Perm
label (22 locales), controller hardening test, both wikis.

* feat(plugins): show page plugins in the mobile bottom nav

Page plugins were reachable from the desktop nav pill (Navbar) but not the mobile
tab bar — you had to type /plugins/:id. BottomNav now reads page plugins from the
plugin store and appends them the same way global addons are, mirroring Navbar.
One-file client nav wiring; no new capability surface.

* feat(plugins): per-user plugin settings form + ctx.settings runtime read

Users can now enter their own per-plugin config (an API key, a preference) —
the prerequisite for almost every real integration, previously unreachable
(scope:'user' settings were only listed read-only in the admin panel).

- migration: plugin_user_config (plugin_id, user_id, config JSON) — each user's
  own values, separate from the admin-owned instance plugins.config
- PluginsService.getUserConfig / updateUserConfig / getUserConfigDecrypted +
  readUserSettingDecrypted: secrets encrypted at rest (apiKeyCrypto), masked to
  the client, an unchanged secret (the mask) keeps its stored ciphertext, and only
  DECLARED scope:'user' keys are ever stored
- GET/POST /api/plugin-settings/:id (PluginUserSettingsController) — its own
  user-gated path (not the admin surface, not the /:id/* proxy), JwtAuthGuard only,
  scoped to the acting user
- runtime: ctx.settings.get(key) -> the acting user's decrypted value (unconditional
  RPC, not sensitive cross-tenant; userless job/onLoad gets undefined)
- client: a Plugins tab in Settings host-renders each active plugin's scope:'user'
  fields as an editable form (secrets write-only), reusing the declarative field
  shape — no plugin markup executes

i18n (22 locales), wiki, rpc-host + service + masking/encryption tests.

* fix(journey): keep skeleton suggestions in sync with linked trip places (#1473)

Journey skeleton suggestions mirror a linked trip's day-assigned places, but
sync relied on scattered per-event hooks that several assignment mutation paths
never called: unassign, move and time-change fired nothing, no remove-on-unassign
capability existed, and every MCP assignment tool synced nothing. Skeletons drifted
from the trip.

Add an idempotent reconcileTripSkeletons(tripId) that re-mirrors the trip's
day-assigned places onto every linked journey (add missing skeletons, refresh
date/time/location on move, remove skeletons for unassigned places; filled entries
are detached + noted, never destroyed). Call it from every REST assignment handler
and MCP assignment tool, and fire onPlaceDeleted on single MCP delete_place for
parity. Extract a shared insertSkeletonEntry helper.

* fix(memories): drop hidden Immich assets so Live Photo motion parts don't show a broken thumbnail (#1474)

* fix(transit): anchor arrive-by search time to the destination timezone (#1479)

* feat(plugins): host-brokered OAuth client + trustworthy inbound webhooks

Two integration primitives where the host owns the sensitive part.

Trustworthy webhooks:
- auth:false routes now receive req.headers, but ONLY an explicit, credential-free
  allowlist (the common provider signature/event headers — stripe-signature,
  x-hub-signature-256, svix-*, x-gitlab-event, …). Cookie/Authorization/X-Socket-Id
  and every session/forwarded-auth header are stripped; authenticated routes get {}.
  A plugin can finally verify a provider signature without any way to leak a session.

Host-brokered outbound OAuth (oauth:client):
- the HOST runs the whole flow — authorize -> callback -> token exchange -> refresh —
  with PKCE + single-use, user-bound, TTL'd state, and HOLDS the tokens. The client
  secret + refresh token never leave the host; the plugin only triggers connect and
  reads a short-lived access token via ctx.oauth.getAccessToken() for the acting user.
- provider config (authorize/token url + scopes + client id/secret) is the plugin's
  admin-owned instance settings; endpoints must be https (SSRF backstop, private/local
  hosts refused). Tokens per-user + encrypted at rest (apiKeyCrypto).
- GET/POST /api/plugin-oauth/:id/{status,connect,callback,disconnect} — JwtAuth-gated,
  the callback always redirects to an in-app /settings path (never leaks an error).
- Settings -> Plugins gains a Connect/Disconnect control per configured plugin.

migration: plugin_oauth_tokens + plugin_oauth_state. Perm labels + form strings
(22 locales), both wikis, service (PKCE/state/exchange/refresh/encrypt) + controller
+ proxy header-allowlist + rpc-host gating + create-rpc-host wiring tests.

* fix(navbar): re-measure sliding tab pill after font load and resize (#1481)

The active tab pill was measured once in a layout effect keyed only on activeTab, so on a hard reload it captured the active (bold) label's width against fallback-font metrics and never re-ran when the web font swapped in, leaving the pill slightly offset.

Re-measure after document.fonts.ready resolves and on ResizeObserver changes (container + active button), with an idempotent state update to avoid redundant renders.

* fix(collections): keep the Add-place button reachable after the first save

On a wide/desktop layout the collection toolbar (which hosts the Add
button) was gated on !mapOverlay, so it unmounted as soon as the list
gained its first place with coordinates — leaving only an easy-to-miss
"+" in the map overlay. Keep the toolbar rendered whenever the user can
add a place, and drop the now-redundant map-overlay Add button so there
is a single, predictable Add affordance in every state.

Fixes #1485

* feat(plugins): days + accommodations reads/writes, endpoints on the reservation write path

Community feedback on the 3.2.1 plugin surface: a plugin could write days but
never list them (no way to learn day ids), day_accommodations had no surface at
all, and trips.getReservations was the one reservation read that dropped the
endpoints/day_positions hydration.

- trips.getDays / trips.getAccommodations under db:read:trips (tripRead gate),
  wired to the same dayService lists the REST GETs use
- trips.getReservations now returns the hydrated REST-parity list (endpoints,
  day_positions, joins, normalized accommodation_id) - strict superset
- new db:write:accommodations scope: ctx.accommodations create/update/delete
  gated by day_edit like the accommodations REST path, with the partner-hotel
  reservation + delete cascade and broadcasts intact
- reservation create/update pin the endpoints shape up front (BadParams instead
  of a mid-transaction NOT-NULL or a silently dropped row)
- perm label in all 22 locales, consent PERM_KEYS, wiki tables

* feat(plugins): day-detail widget slot in the day panel

Widgets can now mount inside the trip planner's day panel
(capabilities.widget.slot: 'day-detail'), scoped to the open day via a dayId in
trek:context - the same pattern as the place-detail slot. Covers the requested
per-day plugin content (logistics, outfit planning, live flight status) without
a new plugin type. Day-detail widgets stay off the dashboard, the consent panel
labels the slot in all 22 locales.

* feat(plugins): let the frame CSP serve a plugin's own static assets

The sandboxed frame runs at an opaque origin, so script-src 'self' never
matched and a plugin's own <script src>/<link> files were blocked - authors had
to inline entire React builds into index.html. Add a scheme-less host-source
pinned to the plugin's own /plugin-frame/<id>/ path (charset-checked Host +
plugin id so a stray token can't widen the policy; malformed Host falls back to
inline-only). Multi-file client builds now load as-is; remote hosts stay
blocked, so script URLs remain useless as an egress channel.

* fix(plugin-sdk): catch the package up to the server capability surface

The npm SDK's validator still knew only the 3.2.1 permission set, so
'trek-plugin-sdk validate' (and pack/publish, which run it) hard-rejected any
manifest using the newer scopes - db:write:reservations, notify:send,
hook:map-marker-provider and 25 more. Sync KNOWN_PERMISSIONS with the server
envelope (48 entries), mirror the full PluginContext (reservations,
accommodations, notify/ai/oauth/settings, packing writes + bags, file writes,
collab, tags/todos/daynotes/collections/atlas/vacay/journal, weather,
categories), type the tableContributor/mapMarkerProvider hooks + the
entity/entityId event hint, accept the day-detail widget slot, and extend
createMockHost so plugin unit tests can exercise all of it.

* feat(plugins): grant-scoped entity snapshots on core events

An events:subscribe handler so far learned only WHICH entity changed - useful
for cache busting, useless for reacting to content, and the userless handler
can't refetch. Now the broadcast tap derives a whitelisted field snapshot of
the changed entity and the supervisor attaches it per plugin, only where the
granted set holds the family's matching db:read:* permission (trips family ->
db:read:trips, budget -> db:read:costs, packing -> db:read:packing, dayNote ->
db:read:daynotes, file -> db:read:files). No acting user is ever synthesized.

The whitelists are explicit per family, so user ids (owner/paid_by/uploaded_by/
participants/members), trips.feed_token and future migration columns never
travel; a private packing item (#858) yields no snapshot at all because its
core broadcast is owner-scoped; deletes/reorders/bulk ops carry none.

* feat(plugins): pdf-section, atlas-layer and journal-entry provider hooks

Three more declarative provider surfaces in the map-marker mould - plugins
return data specs, the host normalizes, caps and renders; a slow or failing
provider contributes nothing:

- hook:pdf-section-provider: sections (title + paragraphs + a simple table)
  appended to the trip PDF export, escaped into the same HTML/print pipeline
  as the core content
- hook:atlas-layer-provider: per-user country tint layers on the Atlas map
  (ISO 3166-1 alpha-2 codes only, tone-whitelisted, non-interactive pane so
  mark/unmark clicks keep working)
- hook:journal-entry-provider: extra rows on a journal entry card, gated by
  the same journey access check as the journal routes + the Journey addon

Permission labels in all 22 locales, consent PERM_KEYS, SDK types + manifest
validator in both SDK copies, wiki tables, per-controller hardening tests.

* feat(plugins): trip-page plugins can replace core planner tabs and pick their spot

A trip-page plugin that takes over a core surface (a transit planner
superseding Transports, a costs plugin superseding the budget tab) had to sit
awkwardly next to the tab it replaces. capabilities.tripPage now names the
core tabs to hide while the plugin is active - whitelisted (transports,
buchungen, listen, finanzplan, dateien, collab), 'plan' deliberately not
replaceable, and the tabs return the moment the plugin is deactivated - plus
an optional 0-based position for the plugin's own tab. The feed re-validates
the values out of the DB blob so a hand-edited row can't hide anything else,
the admin list chips a replacing plugin (all 22 locales), and a saved session
tab that got replaced falls back to the plan view.

Also fixes the plugins feed dropping the day-detail widget slot to 'sidebar',
which would have mounted a day-panel widget on the dashboard.

* fix(plugins): audit follow-ups — normalization, secret cleanup, cron leak, slot filter

Adversarial audit of the whole plugin PR surfaced 12 confirmed issues; this
addresses them:

- place-details provider was the ONE hook controller with no normalization: a
  plugin's href/label/value went to the client raw and unbounded. Now normalized
  like journal-entry-rows (safeUrl http/https/mailto, length + count caps).
- trip-warnings capped message length + per-provider count (was unbounded).
- uninstall(deleteData) now also purges plugin_user_config, plugin_oauth_tokens,
  plugin_oauth_state, plugin_meta_migrations and the capability audit — encrypted
  per-user API keys + OAuth refresh tokens no longer survive a 'delete all data'
  and get silently re-adopted on a same-id reinstall.
- supervisor: a crash-restart cycle leaked the dead child's node-cron tasks and
  re-scheduled fresh ones, so a job fired N+1 times per tick after N crashes.
  onExit now stops them, mirroring kill().
- dashboard sidebar no longer mounts place-detail/day-detail widgets (they belong
  in the planner panels).
- reservation endpoint validation relaxed to match the 3.2.1 service: a coord-less
  endpoint is accepted and dropped downstream instead of BadParams (no breaking
  change), while a bad role/non-string still rejects up front.
- a replaced core tab reached by programmatic nav now falls back to the plan view.
- trips.update caps title/description like the places path; plugin-db guard bans
  load_extension as defense-in-depth.
- wiki: event snapshots, string-typed context ids, dayId in the payload, the live
  provider hooks and the costs update/delete grant are now documented correctly.

* feat(plugins): phase-0 lifecycle hardening + per-plugin RPC rate limit

Operational-readiness fixes from the completeness audit:

- Re-activation after a failure worked again: a plugin left in 'error' state by
  a load-failure or crash-auto-disable stayed in the running map, so the admin's
  'enable' button was a silent no-op. activate() now replaces a dead entry.
- Per-plugin RPC rate limit at the dispatch boundary: every ctx.* call runs
  synchronously on the host thread, so a plugin in a tight loop could freeze the
  whole instance (and the reap sweep). A token bucket (generous burst) + an
  in-flight cap now throttle a runaway plugin with a retryable HOST_ERROR; a
  legitimate plugin never notices.
- plugin_error_log retention (500 rows/plugin) so a crash-looper can't grow
  trek.db without bound; the crash-timestamp array is trimmed to its window too.
- TREK_PLUGIN_PERMISSIONS=off now logs a loud one-time warning that the OS
  permission jail is disabled.

* feat(plugins): read symmetry + broker — collab/journal/atlas reads, file content, trip create, rates

The plugin API leaned write-heavy: collab and journal could be written but not
read, files listed but not read, and there was no way to create a trip or see
exchange rates. This closes those gaps in the established RPC+gate pattern (zero
architecture risk), and it's what unlocks the importer + finance plugin classes:

- collab reads: ctx.collab.listNotes/listPolls/listMessages under a new
  db:read:collab (membership + Collab addon, like the REST GETs)
- ctx.journal.getEntries(journeyId): a journey's entries, journey-access-checked,
  under the existing db:read:journal
- ctx.atlas.bucketList(): the acting user's bucket list, under db:read:atlas
- ctx.files.getContent(tripId, fileId): a file's bytes as base64 under a NEW
  db:read:files:content grant (reading a passport scan is more sensitive than its
  filename), size-capped at 10MB before it crosses the IPC pipe, trashed files
  refused
- ctx.trips.create(input): a new trip owned by the acting user, gated by the app's
  trip_create right + a bound user — the capability importers need
- ctx.rates.get(base): cached currency exchange rates, tenant-free like weather

Also caps trips.update title/description like the places path, and the plugin-db
guard now bans load_extension (defense-in-depth). SDK, mock-host, i18n (22
locales), consent labels and the wikis are all in lockstep.

* feat(plugins): deeper integration + user-facing activity transparency

Wave 2 of the completeness work — richer extension points, deeper metadata, and
the transparency that makes the broad read grants accountable:

- db:meta now attaches to reservations + accommodations too (not just
  trip/place/day), gated by reservation_edit / day_edit respectively — the
  natural home for an external-id mapping (AirTrail/calendar/booking-import sync)
  without forking the core schema.
- reservation-detail widget slot: a widget can mount on a booking card, scoped to
  the open reservation via reservationId in trek:context (the place-detail /
  day-detail pattern, third instance).
- tableContributor gains the transports + todos views, so a plugin can add
  host-rendered columns/actions there too.
- User activity log: GET /api/plugin-activity + a Settings → Plugins panel showing
  every host-mediated action a plugin took bound to the signed-in user, across all
  plugins, newest first — the user-facing half of the hash-chained audit. This is
  what legitimizes the deliberately broad read grants: not just the admin, the
  person whose data is read can see what was done in their name.
- DX: the local dev server now binds a default acting user, so the canonical
  ctx.trips.getPlaces(tripId) call works locally instead of failing RESOURCE_
  FORBIDDEN; the create scaffold drops the dead manifest routes[] / capabilities.nav
  fields the host ignores.

SDK, i18n (22 locales), consent labels and the wikis are all in lockstep.

* fix(memories): load Immich album photos on Immich v3

Immich v3 removed the `assets` property from AlbumResponseDto, so
`GET /api/albums/:id` no longer carries album contents. TREK read album
photos from that property, which now parses as undefined and degrades to
an empty array — hence "No photos yet" in the Journey gallery picker even
though the album header shows the right count (that count comes from
`GET /api/albums` -> assetCount, which v3 still returns).

Two call sites read the removed property. Besides getAlbumPhotos (the
reported bug), syncAlbumAssets failed silently on v3: it reported
`success: true, added: 0` while syncing nothing.

Fetch album contents via an `albumIds`-filtered `POST /api/search/metadata`
when `assets` is absent, and feature-detect rather than probe a version.
The two paths are not interchangeable: on v2, searchMetadata
unconditionally scopes results to `[self, ...partners]`
(`asset.ownerId = ANY(userIds)`), so an albumIds search against an album
shared by a non-partner returns nothing. v3 added an albumIds branch that
checks AlbumRead and skips that owner filter. v2 also hard-defaults
`visibility` to `timeline`, dropping archived assets. So v2 must keep
reading the album detail body, which this preserves exactly.

`withExif: true` is required on the search path: it has no default and
gates an inner join, so without it Immich omits `exifInfo` entirely and
every photo's city/country goes null.

The existing test mock returned an album detail body *with* `assets` — it
encoded the v2 assumption, which is why this shipped green. It now models
v3 by default, with explicit v2 coverage asserting no search call is made.

Fixes #1492

* feat(plugins): daily AI/notify budgets, runtime scheduler & reliable event redelivery

Per-plugin daily caps on ai.complete/ai.extract and notify.send (defaults
200 / 100, overridable via TREK_PLUGIN_AI_PER_DAY / TREK_PLUGIN_NOTIFY_PER_DAY),
seeded from the capability audit so a mid-day restart resumes the count instead
of resetting it. Surfaced at GET /plugins/:id/budget.

ctx.scheduler (at / in / every / cancel): persistent, userless timers that
survive restarts and fire a scheduled() handler, riding the existing jobs:run
grant so no new consent or admin setup is needed. Backed by
plugin_scheduled_tasks, swept every 30s, capped at 100 tasks/plugin with an 8 KB
payload and a 60s recurring floor; rows are removed on uninstall.

Core events that fire while a subscriber is mid-restart are now held in a
bounded in-memory buffer (200/plugin, 15 min TTL) and replayed once it goes
active again, with the events:subscribe grant and snapshot gating re-evaluated
at replay time so nothing leaks if a grant was revoked while the plugin was down.

* feat(plugins): GDPR data-subject rights — durable per-plugin erasure + export

New hook:user-data grant with two userless lifecycle handlers a plugin can put
on its definition: deleteUserData and exportUserData. Neither carries an acting
user — the plugin only learns the userId and touches its own db — so the grant
reads nothing from core data; it exists purely so a plugin can honour a GDPR
erasure or data-access request.

When a TREK account is deleted (admin or self-service), every installed plugin
holding the grant gets a row in a new durable erasure queue and its
deleteUserData runs on the next sweep, retried until it ACKs — so erasure
survives the plugin being offline or the server restarting. The core deletion
path notifies the runtime through a dependency-free relay (like the event sink),
keeping the auth/admin services decoupled from the plugins layer, and a plugin
bookkeeping error can never fail the account deletion.

Portability is served by GET /api/admin/plugins/user-data/:userId/export, which
fans exportUserData out to the active granted plugins and aggregates what each
holds about the user. Queue rows are purged on uninstall; the grant is labelled
in all 22 locales.

* feat(plugins): atomic ctx.db.tx for consistent multi-write on a plugin's own db

Plugins could already query/exec/migrate their own SQLite file, but a multi-step
write (move an item between tables, decrement one row and increment another) had
no way to be atomic. db.tx([{sql, args?}, …]) runs up to 100 statements in a
single transaction — all commit or all roll back — and reads within the batch see
its own earlier writes, so read-modify-write is safe. Each op is one statement:
a read returns { rows }, a write { changes }. The same guard (no ATTACH/PRAGMA/
RECURSIVE, size + row caps) applies to every statement in the batch.

* fix(memories): filter hidden Immich assets at the source, not just the picker

#1474 has the same root cause as #1492: the Immich v3 migration. On v2,
searchAssetBuilder hard-defaulted metadata search to `timeline` visibility
(`visibility = options.visibility ?? Timeline`), so hidden Live Photo
motion parts could never come back from a search. v3 defaults to any
visibility except `locked`, so they do — which is why the reporter is on
Immich 3.0.1 and why the bug never appeared before.

Ask for `visibility: 'timeline'` explicitly on the search path. That
restores v2 semantics on both versions and stops hidden assets crossing
the wire, which also fixes a pagination wart: a full page half-made of
motion parts previously rendered as a half-empty page, because hasMore
counts the raw page length while the filter shrinks the rendered set.

The client-side filter was display-only, applied in searchPhotos and
getAlbumPhotos — both picker-listing paths. Nothing guarded persistence
or rendering: getOrCreateTrekPhoto stores any id it is handed, pipeAsset
forwards Immich's 400/404 verbatim, and the photo grid is a plain <img>
with no onError. So syncAlbumAssets, which filtered `type === 'IMAGE'`
only, could persist a hidden IMAGE as a permanently broken tile. It now
applies the same guard, extracted as isVisibleAsset().

Albums keep their filter rather than requesting `timeline` visibility:
albums legitimately contain archived assets, and both the v2 album body
and the v3 album search return them.

Does not address tiles already persisted before this — those still render
broken and need a separate fix.

Refs #1474

* docs(memories): correct Immich version boundaries in the hidden-asset comments

Verified against the v1.120.0 → v3.0.0 OpenAPI specs and server source. The
previous comments said "Immich v2 hard-defaulted metadata search to timeline
visibility". That is true only for 1.133–1.144.

- `visibility` was added in 1.133.0. Before that, searchAssetBuilder applied
  `.$if(options.isVisible !== undefined, ...)` with no default, so pre-1.133
  servers returned hidden assets too. #1474 was therefore not purely a v3
  regression.
- Those servers strip the `visibility: 'timeline'` filter rather than
  rejecting it: Immich validates with `whitelist: true` and no
  `forbidNonWhitelisted`. So the request stays valid, the filter is a no-op,
  and isVisibleAsset() is the ONLY guard there. Say so, so it does not get
  removed later as redundant.
- `albumIds` only exists from 1.135.0. Because unknown properties are stripped,
  an albumIds search against an older server would silently drop the album
  filter and return the entire library as the album's contents. Feature
  detection on `assets` (present through 1.144.1, absent on v3) makes that
  unreachable; a version probe with a wrong boundary would not.

Also cite Immich's own enum, which documents AssetVisibility.Hidden as
"Video part of the LivePhotos and MotionPhotos".

Comments only — no behavior change.

* feat(plugins): dashboard trip-card badges + a mock-host driver for plugin tests

Two additions that round out the plugin platform's breadth and its authoring DX.

tripCardProvider hook (hook:trip-card-provider): a plugin returns small declarative
badges for the dashboard trip cards. The dashboard fetches all visible cards in one
call; the host access-checks every tripId for the acting user, bounds each field
(label/value length, enum tone, http/https/mailto-only url), caps the count and drops
any badge for a card that wasn't requested — plugin JS never runs on the dashboard.
Rendered as text chips under the card meta; labelled + gated in all 22 locales.

createMockHost now exposes run(def) — the other half of a plugin unit test. Where the
ctx recorders capture what a plugin read, run() fires its own entry points (route, job,
scheduled, event, plugin-event, deleteUserData, exportUserData, provider hooks) against
the same mock ctx, and host.scheduled surfaces the timers it armed. A handler the plugin
didn't declare throws a clear error instead of a silent no-op.

* feat(plugins): include plugin data + code in backups, applied on restart

A TREK backup archived travel.db + uploads + the encryption key, but each plugin's
own SQLite file — the ONLY copy of the user data it holds — and its installed code
lived in separate trees that were never captured, so a restore left the plugins rows
with no data or code behind them.

createBackup now adds plugins-data/ (each plugin's db + WAL sidecars, so SQLite
recovers a consistent snapshot) and plugins-code/ (skipping dev-links by realpath, so
an author's linked source is never bundled). Restore can't swap those live — the
runtime holds each plugin db open — so it STAGES the extracted trees beside the live
ones and the runtime swaps them in at the next boot, before it opens anything. Same
"applies on restart" model the bundled encryption key already uses: no plugin quiesce,
no swap under open handles, no new admin setup. Older archives without the trees restore
exactly as before.

* fix(plugins): audit — runtime robustness, security & data-lifecycle fixes

Fixes from an adversarial audit of the plugin system, host/runtime side:

Robustness:
- getPluginDataDb recreated a handle a terminal-failure dispose had closed but
  left cached, so a re-enabled plugin's db:own threw on every call — recreate
  when the cached handle is shut.
- ctx.ws.broadcast* now carry _inv, so the host can bind the acting user (the
  capability was silently refused, i.e. dead, without it).
- ctx.events.emit swallows a rejected emit instead of crashing the child into a
  terminal 'error'; an uncaught throw AFTER activation is treated as a crash
  (restart with backoff), not a load failure.
- A crash-respawned child gets the same activation deadline as a first activation
  and the buffered-event queue is cleared on the timeout path, so a hung onLoad
  after a crash can't peg a core and orphan events forever.
- Expired buffered events are pruned by the reaper, not only at flush; the
  scheduler + erasure sweeps scope their LIMIT window to ACTIVE plugins so a
  backlog for inactive plugins can't starve deliverable work.

Security / integrity:
- Unix-domain-socket / named-pipe connects are refused by default in the egress
  guard (a host-local pivot to docker.sock / DB sockets), under the same policy
  as private IPs.
- db.tx refuses transaction-control statements (a raw COMMIT would break its
  atomicity) and caps rows across the WHOLE batch, not per statement.
- plugin_capability_audit is retention-capped per plugin (chain-safe: retained
  rows stay self-verifying), so it can't grow unbounded in the shared db.
- A cap of 0 in TREK_PLUGIN_AI_PER_DAY / _NOTIFY_PER_DAY now disables the broker
  instead of falling back to the default.

GDPR data lifecycle:
- Account deletion now erases host-side per-user plugin tables (config, OAuth
  tokens/state) and enqueues the own-db erasure from the CORE path, so it works
  even when the runtime is disabled or pre-boot; guest deletion does the same.
- uninstall keeps a pending erasure when data is retained (deleteData=false);
  erasure delivery is no longer grant-re-checked (a queued erasure is a duty);
  export flags installed-but-inactive plugins as pending instead of omitting them.

Backup/restore:
- Plugin DBs are WAL-checkpointed before archiving (no torn/stale snapshots).
- Restore applies the staged trees immediately by quiescing the plugins (no
  unbounded gap where a later unrelated restart would revert diverged data);
  the swap is content-level (safe on a volume-mounted root) and preserves
  dev-links; the decompressed-size cap is operator-raisable.

* fix(plugins): audit — hook-output hardening, dashboard slot & mock-host parity

- Map-marker and atlas-layer tones were validated on String(tone) but emitted
  raw, so a non-string tone (an object with a matching toString) slipped through
  and crashed the client that renders it — check the raw value against the enum.
- View-contribution column/action caps are now PER ENTITY, not per view, so a
  plugin's columns no longer vanish from every table row past the first 20; the
  dashboard trip-card badge cap is per card (≥ one on every visible card).
- A reservation-detail widget no longer also renders as a context-free dashboard
  sidebar card (the inline filter was missing that slot).
- mock-host matches the real host: it ignores asUserId on trip reads (bind the
  acting user), throws on a wrong user-scope notify target instead of coercing,
  enforces the scheduler caps, and detects RETURNING as a read in db.tx — so a
  passing author test can't hide a production RESOURCE_FORBIDDEN.

* feat(plugins): full ctx parity in the dev server + fire jobs/events/hooks locally

The trek-plugin dev server injected only ~6 of the ~35 ctx areas, so any plugin
touching ctx.costs/packing/files/notify/ai/settings/scheduler/meta/oauth/db.tx/…
hit a TypeError in local dev while the same code passed mock-host tests and worked
installed. It also could only exercise routes.

Delegate every non-db-own capability to a grant-enforcing mock host (the same one
unit tests use) while keeping the real node:sqlite for db:own and dev-native ws
capture + logging — so the whole surface works in dev with the exact production
permission rules. dev-fixtures.json now takes the createMockHost options shape, so
you can seed the full surface. New GET /__dev/fire/<kind>[/<name>][/<fn>] fires a
job, scheduled timer, event subscription, GDPR handler or provider hook against the
dev ctx, closing the "can't test non-routes locally" gap.

* feat(plugins): wire the photoProvider + calendarSource hooks to real core consumers

Both hooks were declared, typed and documented but NO core code ever invoked them,
so an author could build, mock-test and install a photo or calendar plugin that
silently did nothing. Give each a real consumer that fans out to it, exactly like
the other eight provider hooks:

- GET /api/plugin-photos/search (+ /sources, /item) aggregates photoProvider results
  for the picker — {id, title?, thumbnailUrl, fullUrl, takenAt?}, thumbnail/full URLs
  http/https-only (they become <img src>), per-source count capped, failing source
  skipped.
- GET /api/plugin-calendar?start=&end= aggregates calendarSource events for the
  signed-in user — {id, title, start, end, allDay} ISO, count capped, failing source
  skipped, sensible default window.

Both run with the acting user bound. The SDK interfaces now pass ctx as the last arg
(so a source can reach ctx.settings/oauth/http for its backend), and the wiki marks
them live instead of "reserved — no core consumer".

* feat(plugins): close the create-heavy API asymmetries importers/sync hit

Core services implemented these but plugins had no path to them, so the flagship
importer/sync integrations hit real walls. Added, each reusing the EXISTING grant
(no new consent):

- ctx.trips.removeMember(tripId, userId) — reconcile DEPARTURES, not just additions
  (db:write:members + member_manage). Never removes the owner (that would orphan the
  trip); ownership transfer stays a separate deliberate action.
- ctx.journal.createJourney({title, subtitle?, trip_ids?}) / deleteJourney(journeyId)
  — an importer can now bootstrap the journal it fills with entries and clean it up
  (db:write:journal), instead of only appending to journals a human created first.

Wired end-to-end (envelope → rpc-host → create-rpc-host reusing tripService/
journeyService → both SDK copies → mock-host) and documented. (trips.delete needs its
own destructive permission + consent copy and collab edit/delete + collections.delete
remain — tracked as small follow-ups.)

* feat(plugins): strip emojis from plugin-rendered text so it matches TREK's lucide UI

Plugin authors (especially AI-generated ones) sprinkle emojis into the declarative
text TREK renders in its OWN chrome — hook contributions (badges, columns, warnings,
PDF sections, map-marker/atlas labels, journal rows, place details, trip-card badges,
calendar + photo titles) and notifications — which clashes with TREK's lucide-only icon
language.

A shared stripEmoji() removes emojis (incl. flag/ZWJ/variation-selector sequences) and
tidies the leftover whitespace, applied at the render boundary in every hook-contribution
normalizer and in notify.send — so no matter what a plugin returns, the text TREK draws
stays emoji-free. It does NOT touch a plugin's own sandboxed /ui frame (the author's to
design), and it leaves photo ids verbatim (they round-trip to getById). The validate CLI
warns when a manifest name/description contains emojis, nudging authors to the declarative
`icon` field (a lucide name) instead.

* fix(plugins): harden the restore-apply path — regressions from the backup/dev fix pass

A final audit of the fix pass caught three regressions clustered in the two newest
surfaces; the restore path could both crash the server and destroy data.

- CRITICAL: a restore quiesces plugins via supervisor.shutdownAll() AFTER closeDb(), but
  shutdownAll killed children without first marking them stopped, so each child 'exit'
  took the CRASH path and wrote crash-accounting rows into the now-closed core DB — the
  throw escaped an EventEmitter listener as an uncaughtException and killed the whole
  process mid-restore. shutdownAll now marks every entry stopped and drops it from
  `running` BEFORE the kills (so onExit early-returns), and the onStatus/onLog DB hooks
  are wrapped in try/catch (also covers the stderr→onLog path). This also stops a normal
  shutdown from logging phantom "crashed" rows.
- HIGH: swapContents cleared live entries then MOVED staged ones in, so a crash mid-move
  permanently deleted a plugin's only data copy (staging was already emptied, so a retry
  couldn't restore it). It now COPIES each staged entry over the live one and only deletes
  staging at the very end — `staged` stays the complete source of truth, making the whole
  operation crash-idempotent.
- HIGH: the dev server lost the actingUserId=1 default in the mock-host refactor, so a
  fresh scaffold refused every user-bound capability. Restored.

* fix(plugins): final-audit medium/low findings

- GDPR export flags an active plugin whose export errored/timed out as `pending`
  instead of silently omitting it (collectUserExport now returns a discriminated
  result), so a data-access export never reads complete while missing data.
- Account deletion also enqueues an erasure for plugins UNINSTALLED with retained
  data (an orphan data dir) — a same-id reinstall now honours the deletion instead
  of re-adopting the user's data forever.
- oauth.getToken returns null in a userless context (matching the SDK/mock contract)
  instead of throwing RESOURCE_FORBIDDEN a background caller can't handle.
- Crash-backoff restart is identity-guarded (+ the timer is tracked and cleared like
  the activation timer), so a disable + re-enable during the backoff window can no
  longer respawn a ghost child from the replaced entry.
- db.tx transaction-control guard strips leading comments first, so `/* */COMMIT`
  can't slip past the start-anchored check and break batch atomicity.
- createJournal inherits its cover only from a trip that was actually LINKED
  (access-checked), closing a cross-tenant cover-image read on plugin + REST paths.
- trip-warnings drops a null array element instead of losing ALL of that provider's
  warnings; plugin-activity floors a non-integer ?limit so it can't 500.
- The trek-plugin dev server binds loopback only and refuses cross-site requests to
  its side-effectful /__dev/fire endpoints (it serves real routes + no-auth dev
  actions).

* fix(plugins): clear no-misleading-character-class in the emoji stripper

The character class listed the ZWJ, variation selectors and combining keycap
marks as members, which eslint reads as an accidental combined grapheme and
rejected on CI. Pull the emoji glyphs out into Extended_Pictographic /
Regional_Indicator alternatives so only the joiner/selector code points stay in
the class, with a scoped disable where the rule still can't tell them apart.
While here, reset lastIndex before the /g regex is reused in hasEmoji() so a
second call can't resume mid-string and miss a leading emoji.

* fix(security): trip-scope note-file deletion and guard the LLM base URL

Two reported issues:

- deleteNoteFile only matched on the note id and file id, so a member of trip A
  could delete a file attached to a note in trip B by guessing its id. Thread the
  trip id through the service and controller and scope the delete to it, the way
  every other collab operation already does.

- The LLM extraction clients fetched the user-configured base URL directly, so a
  user could point it at the cloud-metadata endpoint (169.254.169.254) and read
  the echoed error body. Route both clients through a new safeFetchLlm() that
  blocks the link-local/metadata range while still allowing a local or LAN Ollama
  (loopback and private ranges stay reachable), pinned to the resolved IP so a
  hostname can't rebind to the metadata address after the check.

* fix(security): route every LLM client through the SSRF guard

The base-URL SSRF fix covered the openai-compatible and anthropic clients but
missed the native Ollama /api/chat client and the /api/tags + /api/pull model-
management calls, whic…

* fix(plugins): repair plain-HTTP egress and forward the private-egress opt-out

Two pre-existing bugs in the plugin egress guard, found by running a plugin
against a real service end to end.

1. Every plain-HTTP request a plugin made was refused, whatever host it had
   declared. Node pre-normalises `net.connect()` args into an [options, cb]
   array and passes THAT array as the single argument; undici's plain-HTTP
   connector takes this path, its TLS connector does not. classifyConnect read
   `host` off the array, got undefined, and fell back to 'localhost' — so a
   fetch to a declared, public host was rejected with the nonsense message
   "localhost is not in the plugin's declared hosts". It failed closed, so it
   was never a security hole, and it went unnoticed because the only shipped
   egress plugin uses HTTPS. unwrapConnectArgs() unwraps the normalised form
   before anything reads host/path.

2. TREK_PLUGIN_ALLOW_PRIVATE_EGRESS could never have any effect. The guard that
   reads it runs INSIDE the child, whose env is scrubbed to a four-entry
   whitelist that never included it — so a documented setting (wiki/
   Environment-Variables.md) was wired to nothing, and no plugin could reach a
   self-hoster's LAN service no matter what the operator set. Forwarded only
   when set, so the default stays the secure block-private policy.

Regression tests cover the normalised form in both directions: the real host is
now resolved, and an undeclared host, a private IP and a unix socket are all
still refused when passed that way.

* feat(notifications): let a plugin register a notification channel

TREK's four channels (in-app, email, webhook, ntfy) were a closed set:
notificationService.send() dispatched with four copy-pasted `if` blocks and no
provider abstraction, so a fifth channel meant editing eight files by hand. A
plugin could produce a notification via ctx.notify.send(), but never deliver
one.

A plugin now registers a channel with `hooks.notificationChannel` +
`hook:notification-channel` on a plain `type: 'integration'` — not a new manifest
type, so the TREK-Plugins registry schema and both its CI gates are untouched.

Core refactor
- New channel registry (services/notifications/): email/webhook/ntfy become
  ExternalChannel providers wrapping the EXISTING send functions — no delivery
  logic is rewritten, only relocated. In-app deliberately stays out: it writes
  typed rows with scope/target/callbacks, not a rendered title+body, the same
  line shared/ already draws with i18n/externalNotifications.
- The event text is now rendered once per recipient instead of once per channel.
- The channel set is open: NotifChannel becomes a string, the matrix is
  registry-derived, and the UI columns are server-driven. The DB column was
  already bare TEXT and the Zod contract already a string record — only the
  TypeScript and the two UIs were ever closed.

The hook runs USERLESS. Every other hook is user-initiated, so actingUserId falls
out of the request; a notification is host-initiated for an ARBITRARY recipient,
so ctx.settings.get() would return undefined. The host resolves the recipient's
decrypted scope:'user' settings itself and passes them as an argument. That is
what lets a channel plugin be handed someone's push token WITHOUT being handed
the right to read their trips as them.

Enabling the plugin is the opt-in: a plugin channel is not gated on the admin's
`notification_channels` list. A built-in always exists in code and needs an
explicit switch; a plugin channel only exists because an admin enabled that
plugin. (Nothing could write a `plugin:` id into that CSV anyway, and the admin
toggle rebuilt it from three booleans, silently dropping anything else — so
requiring a second opt-in meant the channel could never be turned on at all.)

Also fixed, found while building this:
- Plugin settings keys were unvalidated, so a field named `__proto__` or
  `constructor` resolved off Object.prototype: a REQUIRED field with such a name
  reported as configured for every user who had configured nothing — enough, for
  a channel, to be dispatched to everyone with no credentials. Keys are now
  constrained at install and the config blob is parsed null-prototype, so it is
  impossible even for an already-installed plugin.
- A `select` field's options were cast straight through, so the obvious
  `["1","5"]` form rendered every dropdown entry BLANK (the client reads
  value/label). Now coerced, and malformed options are rejected.

Also adds: operator-supplied egress hosts (a plugin talking to a self-hosted
service can't name the operator's host at publish time, so an admin adds it
post-install and the runtime re-spawns the child with the widened allow-list —
only for a plugin that DECLARED operatorEgress, and only an admin, never a user);
settings-page actions (a "Test connection" button, user-initiated so
ctx.settings.get() returns the clicking user's own value); and a Gotify-shaped
notification-channel template in the SDK.

Verified end to end against a real Gotify container, not just in tests.

* docs(wiki): document the plugin notification-channel surface

Covers the pieces added in the previous commits, in the pages a reader would
actually reach for:

- Plugins.md (the admin-facing page) had none of it: notification channels,
  settings actions, and a full "Allowed hosts" section — including what
  operator-supplied egress deliberately does NOT let anyone do.
- Plugin-Development.md: the notificationChannel hook (and why it is the one hook
  with no acting user), settings-page actions, operatorEgress, and the manifest
  reference rows.
- Plugin-Cookbook.md: a "become a notification channel" recipe and a
  "Test connection button" recipe.
- Plugin-Permissions.md: hook:notification-channel, operatorEgress under the
  outbound section, and settings actions under "not a permission".
- Notifications.md: plugin channels alongside the four built-ins.

* fix(sdk): allow empty egress if and only if operatorEgress is true

* ci: don't run repo-specific workflows on forks

Guard release, publish, wiki-deploy and issue/PR-triage workflows with a
`github.repository` check so they no-op in forks instead of failing or
acting on the fork's own issues, PRs, tags and registries.

Also skip the Docker Scout scan for pull requests from forks: Docker Hub
secrets are never exposed there, so the login step could not succeed.

Tests and lint stay ungated — they need no secrets and are the gate for
incoming fork PRs.

* feat(sdk): add missing methods in mock-host

* fix(airports): rebuild the json file

* fix(airports.json): add small airports too

* fix(public transit): only show public transit option when a trip has actual dates

* fix(plugins): reap a queued erasure only once the plugin's data is gone

The orphan reap deleted every queue row whose plugin had left the registry, but
uninstall(deleteData=false) removes the plugins row while deliberately keeping the
data dir AND the queued erasure so a same-id reinstall can still honour it. The reap
now deletes a row only when the plugin's data dir is actually gone; a deleteData=true
uninstall already clears the rows itself.

* fix(backup): snapshot the core DB and swap restores atomically

createBackup archived travel.db via the archiver's lazy live-file read, so a WAL
auto-checkpoint firing mid-stream could write a torn database into the zip. It now
VACUUM INTOs a point-in-time snapshot and archives that, the same guarantee plugin
DBs already get. restoreFromZip swapped the DB by unlink-then-copy, which on an
interrupted restore could leave no valid travel.db; it now copies to a temp file and
renames it into place (atomic), dropping the stale -wal/-shm sidecars first.

* fix(deploy): Recreate strategy for the SQLite volume, pin the root compose image

The Helm Deployment had no strategy, so the default RollingUpdate would start a second
pod holding the same ReadWriteOnce PVC before the old one exits — a Multi-Attach
deadlock or two writers on one SQLite file. Default to Recreate (overridable for
ReadWriteMany). The root docker-compose.yml pinned trek:dev, a tag no workflow builds,
so a clone-and-up at the release tag ran a stale image; pin it to :latest like the README.

* fix(security): re-validate LLM endpoint fetch redirects per hop (GHSA-fmq9)

safeFetchLlm left undici's default redirect:'follow', so a configured LLM
endpoint could 302 to http://169.254.169.254/ and reach cloud-metadata
credentials — the DNS pin does not cover an IP-literal redirect hop, since
net.connect skips the pinned lookup for a literal IP. Follow redirects
manually now, re-resolving/re-checking/re-pinning each hop (allowing LAN/
localhost as before). Also block the Alibaba metadata IPs directly.

* fix(plugins): throttle the plugin log channel to prevent host-thread DoS

The per-plugin RpcRateLimiter only guarded the ctx.* (req) channel; ctx.log.*,
stdout/stderr and unknown evt topics reached a synchronous INSERT+prune on the
host thread unthrottled, so a while(true) ctx.log.error(...) loop could freeze
the instance. Route every plugin-driven log path through a per-plugin log token
bucket; excess lines are dropped with a summary line on resume.

* fix(plugin-sdk): serve dev /ui frame at /ui/index.html so relative assets resolve (#1526)

The dev server embedded the plugin UI as <iframe src="/ui"> (no trailing
slash), so a multi-file build's relative asset URLs (./assets/x.js from Vite
base:'./') resolved against the origin root -> /assets/x.js -> 404, even though
the files are served at /ui/assets/*. The real host loads the frame at
/plugin-frame/<id>/index.html where the same relative URLs resolve correctly, so
dev now matches it by loading /ui/index.html (and also serves /ui/ as index.html).

* i18n: improve Russian translations (#1539)

* v3.4.0 (#1527)

* fix(plugins): unknown column

* fix(mcp): reuse MCP sessions instead of creating one per tool call

The /mcp CORS layer never set exposedHeaders, so Access-Control-Expose-Headers
was absent and browser-context MCP clients (Claude Desktop connectors,
Claude.ai, MCP Inspector) could not read Mcp-Session-Id off the initialize
response. Unable to echo it back, every request looked like a fresh initialize:
one McpServer and one session per tool call, until the per-user cap returned a
429 and the integration died until the container was restarted.

The idle sweep was not at fault — it expires on lastActivity with a 1h default,
so sessions born seconds apart are nowhere near expiry, hence 'cleaned 0'.

- expose Mcp-Session-Id, MCP-Protocol-Version and WWW-Authenticate
- evict a user's least-recently-active session at the cap rather than
  refusing the request, so a client that cannot persist its session id (or a
  proxy that strips the header) can never wedge the server
- close the McpServer/transport orphaned by every session-less non-initialize
  POST, which was leaked: never mapped, never swept, never closed
- return the cap error as JSON-RPC so clients surface the real reason
- warn on session-less POSTs to make a header-stripping proxy diagnosable

* chore(deps): declare @modelcontextprotocol/sdk ^1.29.0

Matches the version already resolved in the lockfile; no dependency-tree change.

* docs(mcp): add the reverse proxy specs for MCP

* fix(plugins): stop the row ⋯ menu being clipped by the sidebar

The menu was an in-flow `absolute` div, and its ancestor (PageSidebar) is
`overflow-hidden` — which clips absolutely-positioned descendants regardless
of z-index. With enough plugins installed a row sits low enough that its menu
runs past the sidebar's bottom edge and gets chopped, taking Delete with it,
so the plugin could no longer be uninstalled from the UI.

Portal the menu to <body> and position it `fixed` against the ⋯ button,
flipping upward when the bottom is tight and re-anchoring on scroll/resize.
A fixed child of <body> has no overflow ancestor, so nothing can clip it.

Closes #1523

* feat(plugins): surface author-signature status and add a scoped re-trust override

TREK has always verified an author's Ed25519 signature and TOFU-pinned the key on
first install, but none of it was ever shown: a successfully-installed UNSIGNED
plugin looked identical to a signed one, and a signature-refused update left the
plugin quietly pinned at its old version with the reason dying in a toast.

Give the four refusal conditions machine-readable codes (SIGNATURE_MISSING /
_INCOMPLETE / _KEY_CHANGED / _INVALID), persist a refusal on the plugin row so the
admin list keeps showing it, and badge Signed/Unsigned in the list and in Discover.

Only SIGNATURE_KEY_CHANGED is overridable — an author can legitimately rotate a key;
a signature that does not verify means the bytes are not what the author signed, and
there is no story where waving that through is right. The override re-pins and
updates in ONE call (POST :id/retrust): a re-pin that waited for a follow-up /update
would leave the plugin pinned to a key no install had ever been verified against if
that second call never came. The artifact must still verify under the new key, so a
re-trust moves the pin from one verified key to another.

assertRetrustable re-derives the condition server-side, so the UI hiding the button
is a convenience, not the control, and it echoes back the full key the admin was
shown so a re-key since the dialog rendered is refused. The rotation is written to
the admin audit log with both fingerprints — after an incident, "which key did we
move from, and to what?" is the question a single key cannot answer.

* fix(plugins): let a plugin's frame reach the hosts an admin added for it

The frame's connect-src was built from the manifest's http:outbound grants alone, but
the child's egress guard is the UNION of those and the hosts an admin added after
install for an operatorEgress plugin (a self-hosted Gotify, an ntfy — hosts the author
cannot know in advance). So such a plugin WITH A UI could call the operator's host from
its server and was CSP-blocked in its own iframe.

Match the frame to the child. The admin consented to these hosts at install and the
child already reaches them, so this widens no trust boundary that isn't already crossed.
Both sources stay validated on the way in, and the interpolation filter is unchanged.

* fix(plugins): report a rejected events.emit on the plugin's own log stream

The host can reject an emit (an undeclared event name, a rate limit). The rejection must
not escape — a detached rejection crashes the child and terminally disables the plugin
over one bad emit — but swallowing it silently left an author with no way to discover
that `emits` was missing from their manifest. Surface it as a warning instead.

* feat(plugin-sdk): expose the raw request body for webhook signature checks

A webhook author must run their HMAC over the exact bytes the sender signed. `body` is
the PARSED value, and re-serializing it will not reproduce those bytes — key order,
whitespace and unicode escaping all differ — so the signature never matches. Document
`rawBodyBase64`, which the host already sets on auth:false routes.

* fix(plugin-sdk): refuse to overwrite a released artifact, and keep the packed zip

A released artifact is IMMUTABLE: the registry pins its sha256, so overwriting the bytes
of a release already in the registry breaks the checksum for everyone who installed that
version — they can no longer install or update it. The old code blanket-caught every
`gh release create` failure (auth, network, a bad repo) and turned it into a --clobber
upload. Probe for the release explicitly and refuse unless --force.

Also stop deleting plugin.zip on the way out. It is the exact bytes the release and the
entry's sha256 pin were computed from; a re-pack on another machine or SDK version can
differ (CRLF, walk order), so anyone re-running `entry`/`sign` afterwards must hash THAT
file, not a rebuild.

* fix(plugin-sdk): don't fail submit when the fork already has an upstream remote

`gh repo clone` of a fork may already have wired `upstream`, in which case a bare
`remote add` exits non-zero and took the whole submit down. Set it either way.

* fix(plugin-sdk): make the dev server behave like the host

Three ways `dev` lied to an author about how their plugin would run in production:

- ctx.db: one try/catch wrapped both the node:sqlite probe AND opening the database, so
  an mkdir/permission failure silently degraded to the in-memory stub — which swallows
  every write while reporting success. A db:own plugin "worked" in dev and persisted
  nothing. Probe separately: fail loudly on a real error, degrade only on old Node, and
  say plainly that the stub discards writes.
- notificationChannel: the host fires it with no acting user and hands the recipient's
  decrypted settings in as a separate `config` argument — send(msg, config, ctx) /
  test(config, ctx). Firing it like an ordinary hook passed `ctx` where `config` belongs,
  so a channel plugin read its settings off ctx and was broken in production.
- /preview pinned tripId 42 while the scaffold seeds trip 1, so the widget's first
  trek:invoke hit assertMember(42) and 500'd. Preview against a trip that exists.

* fix(plugin-sdk): reject unknown flags, wire up create's new flags, and add --help

`parse()` accepts any --x, so a flag a command does not read was silently dropped:
`create --template notification-channel` cheerfully scaffolded a blank plugin. Silently
ignoring an author's explicit instruction is worse than refusing it, so unknown flags are
now an error, and create actually forwards --template/--egress/--required-addons.

A bare --permissions used to split the string "true" into a permission literally named
`true`; listFlag() now treats a valueless flag as absent.

Since an unknown flag is now fatal, `--help` has to exist: it is intercepted before the
flag check and prints usage on stdout with exit 0.

* chore(plugins): drop three unused eslint-disable directives

The no-console rule is not enabled for these files, so the directives were dead and
eslint reports them as unused.

* fix(plugin-sdk): bring preflight back in step with the registry's gates

preflight exists to tell an author what TREK-Plugins' CI will say before they open the
PR. The registry now verifies author signatures, and preflight didn't — so it drifted
into the one failure mode it must never have: a false green. An author trusts a green.

- Verify the signature against the artifact bytes. preflight already downloads them for
  the sha256 check, so this costs one call. Without it, signing with the wrong key (or
  re-packing after signing) sails through and is caught at review.
- Check the signature SHAPE (checkSignatureShape): a key with no signed version, a
  signature with no key, a malformed key or signature. TREK refuses to install a
  half-signed entry, so such an entry is dead on arrival.
- Default apiVersion to 1 before comparing. It is OPTIONAL in the manifest — install/
  manifest.ts and `entry` both default it — so a manifest that legally omits it was
  failing preflight with "manifest apiVersion undefined != entry 1" while the registry
  passed it. A false RED, which teaches authors to ignore the tool.
- Check requiredAddons/pluginDependencies parity, and operatorEgress without an
  http:outbound grant.

The verifier is a port of the host's install/verify-signature.ts (the registry has its
own port); sign.ts's verifyArtifact only understands the bare key/signature pair the SDK
itself emits and cannot judge a minisign key. A test pins all three to the same verdicts.

* feat(plugins): enforce compatibility range

* chore: bump sdk version

* test(e2e): repair the trip-creation specs

create-trip and trip-planner have been failing for a while — long enough for
three separate UI changes to drift past them, which nothing caught because CI
runs vitest only and never invokes Playwright.

Each failure was masking the next:

- The release-notice modal greets a freshly seeded user and its backdrop
  swallowed the click on .add-trip-card. Added a shared dismissSystemNotices()
  helper (the X only shows on the notice's last page, so it has to page through
  first).
- .modal-backdrop no longer exists — the class was namespaced to
  .trek-modal-backdrop so content blockers stop hiding it.
- input[type=text].first() is no longer the Title field: the cover-image search
  inputs now sit above it, so the specs were typing the trip name into the photo
  search box and creating an untitled trip that never matched getByText(title).

* fix(planner): gate drag & drop on pointer type, not viewport width (#1432)

The 3.2.1 fix disabled drag on "mobile", but nothing in the client has ever
detected touch — "mobile" was inferred from viewport width, at four independent
breakpoints. A tablet is a coarse-pointer device at a *desktop* width, so an
iPad (820-1366px) fell on the wrong side of all four, which is why iPhone was
fixed and iPad was not:

- useTripPlanner's isMobile (<768px) is what disarmed `draggable`, so on iPad
  rows stayed draggable and a scroll swipe became an HTML5 drag.
- TripPlannerPage hardcoded isMobile={false} on the desktop PlacesSidebar, so
  its drop handlers and the drop-to-import overlay could never be disabled —
  that overlay is the reported symptom.
- The arrow-button reorder fallback was revealed only below 767px, leaving iPad
  with no drag *and* no fallback.
- touchDragPolyfill loaded drag-drop-touch at >=1024px, synthesising drags from
  touchmove — on a landscape iPad that re-armed the very gesture hijack 3.2.1
  removed.

Adds useIsTouch() ((pointer: coarse)) as a signal separate from isMobile: layout
stays width-driven, so the iPad keeps the desktop two-pane planner, while every
drag affordance is gated on isMobile || isTouch. The reorder arrows now show on
coarse pointers, and the polyfill loads only on hybrid laptops
((pointer: fine) and (any-pointer: coarse)), which also removes the #1440
phantom-dblclick map zoom on tablets.

Guarded by unit tests plus an e2e spec on real WebKit in an iPad Pro 11 context
— the engine matters, since every browser on iPadOS is WebKit underneath, which
is why the reporter hit this in all three they tried.

* test(planner): guard the day-plan reorder arrows against phantom clicks

The hover rule that reveals the reorder arrows had been dead since the
TypeScript migration: it targeted `.place-row:hover .reorder-btns`, and neither
class exists — the component renders `.reorder-buttons` inside an unclassed row.
The buttons sat at opacity:0 on desktop with nothing to reveal them.

That was not merely invisible. opacity:0 still hit-tests, so every itinerary row
and day note carried an invisible, fully clickable target that silently
reordered the trip:

  opacity: "0", visibility: "visible", pointerEvents: "auto"
  elementFromPoint(centre) -> BUTTON, inside .reorder-buttons

The repair — pointer-events: none while hidden, plus a working
.dp-row:hover/:focus-within reveal (focus-within so the buttons are also
reachable by keyboard, which the file's JS-hover pattern cannot do) — lives in
index.css and DayPlanSidebar.tsx. Both files also carry the #1432 drag gating,
so they went with that commit rather than being split mid-file; this commit is
the regression guard for them.

E2E rather than unit, because jsdom does not evaluate :hover. Against the old
CSS it fails on exactly the right assertion: "hidden arrows must not swallow
clicks" — expected false, received true.

* fix(pdf): keep the header gap on day-header overflow pages (#1531)

The repeated <thead> day header (#1471) carried no gap before the first
card on overflow pages: the 12px sat in .day-body's block-start padding,
which a fragmented box only paints on its first fragment. Move it to the
thead cell so it repeats with the header; the day's first page renders
pixel-identically.

* fix(budget): keep "no one paid yet" when editing an expense (#1533)

The ExpenseModal payer initializer fell back to the current user whenever the
edited item had no payer, so reopening an expense saved with "no one paid yet"
silently reselected "You" — and re-saving then recorded the current user as the
payer, corrupting the balances. An absent payer on an existing expense is a
deliberate value, so only a brand-new expense defaults to me.

* feat(sdk): support for plugin icons

* fix(planner): keep the places filter applied and visible across tab switches

The category and all/unplanned/tracks filters lived twice: a local copy in
the sidebar driving the checkboxes and the list, and a page-level copy
driving the map markers, synced only when a checkbox was clicked. Switching
planner tabs unmounts the sidebar, so the local copy reset to "All
Categories" while the markers stayed filtered — and the only way out was
toggling any category on and off again (#1541).

The filter now lives once in the trip store, next to selectedDayId: it
survives the Plan tab unmounting (and the mobile places sheet closing),
keeps both sidebar instances in agreement, and resets when another trip
loads.

* feat(airtrail): import connecting flights as one multi-leg booking

AirTrail flights always imported as separate single-leg reservations, so a
layover could not be expressed and the connection country ended up counted
as visited in Atlas (#1535).

The import picker now detects connection chains among the listed flights —
each leg departing from the airport the previous one landed at, onward
within 24 hours, and never back to the origin (an out-and-back is a return,
not a connection) — and offers to import each chain as one flight with
layover stops, on by default. The joined booking keeps per-leg airline,
flight number, times and seat in metadata.legs, files every leg on its own
trip day, and mirrors the first/last leg flat, exactly like the manual
multi-leg form. With the connection stored as a stop endpoint, the existing
Atlas role filter excludes the layover country on its own.

AirTrail has no multi-leg flight a joined booking could round-trip to, so
it imports detached from live sync, with every source flight id recorded in
metadata.airtrail_ids — the picker and the server-side dedupe both treat
those legs as imported, per leg, even across trip members. The server
re-validates each requested chain and falls back to individual imports when
it does not actually connect.

* fix(airtrail): stop syncing a booking once it grows extra stops

A linked flight that becomes multi-leg locally no longer matches the single
AirTrail flight it was imported from: pushing would rewrite that flight to
span the whole route, and the next pull would flatten the layover chain
back to a plain from/to. Both sync directions now detach the link instead —
the same state a joined import starts in, surfaced by the existing "Not
synced" badge.

TransportModal also carries metadata.airtrail_ids through re-saves (like it
already does for transit itineraries and day positions), so editing a
joined booking cannot cost it its import dedupe and get its legs re-offered
in the picker.

* fix(planner): default a new accommodation to checking out the next day

The hotel picker pre-filled "Apply to days" with the same day for check-in
and check-out — a stay that ends the day it begins. New accommodations now
default to the following day for check-out; the last trip day keeps the
same-day range, and editing still seeds from the stored range.

* docs(wiki): document the AirTrail import and connection joining

* fix(plugins): fold resolvePluginIcon into PluginIcon

pluginIcon.ts and PluginIcon.tsx resolve to the same file on the
case-insensitive filesystems dev checkouts commonly sit on (Windows,
macOS) — tsc sees both casings of one module and fails with TS1149 in
every importer. Keep the resolver and the component in one module.

* feat(sdk): add update verification

* chore: remove test files

* fix(sdk) rework the sdk helpers and DX/UX

* fix(sdk) harden dev environment

* fix(budget): add back the multi payer selection

* fix(map): stop MapLibre mouse rotation from reversing near mid-screen

Since MapLibre 5's camera rewrite, the right-button rotate handler flips
its sign whenever the cursor sits above a mid-screen line it derives by
re-projecting the map center. That line drifts with the bearing by a
fraction of a pixel, so inside the 100px band around the screen center a
steady horizontal drag lands alternately above and below it — every
processed movement reverses the previous one and the camera ping-pongs in
place instead of rotating (#1545). A real hand crossing the line mid-drag
flips the rotation direction outright. maplibre-gl 4.x rotated from plain
horizontal movement and had none of this.

Passing aroundCenter: false opts the handler out of the around-center
mode and restores the 4.x/mapbox-gl behaviour: horizontal drag rotates,
vertical drag pitches, in one continuous motion. Applied to all three GL
map builds (planner, journey, settings preview); mapbox-gl keeps its
options untouched.

* fix(budget): settle in the trip's real currency, not always EUR

The settlement route read `currency` off the row returned by canAccessTrip,
whose SELECT never included the column. A cast hid the mistake, so trip.currency
was always undefined and the settlement was told every trip is in EUR.

Balances are netted in the trip currency and converted to the display currency
once. With the trip mislabelled EUR, expenses in the trip's own currency still
cancelled out, but an expense booked in a foreign currency was divided by its
frozen rate into trip-currency units, then converted again as if those were
euros — inflating balances by the EUR/trip rate (~27x for a RUB trip with a USD
expense, #1543). MCP was unaffected: it reads the currency with its own SELECT.

Select the currency in canAccessTrip so the read is real. The settlement maths
was correct all along; it was simply being lied to about the base currency.

Fixes #1543

* feat(trips): let users set the trip currency, and rebase the budget when it changes

The trip currency is the base every expense and settle-up is netted against, but
the only picker for it lived in the legacy Budget addon panel — so on the Costs
panel a trip was stuck with whatever it was created as. Add the field to the trip
form, on create and on edit, gated on trip_edit. The REST and MCP write paths
already accepted `currency`; only the form was missing.

Changing it is not a rename, though: an expense's frozen `exchange_rate` is
"units of its currency per 1 trip currency", and `currency = NULL` means "the
trip's own", so both are relative to the outgoing base. Swapping it out from
under them redenominates the implicit rows (9 000 RUB becoming 9 000 EUR) and
leaves the frozen rates pointing at a currency the trip no longer uses — the same
mismatch that inflated #1543.

So rebaseTripCurrency() runs first, while the old currency is still on the row:
it pins the implicit rows to the outgoing currency and re-freezes every rate
against the incoming one, for expenses and settle-up transfers alike. No stored
amount is rewritten — each keeps the figure the user typed, in the currency they
typed it in, and its real-world value survives the switch.

Also covers the #1543 data as a settlement regression test.

* feat(budget): give settle-up payments their own currency

A transfer settling a shared bill can be made in any currency — paying a rouble
debt in euros is normal — and the server has stored `currency` + a frozen
`exchange_rate` on every transfer since #1445, re-freezing it on edit. The UI
just never let anyone choose one, so a payment silently inherited whatever the
viewer's display currency happened to be.

Add the picker to the payment modal, mirroring the expense modal, and reopen an
existing payment in the currency it was actually recorded in. The ledger row now
shows a foreign payment as `$30.00 -> 27,00 EUR` like a foreign expense does,
instead of stamping the display currency's symbol onto the raw stored number.

The Settle buttons on the suggested flows keep sending the display currency:
those amounts are computed in it.

* feat(settings): make the display currency optional, falling back to the trip's

Costs already resolved `default_currency || trip.currency || 'EUR'`, but the
setting could never actually be empty: the store seeded 'USD', so a user who had
never touched it silently had every trip converted into dollars, and the picker
offered no way to unset it.

Seed it empty and lead the picker with a "Trip currency" option, so an unset
preference means "show each trip in its own currency" instead of forcing them all
through one code. An explicit empty value persists and beats the admin-set
instance default — it is a deliberate choice, not an absence.

This also brings the public share's fallback to life: the share payload has
resolved sharer's currency -> trip currency since #1361, but the trip-currency
branch was unreachable while every owner had a currency forced on them.

Plugins are handed `formats.currency` as a concrete code, so PluginFrame now
resolves the same chain rather than passing an empty string through the bridge.

* docs(wiki): explain the three currencies and how they relate

Trip currency, expense currency and display currency answer three different
questions, and nothing said so: the trip currency wasn't documented at all (it
had no picker until now), and Budget-Tracking conflated the other two while
still claiming 47 currencies and a display currency that always came from
Settings.

Add a Currencies page as the one place they're defined together — the trip
currency as the accounting base, the expense currency as the receipt with its
rate frozen at entry, the display currency as presentation-only — plus what
happens when a trip's currency changes, which currency a public share renders
in, and what belongs to the Costs addon versus the trip itself.

Rewrite Budget-Tracking's currency section against it, document the currency
field in Creating-a-Trip and the display currency in Display-Settings (which
never mentioned it), and note the sharer-or-trip fallback in Public-Share-Links.

* feat(help): serve the in-app wiki from disk instead of fetching GitHub

The /help pages fetched their markdown from raw.githubusercontent.com at
runtime, so a self-hosted install was served docs from main rather than the
version it was actually running, and help was unusable without network access.
The wiki/ directory was in the repo the whole time; the bundled-snapshot
fallback the code reached for was gitignored and never populated by any build
step, so it was dead code.

Read wiki/ straight from disk instead. server/{src,dist}/services both sit
three levels under the repo root, so a single __dirname anchor resolves in dev,
a built source install, vitest and Docker with no copy or build step. GitHub is
kept strictly as a fallback for when the directory cannot be resolved, decided
once at load by probing for _Sidebar.md: a page missing from a present wiki is
a genuine 404, since falling back per-file would reintroduce the version skew
this removes.

Ship wiki/ in the image: .dockerignore excluded it outright, so the COPY alone
would have produced an image with no wiki, and the GitHub fallback would have
masked that at runtime. Add a real path-containment check on assets now that
the path becomes a filesystem read rather than a URL, and document
TREK_WIKI_DIR as an off-by-default escape hatch across the deployment configs.

* fix(plugins): serve frame assets root-relative and cross-origin loadable

res.sendFile(absolutePath) resolves against the rewritten req.url under
the Nest ExpressAdapter and 404s spuriously (files-download already
works around the same trap), which broke every plugin frame document.
And helmet's CORP: same-origin made the browser drop the opaque-origin
frame's own script/style subresources, so a multi-file plugin client
could never boot. Serve root-relative and mark frame responses
cross-origin — sandbox + per-plugin CSP stay the isolation boundary.

* feat(plugins): let a plugin ship its own settings page

A plugin that declares capabilities.settingsUi: true gets its
client/settings.html framed as a card under Settings -> Plugins — same
opaque-origin sandbox and postMessage bridge as its widget, sized via
trek:resize. Hosts that predate the flag strip it at install, so old
instances keep working and simply don't show the card.

* feat(map): open maps framed on their places (builds on #1393) (#1556)

* fix(map): fit MapLibre routes reliably

* fix(map): default planner map to world view

* fix(map): only await route geometry when a route is actually pending

The fit armed a pending route-refit slot on every fitKey change, even with no
route drawn, and only ever cleared it when a route arrived. So a route toggled
on much later — after the user had panned elsewhere — was mistaken for the
awaited geometry and yanked the camera back, and only on the first toggle.

Arm the slot only when a route is already on screen: updateRouteForDay lays down
straight lines in the same batch as the fit and upgrades them to real geometry a
moment later, so an empty route at fit time means none is coming.

* fix(collections): open the empty collection map on the world view

A collection with no mappable places centred on Paris, the same hardcoded
default this branch removes everywhere else.

* feat(map): open the map framed on its places

A trip in Japan opened on the world view at 0,0 and only then animated a fitBounds
flight across the planet — the hardcoded default was the map's answer to a question
its own places already answer.

Each renderer now derives its opening camera from the places it receives, at
construction: MapView for Leaflet, MapViewGL for MapLibre and Mapbox (whose zoom
runs one level below Leaflet's, measuring against a 512px world tile rather than
256px). Doing it at construction is what confines it to load — the map is built
once, and by then the trip's places are in hand. Nothing recomputes it afterwards,
so the camera stays where the user leaves it, and the opening fit stands down
rather than overruling the gentler zoom a lone place opens at.

A trip with no coordinates still falls back to the world view. Collections and the
public shared-trip page frame themselves the same way.

* refactor(settings): drop the default map centre and zoom

Nothing reads them now that every map frames itself on its own places, and a
home-city default was the wrong answer for the next trip on the other side of the
world. The style preview keeps a fixed location of its own: it needs a city to show
label density and 3D buildings, which open ocean cannot.

* fix(map): frame the map the way each renderer can actually draw

Two defects the unit tests missed and running the app exposed.

MapLibre and Mapbox opened on Null Island at zoom 2 regardless of the places: the
effect that mirrors an external centre prop onto the camera also ran on mount, so
it jumped straight to the default nobody passed and threw away the camera the map
had just been built with. It now only responds to actual changes, which is what it
was for. Leaflet was unaffected — its controller already guarded on the centre
changing.

A trip spanning Sydney, Reykjavik and Santiago lost Sydney's marker entirely. The
narrowest arc containing all three crosses the antimeridian, and framing there is
only sound on a renderer that repeats the world: MapLibre and Mapbox draw a marker
on whichever copy is nearest the camera, Leaflet draws one world and puts the
marker at its absolute position — off-screen. Leaflet now spans the long way round,
as L.latLngBounds would. The test that should have caught this wrapped the x-offset
in its own projection helper, quietly assuming behaviour Leaflet does not have; it
now models each renderer's real wrapping.

---------

Co-authored-by: Azalea <noreply@aza.moe>

* feat(mcp): expose public transit planning tools (#1558)

* feat(mcp): add public transit planning tools

* refactor(transit): reuse local time conversion

* fix(mcp): harden transit journey validation

* refactor(transit): centralize itinerary processing

* fix(mcp): return the transit itineraries the provider actually offers

search_transit_routes validated each itinerary leg's mode against
SCHEDULED_TRANSIT_MODES, but that constant is the request-side filter
whitelist — the modes a caller may ask for — not the modes MOTIS can return.
Its default TRANSIT mode expands to TRAM,FERRY,AIRPLANE,BUS,COACH,RAIL,ODM,
RIDE_SHARING,FUNICULAR,AERIAL_LIFT,OTHER, and street legs can be BIKE/CAR/
RENTAL. Any itinerary carrying one of those failed the parse and was dropped
by the flatMap, so the tool reported fewer routes than exist — or none at all.
Against the live provider, Trondheim → Ålesund returns 5 itineraries and 3 of
them contain an AIRPLANE leg, so the tool silently discarded them; the web app
shows all 5, because it treats mode as a free string and renders anything
non-WALK as a transit leg.

Accept any mode token on a leg and keep the existing "at least one non-WALK
leg" rule as the real gate, which restores parity with the web app. Everything
downstream keeps its mode !== 'WALK' semantics, so a journey created over MCP
is identical to one created in the app.

Dropping an itinerary is still possible when provider data fails the remaining
consistency rules, but it is indistinguishable from "no routes exist" — so
search_transit_routes now reports a `dropped` count alongside the results.

---------

Co-authored-by: Uzini <43294422+Uziniii@users.noreply.github.com>

* fix: show map poi search controls on mobile (#1555)

* fix(pdf): use the trip's actual currency instead of hardcoded EUR (#1519)

* fix(pdf): use the trip's actual currency instead of hardcoded EUR

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(i18n): drop hardcoded currency text from pdf.costLabel across locales

Remove redundant EUR/currency references from pdf.costLabel translations
across 21 locales now that the PDF export correctly renders amounts
with their actual trip currency via formatMoney().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(pdf): drop hardcoded euro-shaped icon from place price chip

svgEuro rendered a fixed € glyph next to the price chip regardless of
the trip's actual currency, undermining the currency fix. Swap for a
currency-neutral coin icon.

---------

Co-authored-by: Nguyen Trong Binh <nguytb15@VN1N07HO1CD1015.local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* fix(trips): pin place prices when the trip currency changes

A place price with no currency of its own means "the trip's own currency" —
that is how the PDF export and the place chips read it since #1519. But
rebaseTripCurrency() only pinned budget_items and budget_settlements, so
switching a trip's currency silently redenominated every implicit place
price: a €15 museum on a trip moved to JPY started reading as ¥15. Same
class of mismatch as #1543, on the places surface.

Pin priced, currency-less places to the outgoing currency inside the same
transaction. No amount is rewritten — the figure the user typed keeps its
real-world value, it just stops being ambiguous about which unit it is in.
Bump updated_at as well: it doubles as the optimistic-concurrency token
(#1135), so a client holding the pre-switch row can no longer write the
pin away.

Document place prices in the wiki's currency model, and drop the now-stale
"in EUR" from the PDF export's estimated-cost stat.

* fix(budget): re-split expenses when a member is removed

* fix(plugins): derive plugin req.user.isAdmin from role, not is_admin

* chore(test): make jsdom Storage work under Node's native Web Storage globals

* fix: recompute vacay calendar when week start changes (#1554)

* feat(mobile): comprehensive mobile UI rewrite

Add a dedicated phone UI (<768px) under client/src/mobile/, mounted by
MobileShell while the desktop/tablet code paths stay untouched. Each page
delegates to an M-screen that reuses the existing hooks, stores and services.

Covers dashboard, trip planner (plan timeline, map, places, tabs and sheets),
vacay, atlas, journey, collections, settings, admin and notifications, with a
shared component kit (MSheet, MBottomNav, MGlassBar, MSegmented, MToggle, …)
and design tokens in mobile.css.

Empty states use an animated TREK mascot (MDancingTrek) with per-scene props.
Adds the mobile* i18n namespaces (mobileTrip/mobileJourney/mobileVacay/
mobileAtlas/mobileNav/mobileAdmin/mobileSettings/mobileCollections) across all
22 locales.

* fix(websocket): handle socket 'error' events to prevent crash on malformed frames (#1584)

* fix(packing): apply templates into the active list tab (#1581)

* Fix packing list readability on mobile

* Localize packing quantity label in overflow menu

* feat(mobile): accommodation & save-to-collection sheets, trip loading splash

Add a dedicated mobile accommodation sheet and a save-to-collection sheet,
plus an animated trip loading splash with stepped status copy. Make the vacay
day/company-holiday toggles optimistic so cells react instantly and roll back
on error. Wire up the "subscribe to all trips" calendar feed and cover-image
add on the new-trip sheet. Sync the new dashboard/trip i18n keys across all
22 locales.

* fix(atlas): stream boundary GeoJSON instead of caching parsed bundles (#1585)

* fix(atlas): stream boundary GeoJSON instead of caching parsed bundles

* fix(atlas): scan admin1 features in one pass to avoid O(n^2) build

createFeatureSplitter re-scanned each partial Feature from the start on
every gunzip chunk, so a large feature (Canada ~5.5MB, ~340 chunks) made
the one-time admin1 store build O(n^2). That pushed the streaming build
(~4.2s) past the 15s test timeout under CI coverage/fork contention and
added the same latency to the first live region request.

Carry scan state (position, brace depth, string state) across chunks so
each character is examined once. Output is byte-identical (3228 features,
197 countries) and peak build memory is unchanged (~192MB RSS under a hard
512MB cap); build time drops to ~1.7s.

* fix(i18n): add packing.quantity to all locales

The packing.quantity key existed only in en (Qty) and de (Menge), so
i18n:parity:strict and the client parity test failed for the other 20
locales. Add the key everywhere; en/de are unchanged.

---------

Co-authored-by: jubnl <jgunther021@gmail.com>

* fix(trips): keep accommodations on their dates when the trip range shifts (#1288)

The v3.1.3 fix re-anchored dated bookings after a trip date change but
explicitly excluded hotels: day_accommodations has no absolute date columns,
so stays remained glued to positionally re-dated day rows and shifted with
the range. updateTrip now snapshots day dates before generateDays and a new
resyncAccommodationDays re-anchors each stay (and its linked hotel
reservation, restamping its stale reservation_time) to the days holding its
pre-change dates; out-of-range stays stay glued so whole-trip moves still
shift together. Unlinked dated hotels resync like any booking, and the
date-change block is wrapped in a transaction.

Changing the start date now also asks how plans should follow via a new
date_shift_mode field ('keep_bookings' default / 'shift_all', which reuses
the reorder/insert restamp path to glue everything), exposed in the trip
edit modal (all 22 locales), the shared contract, and the MCP update_trip
tool. Clients no longer show stale state: the initiator reloads
reservations + accommodations after saving, collaborators refetch on a
date-changing trip:updated, and reconnect hydration nudges the planner's
accommodations too.

* fix(extract): retry with json_object and surface AI import failures (#1546)

OpenAI-compatible providers that only support json_object (DeepSeek,
Mistral, some vLLM/llama.cpp) reject the json_schema response_format
with a 400, and the resulting error was swallowed silently: not logged
server-side and never rendered by the background-task widget, leaving
only a generic "no reservations" message.

- Retry the chat/completions request once with response_format
  json_object when the json_schema attempt returns 400 (non-NuExtract
  only); the system prompt already dictates the output shape
- Log swallowed llm-parse errors with an [llm-parse] tag so failures
  show up in server logs
- Render task warnings under the empty-preview note in the background
  tasks widget so the actual provider error reaches the user

* fix(costs): keep ticket item amounts visible on narrow screens (#1568)

* chore: update repo url

* chore: update repo url

* chore: update repo url

* chore: update repo url

* fix(security): block IPv6 transition addresses (NAT64/6to4/Teredo) in SSRF guard

An attacker-controlled DNS record pointing at a NAT64 (64:ff9b::/96),
6to4 (2002::/16), or Teredo (2001:0000::/32) address that embeds a
private IPv4 (e.g. 64:ff9b::a9fe:a9fe = 169.254.169.254) bypassed the
SSRF guard: none of the guard functions recognised these ranges, so on a
host that routes the transition prefix the connection reached the
embedded private target (cloud metadata / internal SSRF).

Add a shared embeddedTransitionIpv4() detector and re-apply each guard's
own blocklist to the extracted IPv4 in isAlwaysBlocked, isPrivateNetwork,
isLinkLocal (ssrfGuard.ts) and isBlockedIp (egress-policy.ts). A
transition address to a public IPv4 stays allowed so legitimate
IPv6-only egress is unaffected.

egress-policy.ts keeps the detector inline to preserve its dependency-free
contract for the isolated plugin subprocess.

* chore: Add star history

* Revert "chore: Add star history"

This reverts commit f9d5f75837.

* fix(map): draw transit routes even without other places on the day

The reservation overlays hide any transport whose from/to endpoints
project closer than a per-type pixel threshold (200px for transit) to
declutter tiny no-op straight connectors. A transit journey, though,
draws its real rail/bus alignment rather than a straight endpoint line,
so on a zoomed-out day — one with no other places to tighten the map
onto — its stations fall under the threshold and the whole route
vanishes.

Exempt a transit booking that carries real per-leg geometry from the
proximity gate in both renderers (Leaflet + MapLibre); a geometry-less
transit keeps the straight-arc declutter.

* fix(atlas): resolve region AND country by coordinates against the bundled polygons

Rebased onto dev's streaming atlas index (#1576): region resolution now
resolves a place's lat/lng directly against the same bundled admin1
polygons the client renders — offline, deterministic, and guaranteed to
match a bundle feature — rather than trusting Nominatim's address level,
which can name a subdivision the bundle doesn't carry (Barcelona's ES-B
province vs the bundle's ES-CT autonomous community) and never highlight.
Country resolution moves to the same coordinates-first order, so a place
stored "..., San Francisco, CA" no longer resolves to Canada.

admin1 is held as per-country GeoJSON text (never parsed whole, #1576),
so a country's regions are flattened to the compact Float64Array form and
cached on first use — only visited countries pay the parse. The stale GB
constituent-country rescue is removed, and a one-time migration clears the
re-derivable place_regions cache so every place re-resolves under the new
logic.

Closes #1547

* fix(client): convert mixed-currency day/trip cost totals instead of mislabeling raw sums (#1561)

Day headers, the plan sidebar footer and the PDF day/cover totals summed
raw place prices across currencies and labeled the result with a single
currency — a $2,730.27 hotel on a NOK trip read as "2730 NOK".

Totals now convert every amount into a base currency via the existing
frankfurter rates (sidebar: the user's display currency, falling back to
the trip's; PDF: the trip currency, resolved once before rendering so the
document is consistent), marked with "≈" when a conversion happened. When
a rate is unavailable (offline, blocked egress, unknown code) they fall
back to an honest per-currency breakdown ("2 500 kr + $2,730.27") instead
of folding foreign amounts into a mislabeled number. All-same-currency
trips make no FX request, so offline PDF export keeps working.

Also swaps the place inspector's hardcoded € chip icon for a neutral one
and formats the price via formatMoney in the place's own currency.

Note: day-header totals move from "50 EUR" to Intl formatting ("50 €").

* feat(atlas): let a visited region be hidden, cascading to the country when none remain (server)

Countries already have a hide/tombstone mechanism (hidden_countries, #1490):
a zero-count derived country can be dismissed and stays gone across reloads.
Regions had no equivalent — unmarkRegionVisited only ever deleted a
manually-marked visited_regions row, a no-op for the common case of a region
derived fresh from place_regions on every request, so there was no way to
dismiss one at all (place-derived or otherwise).

Adds the region-level counterpart:
- New hidden_regions table (user_id, region_code, country_code), mirroring
  hidden_countries.
- getVisitedRegions() filters its result through it, the same way getStats()
  already filters through getHiddenCountries().
- unmarkRegionVisited() now tombstones unconditionally (not just for a
  manually-marked region — a region with a real place attached, e.g. one
  misassigned by a border-simplification gap, is exactly the case this
  exists for) and derives the country code from the region code's
  "<country>-<rest>" prefix when there's no visited_regions row to read it
  from.
- Cascade: after hiding a region, if the country has no other visible region
  left (checked against place_regions + visited_regions, minus hidden_regions),
  the country is hidden too via the existing unmarkCountryVisited.
- markRegionVisited() clears both tombstones on re-mark, so a region (and its
  cascade-hidden country) can come back.

Note the cascade only has a visible effect on a country with no real place
attached to it — getStats' places-derived country entries are never
suppressed by hidden_countries (#1490's deliberate "reappears with a real
place" rule), so hiding every region of a country that DOES have real places
leaves the country visible, by the same existing design. Test coverage
reflects this.

Server-side only — client wiring (a way to trigger this from the map) is a
separate commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(atlas): let a visited region be hidden, cascading to the country when none remain (client)

Client wiring for the server-side hide-region feature (previous commit).

Clicking a visited region on the map used to open the hide/unmark
confirmation only when it was manually marked (visitedRegions[...].manuallyMarked);
a region derived from real place data instead opened the country-detail
view, with no way to dismiss it at all. Now any visited region offers the
same "Remove this region from your visited list?" confirmation regardless of
how it was derived — country details remain reachable via the country
search/sidebar, which was never gated on this in the first place.

The confirm handler's optimistic country-removal check dropped its
`&& r.manuallyMarked` filter on the remaining-regions count, matching the
server's unconditional cascade — but keeps the existing "only when the
country has zero real places/trips" guard, since a country backed by real
data is never actually hidden server-side (#1490) and removing it from the
UI early would just flash and reappear on the next reload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(settings): make fresh-instance unit defaults internally consistent

A brand-new instance — no admin-set defaults, no saved value — rendered
temperatures in Fahrenheit and times on the 12-hour clock while distances
defaulted to metric, a mix that matches no locale. The store also seeded
'fahrenheit'/'12h' while DisplaySettingsTab's fallback said 'celsius', so the
two paths disagreed about the intended default in the first place.

Default to one system — celsius / metric / 24h — matching the already-metric
distance_unit. The unit defaults now live in a single exported DEFAULT_SETTINGS
that both the store and DisplaySettingsTab's fallback read, so they can't drift
apart again. Admin-set user defaults (getAdminUserDefaults) and any value a user
has already saved still take precedence; only the code-level default changed.

* feat(i18n): complete Catalan (ca) translation

Adds Catalan as a supported language — the full shared/src/i18n/ca locale
(all domain files plus the notification texts), registered in
SUPPORTED_LANGUAGES and the client locale loader. Rebuilt onto current dev so
the locale is at full key parity with en (i18n:parity:strict clean).

* fix(map): stop real road-route fetches from dying under StrictMode

useTransportRoutes cached its AbortController in a ref that was
created once and aborted on unmount. React StrictMode's dev-only
mount->cleanup->remount cycle ran that abort during the *simulated*
cleanup, permanently poisoning the controller before the real mount's
fetch ever started — every road-routed booking (car/bus/taxi/bicycle)
silently fell back to a straight line in local dev, while production
builds (no StrictMode double-invoke) worked fine.

Fix: create a fresh AbortController per effect run instead of a
ref-cached singleton, and synchronously un-mark a job as "attempted"
in that same run's cleanup if it didn't settle before the cleanup
fired — so a StrictMode remount (or any other pre-completion
cancellation) retries instead of being skipped forever. Verified
against the real OSRM endpoint in a running dev server: all four
road-routed legs on a live trip now resolve with real road geometry
instead of straight lines.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(map): add reservation route-visibility util

Extracts the route-visibility filter that was hand-copied, identically,
in both MapView.tsx and MapViewGL.tsx into one pure, unit-tested
function: a reservation's route shows on the map when it's a transit
booking with the day-route toggle on, or its id is in the caller's
visible-ids set. isRoutableReservation (>= 2 endpoints) is exported
separately since callers besides the map filter need the same check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(map): add per-trip connections-visibility resolution util

Pure storage/resolution logic for a trip's booking-route visibility
preference, keyed trek:visible-connections:<tripId>. Two modes:
'only' (nothing shown except the listed ids — today's existing
behavior; a legacy bare-array localStorage value parses as this mode
for backward compatibility) and 'all-except' (everything routable
shown except the listed ids). A trip with no stored preference falls
back to the account-wide default (all or nothing) without writing
anything, so flipping the account setting later never silently
overrides a trip with an explicit per-trip choice already recorded.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(settings): add always-show-booking-routes account setting

New map_always_show_routes account setting, defaulting to off, i18n'd
across all 22 supported locales. Lives in Display > Travel & map,
directly under Booking route labels — its closest sibling — using
that section's immediate-save On/Off pattern rather than a separate
toggle+Save flow, since it's a booking-display preference, not a map
render-config option.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(map): per-trip booking-route visibility with account default and bulk toggle

Lets a user see a booking's route on the map without manually
toggling it per item, two ways:

- The account setting from the previous commit sets the default for
  any trip that's never had its routes touched before.
- A new bulk "show all / hide all" button in the day-plan toolbar
  flips a trip explicitly between the two connectionsVisibility modes,
  independent of the per-item toggle (which still edits whichever
  mode's id list is active, in both directions, including while the
  account default is on).

useTripPlanner.ts resolves a trip's effective visible-connection ids
from connectionsVisibility.ts + the account setting + the trip's
routable reservations, and exposes it through the same
visibleConnections/toggleConnection contract MapView, MapViewGL and
DayPlanSidebar already had, plus allConnectionsShown/
toggleAllConnections for the new bulk control. MapView/MapViewGL
consume it via the shared reservationRoutes util instead of each
carrying their own copy of the filter.

The bulk toggle's tooltip gets its own map.showAllConnections/
hideAllConnections i18n keys (all 22 locales) distinct from the
per-item toggle's text, and matches the per-item toggle's active
(solid blue) styling rather than the toolbar's generic hover tint.

Manually verified end-to-end in a running dev server: the account
default seeding an untouched trip, the bulk toggle flipping a trip
between all-shown/all-hidden, and a single per-leg override while the
trip is in all-shown mode.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Update documentation for booking visibility change

* fix(i18n): add Catalan keys for the booking-route toggle + fix the language-count test

#1483 added map.showAllConnections/hideAllConnections and
settings.alwaysShowRoutes/Hint to every locale that existed when it was
written; Catalan (#1418) landed just after and was missing them, which
broke i18n parity. Also bumps the SUPPORTED_LANGUAGES test to 23 entries
and asserts the Catalan one — the ca addition left that assertion stale.

* test(e2e): add a reproducible documentation screenshot run

The wiki screenshots had drifted three months and two releases behind the UI:
39 of 46 assets came from a single commit in April, and pages such as
Budget-Tracking carry a disclaimer that their own images are out of date.
Retaking them by hand is what created that drift in the first place.

This adds a `screenshots` Playwright project that captures them from a dev
build instead:

- seed.ts populates a demo trip over the REST API — deliberately in JPY, so the
  3.4.0 currency work (per-trip currency, frozen FX rates, foreign-currency
  settle-up) is actually visible rather than hidden behind EUR.
- shot.ts settles the page before capturing (fonts, images, transitions) so
  captures don't catch skeleton loaders, and rewrites /auth/app-config to clear
  dev_mode — the E2E backend runs NODE_ENV=development, which would otherwise
  put a "Dev: Notifications" tab no real deployment has into every admin shot.
- promote.mjs downscales 2880px captures to 1600px on the way into
  wiki/assets/, which is 70% smaller for detail the wiki never renders.

Tabs are located by their visible label, so a rename like Budget → Costs fails
the run loudly instead of quietly capturing the wrong panel.

npm run shots && npm run shots:promote

* docs(wiki): retake screenshots against 3.4.0

Regenerated with `npm run shots` from the dev build. Replaces 21 assets and
adds 13 surfaces that had no screenshot at all.

Notable corrections:

- Collections.md referenced assets/Collections.png, which was never committed —
  the only broken image in the wiki. It now exists.
- The Budget panel became Costs in 3.3.0 (#1464); every budget image still
  showed the old label.
- The trip-create dialog gained a Currency field in 3.4.0, absent from the old
  TripCreate.png.
- Settings grew a Plugins tab and the admin sidebar a Plugins entry; neither
  appeared in the old sidebar shots.
- Weather now renders in Celsius. A fresh instance defaults to Fahrenheit while
  distance defaults to metric, so the seed pins both units — the mismatched
  defaults are a separate bug, not fixed here.

Koffi is installed from the community registry rather than dev-linked, because
dev-link and sideload both stamp a badge on the plugin card that an ordinary
install never shows.

Total 3.6 MB for 34 images, against 26 MB for the 46 assets already in the
directory.

No wiki page text is touched here — pages still point at the old filenames
where those were kept, and the newly added images are not referenced yet.

* fix(help): make wiki links and anchors work in the in-app reader

The wiki pages are written in GitHub-wiki style, and 455 of their links across
81 pages use the bare relative form — `[Currencies](Currencies)` — against only
114 `[[..]]` links. processMarkdown only rewrote the latter, so in-app every one
of those 455 fell through to HelpPage's external-link branch, opened a new tab
and 404'd. Since 6c87bf2f serves the wiki from disk, that is the primary way
users read these docs.

Rewriting them in the renderer fixes every page at once and keeps the sources
GitHub-compatible, so contributors can keep writing either form and neither
target breaks. Rewriting the 81 files instead would have fixed today's pages and
left the trap open for tomorrow's.

Also:

- Code is protected from the rewrite. Plugin-Development.md documents
  `actions[key](ctx)`, which reads as a markdown link and would otherwise be
  corrupted into `actions[key](/help/ctx)` inside a verbatim snippet.
- `[[Page#anchor|Slug]]` no longer renders its anchor as visible link text.
- Headings carry GitHub-compatible ids, so the 22 in-page `](#anchor)` links
  scroll instead of doing nothing.

* docs(wiki): correct settings, map and addon docs against 3.4.0

The wiki described settings that no longer exist and pointed at UI labels that
had been renamed. Each correction below was checked against the code.

- The Settings tab is labelled **General**, not "Display"
  (shared/src/i18n/en/settings.ts:6). Every "Settings → Display" path was wrong.
  The *display currency* setting keeps its name — only the tab was renamed.
- Colour mode is on the **Appearance** tab, not Display.
- The "Route calculation" setting does not exist: no hits for route_calc /
  routeCalc / auto_route / calculateRoutes anywhere in client or server.
- Default map centre and zoom were removed in 3.4.0 (0f4766e1). Replaced with
  what actually happens now, written from client/src/utils/mapViewport.ts:
  every map frames itself on its own places, world view when a trip has no
  coordinates.
- A third map provider, MapLibre GL / OpenFreeMap, was undocumented. It needs no
  access token, which is the reason a reader would choose it over Mapbox.
- Budget-Tracking.md said the feature is called Costs everywhere and then told
  the reader to open the "Budget" tab. The screenshot disclaimer is gone too —
  the images now show Costs.
- The admin tab table was missing Plugins.
- The `packing` addon is seeded as "Lists", not "Packing list management".
- Install docs pinned `mauriceboe/TREK:3.0.15` as the exact-release example,
  four minor versions stale.

* docs(wiki): document passkeys, calendar feeds, appearance, plugins and help

Five shipped features had no user documentation at all:

- **Passkeys** — WebAuthn enrolment and sign-in, admin policy, RP ID/origins.
  Previously mentioned only in passing in Environment-Variables.
- **Calendar Feeds** — the subscribable per-trip and per-user ICS feeds. Note
  Day-Plans-and-Notes documents only the one-off .ics *export*; the two are
  cross-referenced so it is clear which is which.
- **Appearance Settings** — the whole tab, including the custom accent colour
  and its contrast check.
- **Admin: Plugins** — installing from the registry, the pre-install permission
  review, egress hosts, and what Reviewed/Signed/Unsigned actually guarantee.
- **In-App Help** — that the wiki ships in the image and is served from disk
  since 3.4.0, with the GitHub fallback and TREK_WIKI_DIR.

One deliberate deviation from the brief: the Appearance settings are documented
as account-level, not per-device. They persist to /api/settings on the user
account with nothing in localStorage, so the dashboard widget picker's
desktop/mobile split still changes both from either device.

All five are listed in _Sidebar.md.

* docs(wiki): add screenshots for the remaining surfaces

Second capture pass, bringing the run to 42 screenshots. Adds the surfaces that
need more than a navigation to reach: collection and journey detail, MCP access,
two-factor setup, the settle-up payment dialog, and the trip file manager.

Two fixes to the harness itself, both of which had produced misleading images:

- The admin captures showed a "Dev: Notifications" tab that only exists when the
  server runs NODE_ENV=development. The run now clears dev_mode in the
  /auth/app-config response; switching the server to production instead would
  have enabled HSTS and broken the run over http://localhost.
- The settle-up capture clicked "Settle up", which does not open a view — it
  records the transfers. It zeroed every balance and photographed "Everyone's
  square", and because the specs share one database it poisoned Costs.png in the
  same run. Screenshot specs must not mutate state; it now captures the
  "Add payment" dialog instead.

Koffi is installed from the community registry rather than dev-linked, since
dev-link and sideload both badge the plugin card in a way no ordinary install
does.

* test(e2e): capture the detail pages and dialogs

Adds the second wave of screenshot specs (collection/journey detail, MCP access,
2FA, settle-up dialog, files) and enables the mcp, documents and collab addons
in the seed so their surfaces render instead of 404ing.

* docs(wiki): point plugin authors at the agent skill and the registry

Plugin-Development jumped straight into scaffolding without saying that two
supporting resources exist. TREK-Plugins was referenced only in passing far down
the page, and Plugin-Skill — an agent skill that teaches Claude Code and other
SKILL.md-compatible agents to build and publish a plugin — was not mentioned
anywhere in the wiki.

Both are called out up front, with a note that neither is required: the registry
only matters once you want other instances to find your plugin.

* test(e2e): capture the four collab surfaces separately

One Collab.png illustrated chat, notes, polls and the What's Next widget, so at
most one of those four wiki pages showed the feature it described. Each now has
its own capture.

Two things the collab seed needed:

- The conversation is posted by three different people. Every collab write is
  attributed to the acting user, and a single-voice chat log would misrepresent
  the feature outright.
- Each member therefore gets its OWN request context, created with an explicit
  `storageState: undefined`. Without that, newContext inherits the project's
  storageState — the admin's trek_session cookie — and extractToken reads the
  cookie BEFORE the Authorization header (server/src/middleware/auth.ts:9). The
  posts still return 200; they are just all recorded as the admin. That is
  exactly what happened on the first attempt, and the DB was the only place it
  showed.

The collab view is not tabbed — CollabPanel renders all panels at once — so the
captures target cards by seeded content rather than clicking tabs or matching
headings, whose DOM text is 'Notes'/'Polls' while CSS renders them uppercase.

* docs(wiki): show every screenshot on the page it belongs to

Finishes the wiring the screenshot commits deliberately left out.

- Embeds the 14 images that were committed but displayed nowhere: the Costs
  panel and settle-up dialog, the trip planner, transports, documents,
  collection and journey detail, the notifications inbox, the Offline and
  Account settings tabs, appearance, admin user defaults, registration and
  password reset.
- Splits the four collab pages onto their own images. Chat, Notes, Polls and
  What's Next each showed the same Collab.png until now; the overview shot moves
  to Real-Time-Collaboration, which had no image at all.
- Day-Plans-and-Notes pointed at TripPlaner.png — one 'n'. It now uses the
  correctly spelled file, and the misspelled one is deleted since nothing else
  referenced it.
- Removes 45 dead '<!-- TODO: screenshot -->' markers whose screenshot had long
  since been added. 9 remain, each on a page that genuinely still lacks the
  image it asks for — so the marker means something again and the gap is
  greppable, which is how this drifted unnoticed for three months.

Every asset in wiki/assets/ is now referenced by a page, and every image
reference resolves to a file.

* docs(wiki): add the four collab screenshots

Chat now shows a real three-person conversation rather than one voice talking to
itself, and the poll shows three separate votes across two options.

44 images, 4.6 MB total.

* test(help): point the asset test at the correctly spelled screenshot

The integration test hard-coded assets/TripPlaner.png — one 'n' — so deleting
the misspelled file broke it. It was the only thing keeping that filename
alive.

* docs(wiki): regenerate the screenshots on top of the rebased dev

ba3733da changed the fresh-instance defaults to celsius/metric/24h. Every
capture showing a clock — chat timestamps, bookings, day plans — and the General
settings tab itself were still on the 12-hour clock, so 39 of 44 images needed a
new run. Regenerating them is one command, which is the point.

The seed keeps pinning the units explicitly: it now matches the new defaults, but
stating them keeps the captures reproducible if a default moves again.

* docs(wiki): regenerate screenshots after rebasing onto dev

dev added a Catalan translation, an always-show-booking-routes account setting
and a bulk route toggle in the day-plan toolbar since the last run — all visible
on captures we ship. 15 of 44 images changed.

Also resolves the Map-Features conflict from 41d12e89: upstream's new bulk-options
section is kept, with the two 'Settings → Display' paths corrected to 'General'.
The tab is labelled General (shared/src/i18n/en/settings.ts:6), and upstream's own
i18n key for that section is settings.general.travelMap.

* chore: only allow manual trigger for the build&push

* feat(mobile): rebuild Settings & Admin panels natively

* feat(mobile): native booking, transport & expense form sheets

* feat(mobile): unify empty states, streamline nav, collab & collections

* feat(mobile): white atlas bucket button + custom month/year picker

* feat(mobile): show accommodation route legs in the day plan

* feat(mobile): auto-draw the day route in map mode

* feat(mobile): move the vacay invite button left of the year switcher

* fix(mobile): don't render the global dock inside the trip planner

* feat(mobile): reachable add-list, roomier packing rows & owner avatars

* fix(mobile): use the custom date picker for the to-do due date

* feat(mobile): open a running trip on today's day

* chore(client): serve the PWA manifest in dev for on-device testing

* fix(i18n): complete the Catalan (ca) locale for the mobile rewrite

* test(transport): match the shortened Manual/Automated switch labels

* test(mobile): mock the admin permissions panel MAdmin renders

* feat(vacay): rework the desktop vacation planner in the glass style

Move the calendar, sidebar cards and stats onto the --vg-* glass tokens,
soften the person-day fills, pad every month to six week-rows so the
cards line up, tighten the sidebar spacing and add You/Pending badges to
the people list.

* feat(vacay): redesign the vacation settings dialog

Widen the modal and split the options into two columns, and restyle the
holiday calendar rows, inputs and add button to match the rest of the
planner.

* feat(journey): rework the journeys dashboard in the glass style

Move the hero to a blur-mask magazine cover, put the journey cards on
glass with cover/title overlays, widen the grid to match the rest of the
app, drop the top toolbar, and add a subtitle field to the create flow.

* feat(journey): modernise the journey detail page

Centre and widen the feed+map layout, restyle the hero with a frosted
cover, glass stat pill and round controls, turn the view switch into a
glass segmented pill with a labelled add-entry button, and give the
timeline day headers, entry cards, suggestion cards and the entry
editor a rounder, glassier look.

* feat(journey): lay out the journey settings dialog in two columns

Widen the dialog and split the options into two glass cards, and round
the inputs and the save/cancel buttons.

* feat(journey): lay out the entry editor in two columns

Widen the new/edit entry dialog and split it into two columns — title,
photos and the story on the left (the story now grows to fill the
column), date/location, pros & cons, mood and weather on the right.

* feat(journey): polish the detail, settings and share UI

Move the gallery upload into the view switch as a matching pill and drop
the old button, badge the day-place and photo counts, tidy the entry
editor location field, round the settings inputs and buttons, restyle the
contributor role and share controls, and put delete next to copy.

* feat(journey): always feature a hero on the frontpage

The desktop frontpage only rendered the hero header when a journey trip was currently live, so with no live trip there was no header at all. The hero now always features one journey — the live one when there is one, otherwise the most recent — and its eyebrow reads "Latest Journey" when the featured journey is not live.

* feat(mobile): customizable bottom navbar layout

Add a Mobile section under Settings → Appearance where you arrange the bottom navbar: reorder items and choose which sit in the bar versus under the "More" popover, with a live preview. Dashboard stays pinned first. The layout persists per-user in the appearance config and drives MBottomNav; an un-customised account keeps the current Dashboard + Vacay/Atlas dock.

* fix(mobile): widen the nav customizer zone buttons to a single row

* refactor(mobile): show the nav customizer only in mobile settings

The bottom-nav layout only exists on phones, so configuring it from the desktop settings made little sense. Drop the desktop customizer (and its two now-unused labels) and keep the editor on the mobile appearance screen.

* fix(mobile): neutral tiles and no subtitle in the bottom-nav More menu

* feat(desktop): TREK mascot empty states + trip-open splash

Bring the mobile TREK mascot to desktop as a shared EmptyState (mascot + a single title, no subtitle, one uniform look) across the app empty states: reservations, transport, costs, packing, to-dos, files, collab notes/polls/chat, upcoming activities, the day plan, journey timeline and gallery, collections, atlas, dashboard and notifications. The trip planner now plays the same little mascot journey while a trip loads.

* feat(desktop): animated glassmorphism background for the trip loading splash

The trip-open splash now takes over the viewport with a calm, slowly drifting pastel gradient wash (soft pink/lavender/periwinkle, toned down), a gently breathing hue veil, a whisper of filmic grain and a soft vignette, with the mascot journey floating on a frosted liquid-glass card. Theme-aware for light and dark; the background freezes under prefers-reduced-motion.

* fix(planner): keep the empty-day itinerary as small text, no mascot

* fix(mobile): cap the bottom-nav dock at Dashboard + 2 so the More slot fits

* feat(mobile): reorderable dashboard arrangement

Add a mobile-only dashboard order control under Settings → Appearance → Mobile: reorder how the trip list and the inline widgets (currency, collections, world clocks, upcoming reservations) stack on the phone dashboard — e.g. move the currency converter above your trips. The featured trip always stays pinned on top. Stored per-user in the appearance blob (dashboard.mobileOrder); an un-arranged account keeps the current order.

* feat(plugins): map layer provider hook for the trip map

Plugins can now draw bounded vector overlays (polylines, polygons, metric
circles) on the trip map via hooks.mapLayerProvider — routes, reachable-range
corridors, zones. Same contract as map markers: declarative data only, tone
palette styling with clamped numerics, hard per-plugin budgets (4 layers /
150 features / 8000 vertices), oversized or partly-invalid shapes dropped
whole. Rendered beneath the core day route.

The GL renderer now also mounts the plugin markers it was missing — both
hooks work on Leaflet and Mapbox/MapLibre, desktop and mobile.

* feat(plugins): route provider hook with planner route profiles

A routeProvider plugin can now register routing profiles (capabilities.
routeProfiles, max 3) that show up in the planner's route toggle next to
Driving/Walking — e.g. EV routing with charging stops. Selecting one asks
exactly that plugin to route the day's stops (POST /api/plugin-routes,
20 s budget for external solvers); the result is validated whole (leg
count must match the waypoint pairs, vertex/via caps) and rendered like
an OSRM response: geometry on the map, via points as stops on the line,
per-leg times + an optional note ("25 min charge") on the connectors.
A failing provider degrades to straight lines, same as an OSRM outage.

The feed only offers profiles whose hook grant is actually recorded, and
the route endpoint re-checks provider + declared profile against the DB
row, so a hand-edited capabilities blob can't invent routable profiles.

* feat(plugins): day schedule contributions in the planner

A dayScheduleProvider plugin can attach time entries to the day plan —
"35 min charging at this stop", "45 min security before this flight".
Rows are host-rendered under their anchor (a place/booking row, or a
day's start/end) on both the desktop sidebar and the mobile timeline,
and the contributed minutes are folded into the day's route-footer
total as "+X min".

Because this output feeds displayed timing, the server clamps minutes
to a day, checks every dayId against the trip's own days and budgets
the item count per provider; labels go through the usual sanitize+cap
pipeline.

* feat(plugins): geolocation bridge permission

New geolocation:read permission — the first bridge-level grant: a plugin's
sandboxed frames can ask the HOST for the browser position over postMessage
(window.trek.geolocation.get()/watch()). The sandbox itself never gains the
geolocation API; the host page reads navigator.geolocation, so the browser's
own site permission prompt still applies on top of the admin consent, and
the position only travels parent -> frame, never to the server.

The feed flags granted plugins, an ungranted frame gets a 'forbidden'
answer without touching the browser API, one GPS watch per frame, and the
watch is force-stopped when the frame closes. The dev preview answers with
a fixed position so trek.geolocation works without a prompt.

* fix(mobile): render trip-page plugin tabs

The mobile trip shell listed plugin tabs in the Mehr sheet and let the user
open them, but MTripTabPanel had no plugin: branch — the tab came up as an
empty scroller. Mount the same sandboxed PluginFrame the desktop planner
uses, filling the panel above the dock.

* test(plugins): add granted_permissions to the feed fixture schema

The plugins feed now selects granted_permissions (route profiles + the
geolocation flag are gated on the recorded grant), so the hand-written
in-memory schema this suite shares needs the column.

* fix(map): don't crash the trip map when the pane API is absent

PluginMapLayers called map.getPane/createPane unconditionally, which threw
'map.getPane is not a function' on a minimal Leaflet map (breaking MapView
in tests and any renderer state without pane support). Pane placement is a
z-index optimization — guard it and fall back to drawing the plugin
features without the dedicated pane when it isn't available.

* harden(plugins): bound normalize work, scope route cache, enforce geo grant live

Adversarial audit of the four new hooks found no trust-boundary defect; these
close the low-severity gaps it confirmed:

- map-layers / day-schedule / plugin-routes: slice the raw provider array before
  iterating, so an all-invalid oversized payload can't force an unbounded scan on
  the host event loop (the output was already capped; this bounds the work too).
- RouteCalculator: a plugin route is trip-/day-specific, so its cache key now
  includes tripId/dayId — two days with identical coordinates no longer share one
  plugin route (its charging stops/notes differ per day).
- PluginFrame: a live geolocation watch re-checks the grant on every fix and stops
  at once if an admin revokes geolocation:read while the frame stays open.

* test(client): update stale assertions to the mobile-rewrite UI

The mobile rewrite moved empty states to the shared EmptyState mascot
component (title only, no subtitle) and replaced the loading gif with
TripLoadingSplash, but several tests still asserted the old copy/markup:

- CollabChat / PackingListPanel / ReservationsPanel / AtlasPage /
  JourneyDetailPage: assert the current empty-state (title + scene mascot)
  instead of the removed hint paragraphs.
- TripPlannerPage: mock TripLoadingSplash (its mascot-cycling setInterval
  never drains under fake timers, aborting 14 splash-gated tests) and
  assert the current loading node.

Test-only — no component behavior changed.

---------

Co-authored-by: jubnl <jgunther021@gmail.com>
Co-authored-by: Dieter Blomme <dieterblomme@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: sld272 <zjrdmczh@outlook.com>
Co-authored-by: Nguyen Trong Binh <nguytb15@VN1N07HO1CD1015.local>
Co-authored-by: Pavel Zolotarevskiy <code@fxgn.dev>
Co-authored-by: jubnl <66769052+jubnl@users.noreply.github.com>
Co-authored-by: Azalea <noreply@aza.moe>
Co-authored-by: Uzini <43294422+Uziniii@users.noreply.github.com>
Co-authored-by: Daniel <drmoreno271@gmail.com>
Co-authored-by: trongbinhnguyen <43725147+trongbinh15@users.noreply.github.com>
Co-authored-by: Konstantinos Thermos <info@subdee.org>
Co-authored-by: Konstantinos Thermos <subdee@users.noreply.github.com>
Co-authored-by: Lucas Español <lucas.espanol@tutanota.com>
Co-authored-by: fbnlrz <frlrnzn@gmail.com>
2026-07-20 20:44:12 +02:00
Konstantinos Thermos 229ec6d18e fix(notifications): RFC 2047-encode ntfy header umlauts (#1621) 2026-07-20 13:32:55 +02:00
Fabi 0b4737448d Clarify terminology for car rentals in documentation (#1617)
* Clarify terminology for car rentals in documentation

Changed wording for Car / Car-Rentals

* Clarify car rental terminology in transport section
2026-07-20 11:17:51 +02:00
Maurice adbee5aa13 ci: use trek-release app token for version-bump push, annotate release tag 2026-07-19 20:52:43 +02:00
github-actions[bot] a099465889 chore: bump version to 3.4.1 [skip ci] 2026-07-19 17:30:21 +00:00
jubnl 91095ef96a v3.4.1 (#1606)
* fix(transit): lead arrive-by results with the latest arrival (#1479)

MOTIS returns arrive-by itineraries ascending with the deadline-adjacent
connection last, so the visible top of the list arrived ~2h before the
requested time — misread as a timezone bug. Sort arrive-by results by
endTime descending so the connection closest to the requested arrival
leads, mirroring depart-by.

Claude-Session: https://claude.ai/code/session_01BCrZUoPzHZF6H4C6avMXoz

* fix(transit): lead arrive-by results with the latest arrival (#1479)

MOTIS returns arrive-by itineraries ascending with the deadline-adjacent
connection last, so the visible top of the list arrived ~2h before the
requested time — misread as a timezone bug. Sort arrive-by results by
endTime descending so the connection closest to the requested arrival
leads, mirroring depart-by.

* fix(reservations): run AirTrail modal hooks before the isOpen early return (#1602)

The sectionItems useMemo added by #1535 sat below 'if (!isOpen) return null'.
The modal mounts closed, so the first open render executed one more hook
than the previous render and React unmounted the whole tree (error #310),
blanking the page. Move the memo above the early return and cover the
closed-then-open transition with a regression test.

* fix(planner): only draw the check-in-day hotel morning leg when provably at/after check-in (#1597) (#1607)

On an accommodation's check-in day the hotel -> first-stop leg was drawn
by default for any PLACE first stop, suppressed only when that place was
explicitly timed before check-in. An un-timed first place (e.g. "Home"
on day 1 of a driving holiday) therefore always produced a phantom
hotel -> Home leg, regardless of the check-in time.

Flip the default to mirror shouldDrawEveningLeg: the morning leg is now
drawn only when the first place is provably timed at/after check-in
(you dropped your bags first). The drawn map route, the sidebar hotel
connectors, and the Google Maps export all share this helper and
inherit the fix. The optimizer anchors (#1321) are unchanged.

* fix(notifications): never fall back to the admin ntfy topic for per-user sends (#1608)

* chore: correct shields.io url

* chore: correct shields.io url

* chore: update helm repo link

* chore: document new helm chart url

* chore: document new helm chart url

* fix(atlas): make Kosovo selectable on the Atlas map (#1609)

Kosovo's user-assigned ISO code (XK/XKX) was missing from both code
tables in the atlas pipeline: the geo builder's A3_TO_A2 map (so the
shipped admin0 bundle carried ISO_A2: null) and the client's A2_TO_A3
map. With neither resolvable, onEachFeature attached no hover/click
handlers to Kosovo's polygon and the country search dropped it, making
Kosovo impossible to select.

- add XK<->XKX to the client A2_TO_A3 table and the builder A3_TO_A2 map
- stamp ISO_A2: "XK" on the Kosovo feature in the shipped admin0 bundle
- guard both with tests (atlasModel resolution + ATLAS-BUNDLE-003)

* chore: normalize docker image references to mauriceboe/trek (lowercase)

* fix(memories): honor Synology skip-SSL on photo streaming (#1611)

The synology_skip_ssl setting was forwarded on the JSON API path
(login/browse/test-connection) but not on the image-byte fetches, so a
NAS with a self-signed certificate passed Test Connection while
/api/photos/:id/thumbnail and /original failed with 500
"Failed to fetch asset" — and the TLS error was silently swallowed.

- pipeAsset: accept fetchOptions and forward to safeFetch; log the
  underlying error on the 500 path (without the URL, which carries _sid)
- fetchSynologyThumbnailBytes / streamSynologyAsset: pass
  rejectUnauthorized derived from synology_skip_ssl; log on failure
- i18n: replace hardcoded "Immich" with {provider_name} in 7 memories
  keys across 14 locales (source of the reported "Failed to connect to
  Immich" banner on the Synology test), fix translated placeholder
  names in tr/ca/pl, and add a placeholder-parity spec to prevent
  regressions
- client: gallery provider badge matched 'synology' instead of the real
  id 'synologyphotos', showing the raw id; label is now
  "Synology Photos"

* chore: make chart.liketrek.com the canonical helm chart url

* feat(plugins): prefer the registry's resolved store screenshot (#1613)

Browse/detail use the screenshotUrl the aggregate step resolves (cover at the latest commit, or the first resolving README image), falling back to the docs/screenshot.png guess when the field is absent. Fixes blank store cards for plugins without a committed docs/screenshot.png.

---------

Co-authored-by: Maurice <61554723+mauriceboe@users.noreply.github.com>
2026-07-19 19:00:40 +02:00
2259 changed files with 239988 additions and 38223 deletions
+10 -3
View File
@@ -27,11 +27,17 @@ jobs:
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: ${{ secrets.GITHUB_TOKEN }}
token: ${{ steps.app-token.outputs.token }}
- name: Determine bump type and update version
id: bump
@@ -101,9 +107,9 @@ jobs:
# Commit and tag
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package.json package-lock.json server/package.json client/package.json shared/package.json charts/trek/Chart.yaml
git add package.json package-lock.json server/package.json client/package.json shared/package.json nest-mcp/package.json charts/trek/Chart.yaml
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
git tag "v$NEW_VERSION"
git tag -a "v$NEW_VERSION" -m "v$NEW_VERSION"
git push origin main --follow-tags
build:
@@ -208,3 +214,4 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
charts_dir: charts
charts_url: https://chart.liketrek.com
+72 -2
View File
@@ -10,6 +10,7 @@ on:
- 'server/**'
- 'client/**'
- 'shared/**'
- 'nest-mcp/**'
- '.github/workflows/test.yml'
jobs:
@@ -49,6 +50,41 @@ jobs:
- name: Run tests
run: cd shared && npm test
nest-mcp-package:
name: nest-mcp Package
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 nest-mcp
- name: Ensure @swc/core's Linux binary for unplugin-swc
# Same lockfile quirk as server-tests: the Linux native binary is
# omitted, and nest-mcp's vitest config uses the SWC transform too.
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
run: npm run build --workspace=nest-mcp
- name: Typecheck
run: cd nest-mcp && npm run typecheck
- name: Lint
run: cd nest-mcp && npm run lint:check
- name: Run tests
run: cd nest-mcp && npm test
server-tests:
name: Server Tests
runs-on: ubuntu-latest
@@ -77,9 +113,20 @@ jobs:
- name: Build shared
run: npm run build --workspace=shared
- name: Build nest-mcp
# Server typecheck/build resolve @trek/nest-mcp's types from its dist
# (tests alias the package source, but tsc does not).
run: npm run build --workspace=nest-mcp
- name: Build server (tsc -> dist)
run: cd server && npm run build
- name: Smoke production require chain
# Vitest aliases @trek/nest-mcp to its source, so only this exercises
# what production runs: nest-mcp's 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('@trek/nest-mcp')"
- name: Typecheck
run: cd server && npm run typecheck
@@ -97,8 +144,12 @@ jobs:
path: server/coverage/
retention-days: 7
client-tests:
name: Client Tests
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
runs-on: ubuntu-latest
steps:
@@ -125,6 +176,25 @@ 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
@@ -51,6 +51,7 @@ yarn-error.log*
# Coverage
coverage
coverage-*/
*.lcov
.nyc_output
+33 -12
View File
@@ -31,9 +31,12 @@ FROM node:24-alpine AS server-builder
WORKDIR /app
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY nest-mcp/package.json ./nest-mcp/
COPY server/package.json ./server/
RUN npm ci --workspace=server --ignore-scripts
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY nest-mcp/ ./nest-mcp/
RUN npm run build --workspace=nest-mcp
COPY server/ ./server/
RUN npm run build --workspace=server
@@ -44,8 +47,14 @@ WORKDIR /app
# Workspace manifests only — source never enters this stage.
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY nest-mcp/package.json ./nest-mcp/
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.
RUN apt-get update && \
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential \
libkitinerary-bin && \
@@ -53,7 +62,8 @@ RUN apt-get update && \
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
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
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
@@ -65,32 +75,43 @@ ENV QT_QPA_PLATFORM=offscreen
# Override with KITINERARY_EXTRACTOR_PATH if you install it elsewhere.
ENV KITINERARY_EXTRACTOR_PATH=/usr/local/bin/kitinerary-extractor
COPY --from=server-builder /app/server/dist ./server/dist
COPY --chown=node:node --from=server-builder /app/server/dist ./server/dist
# Runtime data assets read from server/assets at runtime: airports.json (flight
# transport search) and atlas/*.geojson.gz (Atlas country/region map). The build
# only emits dist, so these must be copied explicitly or the features silently
# degrade to empty in the image.
COPY --from=server-builder /app/server/assets ./server/assets
COPY --chown=node:node --from=server-builder /app/server/assets ./server/assets
# The in-app help pages (/help) read this straight from disk at runtime, so the
# docs always match the version running. Without it, wikiService falls back to
# fetching the GitHub wiki, which tracks main and needs network access.
COPY wiki ./wiki
COPY --chown=node:node wiki ./wiki
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
COPY server/tsconfig.json ./server/
COPY --chown=node:node server/tsconfig.json ./server/
# Encryption-key rotation is run on demand via tsx (a prod dep) straight from the
# raw .ts source — it never enters dist, so it must be copied in explicitly or
# `node --import tsx scripts/migrate-encryption.ts` fails with module-not-found.
COPY server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts
COPY --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 server/reset-admin.js ./server/reset-admin.js
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY --from=client-builder /app/client/dist ./server/public
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
COPY --chown=node:node server/reset-admin.js ./server/reset-admin.js
COPY --chown=node:node --from=shared-builder /app/shared/dist ./shared/dist
# server dist requires @trek/nest-mcp at runtime through the workspace symlink;
# its dist's MCP SDK subpath requires ride the same tsconfig-paths/register
# hook the server already boots with.
COPY --chown=node:node --from=server-builder /app/nest-mcp/dist ./nest-mcp/dist
COPY --chown=node:node --from=client-builder /app/client/dist ./server/public
COPY --chown=node:node --from=client-builder /app/client/public/fonts ./server/public/fonts
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
# journey/ and places/ must be listed here and in server/src/index.ts (#1762) —
# a dir created lazily on first upload needs write permission on the uploads
# mount point itself, which fails with EACCES when the bind-mounted host dir
# isn't writable by node. Only paths this layer creates are chowned; anything
# already in the image arrived node-owned via --chown above.
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars \
/app/uploads/photos /app/uploads/journey /app/uploads/places && \
ln -s /app/uploads /app/server/uploads && \
ln -s /app/data /app/server/data && \
chown -R node:node /app
chown -R node:node /app/data /app/uploads && \
chown -h node:node /app/server/uploads /app/server/data
ENV NODE_ENV=production
ENV PORT=3000
+1 -575
View File
@@ -1,577 +1,3 @@
# MCP Integration
TREK includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that lets AI
assistants — such as Claude Desktop, Cursor, or any MCP-compatible client — read and modify your trip data through a
structured API.
> **Note:** MCP is an addon that must be enabled by your TREK administrator before it becomes available.
## Table of Contents
- [Setup](#setup)
- [Option A: OAuth 2.1 (recommended)](#option-a-oauth-21-recommended)
- [Option B: Static API Token (deprecated)](#option-b-static-api-token-deprecated)
- [Authentication](#authentication)
- [OAuth Scopes](#oauth-scopes)
- [Limitations & Important Notes](#limitations--important-notes)
- [Resources (read-only)](#resources-read-only)
- [Tools (read-write)](#tools-read-write)
- [Compound Tools](#compound-tools)
- [Prompts](#prompts)
- [Example](#example)
---
## Setup
### 1. Enable the MCP addon (admin)
An administrator must first enable the MCP addon from the **Admin Panel > Addons** page. Until enabled, the `/mcp`
endpoint returns `404` and the MCP section does not appear in user settings.
### 2. Connect your MCP client
#### Option A: OAuth 2.1 (recommended)
MCP clients that support OAuth 2.1 (such as Claude Desktop via `mcp-remote`) authenticate automatically. No token
management required — just provide the server URL:
```json
{
"mcpServers": {
"trek": {
"command": "npx",
"args": [
"mcp-remote",
"https://your-trek-instance.com/mcp"
]
}
}
}
```
> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows).
**What happens automatically:**
1. The client fetches `/.well-known/oauth-protected-resource` (RFC 9728) to discover the authorization server and bind the `/mcp` endpoint.
2. The client fetches `/.well-known/oauth-authorization-server` for the full AS metadata.
3. The client registers itself via [Dynamic Client Registration (RFC 7591)](https://www.rfc-editor.org/rfc/rfc7591).
4. Your browser opens TREK's consent screen, where you choose which scopes (permissions) to grant.
5. The client receives a short-lived access token audience-bound to `/mcp` (RFC 8707) and a rotating refresh token — no re-authorization needed.
> **Requirement:** The `APP_URL` environment variable must be set to your TREK instance's public URL for OAuth
> discovery to work correctly.
**For more control over scopes or to use confidential client mode**, pre-create an OAuth client in
**Settings > Integrations > MCP > OAuth Clients** before connecting. Clients created there have a client secret
(`trekcs_` prefix) and fixed scopes that you define up front.
#### Option B: Static API Token (deprecated)
> **Deprecated:** Static API tokens will stop working in a future version. Migrate to OAuth 2.1 above.
1. Go to **Settings > Integrations > MCP** and create an API token.
2. Click **Create New Token**, give it a name, and **copy the token immediately** — it is shown only once.
3. Add it to your `claude_desktop_config.json`:
```json
{
"mcpServers": {
"trek": {
"command": "npx",
"args": [
"mcp-remote",
"https://your-trek-instance.com/mcp",
"--header",
"Authorization: Bearer trek_your_token_here"
]
}
}
}
```
Static tokens grant full access to all tools and resources (no scope restrictions). Sessions authenticated with a
static token will receive deprecation warnings in the AI client via server instructions and tool results.
Each user can create up to **10 static tokens**.
---
## Authentication
TREK's MCP server supports three authentication methods. OAuth 2.1 is the recommended path for all external clients.
| Method | Token prefix | Access level | TTL | Notes |
|--------|-------------|-------------|-----|-------|
| **OAuth 2.1** | `trekoa_` | Scoped (per-consent) | 1 hour | Recommended. Automatically refreshed via 30-day rolling refresh tokens (`trekrf_` prefix). Replay-detected rotation — replayed tokens cascade-revoke the entire chain. |
| **Static API token** | `trek_` | Full access | No expiry | **Deprecated.** Triggers deprecation warnings in AI clients. Will be removed in a future release. |
| **Web session JWT** | — | Full access | Session-based | Used internally by the TREK web UI. Not intended for external clients. |
All methods require the `Authorization: Bearer <token>` header (strict scheme enforcement — `Bearer` required).
---
## OAuth Scopes
When connecting via OAuth 2.1, you grant specific scopes during the consent step. TREK registers only the MCP tools
that match your granted scopes for that session.
| Scope | Permission | Group |
|-------|-----------|-------|
| `trips:read` | View trips & itineraries | Trips |
| `trips:write` | Edit trips & itineraries | Trips |
| `trips:delete` | Delete trips (irreversible) | Trips |
| `trips:share` | Manage share links | Trips |
| `places:read` | View places & map data | Places |
| `places:write` | Manage places | Places |
| `atlas:read` | View Atlas | Atlas |
| `atlas:write` | Manage Atlas | Atlas |
| `packing:read` | View packing lists | Packing |
| `packing:write` | Manage packing lists | Packing |
| `todos:read` | View to-do lists | To-dos |
| `todos:write` | Manage to-do lists | To-dos |
| `budget:read` | View budget | Budget |
| `budget:write` | Manage budget | Budget |
| `reservations:read` | View reservations | Reservations |
| `reservations:write` | Manage reservations | Reservations |
| `collab:read` | View collaboration | Collaboration |
| `collab:write` | Manage collaboration | Collaboration |
| `notifications:read` | View notifications | Notifications |
| `notifications:write` | Manage notifications | Notifications |
| `vacay:read` | View vacation plans | Vacation |
| `vacay:write` | Manage vacation plans | Vacation |
| `geo:read` | Maps & geocoding | Geo |
| `weather:read` | Weather forecasts | Weather |
| `journey:read` | View journeys | Journey |
| `journey:write` | Manage journeys | Journey |
| `journey:share` | Manage journey share links | Journey |
**Scope rules:**
- A `:write` scope implies `:read` access for the same group (e.g. `budget:write` also grants budget read access).
- Any `trips:*` scope (`trips:read`, `trips:write`, `trips:delete`, or `trips:share`) grants trip read access.
- Any `journey:*` scope (`journey:read`, `journey:write`, or `journey:share`) grants journey read access.
- `list_trips` and `get_trip_summary` are **always available** regardless of scopes — they are navigation tools.
- Static tokens and web session JWTs have full access to all tools (equivalent to all scopes).
- Addon-gated tools (Atlas, Collab, Vacay, Journey) require both the relevant scope **and** the addon to be enabled.
---
## Limitations & Important Notes
| Limitation | Details |
|-----------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------|
| **Admin activation required** | The MCP addon must be enabled by an admin before any user can access it. |
| **Per-user scoping** | Each MCP session is scoped to the authenticated user. You can only access trips you own or are a member of. |
| **No image uploads** | Cover images cannot be set through MCP. Use the web UI to upload trip covers. |
| **Reservations are created as pending** | When the AI creates a reservation, it starts with `pending` status. You must confirm it manually or ask the AI to set the status to `confirmed`. |
| **Demo mode restrictions** | If TREK is running in demo mode, all write operations through MCP are blocked. |
| **Rate limiting** | 300 requests per minute per user (configurable via `MCP_RATE_LIMIT`). Exceeding this returns a `429` error. |
| **Per-client rate limiting** | Rate limits are tracked per user-client pair, so each OAuth client has its own independent rate limit window. |
| **Session limits** | Maximum 20 concurrent MCP sessions per user (configurable via `MCP_MAX_SESSION_PER_USER`). Sessions expire after 1 hour of inactivity. |
| **Token limits** | Maximum 10 static API tokens per user. Maximum 10 OAuth clients per user. |
| **Token revocation** | Deleting a static token or revoking an OAuth session immediately terminates all active MCP sessions for that token/client. |
| **OAuth scope enforcement** | Only tools matching your granted OAuth scopes are registered in the session. Calling an out-of-scope tool returns an error. |
| **Addon toggle invalidation** | When an admin enables or disables an addon, all active MCP sessions are invalidated and must be re-established. |
| **Real-time sync** | Changes made through MCP are broadcast to all connected clients in real-time via WebSocket, just like changes made through the web UI. |
| **Addon-gated features** | Some resources and tools are only available when the corresponding addon (Atlas, Collab, Vacay, Journey) is enabled by an admin. |
---
## Resources (read-only)
Resources provide read-only access to your TREK data. MCP clients can read these to understand the current state before
making changes.
### Core Resources
| Resource | URI | Description |
|-----------------------|-------------------------------------------------|---------------------------------------------------------------------------------------|
| Trips | `trek://trips` | All trips you own or are a member of |
| Trip Detail | `trek://trips/{tripId}` | Single trip with metadata and member count |
| Days | `trek://trips/{tripId}/days` | Days of a trip with their assigned places |
| Places | `trek://trips/{tripId}/places` | All places/POIs saved in a trip. Supports `?assignment=all\|unassigned\|assigned` |
| Budget | `trek://trips/{tripId}/budget` | Budget and expense items |
| Budget Per-Person | `trek://trips/{tripId}/budget/per-person` | Per-person totals and split breakdown |
| Budget Settlement | `trek://trips/{tripId}/budget/settlement` | Suggested transactions to settle who owes whom |
| Packing | `trek://trips/{tripId}/packing` | Packing checklist |
| Packing Bags | `trek://trips/{tripId}/packing/bags` | Packing bags with their assigned members |
| Reservations | `trek://trips/{tripId}/reservations` | Flights, hotels, restaurants, etc. |
| Day Notes | `trek://trips/{tripId}/days/{dayId}/notes` | Notes for a specific day |
| Accommodations | `trek://trips/{tripId}/accommodations` | Hotels/rentals with check-in/out details |
| Members | `trek://trips/{tripId}/members` | Owner and collaborators |
| Collab Notes | `trek://trips/{tripId}/collab-notes` | Shared collaborative notes |
| To-Dos | `trek://trips/{tripId}/todos` | To-do items ordered by position |
| Categories | `trek://categories` | Available place categories (for use when creating places) |
| Bucket List | `trek://bucket-list` | Your personal travel bucket list |
| Visited Countries | `trek://visited-countries` | Countries marked as visited in Atlas |
| Notifications | `trek://notifications/in-app` | Your in-app notifications (most recent 50, unread first) |
### Addon-Gated Resources
These resources are only available when the corresponding addon is enabled by an admin.
| Resource | URI | Addon | Description |
|-----------------------|-------------------------------------------------|----------|---------------------------------------------------------------------|
| Atlas Stats | `trek://atlas/stats` | Atlas | Visited country counts and continent breakdown |
| Atlas Regions | `trek://atlas/regions` | Atlas | Manually visited sub-country regions |
| Collab Polls | `trek://trips/{tripId}/collab/polls` | Collab | All polls for a trip with vote counts per option |
| Collab Messages | `trek://trips/{tripId}/collab/messages` | Collab | Most recent 100 chat messages for a trip |
| Vacay Plan | `trek://vacay/plan` | Vacay | Full snapshot of your active vacation plan (members, years, config) |
| Vacay Entries | `trek://vacay/entries/{year}` | Vacay | All vacation day entries for the active plan and a specific year |
| Vacay Holidays | `trek://vacay/holidays/{year}` | Vacay | Public holidays for the plan's configured region and year |
| Journeys | `trek://journeys` | Journey | All journeys owned or contributed to by the current user |
| Journey Detail | `trek://journeys/{journeyId}` | Journey | Single journey with entries, contributors, and linked trips |
| Journey Entries | `trek://journeys/{journeyId}/entries` | Journey | All entries in a journey (date, text, mood, linked trip) |
| Journey Contributors | `trek://journeys/{journeyId}/contributors` | Journey | Contributors (owner and collaborators) of a journey |
---
## Tools (read-write)
TREK exposes tools organized by feature area. Use `get_trip_summary` as a starting point — it returns everything about a
trip in a single call.
### Trip Summary
| Tool | Description |
|--------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `get_trip_summary` | Full denormalized snapshot of a trip: metadata, members, days with assignments and notes, accommodations, budget, packing, reservations, collab notes, to-dos, and poll/message counts. Use this as your context loader. |
### Compound Tools
Compound tools collapse common multi-step workflows into a single atomic call. Each one wraps two sequential operations in a database transaction — if the second step fails, the first is rolled back automatically.
> **When to use:** Only use compound tools when the place or item does not yet exist. If it already exists, call the individual tools (`assign_place_to_day`, `create_accommodation`, `set_budget_item_members`) directly.
| Tool | Wraps | Description |
|---|---|---|
| `create_and_assign_place` | `create_place` + `assign_place_to_day` | Create a new place and immediately assign it to a specific day. Accepts all `create_place` fields (`place_notes` instead of `notes`) plus `dayId` and optional `assignment_notes`. Returns `{ place, assignment }`. |
| `create_place_accommodation` | `create_place` + `create_accommodation` | Create a new place and immediately book it as an accommodation for a date range. Accepts all `create_place` fields (`place_notes` instead of `notes`) plus `start_day_id`, `end_day_id`, `check_in`, `check_out`, `confirmation`, and `accommodation_notes`. Also auto-creates a linked hotel reservation. Returns `{ place, accommodation }`. |
| `create_budget_item_with_members` | `create_budget_item` + `set_budget_item_members` | Create a budget item and optionally set which members are splitting it. Accepts all `create_budget_item` fields plus an optional `userIds` array. If `userIds` is omitted or empty, behaves identically to `create_budget_item`. Returns `{ item }` with members populated. |
**Scope requirements** match the underlying tools: `places:write` for `create_and_assign_place`, `trips:write` for `create_place_accommodation`, `budget:write` for `create_budget_item_with_members` (Budget addon required).
---
### Trips
| Tool | Description |
|----------------------|---------------------------------------------------------------------------------------------|
| `list_trips` | List all trips you own or are a member of. Supports `include_archived` flag. |
| `create_trip` | Create a new trip with title, dates, currency. Days are auto-generated from the date range. |
| `update_trip` | Update a trip's title, description, dates, or currency. |
| `delete_trip` | Delete a trip. **Owner only.** |
| `list_trip_members` | List the owner and all collaborators of a trip. |
| `add_trip_member` | Add a user to a trip by username or email. **Owner only.** |
| `remove_trip_member` | Remove a collaborator from a trip. **Owner only.** |
| `copy_trip` | Duplicate a trip (days, places, itinerary, packing, budget, reservations). Packing items are reset to unchecked. |
| `export_trip_ics` | Export the trip itinerary and reservations as iCalendar (`.ics`) text for calendar apps. |
| `get_share_link` | Get the current public share link for a trip and its permission flags. |
| `create_share_link` | Create or update the public share link with configurable visibility flags (map, bookings, packing, budget, collab). |
| `delete_share_link` | Revoke the public share link for a trip. |
### Places
> To create a place and assign it to a day in one call, use [`create_and_assign_place`](#compound-tools).
| Tool | Description |
|------------------|--------------------------------------------------------------------------------------------------|
| `list_places` | List places/POIs in a trip, optionally filtered by assignment status, category, tag, or search. |
| `create_place` | Add a place/POI with name, coordinates, address, category, notes, website, phone, and optional `google_place_id` / `osm_id` for opening hours. |
| `update_place` | Update any field of an existing place including transport mode, timing, and price. |
| `delete_place` | Remove a place from a trip. |
| `bulk_delete_places` | Delete multiple places at once by ID. Removes all day assignments as well. **Cannot be undone.** |
| `import_places_from_url` | Import all places from a publicly shared Google Maps or Naver Maps list URL. |
| `list_categories` | List all available place categories with id, name, icon and color. |
| `search_place` | Search for a real-world place by name or address. Returns `osm_id` and `google_place_id` for use in `create_place`. |
### Day Planning
| Tool | Description |
|-----------------------------|--------------------------------------------------------------------------------------|
| `update_day` | Set or clear a day's title (e.g. "Arrival in Paris", "Free day"). |
| `create_day` | Add a new day to a trip with optional date and notes. |
| `delete_day` | Delete a day from a trip. |
| `assign_place_to_day` | Pin a place to a specific day in the itinerary. |
| `unassign_place` | Remove a place assignment from a day. |
| `reorder_day_assignments` | Reorder places within a day by providing assignment IDs in the desired order. |
| `update_assignment_time` | Set start/end times for a place assignment (e.g. "09:00" "11:30"). Pass `null` to clear. |
| `move_assignment` | Move a place assignment to a different day. |
| `get_assignment_participants`| Get the list of users participating in a specific place assignment. |
| `set_assignment_participants`| Set participants for a place assignment (replaces current list). |
### Accommodations
> To create a place and book it as an accommodation in one call, use [`create_place_accommodation`](#compound-tools).
| Tool | Description |
|------------------------|------------------------------------------------------------------------------------------|
| `create_accommodation` | Add an accommodation (hotel, Airbnb, etc.) linked to a place and a check-in/out date range. |
| `update_accommodation` | Update fields on an existing accommodation (dates, times, confirmation, notes). |
| `delete_accommodation` | Delete an accommodation record from a trip. |
### Transport
Transport bookings (flights, trains, cars, cruises) support multi-stop `endpoints[]` — each endpoint has a `role` (`from`/`to`/`stop`), name, optional IATA `code` (for flights), coordinates, timezone, and local time. Use `search_airports` to resolve airport names to IATA codes before creating a flight.
| Tool | Description |
|------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
| `create_transport` | Create a transport booking (`flight`, `train`, `car`, `cruise`) with optional endpoints, departure/arrival times, and confirmation details. Created as pending. |
| `update_transport` | Update an existing transport booking. Pass `endpoints[]` to replace the full stop list. Use `status: "confirmed"` to confirm. |
| `delete_transport` | Delete a transport booking from a trip. |
### Reservations
For flights, trains, cars, and cruises, use the **Transport** tools above. Reservations cover all other booking types.
| Tool | Description |
|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------|
| `create_reservation` | Create a pending reservation. Supports hotels, restaurants, events, tours, activities, and other types. Hotels can be linked to places and check-in/out days. |
| `update_reservation` | Update any field including status (`pending` / `confirmed` / `cancelled`). |
| `delete_reservation` | Delete a reservation and its linked accommodation record if applicable. |
| `reorder_reservations` | Update the display order of reservations (and transports) within a day. |
| `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and associated place. |
### Budget
> To create a budget item and set its members in one call, use [`create_budget_item_with_members`](#compound-tools).
| Tool | Description |
|----------------------------|---------------------------------------------------------------------------------------|
| `create_budget_item` | Add an expense with name, category, and price. |
| `update_budget_item` | Update an expense's details, split (persons/days), or notes. |
| `delete_budget_item` | Remove a budget item. |
| `set_budget_item_members` | Set which trip members are splitting a budget item (replaces current member list). |
| `toggle_budget_member_paid`| Mark or unmark a member as having paid their share of a budget item. |
### Packing
| Tool | Description |
|-------------------------------|-----------------------------------------------------------------------------------|
| `create_packing_item` | Add an item to the packing checklist with optional category. |
| `update_packing_item` | Rename an item or change its category. |
| `toggle_packing_item` | Check or uncheck a packing item. |
| `delete_packing_item` | Remove a packing item. |
| `reorder_packing_items` | Set the display order of packing items within a trip. |
| `bulk_import_packing` | Import multiple packing items at once from a list (with optional quantity). |
| `apply_packing_template` | Apply a saved packing template to a trip (adds items from the template). |
| `save_packing_template` | Save the current packing list as a reusable template. |
| `list_packing_bags` | List all packing bags for a trip. |
| `create_packing_bag` | Create a new packing bag (e.g. "Carry-on", "Checked bag"). |
| `update_packing_bag` | Rename or recolor a packing bag. |
| `delete_packing_bag` | Delete a packing bag (items are unassigned, not deleted). |
| `set_bag_members` | Assign trip members to a packing bag. |
| `get_packing_category_assignees` | Get which trip members are assigned to each packing category. |
| `set_packing_category_assignees` | Assign trip members to a packing category. |
### Day Notes
| Tool | Description |
|-------------------|------------------------------------------------------------------------|
| `create_day_note` | Add a note to a specific day with optional time label and emoji icon. |
| `update_day_note` | Edit a day note's text, time, or icon. |
| `delete_day_note` | Remove a note from a day. |
### To-Dos
| Tool | Description |
|-------------------------------|---------------------------------------------------------------------------------------------------|
| `list_todos` | List all to-do items for a trip, ordered by position. |
| `create_todo` | Create a to-do item with name, category, due date, description, assignee, and priority. |
| `update_todo` | Update an existing to-do item. Pass `null` to clear nullable fields. |
| `toggle_todo` | Mark a to-do item as done or undone. |
| `delete_todo` | Delete a to-do item. |
| `reorder_todos` | Reorder to-do items within a trip by providing a new ordered list of IDs. |
| `get_todo_category_assignees` | Get the default assignees configured per to-do category for a trip. |
| `set_todo_category_assignees` | Set default assignees for a to-do category. Pass an empty array to clear. |
### Tags
| Tool | Description |
|--------------|--------------------------------------------------------------------------|
| `list_tags` | List all tags belonging to the current user. |
| `create_tag` | Create a new tag (user-scoped label for places) with optional hex color. |
| `update_tag` | Update the name or color of an existing tag. |
| `delete_tag` | Delete a tag (removes it from all places it was attached to). |
### Notifications
| Tool | Description |
|---------------------------------|------------------------------------------------------|
| `list_notifications` | List in-app notifications with pagination and unread filter. |
| `get_unread_notification_count` | Get the count of unread in-app notifications. |
| `mark_notification_read` | Mark a single notification as read. |
| `mark_notification_unread` | Mark a single notification as unread. |
| `mark_all_notifications_read` | Mark all notifications as read. |
### Maps & Weather
| Tool | Description |
|-----------------------|-----------------------------------------------------------------------------------------------------|
| `search_place` | Search for a real-world place by name/address and get coordinates, `osm_id`, and `google_place_id`. |
| `get_place_details` | Fetch detailed information (hours, photos, ratings) about a place by its Google Place ID. |
| `reverse_geocode` | Get a human-readable address for given coordinates. |
| `resolve_maps_url` | Resolve a Google Maps share URL to coordinates and place name. |
| `get_weather` | Get weather forecast for a location and date. |
| `get_detailed_weather`| Get hourly/detailed weather forecast for a location and date. |
### Airports
| Tool | Description |
|-------------------|-------------------------------------------------------------------------------------------------------------------|
| `search_airports` | Search for airports by name, city, or IATA code. Returns IATA code, name, city, country, coordinates, timezone. |
| `get_airport` | Look up a single airport by IATA code (e.g. `"ZRH"`, `"AMS"`, `"CDG"`). |
### Collab Notes _(Collab addon required)_
| Tool | Description |
|----------------------|-------------------------------------------------------------------------------------------------|
| `create_collab_note` | Create a shared note visible to all trip members. Supports title, content, category, and color. |
| `update_collab_note` | Edit a collab note's content, category, color, or pin status. |
| `delete_collab_note` | Delete a collab note. |
### Collab Polls & Chat _(Collab addon required)_
| Tool | Description |
|-----------------------|------------------------------------------------------------------------------------------|
| `list_collab_polls` | List all polls for a trip. |
| `create_collab_poll` | Create a new poll with a question, options, optional multiple choice, and deadline. |
| `vote_collab_poll` | Vote on a poll option (or remove vote if already voted). |
| `close_collab_poll` | Close a poll so no more votes can be cast. |
| `delete_collab_poll` | Delete a poll and all its votes. |
| `list_collab_messages`| List chat messages for a trip (most recent 100, supports pagination via `before`). |
| `send_collab_message` | Send a chat message to a trip's collab channel, with optional reply threading. |
| `delete_collab_message`| Delete a chat message (own messages only). |
| `react_collab_message`| Toggle a reaction emoji on a chat message. |
### Bucket List _(Atlas addon required)_
| Tool | Description |
|---------------------------|--------------------------------------------------------------------------------------------|
| `create_bucket_list_item` | Add a destination to your personal bucket list with optional coordinates and country code. |
| `delete_bucket_list_item` | Remove an item from your bucket list. |
### Atlas _(Atlas addon required)_
| Tool | Description |
|--------------------------|---------------------------------------------------------------------------------|
| `mark_country_visited` | Mark a country as visited using its ISO 3166-1 alpha-2 code (e.g. "FR", "JP"). |
| `unmark_country_visited` | Remove a country from your visited list. |
### Atlas Extended _(Atlas addon required)_
| Tool | Description |
|----------------------------|------------------------------------------------------------------------------|
| `get_atlas_stats` | Get atlas statistics — visited country counts, region counts, continent breakdown. |
| `list_visited_regions` | List all manually visited sub-country regions for the current user. |
| `mark_region_visited` | Mark a sub-country region as visited (e.g. ISO code "US-CA"). |
| `unmark_region_visited` | Remove a region from the visited list. |
| `get_country_atlas_places` | Get places saved in the user's atlas for a specific country. |
| `update_bucket_list_item` | Update a bucket list item (name, notes, coordinates, target date). |
### Vacay _(Vacay addon required)_
| Tool | Description |
|----------------------------|---------------------------------------------------------------------------------------|
| `get_vacay_plan` | Get the current user's active vacation plan (own or joined). |
| `update_vacay_plan` | Update vacation plan settings (weekend blocking, holidays, carry-over). |
| `set_vacay_color` | Set the current user's color in the vacation plan calendar. |
| `get_available_vacay_users`| List users who can be invited to the current vacation plan. |
| `send_vacay_invite` | Invite a user to join the vacation plan by their user ID. |
| `accept_vacay_invite` | Accept a pending invitation to join another user's vacation plan. |
| `decline_vacay_invite` | Decline a pending vacation plan invitation. |
| `cancel_vacay_invite` | Cancel an outgoing invitation (owner cancels an invite they sent). |
| `dissolve_vacay_plan` | Dissolve the shared plan — all members return to their own individual plan. |
| `list_vacay_years` | List calendar years tracked in the current vacation plan. |
| `add_vacay_year` | Add a calendar year to the vacation plan. |
| `delete_vacay_year` | Remove a calendar year from the vacation plan. |
| `get_vacay_entries` | Get all vacation day entries for the active plan and a specific year. |
| `toggle_vacay_entry` | Toggle a day on or off as a vacation day for the current user. |
| `toggle_company_holiday` | Toggle a date as a company holiday for the whole plan. |
| `get_vacay_stats` | Get vacation statistics for a specific year (days used, remaining, carried over). |
| `update_vacay_stats` | Update the vacation day allowance for a specific user and year. |
| `add_holiday_calendar` | Add a public holiday calendar (by region code) to the vacation plan. |
| `update_holiday_calendar` | Update label or color for a holiday calendar. |
| `delete_holiday_calendar` | Remove a holiday calendar from the vacation plan. |
| `list_holiday_countries` | List countries available for public holiday calendars. |
| `list_holidays` | List public holidays for a country and year. |
### Journey _(Journey addon required)_
| Tool | Description |
|-----------------------------------|------------------------------------------------------------------------------------------------------------|
| `list_journeys` | List all journeys owned or contributed to by the current user. |
| `get_journey` | Get a full snapshot of a journey: metadata, entries, contributors, and linked trips. |
| `create_journey` | Create a new journey with title, optional subtitle, and an initial list of trip IDs. |
| `update_journey` | Update a journey's title, subtitle, or status. |
| `delete_journey` | Delete a journey. |
| `add_journey_trip` | Link an existing trip to a journey. |
| `remove_journey_trip` | Remove a trip from a journey. |
| `list_journey_entries` | List all entries in a journey (date, text, mood, linked trip). |
| `create_journey_entry` | Add an entry to a journey with optional title, body text, date, linked trip, and sort order. |
| `update_journey_entry` | Edit a journey entry's title, body, date, or mood. |
| `delete_journey_entry` | Remove an entry from a journey. |
| `reorder_journey_entries` | Reorder entries in a journey by providing the new ordered list of entry IDs. |
| `list_journey_contributors` | List the contributors of a journey (owner and invited editors/viewers). |
| `add_journey_contributor` | Invite a user to a journey with `editor` or `viewer` role. |
| `update_journey_contributor_role` | Change a contributor's role between `editor` and `viewer`. |
| `remove_journey_contributor` | Remove a contributor from a journey. |
| `update_journey_preferences` | Update display preferences for a journey (e.g. hide skeleton entries). |
| `get_journey_suggestions` | Get suggested trips to add to journeys (based on recent trip history). |
| `list_journey_available_trips` | List all trips available to the current user for linking to a journey. |
| `get_journey_share_link` | Get the current public share link for a journey. |
| `create_journey_share_link` | Create or update the public share link for a journey. |
| `delete_journey_share_link` | Revoke the public share link for a journey. |
---
## Prompts
MCP prompts are pre-built context loaders your AI client can invoke to get a structured starting point for common tasks.
| Prompt | Description |
|----------------------|---------------------------------------------------------------------------------|
| `trip-summary` | Load a formatted summary of a trip (dates, members, days, budget, packing, reservations) before planning or modifying it. |
| `packing-list` | Get a formatted packing checklist for a trip, grouped by category. |
| `budget-overview` | Get a formatted budget summary with totals by category and per-person cost. |
| `token_auth_notice` | Static token deprecation notice and migration guide. Only available in sessions authenticated with a legacy `trek_` token. |
---
## Example
Conversation with Claude: https://claude.ai/share/51572203-6a4d-40f8-a6bd-eba09d4b009d
Initial prompt (1st message):
```
I'd like to plan a week-long trip to Kyoto, Japan, arriving April 5 2027
and leaving April 11 2027. It's cherry blossom season so please keep that
in mind when picking spots.
Before writing anything to TREK, do some research: look up what's worth
visiting, figure out a logical day-by-day flow (group nearby spots together
to avoid unnecessary travel), find a well-reviewed hotel in a central
neighbourhood, and think about what kind of food and restaurant experiences
are worth including.
Once you have a solid plan, write the whole thing to TREK:
- Create the trip
- Add all the places you've researched with their real coordinates
- Build out the daily itinerary with sensible visiting times
- Book the hotel as a reservation and link it properly to the accommodation days
- Add any notable restaurant reservations
- Put together a realistic budget in EUR
- Build a packing list suited to April in Kyoto
- Leave a pinned collab note with practical tips (transport, etiquette, money, etc.)
- Add a day note for each day with any important heads-up (early start, crowd
tips, booking requirements, etc.)
- Mark Japan as visited in my Atlas
Currency: CHF. Use get_trip_summary at the end and give me a quick recap
of everything that was added.
```
PDF of the generated trip: [./docs/TREK-Generated-by-MCP.pdf](./docs/TREK-Generated-by-MCP.pdf)
![trip](./docs/screenshot-trip-mcp.png)
Please refer to the [MCP wiki](https://github.com/liketrek/TREK/wiki/MCP-Overview) for more information.
+17 -11
View File
@@ -20,7 +20,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
<a href="https://demo.liketrek.com"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
&nbsp;
<a href="https://hub.docker.com/r/mauriceboe/TREK"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
&nbsp;
<a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?style=for-the-badge" /></a>
&nbsp;
@@ -31,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://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/liketrek/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/liketrek/trek?include_prereleases&style=flat-square&color=6B7280" /></a>
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/trek?style=flat-square&color=6B7280" /></a>
<a href="https://github.com/liketrek/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/liketrek/trek?style=flat-square&color=6B7280" /></a>
</div>
@@ -133,7 +133,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
- **Costs** — expense tracker with splits and settle-up (who owes whom), multi-currency
- **Documents** — file attachments on trips, places, and reservations
- **Collab** — chat, notes, polls, day-by-day attendance
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, approved school holiday overlays, carry-over tracking
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
- **AirTrail** — connect a self-hosted AirTrail instance to import and sync flights into reservations
@@ -176,7 +176,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
```bash
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
-e ENCRYPTION_KEY=$ENCRYPTION_KEY \
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/TREK
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
```
Open `http://localhost:3000`. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
@@ -217,7 +217,7 @@ Real-time sync via WebSocket (`ws`). Backend on NestJS 11. State with Zustand. A
```yaml
services:
app:
image: mauriceboe/TREK:latest
image: mauriceboe/trek:latest
container_name: trek
read_only: true
security_opt:
@@ -275,7 +275,7 @@ docker compose up -d
<h2 id="helm-kubernetes">Helm (Kubernetes)</h2>
```bash
helm repo add trek https://mauriceboe.github.io/TREK
helm repo add trek https://chart.liketrek.com
helm repo update
helm install trek trek/trek
```
@@ -305,9 +305,9 @@ docker compose pull && docker compose up -d
**Docker run** — reuse the original volume paths:
```bash
docker pull mauriceboe/TREK
docker pull mauriceboe/trek
docker rm -f trek
docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/TREK
docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/trek
```
> Not sure which paths you used? `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
@@ -405,6 +405,13 @@ 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>
@@ -472,4 +479,3 @@ 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.
+1 -1
View File
@@ -21,6 +21,6 @@ You will receive a response within 48 hours. Once confirmed, a fix will be relea
## Scope
This policy covers the TREK application and its Docker image (`mauriceboe/TREK`).
This policy covers the TREK application and its Docker image (`mauriceboe/trek`).
Third-party dependencies are monitored via GitHub Dependabot.
-25
View File
@@ -1,25 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")" && pwd)"
CLIENT_DIR="$REPO_ROOT/client"
SERVER_DIR="$REPO_ROOT/server"
PUBLIC_DIR="$REPO_ROOT/server/public"
echo "==> Installing client dependencies"
cd "$CLIENT_DIR"
npm ci
echo "==> Building client"
npm run build
echo "==> Installing server dependencies"
cd "$SERVER_DIR"
npm ci
echo "==> Populating server/public"
find "$PUBLIC_DIR" -mindepth 1 ! -name '.gitkeep' -delete
cp -r "$CLIENT_DIR/dist/." "$PUBLIC_DIR/"
cp -r "$CLIENT_DIR/public/fonts" "$PUBLIC_DIR/fonts"
echo "==> Done — server/public is ready"
+3 -1
View File
@@ -15,11 +15,13 @@ This is a minimal Helm chart for deploying the TREK app.
A hosted Helm repository is available:
```sh
helm repo add trek https://mauriceboe.github.io/TREK
helm repo add trek https://chart.liketrek.com
helm repo update
helm install trek trek/trek
```
> **Note:** `chart.liketrek.com` is a custom domain (CNAME) for the GitHub Pages site at `https://liketrek.github.io/TREK` — both URLs serve the same repository. The github.io URL keeps working (it redirects to `chart.liketrek.com`), but the custom domain is the canonical one to use.
## Usage
Or install directly from the local chart:
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 3.4.0
version: 3.4.1
description: Minimal Helm chart for TREK app
appVersion: "3.4.0"
appVersion: "3.4.1"
+1 -1
View File
@@ -1,6 +1,6 @@
image:
repository: liketrek/TREK
repository: mauriceboe/trek
# tag: latest
pullPolicy: IfNotPresent
+3
View File
@@ -3,3 +3,6 @@ e2e/.tmp/
test-results/
playwright-report/
playwright/.cache/
# vite-plugin-pwa dev output (devOptions.enabled)
dev-dist/
+7 -10
View File
@@ -1,4 +1,5 @@
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.
@@ -27,16 +28,12 @@ setup('authenticate the seeded admin (incl. forced password change)', async ({ p
await page.waitForURL('**/dashboard', { timeout: 30_000 })
// Dismiss the first-run "Welcome to TREK" system-notice modal(s). It renders
// asynchronously (after the notices fetch), so wait for it before clicking.
// Dismissal is recorded server-side against this user, so clearing it here
// keeps it cleared for every authenticated flow in the run (shared test DB).
const ok = page.getByRole('button', { name: 'OK', exact: true })
await ok.waitFor({ state: 'visible', timeout: 10_000 }).catch(() => {})
for (let i = 0; i < 8 && (await ok.isVisible().catch(() => false)); i++) {
await ok.click()
await page.waitForTimeout(400)
}
// Dismiss the first-run system-notice modal(s) — currently the thank-you /
// support modal, which has NO "OK" button (only CTAs + the X). The shared
// helper handles both notice shapes; dismissal is recorded server-side
// against this user, so clearing it here keeps it cleared for every
// authenticated flow in the run (shared test DB).
await dismissSystemNotices(page, 10_000)
await page.context().storageState({ path: stateFile })
})
+35 -14
View File
@@ -1,22 +1,43 @@
import type { Page } from '@playwright/test'
/**
* Dismiss the release-notice modal (SystemNoticeHost), which greets a freshly seeded
* user on first load and covers the dashboard — its backdrop swallows clicks aimed at
* anything underneath, `.add-trip-card` included.
* 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 X only appears on the notice's last page, so page through first. Dismissal is
* persisted server-side per user, but each spec gets a fresh DB, so every spec that
* touches the dashboard has to clear it.
* 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): 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())) break
await next.click()
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)
}
const dismiss = page.getByRole('button', { name: 'Dismiss' })
if (await dismiss.isVisible().catch(() => false)) await dismiss.click()
await dismiss.waitFor({ state: 'detached' }).catch(() => {})
await dialog.waitFor({ state: 'detached', timeout: 5_000 }).catch(() => {})
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "3.4.0",
"version": "3.4.1",
"private": true,
"type": "module",
"scripts": {
@@ -86,7 +86,7 @@
"prettier": "^3.8.3",
"prettier-plugin-organize-imports": "^4.3.0",
"prettier-plugin-tailwindcss": "^0.8.0",
"sharp": "^0.33.0",
"sharp": "^0.35.0",
"tailwindcss": "^3.4.1",
"typescript": "^6.0.2",
"typescript-eslint": "^8.58.2",
+11 -8
View File
@@ -27,8 +27,10 @@ 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 BottomNav from './components/Layout/BottomNav'
import MobileShell from './mobile/MobileShell'
import { useIsPhone } from './mobile/useIsPhone'
import { TranslationProvider, useTranslation } from './i18n'
import { authApi } from './api/client'
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
@@ -53,6 +55,7 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
const addonStore = useAddonStore()
const { t } = useTranslation()
const location = useLocation()
const isPhone = useIsPhone()
if (isLoading) {
return (
@@ -87,12 +90,11 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
return <Navigate to="/dashboard" replace />
}
return (
<div className="flex flex-col h-dvh md:block md:h-auto">
<div className="flex-1 overflow-y-auto md:overflow-visible">{children}</div>
<BottomNav />
</div>
)
// 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.
return <MobileShell isPhone={isPhone}>{children}</MobileShell>
}
function RootRedirect() {
@@ -200,6 +202,7 @@ export default function App() {
}
}, [settings.dark_mode, settings.appearance, isSharedPage])
const isPhone = useIsPhone()
const isAuthPage = location.pathname.startsWith('/login')
|| location.pathname.startsWith('/register')
|| location.pathname.startsWith('/forgot-password')
@@ -210,7 +213,7 @@ export default function App() {
{!isAuthPage && <SystemNoticeHost />}
<ToastContainer />
{!isAuthPage && <BackgroundTasksWidget />}
{!isAuthPage && <SaveToCollectionModal />}
{!isAuthPage && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
<OfflineBanner />
<Routes>
<Route path="/" element={<RootRedirect />} />
+490
View File
@@ -0,0 +1,490 @@
// 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')
})
})
+843
View File
@@ -0,0 +1,843 @@
// 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: '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')
})
})
+106 -16
View File
@@ -1,5 +1,6 @@
import axios, { AxiosInstance } from 'axios'
import type { z } from 'zod'
import type { Place } from '../types'
import {
weatherResultSchema, type WeatherResult,
inAppListResultSchema, type InAppListResult,
@@ -26,7 +27,7 @@ import {
type BudgetCreateItemRequest, type BudgetUpdateItemRequest,
type PackingCreateItemRequest, type PackingUpdateItemRequest, type PackingSetSharingRequest,
type TodoCreateItemRequest, type TodoUpdateItemRequest,
type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest,
type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest, type AssignmentTransportRequest,
type PlaceBulkDeleteRequest,
type PlaceBulkUpdateRequest,
type DayNoteCreateRequest, type DayNoteUpdateRequest,
@@ -104,6 +105,9 @@ const RATE_LIMIT_MESSAGES: Record<string, string> = {
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 {
@@ -227,9 +231,11 @@ apiClient.interceptors.response.use(
}
if (error.response?.status === 429) {
const translated = translateRateLimit()
const data = error.response.data as { error?: string } | undefined
if (data && typeof data === 'object') {
data.error = translated
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
} else {
error.response.data = { error: translated }
}
@@ -387,6 +393,8 @@ 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),
}
@@ -398,6 +406,15 @@ 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)
@@ -433,6 +450,8 @@ 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).
updateTransport: (tripId: number | string, id: number, mode: string | null) => apiClient.put(`/trips/${tripId}/assignments/${id}/transport`, { transport_mode: mode } satisfies AssignmentTransportRequest).then(r => r.data),
}
export const packingApi = {
@@ -537,24 +556,35 @@ export const adminApi = {
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ baseUrl, model }),
})
if (!res.ok || !res.body) {
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 = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
buf += dec.decode(value, { stream: true })
const lines = buf.split('\n')
buf = lines.pop() ?? ''
for (const line of lines) {
if (!line.trim()) continue
try { onProgress(JSON.parse(line)) } catch { /* skip partial */ }
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),
@@ -624,6 +654,50 @@ export interface PluginMapMarker {
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';
}
/** 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 {
@@ -667,6 +741,19 @@ export const pluginsApi = {
// (#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[] }),
// 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) =>
@@ -814,8 +901,8 @@ export const mapsApi = {
// 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 }, 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 }),
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 }),
}
export const airportsApi = {
@@ -860,6 +947,8 @@ 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> => {
const fd = new FormData()
@@ -890,6 +979,7 @@ export const healthApi = {
export const weatherApi = {
get: (lat: number, lng: number, date: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date } }).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')),
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')),
}
+342
View File
@@ -0,0 +1,342 @@
// 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)
})
})
+6
View File
@@ -70,8 +70,14 @@ export const collectionsApi = {
ax.post(`${base}/places/from-trip-many`, { collection_id: collectionId, source_trip_id: tripId, source_place_ids: placeIds, force }).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),
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> =>
+4 -2
View File
@@ -7,6 +7,7 @@ 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',
@@ -16,6 +17,7 @@ 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)
@@ -32,8 +34,8 @@ describe('SCOPE_GROUPS', () => {
})
describe('ALL_SCOPES', () => {
it('FE-OAUTH-SCOPES-003: contains exactly 27 scopes', () => {
expect(ALL_SCOPES).toHaveLength(27)
it('FE-OAUTH-SCOPES-003: contains exactly 29 scopes', () => {
expect(ALL_SCOPES).toHaveLength(29)
})
it('FE-OAUTH-SCOPES-004: matches Object.keys(SCOPE_GROUPS)', () => {
+2
View File
@@ -20,6 +20,8 @@ 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' },
+267
View File
@@ -0,0 +1,267 @@
// 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)
})
})
+96
View File
@@ -0,0 +1,96 @@
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',
// 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-011
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-025
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { delay, http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore';
@@ -21,6 +21,45 @@ 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,
@@ -229,4 +268,332 @@ 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-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);
});
});
File diff suppressed because it is too large Load Diff
@@ -5,7 +5,7 @@ import {
ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert,
SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard,
Radio, Luggage, Globe, Image, CalendarDays, Bell,
Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText,
Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, Route, Navigation, Clock, LocateFixed,
} from 'lucide-react'
import PluginIcon from '../shared/PluginIcon'
import { adminApi } from '../../api/client'
@@ -96,15 +96,19 @@ interface RegistryDetail extends RegistryItem {
size: number | null
publishedAt: string | null
manifest: {
permissions: string[]
egress: string[]
// Optional because a registry may omit an empty list — the detail view has to
// degrade rather than throw halfway through its render.
permissions?: string[]
egress?: string[]
/** The plugin needs OPERATOR-supplied hosts — its egress list is not the whole story. */
operatorEgress?: boolean
settings: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }>
settings?: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }>
license: string | null
icon: string | null
requiredAddons?: string[]
pluginDependencies?: PluginDep[]
/** Display slice of the manifest's capabilities — drives the same chips as an installed row. */
capabilities?: { widget?: { slot?: string }; tripPage?: { replaces?: string[] } }
} | null
}
@@ -188,6 +192,7 @@ const PERM_KEYS = [
'events:subscribe', 'jobs:run',
'ws:broadcast:trip', 'ws:broadcast:user',
'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider', 'hook:trip-warning-provider', 'hook:table-contributor', 'hook:map-marker-provider',
'hook:map-layer-provider', 'hook:route-provider', 'hook:day-schedule-provider', 'geolocation:read',
'hook:pdf-section-provider', 'hook:atlas-layer-provider', 'hook:journal-entry-provider', 'hook:trip-card-provider', 'hook:notification-channel', 'hook:user-data', 'http:outbound',
]
@@ -239,6 +244,10 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag
if (perms.includes('hook:calendar-source')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.calendar') })
if (perms.includes('hook:place-detail-provider')) out.push({ icon: MapPin, label: t('admin.plugins.cap.placeDetails') })
if (perms.includes('hook:trip-warning-provider')) out.push({ icon: AlertTriangle, label: t('admin.plugins.cap.warnings') })
if (perms.includes('hook:map-layer-provider')) out.push({ icon: Route, label: t('admin.plugins.cap.mapLayers') })
if (perms.includes('hook:route-provider')) out.push({ icon: Navigation, label: t('admin.plugins.cap.routing') })
if (perms.includes('hook:day-schedule-provider')) out.push({ icon: Clock, label: t('admin.plugins.cap.daySchedule') })
if (perms.includes('geolocation:read')) out.push({ icon: LocateFixed, label: t('admin.plugins.cap.geolocation') })
if (perms.includes('hook:notification-channel')) out.push({ icon: Bell, label: t('admin.plugins.cap.notificationChannel') })
if (perms.includes('events:subscribe')) out.push({ icon: Radio, label: t('admin.plugins.cap.events') })
for (const h of perms.filter(p => p.startsWith('http:outbound:')).map(p => p.slice('http:outbound:'.length)).filter(Boolean)) {
@@ -295,13 +304,8 @@ function installOffer(item: RegistryItem, t: T): { blocked: boolean; version?: s
return { blocked: true, label: t('admin.plugins.incompatible'), title }
}
function ReviewedBadge({ t, compact }: { t: T; compact?: boolean }) {
if (compact) return <ShieldCheck size={13} className="text-success shrink-0" aria-label={t('admin.plugins.reviewed')} />
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-success-soft text-success">
<ShieldCheck size={11} /> {t('admin.plugins.reviewed')}
</span>
)
function ReviewedBadge({ t }: { t: T }) {
return <ShieldCheck size={13} className="text-success shrink-0" aria-label={t('admin.plugins.reviewed')} />
}
/** Marks a manually-uploaded (sideloaded) plugin: no registry, unsigned, not reviewed. */
@@ -1144,7 +1148,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[14.5px] font-semibold tracking-[-.006em] text-content">{p.name}</span>
{p.version && <span className="text-[11.5px] text-content-faint font-medium tabular-nums">v{p.version}</span>}
{p.reviewed_at && <ReviewedBadge t={t} compact />}
{p.reviewed_at && <ReviewedBadge t={t} />}
{p.source_repo === 'local:upload' && <SideloadedBadge t={t} />}
{p.source_repo === 'local:link' && <DevLinkBadge t={t} />}
{/* Registry plugins only — a sideloaded/dev-linked plugin already says something
@@ -1396,7 +1400,10 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
}, [item.id])
const manifest = detail?.manifest ?? null
const caps = manifest ? deriveCaps(manifest.permissions, {}, t) : []
const permissions = manifest?.permissions ?? []
const egress = manifest?.egress ?? []
const settings = manifest?.settings ?? []
const caps = manifest ? deriveCaps(permissions, manifest.capabilities ?? {}, t) : []
const repoUrl = `https://github.com/${item.repo}`
const homepage = item.homepage && /^https?:\/\//i.test(item.homepage) && item.homepage !== repoUrl ? item.homepage : null
const sizeKb = detail?.size ? Math.max(1, Math.round(detail.size / 1024)) : null
@@ -1420,7 +1427,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
<div className="flex-1 min-w-0 pt-8">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-lg font-semibold tracking-tight text-content">{item.name}</h3>
{item.reviewedAt && <ReviewedBadge t={t} compact />}
{item.reviewedAt && <ReviewedBadge t={t} />}
</div>
<p className="text-[12.5px] text-content-faint mt-0.5">{item.author}{item.latest ? ` · v${item.latest}` : ''}</p>
</div>
@@ -1446,7 +1453,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
{manifest && (
<div className="mt-5">
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.accessTitle')}</h4>
{caps.filter(c => !c.net).length === 0 && !manifest.permissions.includes('db:own') ? (
{caps.filter(c => !c.net).length === 0 && !permissions.includes('db:own') ? (
<p className="text-xs text-content-faint mt-2">{t('admin.plugins.noAccess')}</p>
) : (
<div className="mt-2 space-y-1.5">
@@ -1455,7 +1462,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
<c.icon size={15} className="text-accent mt-0.5 shrink-0" /><span>{c.label}</span>
</div>
))}
{manifest.permissions.includes('db:own') && (
{permissions.includes('db:own') && (
<div className="flex items-start gap-2.5 text-[13px] text-content-secondary py-0.5">
<Database size={15} className="text-accent mt-0.5 shrink-0" /><span>{t('admin.plugins.perm.db:own')}</span>
</div>
@@ -1465,11 +1472,11 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
</div>
)}
{manifest && (manifest.egress.length > 0 || manifest.operatorEgress) && (
{manifest && (egress.length > 0 || manifest.operatorEgress) && (
<div className="mt-5">
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.connectsTitle')}</h4>
<div className="flex flex-wrap items-center gap-1.5 mt-2">
{manifest.egress.map(h => (
{egress.map(h => (
<code key={h} className="text-[12px] font-mono text-info bg-info-soft rounded-md px-2 py-1">{h}</code>
))}
{/* The hosts above are NOT the whole story for this plugin: it talks to a
@@ -1488,11 +1495,11 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
</div>
)}
{manifest && manifest.settings.length > 0 && (
{manifest && settings.length > 0 && (
<div className="mt-5">
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.setupTitle')}</h4>
<ul className="mt-2 space-y-1.5">
{manifest.settings.map(s => (
{settings.map(s => (
<li key={s.key} className="flex items-center gap-2 text-xs text-content-muted flex-wrap">
<span className="font-medium">{s.label}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-surface-tertiary text-content-faint">{t(`admin.plugins.scope.${s.scope}` as never)}</span>
@@ -310,4 +310,292 @@ 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)')
})
})
@@ -1,5 +1,5 @@
// FE-COMP-CAT-001 to FE-COMP-CAT-012
import { render, screen, waitFor } from '../../../tests/helpers/render';
// FE-COMP-CAT-001 to FE-COMP-CAT-020
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
@@ -156,4 +156,148 @@ 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) {
@@ -0,0 +1,422 @@
// 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();
});
});
@@ -8,6 +8,7 @@ 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,
@@ -19,7 +20,7 @@ import {
} from '../Map/glProviders'
const MAP_PRESETS = [
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
{ name: 'OpenStreetMap', url: 'https://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' },
@@ -48,8 +49,8 @@ function normalizeProvider(value: unknown): MapProvider {
return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet'
}
function styleForProvider(provider: MapProvider, style?: string | null): string {
if (provider === 'leaflet') return style || MAPBOX_DEFAULT_STYLE
/** 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)
}
@@ -114,7 +115,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
adminApi.getDefaultUserSettings().then((data: Defaults) => {
const provider = normalizeProvider(data.map_provider)
setDefaults(data)
setMapTileUrl(data.map_tile_url || '')
setMapTileUrl(normalizeTileUrl(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))
setLoaded(true)
@@ -328,7 +329,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://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
placeholder="https://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>
@@ -1,5 +1,5 @@
// FE-ADMIN-DEVNOTIF-001 to FE-ADMIN-DEVNOTIF-010
import { render, screen, waitFor } from '../../../tests/helpers/render';
// FE-ADMIN-DEVNOTIF-001 to FE-ADMIN-DEVNOTIF-016
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
@@ -110,7 +110,20 @@ describe('DevNotificationsPanel', () => {
});
});
it('FE-ADMIN-DEVNOTIF-008: error toast shown on API failure', async () => {
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 () => {
server.use(
http.post('/api/admin/dev/test-notification', () =>
HttpResponse.json({ message: 'Server error' }, { status: 500 }),
@@ -120,7 +133,7 @@ describe('DevNotificationsPanel', () => {
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing');
await user.click(screen.getByText('Simple → Me').closest('button')!);
await screen.findByText(/failed|error/i);
await screen.findByText('Failed');
});
it('FE-ADMIN-DEVNOTIF-009: changing trip selector updates payload targetId', async () => {
@@ -157,4 +170,141 @@ 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,5 +1,6 @@
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 {
@@ -46,8 +47,8 @@ export default function DevNotificationsPanel(): React.ReactElement {
try {
await adminApi.sendTestNotification(payload)
toast.success(`Sent: ${label}`)
} catch (err: any) {
toast.error(err.message || 'Failed')
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, 'Failed'))
} finally {
setSending(null)
}
@@ -1,5 +1,5 @@
// FE-ADMIN-PKG-001 to FE-ADMIN-PKG-020
import { render, screen, waitFor } from '../../../tests/helpers/render';
// FE-ADMIN-PKG-001 to FE-ADMIN-PKG-032
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
@@ -18,6 +18,23 @@ 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(
@@ -508,4 +525,296 @@ 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,12 +115,13 @@ 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.deleteError')) }
} catch { toast.error(t('admin.packingTemplates.deleteCategoryError')) }
}
// Item CRUD
const handleAddItem = async (catId: number) => {
if (!newItemName.trim() || !expandedId) return
// The name is already guaranteed non-empty by the button and the Enter handler.
if (!expandedId) return
try {
const data = await adminApi.addTemplateItem(expandedId, catId, { name: newItemName.trim() })
setItems(prev => [...prev, data.item])
@@ -143,7 +144,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.deleteError')) }
} catch { toast.error(t('admin.packingTemplates.deleteItemError')) }
}
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'
@@ -0,0 +1,247 @@
// 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-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom')
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,14 +1,19 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen } from '@testing-library/react'
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(() => {})) },
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',
@@ -22,8 +27,14 @@ const task = (overrides: Partial<BackgroundImportTask> = {}): BackgroundImportTa
...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: [] })
})
@@ -42,4 +53,84 @@ describe('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)
})
})
})
@@ -1,10 +1,11 @@
import ReactDOM from 'react-dom'
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Loader2, CheckCircle2, AlertCircle, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { addListener, removeListener } from '../../api/websocket'
import { reservationsApi } from '../../api/client'
import { reservationsApi, healthApi } from '../../api/client'
import { saveImportFiles } from '../../db/offlineDb'
import { useBackgroundTasksStore, type BackgroundImportTask } from '../../store/backgroundTasksStore'
/**
@@ -23,6 +24,34 @@ export default function BackgroundTasksWidget() {
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
@@ -136,12 +165,24 @@ export default function BackgroundTasksWidget() {
{t('common.import')}
</button>
) : (
<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>
<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>
)
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { calcPP, hasCustomMemberSplit } from './BudgetPanel.helpers'
import { calcPP, hasCustomMemberSplit, normalizePastedAmount } from './BudgetPanel.helpers'
describe('BudgetPanel.helpers', () => {
describe('hasCustomMemberSplit (#1458)', () => {
@@ -25,4 +25,18 @@ describe('BudgetPanel.helpers', () => {
expect(calcPP(100, 0)).toBeNull()
expect(calcPP(100, null)).toBeNull()
})
describe('normalizePastedAmount', () => {
it('keeps the last separator as the decimal point', () => {
expect(normalizePastedAmount('1.234,56 €')).toBe('1234.56')
expect(normalizePastedAmount('$1,234.56')).toBe('1234.56')
expect(normalizePastedAmount(' -12,5 ')).toBe('-12.5')
})
it('drops everything that is not part of the number', () => {
expect(normalizePastedAmount('EUR 1 234 567')).toBe('1234567')
expect(normalizePastedAmount('42')).toBe('42')
expect(normalizePastedAmount('abc')).toBe('')
})
})
})
@@ -77,3 +77,15 @@ export function splitColorFor(userId: number, order: number) {
export function colorForUserId(userId: number) {
return SPLIT_COLORS[((userId | 0) - 1 + SPLIT_COLORS.length * 1000) % SPLIT_COLORS.length]
}
/**
* Normalises a pasted amount to a plain `1234.56` string: drops currency
* symbols and spaces, treats the last comma/dot as the decimal separator and
* removes every thousand separator before it.
*/
export function normalizePastedAmount(raw: string): string {
const text = raw.trim().replace(/[^\d.,-]/g, '')
const decimalPos = Math.max(text.lastIndexOf(','), text.lastIndexOf('.'))
if (decimalPos === -1) return text.replace(/[.,]/g, '')
return text.substring(0, decimalPos).replace(/[.,]/g, '') + '.' + text.substring(decimalPos + 1)
}
@@ -0,0 +1,119 @@
// FE-W4AIR-001 to FE-W4AIR-009
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import AddItemRow from './BudgetPanelAddItemRow'
const t = (key: string) => key
function setup() {
const onAdd = vi.fn()
const utils = render(<table><tbody><AddItemRow onAdd={onAdd} t={t} /></tbody></table>)
return { onAdd, ...utils }
}
const nameInput = () => screen.getByPlaceholderText('budget.newEntry')
const priceInput = () => screen.getByPlaceholderText('0,00')
const noteInput = () => screen.getByPlaceholderText('budget.table.note')
const numberInputs = () => screen.getAllByPlaceholderText('-')
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
})
describe('BudgetPanelAddItemRow', () => {
it('FE-W4AIR-001: the add button stays disabled until a name is typed', () => {
setup()
const button = screen.getByRole('button', { name: 'reservations.add' })
expect(button).toBeDisabled()
fireEvent.change(nameInput(), { target: { value: 'Ferry' } })
expect(button).toBeEnabled()
})
it('FE-W4AIR-002: submits the trimmed name with parsed numbers', () => {
const { onAdd } = setup()
fireEvent.change(nameInput(), { target: { value: ' Ferry ' } })
fireEvent.change(priceInput(), { target: { value: '129,90' } })
fireEvent.change(numberInputs()[0], { target: { value: '2' } })
fireEvent.change(numberInputs()[1], { target: { value: '3' } })
fireEvent.change(noteInput(), { target: { value: ' one way ' } })
fireEvent.click(screen.getByRole('button', { name: 'reservations.add' }))
expect(onAdd).toHaveBeenCalledWith({
name: 'Ferry', total_price: 129.9, persons: 2, days: 3, note: 'one way', expense_date: null,
})
})
it('FE-W4AIR-003: falls back to zero price and null optionals', () => {
const { onAdd } = setup()
fireEvent.change(nameInput(), { target: { value: 'Ferry' } })
fireEvent.click(screen.getByRole('button', { name: 'reservations.add' }))
expect(onAdd).toHaveBeenCalledWith({
name: 'Ferry', total_price: 0, persons: null, days: null, note: null, expense_date: null,
})
})
it('FE-W4AIR-004: ignores a whitespace-only name', () => {
const { onAdd } = setup()
fireEvent.change(nameInput(), { target: { value: ' ' } })
fireEvent.keyDown(nameInput(), { key: 'Enter' })
expect(onAdd).not.toHaveBeenCalled()
})
it('FE-W4AIR-005: Enter in any field submits the row', () => {
const { onAdd } = setup()
fireEvent.change(nameInput(), { target: { value: 'Ferry' } })
fireEvent.keyDown(priceInput(), { key: 'Enter' })
expect(onAdd).toHaveBeenCalledTimes(1)
})
it('FE-W4AIR-006: a non-Enter key does not submit', () => {
const { onAdd } = setup()
fireEvent.change(nameInput(), { target: { value: 'Ferry' } })
fireEvent.keyDown(nameInput(), { key: 'a' })
expect(onAdd).not.toHaveBeenCalled()
})
it('FE-W4AIR-007: clears the row and refocuses the name field after adding', () => {
setup()
fireEvent.change(nameInput(), { target: { value: 'Ferry' } })
fireEvent.change(priceInput(), { target: { value: '12' } })
fireEvent.click(screen.getByRole('button', { name: 'reservations.add' }))
expect(nameInput()).toHaveValue('')
expect(priceInput()).toHaveValue('')
vi.advanceTimersByTime(60)
expect(nameInput()).toHaveFocus()
})
it('FE-W4AIR-008: pasting a formatted amount normalizes the separators', () => {
setup()
fireEvent.paste(priceInput(), { clipboardData: { getData: () => '1.234,56 EUR' } })
expect(priceInput()).toHaveValue('1234.56')
fireEvent.paste(priceInput(), { clipboardData: { getData: () => '$2,345.67' } })
expect(priceInput()).toHaveValue('2345.67')
})
it('FE-W4AIR-009: pasting a separator-free amount keeps the digits', () => {
setup()
fireEvent.paste(priceInput(), { clipboardData: { getData: () => 'EUR 4200' } })
expect(priceInput()).toHaveValue('4200')
})
})
@@ -1,6 +1,7 @@
import { useState, useRef } from 'react'
import { Plus } from 'lucide-react'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import { normalizePastedAmount } from './BudgetPanel.helpers'
interface AddItemRowProps {
onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null; expense_date: string | null }) => void
@@ -33,7 +34,7 @@ export default function AddItemRow({ onAdd, t }: AddItemRowProps) {
</td>
<td style={{ padding: '4px 6px' }}>
<input value={price} onChange={e => setPrice(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
onPaste={e => { e.preventDefault(); let t = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = t.lastIndexOf(','), ld = t.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { t = t.substring(0, dp).replace(/[.,]/g, '') + '.' + t.substring(dp + 1) } else { t = t.replace(/[.,]/g, '') } setPrice(t) }}
onPaste={e => { e.preventDefault(); setPrice(normalizePastedAmount(e.clipboardData.getData('text'))) }}
placeholder="0,00" inputMode="decimal" style={{ ...inp, textAlign: 'center' }} />
</td>
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
@@ -0,0 +1,610 @@
// FE-W4BCT-001 to FE-W4BCT-053
import type { CSSProperties } from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { BudgetItem } from '../../types'
import { render, screen, fireEvent, createEvent, within } from '../../../tests/helpers/render'
import BudgetCategoryTable from './BudgetPanelCategoryTable'
import type { TripMember } from './BudgetPanelMemberChips'
const contribFor = vi.fn((_id: number) => [] as unknown[])
vi.mock('../Plugins/PluginContributions', () => ({
usePluginViewContributions: () => contribFor,
PluginCardFooter: ({ items }: { items: unknown[] }) => <div data-testid="plugin-footer">{items.length}</div>,
}))
const TRIP_MEMBERS: TripMember[] = [
{ id: 1, username: 'ada', avatar_url: null },
{ id: 2, username: 'bob', avatar_url: null },
]
function budgetItem(overrides: Partial<BudgetItem> = {}): BudgetItem {
return {
id: 1, trip_id: 7, category: 'Food', name: 'Ferry', total_price: 100,
persons: 2, days: 5, note: null, expense_date: null, reservation_id: null, members: [],
...overrides,
} as unknown as BudgetItem
}
type Props = Parameters<typeof BudgetCategoryTable>[0]
function setup(overrides: Partial<Props> = {}, items: BudgetItem[] = [budgetItem()]) {
const spies = {
setEditingCat: vi.fn(),
setDragCat: vi.fn(),
setDragOverCat: vi.fn(),
setDragItem: vi.fn(),
setDragOverItem: vi.fn(),
setDragItemCat: vi.fn(),
reorderBudgetCategories: vi.fn(async () => {}),
reorderBudgetItems: vi.fn(async () => {}),
handleRenameCategory: vi.fn(async () => {}),
handleDeleteCategory: vi.fn(async () => {}),
handleDeleteItem: vi.fn(async () => {}),
handleUpdateField: vi.fn(async () => {}),
handleAddItem: vi.fn(async () => {}),
setBudgetItemMembers: vi.fn(async () => ({ members: [], item: {} })),
toggleBudgetMemberPaid: vi.fn(async () => {}),
}
const props = {
cat: 'Food',
grouped: new Map([['Food', items]]),
categoryColor: () => '#ef4444',
canEdit: true,
editingCat: null,
dragCat: null,
dragOverCat: null,
dragItem: null,
dragOverItem: null,
dragItemCat: null,
categoryNames: ['Transport', 'Food', 'Hotels'],
tripId: 7,
currency: 'EUR',
locale: 'en-US',
t: (key: string) => key,
fmt: (v: number | null | undefined, cur: string) => `${v ?? '-'} ${cur}`,
hasMultipleMembers: false,
tripMembers: TRIP_MEMBERS,
th: {} as CSSProperties,
td: {} as CSSProperties,
...spies,
...overrides,
} as unknown as Props
const utils = render(<BudgetCategoryTable {...props} />)
return { ...spies, ...utils }
}
/** dragleave carrying a relatedTarget — jsdom lacks DragEvent, so fireEvent drops it. */
function dragLeaveInto(target: Element, relatedTarget: Element) {
const event = createEvent.dragLeave(target)
Object.defineProperty(event, 'relatedTarget', { value: relatedTarget })
fireEvent(target, event)
}
beforeEach(() => {
contribFor.mockReset()
contribFor.mockReturnValue([])
})
describe('BudgetCategoryTable — header', () => {
it('FE-W4BCT-001: shows the category name and the summed subtotal', () => {
setup({}, [budgetItem(), budgetItem({ id: 2, total_price: 50 })])
expect(screen.getByText('Food')).toBeInTheDocument()
expect(screen.getByText('150 EUR')).toBeInTheDocument()
})
it('FE-W4BCT-002: treats a priceless item as zero in the subtotal', () => {
const { container } = setup({}, [budgetItem({ total_price: null } as Partial<BudgetItem>)])
// The subtotal sits in the black category header, before the table.
expect(container.querySelectorAll('span')[1]).toHaveTextContent('0 EUR')
})
it('FE-W4BCT-003: renders an empty category with only the add row', () => {
setup({ grouped: new Map() } as Partial<Props>)
expect(screen.getByPlaceholderText('budget.newEntry')).toBeInTheDocument()
expect(screen.queryByDisplayValue('Ferry')).toBeNull()
})
it('FE-W4BCT-004: the pencil starts renaming the category', () => {
const { setEditingCat } = setup()
fireEvent.click(screen.getAllByRole('button')[0])
expect(setEditingCat).toHaveBeenCalledWith({ name: 'Food', value: 'Food' })
})
it('FE-W4BCT-005: Enter in the rename input commits and closes the editor', () => {
const { handleRenameCategory, setEditingCat } = setup({ editingCat: { name: 'Food', value: 'Groceries' } })
const input = screen.getByDisplayValue('Groceries')
fireEvent.keyDown(input, { key: 'Enter' })
expect(handleRenameCategory).toHaveBeenCalledWith('Food', 'Groceries')
expect(setEditingCat).toHaveBeenCalledWith(null)
})
it('FE-W4BCT-006: blurring the rename input commits it', () => {
const { handleRenameCategory } = setup({ editingCat: { name: 'Food', value: 'Groceries' } })
fireEvent.blur(screen.getByDisplayValue('Groceries'))
expect(handleRenameCategory).toHaveBeenCalledWith('Food', 'Groceries')
})
it('FE-W4BCT-007: Escape abandons the rename', () => {
const { handleRenameCategory, setEditingCat } = setup({ editingCat: { name: 'Food', value: 'Groceries' } })
fireEvent.keyDown(screen.getByDisplayValue('Groceries'), { key: 'Escape' })
expect(handleRenameCategory).not.toHaveBeenCalled()
expect(setEditingCat).toHaveBeenCalledWith(null)
})
it('FE-W4BCT-008: typing updates the pending rename value', () => {
const { setEditingCat } = setup({ editingCat: { name: 'Food', value: 'Food' } })
fireEvent.change(screen.getByDisplayValue('Food'), { target: { value: 'Fuel' } })
expect(setEditingCat).toHaveBeenCalledWith({ name: 'Food', value: 'Fuel' })
})
it('FE-W4BCT-009: the trash button deletes the category', () => {
const { handleDeleteCategory } = setup()
fireEvent.click(screen.getByTitle('budget.deleteCategory'))
expect(handleDeleteCategory).toHaveBeenCalledWith('Food')
})
it('FE-W4BCT-010: a read-only table hides every editing affordance', () => {
setup({ canEdit: false })
expect(screen.queryByTitle('budget.deleteCategory')).toBeNull()
expect(screen.queryByTitle('common.delete')).toBeNull()
expect(screen.queryByPlaceholderText('budget.newEntry')).toBeNull()
expect(document.querySelectorAll('[draggable="true"]')).toHaveLength(0)
})
})
describe('BudgetCategoryTable — rows', () => {
it('FE-W4BCT-011: derives the per-person, per-day and per-person-day figures', () => {
setup({}, [budgetItem({ total_price: 100, persons: 2, days: 5 })])
expect(screen.getByText('50 EUR')).toBeInTheDocument()
expect(screen.getByText('20 EUR')).toBeInTheDocument()
expect(screen.getByText('10 EUR')).toBeInTheDocument()
})
it('FE-W4BCT-012: blanks the per-person columns for a custom member split', () => {
setup({}, [budgetItem({
total_price: 100, persons: 2, days: 5,
members: [{ user_id: 1, username: 'ada', amount: 70 }, { user_id: 2, username: 'bob', amount: 30 }],
} as unknown as Partial<BudgetItem>)])
// per-day still resolves; per-person and per-person-day are dashed out.
expect(screen.getByText('20 EUR')).toBeInTheDocument()
expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(2)
})
it('FE-W4BCT-013: deleting a row reports its id', () => {
const { handleDeleteItem } = setup()
fireEvent.click(screen.getByTitle('common.delete'))
expect(handleDeleteItem).toHaveBeenCalledWith(1)
})
it('FE-W4BCT-014: editing the name cell saves through handleUpdateField', () => {
const { handleUpdateField } = setup()
fireEvent.click(screen.getByText('Ferry'))
const input = screen.getByDisplayValue('Ferry')
fireEvent.change(input, { target: { value: 'Bus' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(handleUpdateField).toHaveBeenCalledWith(1, 'name', 'Bus')
})
it('FE-W4BCT-015: a reservation-linked row locks the name cell', () => {
setup({}, [budgetItem({ reservation_id: 42 } as Partial<BudgetItem>)])
fireEvent.click(screen.getByText('Ferry'))
expect(screen.queryByDisplayValue('Ferry')).toBeNull()
expect(screen.getByText('Ferry')).not.toHaveAttribute('title')
})
it('FE-W4BCT-016: the persons cell coerces the entry to an integer', () => {
const { handleUpdateField } = setup()
fireEvent.click(screen.getByText('2'))
const input = screen.getByDisplayValue('2')
fireEvent.change(input, { target: { value: '4.7' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(handleUpdateField).toHaveBeenCalledWith(1, 'persons', 4)
})
it('FE-W4BCT-017: clearing the days cell stores null', () => {
const { handleUpdateField } = setup()
fireEvent.click(screen.getByText('5'))
const input = screen.getByDisplayValue('5')
fireEvent.change(input, { target: { value: '' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(handleUpdateField).toHaveBeenCalledWith(1, 'days', null)
})
it('FE-W4BCT-018: swaps the persons cell for member chips on a multi-member trip', () => {
setup({ hasMultipleMembers: true }, [budgetItem({ members: [{ user_id: 1, username: 'ada', paid: 0 }] } as unknown as Partial<BudgetItem>)])
// One chip in the persons column and one in the mobile stack under the name.
expect(screen.getAllByText('A')).toHaveLength(2)
})
it('FE-W4BCT-019: shows the raw expense date instead of a picker when read-only', () => {
setup({ canEdit: false }, [budgetItem({ expense_date: '2026-06-15' } as Partial<BudgetItem>)])
expect(screen.getByText('2026-06-15')).toBeInTheDocument()
})
it('FE-W4BCT-020: falls back to an em dash for a read-only row without a date', () => {
setup({ canEdit: false })
expect(screen.getByText('—')).toBeInTheDocument()
})
it('FE-W4BCT-021: appends a plugin footer row when a plugin contributes', () => {
contribFor.mockReturnValue([{ kind: 'column' }])
setup()
expect(screen.getByTestId('plugin-footer')).toHaveTextContent('1')
})
it('FE-W4BCT-022: adding an item routes the payload into the category', () => {
const { handleAddItem } = setup()
fireEvent.change(screen.getByPlaceholderText('budget.newEntry'), { target: { value: 'Taxi' } })
fireEvent.click(screen.getByTitle('reservations.add'))
expect(handleAddItem).toHaveBeenCalledWith('Food', expect.objectContaining({ name: 'Taxi' }))
})
})
describe('BudgetCategoryTable — drag and drop', () => {
it('FE-W4BCT-023: the category handle starts and ends a category drag', () => {
const { setDragCat, setDragOverCat, container } = setup()
const handle = container.querySelectorAll('[draggable="true"]')[0] as HTMLElement
fireEvent.dragStart(handle, { dataTransfer: { effectAllowed: '', setData: vi.fn() } })
expect(setDragCat).toHaveBeenCalledWith('Food')
fireEvent.dragEnd(handle)
expect(setDragCat).toHaveBeenLastCalledWith(null)
expect(setDragOverCat).toHaveBeenCalledWith(null)
})
it('FE-W4BCT-024: dragging another category over this one marks the drop line', () => {
const { setDragOverCat, container } = setup({ dragCat: 'Transport' })
fireEvent.dragOver(container.firstElementChild!, { dataTransfer: { dropEffect: '' } })
expect(setDragOverCat).toHaveBeenCalledWith('Food')
})
it('FE-W4BCT-025: ignores a drag-over from the same category or from an item', () => {
const same = setup({ dragCat: 'Food' })
fireEvent.dragOver(same.container.firstElementChild!, { dataTransfer: { dropEffect: '' } })
expect(same.setDragOverCat).not.toHaveBeenCalled()
const item = setup({ dragCat: 'Transport', dragItem: 5 })
fireEvent.dragOver(item.container.firstElementChild!, { dataTransfer: { dropEffect: '' } })
expect(item.setDragOverCat).not.toHaveBeenCalled()
})
it('FE-W4BCT-026: dropping a category reorders the list around this one', () => {
const { reorderBudgetCategories, setDragCat, container } = setup({ dragCat: 'Hotels' })
fireEvent.drop(container.firstElementChild!)
expect(reorderBudgetCategories).toHaveBeenCalledWith(7, ['Transport', 'Hotels', 'Food'])
expect(setDragCat).toHaveBeenCalledWith(null)
})
it('FE-W4BCT-027: dropping a category on itself only clears the drag state', () => {
const { reorderBudgetCategories, setDragOverCat, container } = setup({ dragCat: 'Food' })
fireEvent.drop(container.firstElementChild!)
expect(reorderBudgetCategories).not.toHaveBeenCalled()
expect(setDragOverCat).toHaveBeenCalledWith(null)
})
it('FE-W4BCT-028: leaving the category clears the drop marker', () => {
const { setDragOverCat, container } = setup({ dragCat: 'Transport' })
fireEvent.dragLeave(container.firstElementChild!, { relatedTarget: document.body })
expect(setDragOverCat).toHaveBeenCalledWith(null)
})
it('FE-W4BCT-029: dragging a row over a sibling marks it as the target', () => {
const rows = [budgetItem(), budgetItem({ id: 2, name: 'Bus' })]
const { setDragOverItem, container } = setup({ dragItem: 2, dragItemCat: 'Food' }, rows)
fireEvent.dragOver(container.querySelectorAll('tbody tr')[0], { dataTransfer: { dropEffect: '' } })
expect(setDragOverItem).toHaveBeenCalledWith(1)
})
it('FE-W4BCT-030: dropping a row reorders the ids inside the category', () => {
const rows = [budgetItem(), budgetItem({ id: 2, name: 'Bus' }), budgetItem({ id: 3, name: 'Taxi' })]
const { reorderBudgetItems, setDragItem, container } = setup({ dragItem: 3, dragItemCat: 'Food' }, rows)
fireEvent.drop(container.querySelectorAll('tbody tr')[0])
expect(reorderBudgetItems).toHaveBeenCalledWith(7, [3, 1, 2])
expect(setDragItem).toHaveBeenCalledWith(null)
})
it('FE-W4BCT-031: a row from another category is not reordered here', () => {
const { reorderBudgetItems, container } = setup({ dragItem: 9, dragItemCat: 'Transport' })
fireEvent.drop(container.querySelectorAll('tbody tr')[0])
expect(reorderBudgetItems).not.toHaveBeenCalled()
})
it('FE-W4BCT-032: the row handle starts and ends an item drag', () => {
const { setDragItem, setDragItemCat, container } = setup()
const handle = container.querySelectorAll('[draggable="true"]')[1] as HTMLElement
fireEvent.dragStart(handle, { dataTransfer: { effectAllowed: '' } })
expect(setDragItem).toHaveBeenCalledWith(1)
expect(setDragItemCat).toHaveBeenCalledWith('Food')
fireEvent.dragEnd(handle)
expect(setDragItem).toHaveBeenLastCalledWith(null)
expect(setDragItemCat).toHaveBeenLastCalledWith(null)
})
it('FE-W4BCT-033: leaving a row clears the item drop marker', () => {
const { setDragOverItem, container } = setup()
fireEvent.dragLeave(container.querySelectorAll('tbody tr')[0], { relatedTarget: document.body })
expect(setDragOverItem).toHaveBeenCalledWith(null)
})
it('FE-W4BCT-034: dims the dragged category and marks the drop line', () => {
const dragged = setup({ dragCat: 'Food' })
expect((dragged.container.firstElementChild as HTMLElement).style.opacity).toBe('0.4')
const over = setup({ dragCat: 'Transport', dragOverCat: 'Food' })
const marker = within(over.container.firstElementChild as HTMLElement).getAllByRole('generic')
expect(marker.length).toBeGreaterThan(0)
expect((over.container.firstElementChild as HTMLElement).firstElementChild).toHaveStyle({ height: '4px' })
})
it('FE-W4BCT-035: dims the dragged row and outlines the drop target', () => {
const dragged = setup({ dragItem: 1 })
expect((dragged.container.querySelector('tbody tr') as HTMLElement).style.opacity).toBe('0.4')
const over = setup({ dragOverItem: 1 })
expect((over.container.querySelector('tbody tr') as HTMLElement).style.boxShadow).toBe('inset 4px 0 0 0 var(--accent)')
})
it('FE-W4BCT-036: the table body accepts a category drop only while one is being dragged', () => {
const dragging = setup({ dragCat: 'Transport' })
const activeTransfer = { dropEffect: '' }
fireEvent.dragOver(dragging.container.querySelector('table')!.parentElement!, { dataTransfer: activeTransfer })
expect(activeTransfer.dropEffect).toBe('move')
const idle = setup()
const idleTransfer = { dropEffect: '' }
fireEvent.dragOver(idle.container.querySelector('table')!.parentElement!, { dataTransfer: idleTransfer })
expect(idleTransfer.dropEffect).toBe('')
})
it('FE-W4BCT-037: a row accepts a dragged category without becoming an item drop target', () => {
const { setDragOverItem, container } = setup({ dragCat: 'Transport' })
const transfer = { dropEffect: '' }
fireEvent.dragOver(container.querySelectorAll('tbody tr')[0], { dataTransfer: transfer })
expect(transfer.dropEffect).toBe('move')
expect(setDragOverItem).not.toHaveBeenCalled()
})
it('FE-W4BCT-038: moving between a row and its own cells keeps the drop marker', () => {
const { setDragOverItem, setDragOverCat, container } = setup({ dragCat: 'Transport', dragItem: 2, dragItemCat: 'Food' })
const row = container.querySelectorAll('tbody tr')[0]
// jsdom has no DragEvent, so relatedTarget has to be attached by hand.
dragLeaveInto(row, row.querySelector('td')!)
dragLeaveInto(container.firstElementChild as HTMLElement, container.querySelector('table')!)
expect(setDragOverItem).not.toHaveBeenCalled()
expect(setDragOverCat).not.toHaveBeenCalled()
})
})
describe('BudgetCategoryTable — hover affordances', () => {
it('FE-W4BCT-039: the rename pencil brightens while hovered', () => {
setup()
const pencil = screen.getAllByRole('button')[0]
fireEvent.mouseEnter(pencil)
expect(pencil.style.color).toBe('rgb(255, 255, 255)')
fireEvent.mouseLeave(pencil)
expect(pencil.style.color).toBe('rgba(255, 255, 255, 0.4)')
})
it('FE-W4BCT-040: the delete-category button fades in while hovered', () => {
setup()
const trash = screen.getByTitle('budget.deleteCategory')
fireEvent.mouseEnter(trash)
expect(trash.style.opacity).toBe('1')
fireEvent.mouseLeave(trash)
expect(trash.style.opacity).toBe('0.6')
})
it('FE-W4BCT-041: a row highlights while hovered', () => {
const { container } = setup()
const row = container.querySelector('tbody tr') as HTMLElement
fireEvent.mouseEnter(row)
expect(row.style.background).toBe('var(--bg-hover)')
fireEvent.mouseLeave(row)
expect(row.style.background).toBe('transparent')
})
it('FE-W4BCT-042: the delete-row button turns red while hovered', () => {
setup()
const trash = screen.getByTitle('common.delete')
fireEvent.mouseEnter(trash)
expect(trash.style.color).toBe('rgb(239, 68, 68)')
fireEvent.mouseLeave(trash)
expect(trash.style.color).toBe('rgb(209, 213, 219)')
})
})
describe('BudgetCategoryTable — remaining cells', () => {
it('FE-W4BCT-043: editing the total saves the parsed number', () => {
const { handleUpdateField } = setup()
fireEvent.click(screen.getByText('100.00'))
const input = screen.getByDisplayValue('100')
fireEvent.change(input, { target: { value: '120,50' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(handleUpdateField).toHaveBeenCalledWith(1, 'total_price', 120.5)
})
it('FE-W4BCT-044: a zero-decimal currency drops the decimals from the amount placeholder', () => {
setup({ currency: 'JPY' }, [budgetItem({ total_price: null } as Partial<BudgetItem>)])
expect(screen.getByText('0')).toBeInTheDocument()
expect(screen.queryByText('0,00')).toBeNull()
})
it('FE-W4BCT-045: editing the note saves it', () => {
const { handleUpdateField } = setup({}, [budgetItem({ note: 'Return trip' } as Partial<BudgetItem>)])
fireEvent.click(screen.getByText('Return trip'))
const input = screen.getByDisplayValue('Return trip')
fireEvent.change(input, { target: { value: 'One way' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(handleUpdateField).toHaveBeenCalledWith(1, 'note', 'One way')
})
it('FE-W4BCT-046: picking a day from the date cell stores the ISO date', () => {
const { handleUpdateField, container } = setup({}, [budgetItem({ expense_date: '2026-06-15' } as Partial<BudgetItem>)])
const dateCell = container.querySelectorAll('tbody tr')[0].querySelectorAll('td')[7]
fireEvent.click(dateCell.querySelector('button')!)
const day20 = screen.getAllByRole('button').find(b => b.textContent?.trim() === '20')
fireEvent.click(day20!)
expect(handleUpdateField).toHaveBeenCalledWith(1, 'expense_date', '2026-06-20')
})
it('FE-W4BCT-047: clearing the date cell stores null', () => {
const { handleUpdateField, container } = setup({}, [budgetItem({ expense_date: '2026-06-15' } as Partial<BudgetItem>)])
const dateCell = container.querySelectorAll('tbody tr')[0].querySelectorAll('td')[7]
fireEvent.click(dateCell.querySelector('button')!)
fireEvent.click(screen.getByLabelText('Clear date'))
expect(handleUpdateField).toHaveBeenCalledWith(1, 'expense_date', null)
})
it('FE-W4BCT-048: a non-numeric persons entry stores null', () => {
const { handleUpdateField } = setup()
fireEvent.click(screen.getByText('2'))
const input = screen.getByDisplayValue('2')
fireEvent.change(input, { target: { value: 'abc' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(handleUpdateField).toHaveBeenCalledWith(1, 'persons', null)
})
it('FE-W4BCT-049: zero days is stored as null rather than 0', () => {
const { handleUpdateField } = setup()
fireEvent.click(screen.getByText('5'))
const input = screen.getByDisplayValue('5')
fireEvent.change(input, { target: { value: '0' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(handleUpdateField).toHaveBeenCalledWith(1, 'days', null)
})
it('FE-W4BCT-050: an item without persons, days or members dashes out every derived column', () => {
const { container } = setup({}, [budgetItem({ persons: null, days: null, members: undefined } as unknown as Partial<BudgetItem>)])
const cells = container.querySelectorAll('tbody tr')[0].querySelectorAll('td')
// per person, per day, per person-day — none of them is derivable.
expect(cells[4]).toHaveTextContent('-')
expect(cells[5]).toHaveTextContent('-')
expect(cells[6]).toHaveTextContent('-')
expect(screen.queryByText('100.00 EUR')).toBeNull()
})
})
describe('BudgetCategoryTable — member chips', () => {
const withMembers = () => setup(
{ hasMultipleMembers: true },
[budgetItem({ members: [{ user_id: 1, username: 'ada', paid: 0 }] } as unknown as Partial<BudgetItem>)],
)
it('FE-W4BCT-051: tapping a chip toggles that members paid flag', () => {
const { toggleBudgetMemberPaid } = withMembers()
// The chip is rendered twice: the mobile stack under the name and the persons column.
const chips = screen.getAllByText('A')
fireEvent.click(chips[0])
fireEvent.click(chips[1])
expect(toggleBudgetMemberPaid).toHaveBeenCalledTimes(2)
expect(toggleBudgetMemberPaid).toHaveBeenCalledWith(7, 1, 1, true)
})
it('FE-W4BCT-052: picking a member from either chip dropdown sets the item members', () => {
const { setBudgetItemMembers, container } = withMembers()
// The mobile stack under the name and the persons column both carry an editor.
const editors = container.querySelectorAll('td button')
fireEvent.click(editors[0])
fireEvent.click(screen.getByText('bob'))
fireEvent.click(editors[0]) // picking an option leaves the dropdown open
fireEvent.click(editors[1])
fireEvent.click(screen.getByText('bob'))
expect(setBudgetItemMembers).toHaveBeenCalledTimes(2)
expect(setBudgetItemMembers).toHaveBeenCalledWith(7, 1, [1, 2])
})
it('FE-W4BCT-053: read-only member chips expose no editing controls', () => {
setup(
{ hasMultipleMembers: true, canEdit: false },
[budgetItem({ members: [{ user_id: 1, username: 'ada', paid: 1 }] } as unknown as Partial<BudgetItem>)],
)
expect(screen.getAllByText('A')).toHaveLength(2)
expect(document.querySelectorAll('table button')).toHaveLength(0)
})
})
@@ -0,0 +1,166 @@
// FE-W4IEC-001 to FE-W4IEC-016
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import InlineEditCell from './BudgetPanelInlineEditCell'
function setup(props: Partial<Parameters<typeof InlineEditCell>[0]> = {}) {
const onSave = vi.fn()
const utils = render(<InlineEditCell value="Ferry" onSave={onSave} locale="en-US" {...props} />)
return { onSave, ...utils }
}
function paste(input: HTMLElement, text: string) {
fireEvent.paste(input, { clipboardData: { getData: () => text } })
}
describe('InlineEditCell — display', () => {
it('FE-W4IEC-001: shows the raw text value', () => {
setup()
expect(screen.getByText('Ferry')).toBeInTheDocument()
})
it('FE-W4IEC-002: formats a number with the given decimals and locale', () => {
setup({ value: 1234.5, type: 'number' })
expect(screen.getByText('1,234.50')).toBeInTheDocument()
})
it('FE-W4IEC-003: honours a custom decimal count', () => {
setup({ value: 12, type: 'number', decimals: 0 })
expect(screen.getByText('12')).toBeInTheDocument()
})
it('FE-W4IEC-004: falls back to the placeholder, then to a dash', () => {
const { unmount } = setup({ value: null, placeholder: 'Add note' })
expect(screen.getByText('Add note')).toBeInTheDocument()
unmount()
setup({ value: null })
expect(screen.getByText('-')).toBeInTheDocument()
})
it('FE-W4IEC-005: exposes the edit tooltip and hover feedback when editable', () => {
const { container } = setup({ editTooltip: 'Click to edit' })
const cell = container.firstElementChild as HTMLElement
expect(cell).toHaveAttribute('title', 'Click to edit')
fireEvent.mouseEnter(cell)
expect(cell.style.background).toBe('var(--bg-hover)')
fireEvent.mouseLeave(cell)
expect(cell.style.background).toBe('transparent')
})
it('FE-W4IEC-006: a read-only cell has no tooltip, no hover and cannot be opened', () => {
const { container } = setup({ readOnly: true, editTooltip: 'Click to edit' })
const cell = container.firstElementChild as HTMLElement
expect(cell).not.toHaveAttribute('title')
fireEvent.mouseEnter(cell)
expect(cell.style.background).toBe('')
fireEvent.click(cell)
expect(screen.queryByRole('textbox')).toBeNull()
})
it('FE-W4IEC-007: centres the content when the caller centres the text', () => {
const { container } = setup({ style: { textAlign: 'center' } })
expect((container.firstElementChild as HTMLElement).style.justifyContent).toBe('center')
})
})
describe('InlineEditCell — editing', () => {
it('FE-W4IEC-008: clicking opens a focused, pre-selected input', () => {
const { container } = setup()
fireEvent.click(container.firstElementChild!)
const input = screen.getByRole('textbox') as HTMLInputElement
expect(input).toHaveValue('Ferry')
expect(input).toHaveFocus()
})
it('FE-W4IEC-009: Enter saves the changed value', () => {
const { onSave, container } = setup()
fireEvent.click(container.firstElementChild!)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'Bus' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onSave).toHaveBeenCalledWith('Bus')
expect(screen.queryByRole('textbox')).toBeNull()
})
it('FE-W4IEC-010: blur saves and an unchanged value does not fire onSave', () => {
const { onSave, container } = setup()
fireEvent.click(container.firstElementChild!)
fireEvent.blur(screen.getByRole('textbox'))
expect(onSave).not.toHaveBeenCalled()
})
it('FE-W4IEC-011: Escape restores the original value without saving', () => {
const { onSave, container } = setup()
fireEvent.click(container.firstElementChild!)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'Bus' } })
fireEvent.keyDown(input, { key: 'Escape' })
expect(onSave).not.toHaveBeenCalled()
expect(screen.getByText('Ferry')).toBeInTheDocument()
})
it('FE-W4IEC-012: a numeric cell parses a comma decimal and uses a decimal keypad', () => {
const { onSave, container } = setup({ value: 10, type: 'number' })
fireEvent.click(container.firstElementChild!)
const input = screen.getByRole('textbox')
expect(input).toHaveAttribute('inputmode', 'decimal')
fireEvent.change(input, { target: { value: '12,50' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onSave).toHaveBeenCalledWith(12.5)
})
it('FE-W4IEC-013: an unparseable numeric entry saves null', () => {
const { onSave, container } = setup({ value: 10, type: 'number' })
fireEvent.click(container.firstElementChild!)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'abc' } })
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' })
expect(onSave).toHaveBeenCalledWith(null)
})
it('FE-W4IEC-014: pasting a formatted amount normalizes separators', () => {
const { container } = setup({ value: 0, type: 'number' })
fireEvent.click(container.firstElementChild!)
const input = screen.getByRole('textbox')
paste(input, '1.234,56 EUR')
expect(input).toHaveValue('1234.56')
paste(input, '$2,345.67')
expect(input).toHaveValue('2345.67')
})
it('FE-W4IEC-015: pasting a separator-free amount keeps the digits', () => {
const { container } = setup({ value: 0, type: 'number' })
fireEvent.click(container.firstElementChild!)
const input = screen.getByRole('textbox')
paste(input, 'EUR 4200')
expect(input).toHaveValue('4200')
})
it('FE-W4IEC-016: a text cell leaves pasted content to the browser', () => {
const { container } = setup()
fireEvent.click(container.firstElementChild!)
const input = screen.getByRole('textbox')
paste(input, '1.234,56')
expect(input).toHaveValue('Ferry')
})
})
@@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react'
import { normalizePastedAmount } from './BudgetPanel.helpers'
interface InlineEditCellProps {
value: string | number | null | undefined
@@ -29,21 +30,7 @@ export default function InlineEditCell({ value, onSave, type = 'text', style = {
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
if (type !== 'number') return
e.preventDefault()
let text = e.clipboardData.getData('text').trim()
// Strip everything except digits, dots, commas, minus
text = text.replace(/[^\d.,-]/g, '')
// Remove all thousand separators (dots or commas before 3-digit groups), keep last separator as decimal
const lastComma = text.lastIndexOf(',')
const lastDot = text.lastIndexOf('.')
const decimalPos = Math.max(lastComma, lastDot)
if (decimalPos > -1) {
const intPart = text.substring(0, decimalPos).replace(/[.,]/g, '')
const decPart = text.substring(decimalPos + 1)
text = intPart + '.' + decPart
} else {
text = text.replace(/[.,]/g, '')
}
setEditValue(text)
setEditValue(normalizePastedAmount(e.clipboardData.getData('text')))
}
if (editing) {
@@ -0,0 +1,178 @@
// FE-W4BMC-001 to FE-W4BMC-016
import { describe, it, expect, vi } from 'vitest'
import type { BudgetItemMember } from '../../types'
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import BudgetMemberChips, { ChipWithTooltip, type TripMember } from './BudgetPanelMemberChips'
const TRIP_MEMBERS: TripMember[] = [
{ id: 1, username: 'ada', avatar_url: '/uploads/avatars/ada.png' },
{ id: 2, username: 'bob', avatar_url: null },
]
function member(overrides: Partial<BudgetItemMember> = {}): BudgetItemMember {
return { user_id: 1, username: 'ada', avatar_url: null, paid: 0, ...overrides } as unknown as BudgetItemMember
}
function setup(props: Partial<Parameters<typeof BudgetMemberChips>[0]> = {}) {
const onSetMembers = vi.fn()
const onTogglePaid = vi.fn()
const utils = render(
<BudgetMemberChips members={[member()]} tripMembers={TRIP_MEMBERS} onSetMembers={onSetMembers} onTogglePaid={onTogglePaid} {...props} />,
)
return { onSetMembers, onTogglePaid, ...utils }
}
describe('ChipWithTooltip', () => {
it('FE-W4BMC-001: falls back to the uppercased initial', () => {
const { container } = render(<ChipWithTooltip label="ada" avatarUrl={null} />)
expect(container.firstElementChild).toHaveTextContent('A')
})
it('FE-W4BMC-002: renders the avatar when one is given', () => {
const { container } = render(<ChipWithTooltip label="ada" avatarUrl="/uploads/avatars/ada.png" />)
expect(container.querySelector('img')).toHaveAttribute('src', '/uploads/avatars/ada.png')
})
it('FE-W4BMC-003: hovering portals a name tooltip and leaving removes it', () => {
const { container } = render(<ChipWithTooltip label="Ada Lovelace" avatarUrl={null} />)
const chip = container.firstElementChild as HTMLElement
fireEvent.mouseEnter(chip)
expect(screen.getByText('Ada Lovelace')).toBeInTheDocument()
fireEvent.mouseLeave(chip)
expect(screen.queryByText('Ada Lovelace')).toBeNull()
})
it('FE-W4BMC-004: a paid chip turns green and the tooltip carries a Paid tag', () => {
const { container } = render(<ChipWithTooltip label="ada" avatarUrl={null} paid />)
const chip = container.firstElementChild as HTMLElement
expect(chip.style.border).toBe('2px solid rgb(34, 197, 94)')
fireEvent.mouseEnter(chip)
expect(screen.getByText('Paid')).toBeInTheDocument()
})
it('FE-W4BMC-005: only a clickable chip gets the pointer cursor', () => {
const onClick = vi.fn()
const { container, unmount } = render(<ChipWithTooltip label="ada" avatarUrl={null} onClick={onClick} />)
const chip = container.firstElementChild as HTMLElement
expect(chip.style.cursor).toBe('pointer')
fireEvent.click(chip)
expect(onClick).toHaveBeenCalledOnce()
unmount()
const plain = render(<ChipWithTooltip label="ada" avatarUrl={null} />)
expect((plain.container.firstElementChild as HTMLElement).style.cursor).toBe('default')
})
})
describe('BudgetMemberChips', () => {
it('FE-W4BMC-006: renders one chip per assigned member plus the picker button', () => {
setup({ members: [member(), member({ user_id: 2, username: 'bob' })] })
expect(screen.getByText('A')).toBeInTheDocument()
expect(screen.getByText('B')).toBeInTheDocument()
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('FE-W4BMC-007: uses the people icon while nobody is assigned and the pencil afterwards', () => {
const { container, unmount } = setup({ members: [] })
expect(container.querySelector('.lucide-users')).not.toBeNull()
unmount()
const withMembers = setup()
expect(withMembers.container.querySelector('.lucide-pencil')).not.toBeNull()
})
it('FE-W4BMC-008: clicking a chip toggles that member paid flag', () => {
const { onTogglePaid } = setup()
fireEvent.click(screen.getByText('A'))
expect(onTogglePaid).toHaveBeenCalledWith(1, true)
})
it('FE-W4BMC-009: clicking an already-paid chip clears the flag', () => {
const { onTogglePaid } = setup({ members: [member({ paid: 1 })] })
fireEvent.click(screen.getByText('A'))
expect(onTogglePaid).toHaveBeenCalledWith(1, false)
})
it('FE-W4BMC-010: a read-only strip has no picker and no paid toggling', () => {
const { onTogglePaid } = setup({ readOnly: true })
expect(screen.queryByRole('button')).toBeNull()
fireEvent.click(screen.getByText('A'))
expect(onTogglePaid).not.toHaveBeenCalled()
})
it('FE-W4BMC-011: the picker lists every trip member and marks the assigned ones', () => {
setup()
fireEvent.click(screen.getByRole('button'))
const rows = screen.getAllByRole('button').slice(1)
expect(rows).toHaveLength(2)
expect(rows[0]).toHaveTextContent('ada')
expect(rows[0].querySelector('.lucide-check')).not.toBeNull()
expect(rows[1].querySelector('.lucide-check')).toBeNull()
expect(rows[0].querySelector('img')).toHaveAttribute('src', '/uploads/avatars/ada.png')
expect(rows[1]).toHaveTextContent('B')
})
it('FE-W4BMC-012: picking an unassigned member adds them', () => {
const { onSetMembers } = setup()
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getAllByRole('button')[2])
expect(onSetMembers).toHaveBeenCalledWith([1, 2])
})
it('FE-W4BMC-013: picking an assigned member removes them', () => {
const { onSetMembers } = setup()
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getAllByRole('button')[1])
expect(onSetMembers).toHaveBeenCalledWith([])
})
it('FE-W4BMC-014: a mousedown outside closes the picker, inside keeps it', () => {
setup()
const trigger = screen.getByRole('button')
fireEvent.click(trigger)
fireEvent.mouseDown(screen.getAllByRole('button')[1])
expect(screen.getAllByRole('button')).toHaveLength(3)
fireEvent.mouseDown(trigger)
expect(screen.getAllByRole('button')).toHaveLength(3)
fireEvent.mouseDown(document.body)
expect(screen.getAllByRole('button')).toHaveLength(1)
})
it('FE-W4BMC-015: the trigger toggles the picker closed again', () => {
setup()
const trigger = screen.getByRole('button')
fireEvent.click(trigger)
expect(screen.getAllByRole('button')).toHaveLength(3)
fireEvent.click(trigger)
expect(screen.getAllByRole('button')).toHaveLength(1)
})
it('FE-W4BMC-016: the non-compact variant renders larger chips', () => {
setup({ compact: false })
expect(screen.getByText('A')).toHaveStyle({ width: '30px' })
expect((screen.getByRole('button') as HTMLElement).style.width).toBe('28px')
})
})
@@ -0,0 +1,54 @@
// FE-W4PIE-001 to FE-W4PIE-006
import { describe, it, expect } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
import PieChart from './BudgetPanelPieChart'
const SEGMENTS = [
{ label: 'Food', value: 300, color: '#ef4444' },
{ label: 'Hotels', value: 100, color: '#3b82f6' },
]
describe('BudgetPanelPieChart', () => {
it('FE-W4PIE-001: renders nothing without segments', () => {
const { container } = render(<PieChart segments={[]} totalLabel="1.200 €" />)
expect(container).toBeEmptyDOMElement()
})
it('FE-W4PIE-002: renders nothing when every segment is zero', () => {
const { container } = render(
<PieChart segments={[{ label: 'Food', value: 0, color: '#ef4444' }]} totalLabel="0 €" />,
)
expect(container).toBeEmptyDOMElement()
})
it('FE-W4PIE-003: turns the segment shares into consecutive conic-gradient stops', () => {
const { container } = render(<PieChart segments={SEGMENTS} totalLabel="400 €" />)
const pie = container.querySelector('.trek-pie-reveal') as HTMLElement
expect(pie.style.background).toBe('conic-gradient(rgb(239, 68, 68) 0deg 270deg, rgb(59, 130, 246) 270deg 360deg)')
})
it('FE-W4PIE-004: shows the total label in the donut hole', () => {
render(<PieChart segments={SEGMENTS} totalLabel="400 €" />)
expect(screen.getByText('400 €')).toBeInTheDocument()
})
it('FE-W4PIE-005: defaults to a 200px pie with a 55% hole', () => {
const { container } = render(<PieChart segments={SEGMENTS} totalLabel="400 €" />)
const root = container.firstElementChild as HTMLElement
const hole = screen.getByText('400 €').parentElement as HTMLElement
expect(root.style.width).toBe('200px')
expect(Math.round(parseFloat(hole.style.width))).toBe(110)
})
it('FE-W4PIE-006: scales pie and hole from the size prop', () => {
const { container } = render(<PieChart segments={SEGMENTS} size={120} totalLabel="400 €" />)
const root = container.firstElementChild as HTMLElement
const hole = screen.getByText('400 €').parentElement as HTMLElement
expect(root.style.height).toBe('120px')
expect(Math.round(parseFloat(hole.style.height))).toBe(66)
})
})
File diff suppressed because it is too large Load Diff
+18 -17
View File
@@ -10,7 +10,8 @@ import { useTranslation } from '../../i18n'
import { budgetApi } from '../../api/client'
import { useExchangeRates } from '../../hooks/useExchangeRates'
import { useIsMobile } from '../../hooks/useIsMobile'
import { formatMoney, currencyDecimals, currencyLocale } from '../../utils/formatters'
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput } from '../../utils/formatters'
import { downloadBlob } from '../../utils/fileDownload'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
@@ -21,6 +22,7 @@ import type { BudgetItem } from '../../types'
import type { TripMember } from './BudgetPanelMemberChips'
import GuestBadge from '../shared/GuestBadge'
import { NumericInput } from '../shared/NumericInput'
import EmptyState from '../shared/EmptyState'
export function splitEqualShares(total: number, members: { user_id: number }[], itemId: number): Record<number, number> {
const n = members.length
@@ -318,13 +320,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const bom = ''
const blob = new Blob([bom + rows.join('\r\n')], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const safeName = (trip?.title || 'trip').replace(/[^a-zA-Z0-9À-ɏ _-]/g, '').trim()
a.download = `costs-${safeName}.csv`
a.click()
URL.revokeObjectURL(url)
downloadBlob(blob, `costs-${safeName}.csv`)
}
// ── small presentational helpers ────────────────────────────────────────
@@ -467,9 +464,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{dayBanner}
{dayGroups.length === 0 ? (
<div className="text-content-faint" style={{ textAlign: 'center', padding: '60px 20px' }}>
{search ? t('costs.noMatch') : t('costs.emptyText')}
</div>
search ? (
<div className="text-content-faint" style={{ textAlign: 'center', padding: '60px 20px' }}>
{t('costs.noMatch')}
</div>
) : (
<EmptyState scene="costs" title={t('costs.emptyText')} />
)
) : dayGroups.map(g => {
const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0)
return (
@@ -758,7 +759,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
<div style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<div className="text-content" style={{ fontSize: 'calc(18px * var(--fs-scale-subtitle, 1))', fontWeight: 600 }}>{fmt(baseTotal(e))}</div>
{!isUnfinished && (e.members || []).length > 0 && Math.abs(net) > 0.01 && (
{!unfinished && (e.members || []).length > 0 && Math.abs(net) > 0.01 && (
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 2, fontWeight: 500, whiteSpace: 'nowrap', color: net > 0 ? '#16a34a' : '#dc2626' }}>
{net > 0 ? t('costs.youLent', { amount: fmt(net) }) : t('costs.youBorrowed', { amount: fmt(-net) })}
</div>
@@ -1290,7 +1291,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
<label className={labelCls}>{t('costs.totalAmount')}</label>
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px', opacity: isTicketMode ? 0.6 : 1 }}>
<span className="text-content-faint" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))' }}>{sym(currency)}</span>
<NumericInput mode="decimal" placeholder="0.00" value={isTicketMode ? ticketInfo.total.toFixed(2) : total}
<NumericInput mode="decimal" placeholder={localizeAmountInput('0.00', currency)} value={localizeAmountInput(isTicketMode ? ticketInfo.total.toFixed(2) : total, currency)}
onValueChange={onTotalChange}
disabled={isTicketMode}
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, paddingLeft: 6, width: '100%' }} />
@@ -1373,8 +1374,8 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
{on ? (
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
<span className="text-content-faint" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{sym(currency)}</span>
<NumericInput mode="decimal" placeholder="0.00" data-testid="payer-amount"
value={payerAmounts[p.id] || ''}
<NumericInput mode="decimal" placeholder={localizeAmountInput('0.00', currency)} data-testid="payer-amount"
value={localizeAmountInput(payerAmounts[p.id] || '', currency)}
onValueChange={v => onPayerAmountChange(p.id, v)}
className="text-content"
style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
@@ -1438,8 +1439,8 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
<span className="text-content-faint" style={{ fontSize: 12 }}>{sym(currency)}</span>
<NumericInput
mode="decimal"
placeholder="0.00"
value={item.price}
placeholder={localizeAmountInput('0.00', currency)}
value={localizeAmountInput(item.price, currency)}
onValueChange={v => handleUpdateItemPrice(item.id, v)}
className="text-content"
style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 13, fontWeight: 600, textAlign: 'right', padding: '6px 0' }}
@@ -1521,7 +1522,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
on ? (
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
<input type="text" inputMode="decimal" placeholder={(placeholderShares[p.id] || 0).toFixed(2)} value={customAmounts[p.id] || ''}
<input type="text" inputMode="decimal" placeholder={localizeAmountInput((placeholderShares[p.id] || 0).toFixed(2), currency)} value={localizeAmountInput(customAmounts[p.id] || '', currency)}
onChange={e => handleCustomAmountChange(p.id, e.target.value)}
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
</div>
@@ -124,9 +124,13 @@ describe('CollabChat', () => {
expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument();
});
it('FE-COMP-CHAT-009: shows hint text in empty state', async () => {
it('FE-COMP-CHAT-009: shows guidance in empty state', async () => {
render(<CollabChat {...defaultProps} />);
await screen.findByText(/Share ideas, plans/i);
// The empty state now renders the shared EmptyState: a chat-scene mascot
// plus the single "Start the conversation" title (the separate hint
// paragraph was dropped in the mobile rewrite).
await screen.findByText('Start the conversation');
expect(document.querySelector('svg.trek--chat')).toBeInTheDocument();
});
it('FE-COMP-CHAT-010: chat container renders', () => {
@@ -0,0 +1,300 @@
// FE-W5CCM-001 to FE-W5CCM-024
// ChatMessages is a pure presentational component — every piece of state arrives
// as a prop from useCollabChat, so the tests drive it directly instead of going
// through CollabChat (that path is covered in CollabChat.test.tsx).
vi.mock('./CollabChatLinkPreview', () => ({
LinkPreview: ({ url, onLoad }: { url: string; onLoad?: () => void }) => (
<button type="button" data-testid={`preview-${url}`} onClick={() => onLoad?.()}>
preview
</button>
),
}))
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
import { ChatMessages } from './CollabChatMessages'
interface ChatMsg {
id: number
user_id: number
username: string
text: string
created_at: string
user_avatar?: string | null
reply_text?: string | null
reply_username?: string | null
reply_to?: number | null
reactions?: { emoji: string; count: number; users: { user_id: number; username: string }[] }[]
_deleted?: boolean
}
const currentUser = { id: 1, username: 'me' }
function buildMsg(overrides: Partial<ChatMsg> = {}): ChatMsg {
return {
id: 1,
user_id: 2,
username: 'alice',
text: 'hello',
created_at: '2025-06-01T10:00:00.000Z',
reactions: [],
...overrides,
}
}
interface Handles {
setHoveredId: ReturnType<typeof vi.fn>
setReplyTo: ReturnType<typeof vi.fn>
setReactMenu: ReturnType<typeof vi.fn>
handleDelete: ReturnType<typeof vi.fn>
handleReact: ReturnType<typeof vi.fn>
handleLoadMore: ReturnType<typeof vi.fn>
scrollToBottom: ReturnType<typeof vi.fn>
}
function renderMessages(
messages: ChatMsg[],
overrides: Record<string, unknown> = {},
): Handles {
const handles: Handles = {
setHoveredId: vi.fn(),
setReplyTo: vi.fn(),
setReactMenu: vi.fn(),
handleDelete: vi.fn(),
handleReact: vi.fn(),
handleLoadMore: vi.fn(),
scrollToBottom: vi.fn(),
}
const props = {
currentUser,
tripId: 1,
t: (key: string) => key,
is12h: false,
canEdit: true,
messages,
loading: false,
hasMore: false,
loadingMore: false,
hoveredId: null,
deletingIds: new Set<number>(),
scrollRef: { current: null },
isAtBottom: { current: false },
checkAtBottom: vi.fn(),
isOwn: (m: ChatMsg) => String(m.user_id) === String(currentUser.id),
isEmojiOnly: (text: string) => /^\p{Extended_Pictographic}$/u.test(text),
...handles,
...overrides,
}
render(<ChatMessages {...props} />)
return handles
}
describe('ChatMessages', () => {
it('FE-W5CCM-001: renders the empty state when there are no messages', () => {
renderMessages([])
expect(screen.getByText('collab.chat.empty')).toBeInTheDocument()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('FE-W5CCM-002: the load-more button reports its loading state and calls back', () => {
const { handleLoadMore } = renderMessages([buildMsg()], { hasMore: true })
const btn = screen.getByRole('button', { name: 'collab.chat.loadMore' })
fireEvent.click(btn)
expect(handleLoadMore).toHaveBeenCalledTimes(1)
})
it('FE-W5CCM-003: the load-more button is disabled and shows an ellipsis while loading', () => {
renderMessages([buildMsg()], { hasMore: true, loadingMore: true })
const btn = screen.getByText('...').closest('button')
expect(btn).toBeDisabled()
})
it('FE-W5CCM-004: a deleted message renders the placeholder line with its author', () => {
renderMessages([buildMsg({ _deleted: true })])
expect(screen.getByText(/collab\.chat\.deletedMessage/)).toBeInTheDocument()
expect(screen.queryByText('hello')).not.toBeInTheDocument()
})
it('FE-W5CCM-005: the deleted placeholder falls back to English when the key is missing', () => {
renderMessages([buildMsg({ _deleted: true })], {
t: (key: string) => (key === 'collab.chat.deletedMessage' ? '' : key),
})
expect(screen.getByText(/deleted a message/)).toBeInTheDocument()
})
it('FE-W5CCM-006: a message not at the end of its group keeps the rounded tail', () => {
renderMessages([
buildMsg({ id: 1, user_id: 1, username: 'me', text: 'first' }),
buildMsg({ id: 2, user_id: 1, username: 'me', text: 'second' }),
])
const first = screen.getByText('first').closest('div[style]')!
const last = screen.getByText('second').closest('div[style]')!
expect(first.getAttribute('style')).toContain('border-radius: 18px 18px 18px 18px')
expect(last.getAttribute('style')).toContain('border-radius: 18px 18px 4px 18px')
})
it('FE-W5CCM-007: the avatar image is rendered for a foreign author who has one', () => {
renderMessages([buildMsg({ user_avatar: '/uploads/avatars/alice.png' })])
const avatar = document.querySelector('img[src="/uploads/avatars/alice.png"]')
expect(avatar).toBeInTheDocument()
})
it('FE-W5CCM-008: the avatar initial falls back to a question mark without a username', () => {
renderMessages([buildMsg({ username: '' })])
expect(screen.getByText('?')).toBeInTheDocument()
})
it('FE-W5CCM-009: hovering a bubble reports the hovered id and clears it on leave', () => {
const { setHoveredId } = renderMessages([buildMsg({ id: 7 })])
const bubble = screen.getByText('hello').closest('div[style="position: relative;"]')!
fireEvent.mouseEnter(bubble)
expect(setHoveredId).toHaveBeenCalledWith(7)
fireEvent.mouseLeave(bubble)
expect(setHoveredId).toHaveBeenLastCalledWith(null)
})
it('FE-W5CCM-010: the hover actions become visible for the hovered message only', () => {
renderMessages(
[buildMsg({ id: 1, text: 'one' }), buildMsg({ id: 2, text: 'two' })],
{ hoveredId: 1 },
)
const actions = screen.getAllByTitle('collab.chat.reply').map(b => b.parentElement!)
expect(actions[0].getAttribute('style')).toContain('opacity: 1')
expect(actions[1].getAttribute('style')).toContain('opacity: 0')
})
it('FE-W5CCM-011: right-clicking a bubble opens the reaction menu at the cursor', () => {
const { setReactMenu } = renderMessages([buildMsg({ id: 9 })])
const bubble = screen.getByText('hello').closest('div[style="position: relative;"]')!
fireEvent.contextMenu(bubble, { clientX: 120, clientY: 240 })
expect(setReactMenu).toHaveBeenCalledWith({ msgId: 9, x: 120, y: 240 })
})
it('FE-W5CCM-012: right-clicking does nothing without edit rights', () => {
const { setReactMenu } = renderMessages([buildMsg()], { canEdit: false })
const bubble = screen.getByText('hello').closest('div[style="position: relative;"]')!
fireEvent.contextMenu(bubble, { clientX: 10, clientY: 20 })
expect(setReactMenu).not.toHaveBeenCalled()
})
it('FE-W5CCM-013: a single tap only records the tap, a double tap opens the reaction menu', () => {
const { setReactMenu } = renderMessages([buildMsg({ id: 4 })])
const bubble = screen.getByText('hello').closest('div[style="position: relative;"]') as HTMLElement
fireEvent.touchEnd(bubble, { changedTouches: [{ clientX: 5, clientY: 6 }] })
expect(setReactMenu).not.toHaveBeenCalled()
expect(bubble.dataset.lastTap).toBeTruthy()
fireEvent.touchEnd(bubble, { changedTouches: [{ clientX: 33, clientY: 44 }] })
expect(setReactMenu).toHaveBeenCalledWith({ msgId: 4, x: 33, y: 44 })
})
it('FE-W5CCM-014: a double tap without touch coordinates does not open the menu', () => {
const { setReactMenu } = renderMessages([buildMsg()])
const bubble = screen.getByText('hello').closest('div[style="position: relative;"]') as HTMLElement
fireEvent.touchEnd(bubble, { changedTouches: [] })
fireEvent.touchEnd(bubble, { changedTouches: [] })
expect(setReactMenu).not.toHaveBeenCalled()
})
it('FE-W5CCM-015: a double tap is ignored without edit rights', () => {
const { setReactMenu } = renderMessages([buildMsg()], { canEdit: false })
const bubble = screen.getByText('hello').closest('div[style="position: relative;"]') as HTMLElement
fireEvent.touchEnd(bubble, { changedTouches: [{ clientX: 1, clientY: 2 }] })
fireEvent.touchEnd(bubble, { changedTouches: [{ clientX: 1, clientY: 2 }] })
expect(setReactMenu).not.toHaveBeenCalled()
})
it('FE-W5CCM-016: an own reply quote shows the quoted author and a truncated body', () => {
const longQuote = 'q'.repeat(120)
renderMessages([
buildMsg({ user_id: 1, username: 'me', reply_username: 'alice', reply_text: longQuote }),
])
expect(screen.getByText('alice')).toBeInTheDocument()
expect(screen.getByText('q'.repeat(80))).toBeInTheDocument()
})
it('FE-W5CCM-017: a reply without stored quote data renders empty quote fields', () => {
renderMessages([buildMsg({ reply_to: 55, reply_text: null, reply_username: null })])
const quote = screen.getByText('hello').closest('div[style]')!.parentElement!
// The quote block renders before the message text with both fields blank
expect(quote.textContent).toBe('hello')
})
it('FE-W5CCM-018: a resolved link preview scrolls down when the view is pinned to the bottom', async () => {
const { scrollToBottom } = renderMessages(
[buildMsg({ text: 'look at https://example.com/a' })],
{ isAtBottom: { current: true } },
)
fireEvent.click(screen.getByTestId('preview-https://example.com/a'))
await waitFor(() => expect(scrollToBottom).toHaveBeenCalledWith('smooth'))
})
it('FE-W5CCM-019: a resolved link preview does not scroll when the user scrolled up', async () => {
const { scrollToBottom } = renderMessages(
[buildMsg({ text: 'look at https://example.com/b' })],
{ isAtBottom: { current: false } },
)
fireEvent.click(screen.getByTestId('preview-https://example.com/b'))
await new Promise(r => setTimeout(r, 80))
expect(scrollToBottom).not.toHaveBeenCalled()
})
it('FE-W5CCM-020: the reply and delete buttons react to hover and fire their handlers', () => {
const { setReplyTo, handleDelete } = renderMessages([
buildMsg({ id: 3, user_id: 1, username: 'me', text: 'mine' }),
])
const replyBtn = screen.getByTitle('collab.chat.reply')
fireEvent.mouseEnter(replyBtn)
expect(replyBtn.style.transform).toBe('scale(1.2)')
fireEvent.mouseLeave(replyBtn)
expect(replyBtn.style.transform).toBe('scale(1)')
fireEvent.click(replyBtn)
expect(setReplyTo).toHaveBeenCalledWith(expect.objectContaining({ id: 3 }))
const deleteBtn = screen.getByTitle('common.delete')
fireEvent.mouseEnter(deleteBtn)
expect(deleteBtn.style.background).toBe('rgb(239, 68, 68)')
fireEvent.mouseLeave(deleteBtn)
expect(deleteBtn.style.background).toBe('var(--accent)')
fireEvent.click(deleteBtn)
expect(handleDelete).toHaveBeenCalledWith(3)
})
it('FE-W5CCM-021: clicking a reaction badge on an own message reacts again', () => {
const { handleReact } = renderMessages([
buildMsg({
id: 8,
user_id: 1,
username: 'me',
reactions: [{ emoji: '🔥', count: 2, users: [{ user_id: 1, username: 'me' }] }],
}),
])
fireEvent.click(screen.getByAltText('🔥').closest('button')!)
expect(handleReact).toHaveBeenCalledWith(8, '🔥')
})
it('FE-W5CCM-022: reaction badges are inert without edit rights', () => {
const { handleReact } = renderMessages(
[
buildMsg({
reactions: [{ emoji: '👍', count: 1, users: [{ user_id: 2, username: 'alice' }] }],
}),
],
{ canEdit: false },
)
fireEvent.click(screen.getByAltText('👍').closest('button')!)
expect(handleReact).not.toHaveBeenCalled()
})
it('FE-W5CCM-023: a message being deleted collapses instead of disappearing instantly', () => {
renderMessages([buildMsg({ id: 12 })], { deletingIds: new Set([12]) })
const row = screen.getByText('hello').closest('div[style*="row"]')!
expect(row.getAttribute('style')).toContain('opacity: 0')
})
it('FE-W5CCM-024: a single emoji message renders without a bubble background', () => {
renderMessages([buildMsg({ text: '🎉' })])
const big = screen.getByText('🎉')
expect(big.getAttribute('style')).toContain('font-size: calc(40px')
})
})
@@ -1,10 +1,11 @@
import React from 'react'
import { Trash2, Reply, ChevronUp, MessageCircle } from 'lucide-react'
import { Trash2, Reply, ChevronUp } from 'lucide-react'
import { URL_REGEX } from './CollabChat.constants'
import { formatTime, formatDateSeparator, shouldShowDateSeparator } from './CollabChat.helpers'
import { MessageText } from './CollabChatMessageText'
import { LinkPreview } from './CollabChatLinkPreview'
import { ReactionBadge } from './CollabChatReactionBadge'
import EmptyState from '../shared/EmptyState'
export function ChatMessages(props: any) {
const { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } = props
@@ -12,11 +13,7 @@ export function ChatMessages(props: any) {
<>
{/* Messages */}
{messages.length === 0 ? (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8, color: 'var(--text-faint)', padding: 32, textAlign: 'center' }}>
<MessageCircle size={40} strokeWidth={1.2} style={{ opacity: 0.4 }} />
<span style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('collab.chat.empty')}</span>
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', opacity: 0.6, fontFamily: 'var(--font-subtext)' }}>{t('collab.chat.emptyDesc') || ''}</span>
</div>
<EmptyState scene="chat" title={t('collab.chat.empty')} />
) : (
<div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{
flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '8px 14px 4px', WebkitOverflowScrolling: 'touch',
@@ -221,12 +218,9 @@ export function ChatMessages(props: any) {
borderRadius: 99, background: 'var(--bg-card)',
boxShadow: '0 1px 6px rgba(0,0,0,0.12)', border: '1px solid var(--border-faint)',
}}>
{msg.reactions.map(r => {
const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id))
return (
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => { if (canEdit) handleReact(msg.id, r.emoji) }} />
)
})}
{msg.reactions.map(r => (
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => { if (canEdit) handleReact(msg.id, r.emoji) }} />
))}
</div>
</div>
)}
@@ -0,0 +1,187 @@
// FE-W4CCS-001 to FE-W4CCS-016
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
import type { ChatReaction } from './CollabChat.types'
import type { NoteAuthor } from './CollabNotes.types'
const linkPreview = vi.fn(async (_tripId: number, _url: string) => ({ title: 'TREK', image: null as string | null }))
vi.mock('../../api/client', () => ({ collabApi: { linkPreview: (tripId: number, url: string) => linkPreview(tripId, url) } }))
import { TwemojiImg } from './CollabChatTwemojiImg'
import { ReactionBadge } from './CollabChatReactionBadge'
import { UserAvatar } from './CollabNotesUserAvatar'
import { WebsiteThumbnail } from './CollabNotesWebsiteThumbnail'
function reaction(overrides: Partial<ChatReaction> = {}): ChatReaction {
return { emoji: '👍', count: 1, users: [{ id: 1, username: 'ada' }], ...overrides } as unknown as ChatReaction
}
beforeEach(() => {
linkPreview.mockReset()
linkPreview.mockResolvedValue({ title: 'TREK', image: null })
})
describe('TwemojiImg', () => {
it('FE-W4CCS-001: renders the twemoji asset for the codepoint', () => {
const { container } = render(<TwemojiImg emoji="👍" />)
const img = container.querySelector('img') as HTMLImageElement
expect(img).toHaveAttribute('alt', '👍')
expect(img.getAttribute('src')).toContain('/1f44d.png')
expect(img).toHaveStyle({ width: '20px', height: '20px' })
})
it('FE-W4CCS-002: strips the variation selector from a multi-codepoint emoji', () => {
const { container } = render(<TwemojiImg emoji="❤️" size={16} />)
expect((container.querySelector('img') as HTMLImageElement).getAttribute('src')).toContain('/2764.png')
})
it('FE-W4CCS-003: falls back to the plain glyph when the asset fails to load', () => {
const { container } = render(<TwemojiImg emoji="👍" size={24} />)
fireEvent.error(container.querySelector('img')!)
expect(container.querySelector('img')).toBeNull()
expect(screen.getByText('👍')).toHaveStyle({ fontSize: '24px' })
})
})
describe('ReactionBadge', () => {
it('FE-W4CCS-004: hides the counter for a single reactor', () => {
render(<ReactionBadge reaction={reaction()} currentUserId={1} onReact={() => {}} />)
expect(screen.queryByText('1')).toBeNull()
})
it('FE-W4CCS-005: shows the counter once more than one person reacted', () => {
render(
<ReactionBadge
reaction={reaction({ count: 3, users: [{ id: 1, username: 'ada' }, { id: 2, username: 'bob' }] } as Partial<ChatReaction>)}
currentUserId={1}
onReact={() => {}}
/>,
)
expect(screen.getByText('3')).toBeInTheDocument()
})
it('FE-W4CCS-006: clicking toggles the own reaction', () => {
const onReact = vi.fn()
render(<ReactionBadge reaction={reaction()} currentUserId={1} onReact={onReact} />)
fireEvent.click(screen.getByRole('button'))
expect(onReact).toHaveBeenCalledOnce()
})
it('FE-W4CCS-007: hovering portals the list of reactors and leaving removes it', () => {
render(
<ReactionBadge
reaction={reaction({ users: [{ id: 1, username: 'ada' }, { id: 2, username: 'bob' }] } as Partial<ChatReaction>)}
currentUserId={1}
onReact={() => {}}
/>,
)
const badge = screen.getByRole('button')
fireEvent.mouseEnter(badge)
expect(screen.getByText('ada, bob')).toBeInTheDocument()
fireEvent.mouseLeave(badge)
expect(screen.queryByText('ada, bob')).toBeNull()
})
it('FE-W4CCS-008: shows no tooltip when nobody is named', () => {
render(<ReactionBadge reaction={reaction({ users: [] } as Partial<ChatReaction>)} currentUserId={1} onReact={() => {}} />)
fireEvent.mouseEnter(screen.getByRole('button'))
expect(document.body.querySelectorAll('[style*="translate(-50%, -100%)"]')).toHaveLength(0)
})
})
describe('UserAvatar', () => {
it('FE-W4CCS-009: renders nothing without a user', () => {
const { container } = render(<UserAvatar user={null} />)
expect(container).toBeEmptyDOMElement()
})
it('FE-W4CCS-010: renders the avatar image when one is set', () => {
const { container } = render(<UserAvatar user={{ username: 'ada', avatar: '/uploads/avatars/ada.png' } as NoteAuthor} size={20} />)
const img = container.querySelector('img') as HTMLImageElement
expect(img).toHaveAttribute('src', '/uploads/avatars/ada.png')
expect(img).toHaveAttribute('alt', 'ada')
expect(img).toHaveStyle({ width: '20px' })
})
it('FE-W4CCS-011: falls back to the first letter, and to ? without a name', () => {
const { unmount } = render(<UserAvatar user={{ username: 'ada', avatar: null } as NoteAuthor} />)
expect(screen.getByText('a')).toBeInTheDocument()
unmount()
render(<UserAvatar user={{ username: '', avatar: null } as NoteAuthor} />)
expect(screen.getByText('?')).toBeInTheDocument()
})
})
describe('WebsiteThumbnail', () => {
it('FE-W4CCS-012: shows the domain until an OG image arrives', async () => {
render(<WebsiteThumbnail url="https://www.liketrek.com/docs" tripId={4} color="#000" />)
expect(screen.getByText('liketrek.com')).toBeInTheDocument()
await waitFor(() => expect(screen.getByRole('link')).toHaveAttribute('title', 'TREK'))
expect(linkPreview).toHaveBeenCalledWith(4, 'https://www.liketrek.com/docs')
})
it('FE-W4CCS-013: renders the OG image once the preview resolves', async () => {
linkPreview.mockResolvedValue({ title: 'Docs', image: 'https://cdn.example/og.png' })
const { container } = render(<WebsiteThumbnail url="https://example.com/a" tripId={4} color="#000" />)
await waitFor(() => expect(container.querySelector('img')).not.toBeNull())
expect(container.querySelector('img')).toHaveAttribute('src', 'https://cdn.example/og.png')
// A broken OG image falls back to the domain chip.
fireEvent.error(container.querySelector('img')!)
expect(screen.getByText('example.com')).toBeInTheDocument()
})
it('FE-W4CCS-014: falls back to a link label for an unparseable url and a failing preview', async () => {
linkPreview.mockRejectedValue(new Error('blocked'))
render(<WebsiteThumbnail url="not a url" tripId={4} color="#000" />)
expect(screen.getByText('link')).toBeInTheDocument()
await waitFor(() => expect(linkPreview).toHaveBeenCalled())
expect(screen.getByRole('link')).toHaveAttribute('title', 'not a url')
})
it('FE-W4CCS-015: caches per trip, because the preview endpoint is trip-scoped', async () => {
linkPreview.mockResolvedValue({ title: 'Trip 4', image: null })
const shared = 'https://example.com/shared'
const first = render(<WebsiteThumbnail url={shared} tripId={4} color="#000" />)
await waitFor(() => expect(screen.getByRole('link')).toHaveAttribute('title', 'Trip 4'))
first.unmount()
linkPreview.mockResolvedValue({ title: 'Trip 9', image: null })
render(<WebsiteThumbnail url={shared} tripId={9} color="#000" />)
await waitFor(() => expect(screen.getByRole('link')).toHaveAttribute('title', 'Trip 9'))
expect(linkPreview).toHaveBeenCalledTimes(2)
expect(linkPreview).toHaveBeenLastCalledWith(9, shared)
})
it('FE-W4CCS-016: a broken OG image does not poison the next url', async () => {
linkPreview.mockResolvedValue({ title: 'A', image: 'https://cdn.example/a.png' })
const { container, rerender } = render(<WebsiteThumbnail url="https://a.example/x" tripId={4} color="#000" />)
await waitFor(() => expect(container.querySelector('img')).not.toBeNull())
fireEvent.error(container.querySelector('img')!)
expect(container.querySelector('img')).toBeNull()
linkPreview.mockResolvedValue({ title: 'B', image: 'https://cdn.example/b.png' })
rerender(<WebsiteThumbnail url="https://b.example/y" tripId={4} color="#000" />)
await waitFor(() => expect(container.querySelector('img')).not.toBeNull())
expect(container.querySelector('img')).toHaveAttribute('src', 'https://cdn.example/b.png')
})
})
@@ -0,0 +1,65 @@
// FE-W4CNH-001 to FE-W4CNH-008
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
import { formatTimestamp } from './CollabNotes.helpers'
// The helper feeds t() with a key and params; echo them so the branches are visible.
const t = (key: string, params?: Record<string, number>) =>
params ? `${key}:${Object.values(params)[0]}` : key
const NOW = new Date('2026-06-15T12:00:00Z')
beforeAll(() => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
})
afterAll(() => {
vi.useRealTimers()
})
function ago(minutes: number): string {
return new Date(NOW.getTime() - minutes * 60_000).toISOString().replace('Z', '')
}
describe('formatTimestamp', () => {
it('FE-W4CNH-001: renders an empty string for a missing timestamp', () => {
expect(formatTimestamp(null, t, 'en')).toBe('')
expect(formatTimestamp('', t, 'en')).toBe('')
})
it('FE-W4CNH-002: labels the last minute as just now', () => {
expect(formatTimestamp(ago(0), t, 'en')).toBe('collab.chat.justNow')
})
it('FE-W4CNH-003: counts minutes below the hour', () => {
expect(formatTimestamp(ago(5), t, 'en')).toBe('collab.chat.minutesAgo:5')
expect(formatTimestamp(ago(59), t, 'en')).toBe('collab.chat.minutesAgo:59')
})
it('FE-W4CNH-004: counts hours below the day', () => {
expect(formatTimestamp(ago(60), t, 'en')).toBe('collab.chat.hoursAgo:1')
expect(formatTimestamp(ago(60 * 23), t, 'en')).toBe('collab.chat.hoursAgo:23')
})
it('FE-W4CNH-005: counts days below a week', () => {
expect(formatTimestamp(ago(60 * 24), t, 'en')).toBe('collab.notes.daysAgo:1')
expect(formatTimestamp(ago(60 * 24 * 6), t, 'en')).toBe('collab.notes.daysAgo:6')
})
it('FE-W4CNH-006: falls back to a localized short date beyond a week', () => {
expect(formatTimestamp(ago(60 * 24 * 10), t, 'en-US')).toBe('Jun 5')
})
it('FE-W4CNH-007: treats a naive timestamp as UTC and accepts an explicit Z', () => {
const withZ = new Date(NOW.getTime() - 5 * 60_000).toISOString()
expect(formatTimestamp(withZ, t, 'en')).toBe('collab.chat.minutesAgo:5')
})
it('FE-W4CNH-008: falls back to English labels when the translation is missing', () => {
const empty = () => ''
expect(formatTimestamp(ago(0), empty, 'en')).toBe('just now')
expect(formatTimestamp(ago(5), empty, 'en')).toBe('5m ago')
expect(formatTimestamp(ago(120), empty, 'en')).toBe('2h ago')
expect(formatTimestamp(ago(60 * 24 * 2), empty, 'en')).toBe('2d ago')
})
})
@@ -10,7 +10,7 @@ vi.mock('../../api/websocket', () => ({
removeListener: vi.fn(),
}));
import { render, screen, waitFor, act } from '../../../tests/helpers/render';
import { render, screen, waitFor, act, fireEvent, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
@@ -19,6 +19,7 @@ import { useTripStore } from '../../store/tripStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
import CollabNotes from './CollabNotes';
import { addListener } from '../../api/websocket';
const currentUser = buildUser({ id: 1, username: 'testuser' });
@@ -1268,3 +1269,554 @@ describe('CollabNotes', () => {
expect(document.body.innerHTML.indexOf('Pinned')).toBeLessThan(document.body.innerHTML.indexOf('Unpinned'));
});
});
// FE-W5CNT-001 to FE-W5CNT-029
// Fills in the load/error/attachment/category branches of useCollabNotes and the
// view modal that the smoke tests above do not reach.
type AddToast = NonNullable<typeof window.__addToast>;
const buildNote = (overrides: Record<string, unknown> = {}) => ({
id: 1,
trip_id: 1,
user_id: 1,
author_username: 'testuser',
author_avatar: null,
title: 'A note',
content: 'Body text',
category: null,
website: null,
color: '#3b82f6',
pinned: false,
files: [],
attachments: [],
created_at: '2025-06-01T10:00:00.000Z',
updated_at: '2025-06-01T10:00:00.000Z',
...overrides,
});
function serveNotes(payload: unknown) {
server.use(http.get('/api/trips/1/collab/notes', () => HttpResponse.json(payload)));
}
/** Serves a different payload per GET so reload-after-upload can be observed. */
function serveNotesSequence(payloads: unknown[]) {
let call = 0;
server.use(
http.get('/api/trips/1/collab/notes', () => {
const payload = payloads[Math.min(call, payloads.length - 1)];
call += 1;
return HttpResponse.json(payload);
}),
);
}
function pasteFile(name: string, type = 'image/png') {
const file = new File(['x'], name, { type });
fireEvent.paste(document.querySelector('form')!, {
clipboardData: { items: [{ type, getAsFile: () => file }] },
});
}
function wsHandler(): (msg: Record<string, unknown>) => void {
return (addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
}
describe('CollabNotes details', () => {
let addToast: ReturnType<typeof vi.fn<AddToast>>;
let filesChanged: number;
let onFilesChanged: () => void;
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
addToast = vi.fn<AddToast>(() => 0);
window.__addToast = addToast;
filesChanged = 0;
onFilesChanged = () => { filesChanged += 1; };
window.addEventListener('collab-files-changed', onFilesChanged);
});
afterEach(() => {
window.removeEventListener('collab-files-changed', onFilesChanged);
delete window.__addToast;
localStorage.clear();
});
it('FE-W5CNT-001: a corrupt category cache in localStorage is ignored', async () => {
localStorage.setItem('collab-cats-1', '{not json');
render(<CollabNotes {...defaultProps} />);
await screen.findByText('No notes yet');
});
it('FE-W5CNT-002: a category without a stored colour falls back to the first palette entry', async () => {
serveNotes({ notes: [buildNote({ category: 'Ideas', color: null })] });
render(<CollabNotes {...defaultProps} />);
await screen.findByText('A note');
// The card chip is a span, the filter pill above the grid is a button
const chip = screen.getAllByText('Ideas').find(el => el.tagName === 'SPAN')!;
expect(chip.style.color).toBe('rgb(99, 102, 241)');
});
it('FE-W5CNT-003: without a trip id nothing is fetched and the panel stays in its loading state', () => {
render(<CollabNotes tripId={0} currentUser={currentUser} />);
expect(screen.getByRole('heading', { name: 'Notes' })).toBeInTheDocument();
expect(screen.queryByText('New Note')).not.toBeInTheDocument();
});
it('FE-W5CNT-004: notes served as a bare array are rendered', async () => {
serveNotes([buildNote({ title: 'Array note' })]);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Array note');
});
it('FE-W5CNT-005: an empty payload yields an empty list', async () => {
serveNotes(null);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('No notes yet');
});
it('FE-W5CNT-006: a failing load falls back to the empty state', async () => {
server.use(
http.get('/api/trips/1/collab/notes', () => new HttpResponse(null, { status: 500 })),
);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('No notes yet');
});
it('FE-W5CNT-007: a WebSocket create for a note already in the list does not duplicate it', async () => {
serveNotes({ notes: [buildNote({ id: 4, title: 'Already here' })] });
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Already here');
const handler = wsHandler();
await act(async () => {
handler({ type: 'collab:note:created', note: buildNote({ id: 4, title: 'Already here' }) });
});
expect(screen.getAllByText('Already here')).toHaveLength(1);
});
it('FE-W5CNT-008: a WebSocket update only touches the matching note', async () => {
serveNotes({
notes: [buildNote({ id: 1, title: 'First' }), buildNote({ id: 2, title: 'Second' })],
});
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Second');
const handler = wsHandler();
await act(async () => {
handler({ type: 'collab:note:updated', note: { id: 2, title: 'Second renamed' } });
});
expect(await screen.findByText('Second renamed')).toBeInTheDocument();
expect(screen.getByText('First')).toBeInTheDocument();
});
it('FE-W5CNT-009: a WebSocket delete accepts a plain id and ignores events without one', async () => {
serveNotes({ notes: [buildNote({ id: 9, title: 'Doomed' })] });
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Doomed');
const handler = wsHandler();
await act(async () => { handler({ type: 'collab:note:deleted' }); });
expect(screen.getByText('Doomed')).toBeInTheDocument();
await act(async () => { handler({ type: 'collab:note:deleted', id: 9 }); });
await waitFor(() => expect(screen.queryByText('Doomed')).not.toBeInTheDocument());
});
it('FE-W5CNT-010: an unwrapped create response is prepended to the list', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/trips/1/collab/notes', () =>
HttpResponse.json(buildNote({ id: 20, title: 'Fresh note' })),
),
);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('No notes yet');
await user.click(screen.getByText('New Note'));
await user.type(await screen.findByPlaceholderText('Note title'), 'Fresh note');
await user.click(screen.getByRole('button', { name: 'Create' }));
await screen.findByText('Fresh note');
});
it('FE-W5CNT-011: an empty create response leaves the list untouched', async () => {
const user = userEvent.setup();
server.use(http.post('/api/trips/1/collab/notes', () => HttpResponse.json(null)));
render(<CollabNotes {...defaultProps} />);
await screen.findByText('No notes yet');
await user.click(screen.getByText('New Note'));
await user.type(await screen.findByPlaceholderText('Note title'), 'Ghost note');
await user.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() => expect(screen.queryByPlaceholderText('Note title')).not.toBeInTheDocument());
expect(screen.getByText('No notes yet')).toBeInTheDocument();
});
it('FE-W5CNT-012: a failing create reports an error and keeps the modal open', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/trips/1/collab/notes', () => new HttpResponse(null, { status: 500 })),
);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('No notes yet');
await user.click(screen.getByText('New Note'));
await user.type(await screen.findByPlaceholderText('Note title'), 'Doomed note');
await user.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
expect(screen.getByPlaceholderText('Note title')).toBeInTheDocument();
});
it('FE-W5CNT-013: a pasted attachment is uploaded and the list is reloaded afterwards', async () => {
const user = userEvent.setup();
let uploaded = 0;
serveNotesSequence([
{ notes: [] },
{ notes: [buildNote({ id: 30, title: 'With file' })] },
]);
server.use(
http.post('/api/trips/1/collab/notes', () =>
HttpResponse.json({ note: buildNote({ id: 30, title: 'With file' }) }),
),
http.post('/api/trips/1/collab/notes/30/files', () => {
uploaded += 1;
return HttpResponse.json({ file: { id: 1 } });
}),
);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('No notes yet');
await user.click(screen.getByText('New Note'));
await user.type(await screen.findByPlaceholderText('Note title'), 'With file');
pasteFile('screenshot.png');
await user.click(screen.getByRole('button', { name: 'Create' }));
await screen.findByText('With file');
expect(uploaded).toBe(1);
expect(filesChanged).toBe(1);
});
it('FE-W5CNT-014: a failing upload reports an error and the array-shaped reload is ignored', async () => {
const user = userEvent.setup();
serveNotesSequence([{ notes: [] }, [buildNote({ id: 31, title: 'Never shown' })]]);
server.use(
http.post('/api/trips/1/collab/notes', () =>
HttpResponse.json({ note: buildNote({ id: 31, title: 'Upload fails' }) }),
),
http.post('/api/trips/1/collab/notes/31/files', () => new HttpResponse(null, { status: 500 })),
);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('No notes yet');
await user.click(screen.getByText('New Note'));
await user.type(await screen.findByPlaceholderText('Note title'), 'Upload fails');
pasteFile('broken.png');
await user.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
await waitFor(() => expect(filesChanged).toBe(1));
expect(screen.queryByText('Never shown')).not.toBeInTheDocument();
});
it('FE-W5CNT-015: pinning a note applies the unwrapped response to that note only', async () => {
const user = userEvent.setup();
serveNotes({
notes: [buildNote({ id: 1, title: 'Pin me' }), buildNote({ id: 2, title: 'Leave me' })],
});
server.use(
http.put('/api/trips/1/collab/notes/1', () =>
HttpResponse.json(buildNote({ id: 1, title: 'Pinned now', pinned: true })),
),
);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Pin me');
const pinBtn = screen.getAllByTitle('Pin')[0];
await user.click(pinBtn);
await screen.findByText('Pinned now');
expect(screen.getByText('Leave me')).toBeInTheDocument();
});
it('FE-W5CNT-016: an empty update response leaves the note as it was', async () => {
const user = userEvent.setup();
serveNotes({ notes: [buildNote({ id: 1, title: 'Unchanged' })] });
server.use(http.put('/api/trips/1/collab/notes/1', () => HttpResponse.json(null)));
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Unchanged');
await user.click(screen.getByTitle('Pin'));
await waitFor(() => expect(screen.getByText('Unchanged')).toBeInTheDocument());
expect(screen.getByTitle('Pin')).toBeInTheDocument();
});
it('FE-W5CNT-017: a failing edit reports an error and keeps the edit modal open', async () => {
const user = userEvent.setup();
serveNotes({ notes: [buildNote({ id: 3, title: 'Edit me' })] });
server.use(
http.put('/api/trips/1/collab/notes/3', () => new HttpResponse(null, { status: 500 })),
);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Edit me');
await user.click(screen.getByTitle('Edit'));
const titleInput = await screen.findByDisplayValue('Edit me');
await user.type(titleInput, ' v2');
await user.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
expect(screen.getByDisplayValue('Edit me v2')).toBeInTheDocument();
});
it('FE-W5CNT-018: saving a new category colour rewrites every note in that category', async () => {
const user = userEvent.setup();
const bodies: Record<string, unknown>[] = [];
serveNotes({ notes: [buildNote({ id: 1, title: 'Sushi', category: 'Food', color: '#ef4444' })] });
server.use(
http.put('/api/trips/1/collab/notes/1', async ({ request }) => {
bodies.push((await request.json()) as Record<string, unknown>);
return HttpResponse.json({ note: buildNote({ id: 1, title: 'Sushi', category: 'Food', color: '#10b981' }) });
}),
);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Sushi');
await user.click(screen.getByTitle('Manage Categories'));
const label = (await screen.findAllByText('Food')).find(el => el.title === 'Click to rename')!;
const swatches = label.parentElement!.querySelectorAll('button');
await user.click(swatches[3]);
await user.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(bodies).toEqual([{ color: '#10b981' }]));
});
it('FE-W5CNT-019: attaching a file while editing uploads it and refreshes the note', async () => {
const user = userEvent.setup();
let uploaded = 0;
serveNotesSequence([
{ notes: [buildNote({ id: 3, title: 'Edit me' })] },
{ notes: [buildNote({ id: 3, title: 'Edited', attachments: [] })] },
]);
server.use(
http.put('/api/trips/1/collab/notes/3', () =>
HttpResponse.json({ note: buildNote({ id: 3, title: 'Edited' }) }),
),
http.post('/api/trips/1/collab/notes/3/files', () => {
uploaded += 1;
return HttpResponse.json({ file: { id: 2 } });
}),
);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Edit me');
await user.click(screen.getByTitle('Edit'));
await screen.findByDisplayValue('Edit me');
pasteFile('attachment.png');
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Edited');
expect(uploaded).toBe(1);
expect(filesChanged).toBe(1);
});
it('FE-W5CNT-020: a failing upload during an edit reports an error', async () => {
const user = userEvent.setup();
serveNotesSequence([
{ notes: [buildNote({ id: 3, title: 'Edit me' })] },
[buildNote({ id: 3, title: 'Ignored reload' })],
]);
server.use(
http.put('/api/trips/1/collab/notes/3', () =>
HttpResponse.json({ note: buildNote({ id: 3, title: 'Edit me' }) }),
),
http.post('/api/trips/1/collab/notes/3/files', () => new HttpResponse(null, { status: 500 })),
);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Edit me');
await user.click(screen.getByTitle('Edit'));
await screen.findByDisplayValue('Edit me');
pasteFile('nope.png');
await user.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
expect(screen.queryByText('Ignored reload')).not.toBeInTheDocument();
});
it('FE-W5CNT-021: a failing attachment removal reports an error', async () => {
const user = userEvent.setup();
serveNotes({
notes: [buildNote({
id: 3,
title: 'Has file',
attachments: [{ id: 9, filename: 's.pdf', original_name: 'plan.pdf', mime_type: 'application/pdf', url: '/uploads/plan.pdf' }],
})],
});
server.use(
http.delete('/api/trips/1/collab/notes/3/files/9', () => new HttpResponse(null, { status: 500 })),
);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Has file');
await user.click(screen.getByTitle('Edit'));
const chip = (await screen.findByText('plan.pdf')).closest('div')!;
await user.click(chip.querySelector('button')!);
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
expect(filesChanged).toBe(1);
});
it('FE-W5CNT-022: pinned notes sort first and notes without timestamps sort last', async () => {
serveNotes({
notes: [
buildNote({ id: 1, title: 'No timestamps', updated_at: null, created_at: null }),
buildNote({ id: 2, title: 'Pinned one', pinned: true }),
buildNote({ id: 3, title: 'Created only', updated_at: null, created_at: '2025-06-02T10:00:00.000Z' }),
buildNote({ id: 4, title: 'Also undated', updated_at: null, created_at: null }),
],
});
const known = ['No timestamps', 'Pinned one', 'Created only', 'Also undated'];
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Pinned one');
const titles = Array.from(document.querySelectorAll('span'))
.filter(el => el.childElementCount === 0)
.map(el => el.textContent)
.filter(text => known.includes(text ?? ''));
expect(titles).toEqual(['Pinned one', 'Created only', 'No timestamps', 'Also undated']);
});
it('FE-W5CNT-023: clicking the active category pill clears the filter again', async () => {
const user = userEvent.setup();
serveNotes({
notes: [
buildNote({ id: 1, title: 'Food note', category: 'Food', color: '#ef4444' }),
buildNote({ id: 2, title: 'Plain note' }),
],
});
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Plain note');
const pill = screen.getAllByRole('button').find(b => b.textContent === 'Food')!;
await user.click(pill);
await waitFor(() => expect(screen.queryByText('Plain note')).not.toBeInTheDocument());
await user.click(pill);
expect(await screen.findByText('Plain note')).toBeInTheDocument();
});
it('FE-W5CNT-024: a narrow viewport lays the grid out in a single column', async () => {
const original = window.innerWidth;
Object.defineProperty(window, 'innerWidth', { value: 500, writable: true, configurable: true });
try {
serveNotes({ notes: [buildNote({ title: 'Mobile note' })] });
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Mobile note');
const grid = document.querySelector('[style*="grid-template-columns"]') as HTMLElement;
expect(grid.style.gridTemplateColumns).toBe('1fr');
} finally {
Object.defineProperty(window, 'innerWidth', { value: original, writable: true, configurable: true });
}
});
it('FE-W5CNT-025: the expanded note closes on a backdrop click and its buttons highlight on hover', async () => {
const user = userEvent.setup();
serveNotes({ notes: [buildNote({ id: 5, title: 'Long note', content: 'Full body', category: 'Food', color: '#ef4444' })] });
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Long note');
await user.click(screen.getByTitle('collab.notes.expand'));
const modal = await waitFor(() => {
const md = document.querySelector('.collab-note-md-full')
if (!md) throw new Error('view modal not open yet')
return md.closest('div[style*="position: fixed"]') as HTMLElement
});
expect(within(modal).getByText('Full body')).toBeInTheDocument();
const [editBtn, closeBtn] = Array.from(modal.querySelectorAll('button'));
fireEvent.mouseEnter(editBtn);
expect(editBtn.style.color).toBe('var(--text-primary)');
fireEvent.mouseLeave(editBtn);
expect(editBtn.style.color).toBe('var(--text-faint)');
fireEvent.mouseEnter(closeBtn);
expect(closeBtn.style.color).toBe('var(--text-primary)');
fireEvent.mouseLeave(closeBtn);
expect(closeBtn.style.color).toBe('var(--text-faint)');
fireEvent.click(modal);
await waitFor(() => expect(document.querySelector('.collab-note-md-full')).toBeNull());
});
it('FE-W5CNT-026: attachments in the expanded note open the preview and react to hover', async () => {
const user = userEvent.setup();
serveNotes({
notes: [buildNote({
id: 6,
title: 'Trip docs',
content: 'See attachments',
attachments: [
{ id: 1, filename: 'a.png', original_name: 'map.png', mime_type: 'image/png', url: '/uploads/map.png' },
{ id: 2, filename: 'b.zip', original_name: 'itinerary.zip', mime_type: 'application/zip', url: '/uploads/itinerary.zip' },
{ id: 3, filename: 'c', url: '/uploads/c' },
],
})],
});
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Trip docs');
await user.click(screen.getByTitle('collab.notes.expand'));
const modal = await waitFor(() => {
const md = document.querySelector('.collab-note-md-full')
if (!md) throw new Error('view modal not open yet')
return md.closest('div[style*="position: fixed"]') as HTMLElement
});
// Unknown mime type and missing name fall back to a "?" tile
expect(within(modal).getByText('?')).toBeInTheDocument();
const zipTile = within(modal).getByTitle('itinerary.zip');
expect(zipTile.style.background).toBe('var(--bg-secondary)');
expect(within(modal).getByText('ZIP')).toBeInTheDocument();
fireEvent.mouseEnter(zipTile);
expect(zipTile.style.transform).toBe('scale(1.06)');
fireEvent.mouseLeave(zipTile);
expect(zipTile.style.transform).toBe('scale(1)');
fireEvent.click(zipTile);
// FilePreviewPortal shows a download action for non-image files
expect(await screen.findByText('Download itinerary.zip')).toBeInTheDocument();
const image = await waitFor(() => {
const img = modal.querySelector('img[alt="map.png"]') as HTMLImageElement | null;
if (!img) throw new Error('image attachment not rendered yet');
return img;
});
fireEvent.mouseEnter(image);
expect(image.style.transform).toBe('scale(1.06)');
fireEvent.mouseLeave(image);
expect(image.style.transform).toBe('scale(1)');
fireEvent.click(image);
await waitFor(() => expect(screen.queryByText('Download itinerary.zip')).not.toBeInTheDocument());
});
it('FE-W5CNT-027: a create response for a note already in the list is not added twice', async () => {
const user = userEvent.setup();
serveNotes({ notes: [buildNote({ id: 20, title: 'Fresh note' })] });
server.use(
http.post('/api/trips/1/collab/notes', () =>
HttpResponse.json({ note: buildNote({ id: 20, title: 'Fresh note' }) }),
),
);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Fresh note');
await user.click(screen.getByText('New Note'));
await user.type(await screen.findByPlaceholderText('Note title'), 'Fresh note');
await user.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() => expect(screen.queryByPlaceholderText('Note title')).not.toBeInTheDocument());
expect(screen.getAllByText('Fresh note')).toHaveLength(1);
});
it('FE-W5CNT-028: a second note created elsewhere is prepended to the existing list', async () => {
const user = userEvent.setup();
serveNotes({ notes: [buildNote({ id: 20, title: 'Older note' })] });
server.use(
http.post('/api/trips/1/collab/notes', () =>
HttpResponse.json({ note: buildNote({ id: 21, title: 'Newer note' }) }),
),
);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Older note');
await user.click(screen.getByText('New Note'));
await user.type(await screen.findByPlaceholderText('Note title'), 'Newer note');
await user.click(screen.getByRole('button', { name: 'Create' }));
await screen.findByText('Newer note');
expect(screen.getByText('Older note')).toBeInTheDocument();
});
it('FE-W5CNT-029: a failing delete reports an error and keeps the note in the list', async () => {
const user = userEvent.setup();
serveNotes({ notes: [buildNote({ id: 30, title: 'Stubborn note' })] });
server.use(
http.delete('/api/trips/1/collab/notes/30', () => new HttpResponse(null, { status: 500 })),
);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Stubborn note');
await user.click(screen.getByTitle('Delete'));
const dialog = (await screen.findByText('Delete note?')).closest('div.trek-modal-enter') as HTMLElement;
await user.click(within(dialog).getByRole('button', { name: 'Delete' }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
expect(screen.getByText('Stubborn note')).toBeInTheDocument();
});
});
+5 -14
View File
@@ -11,6 +11,7 @@ import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import ConfirmDialog from '../shared/ConfirmDialog'
import EmptyState from '../shared/EmptyState'
import type { User } from '../../types'
import type { CollabNote } from './CollabNotes.types'
import { FONT, NOTE_COLORS } from './CollabNotes.constants'
@@ -270,7 +271,7 @@ function CollabNotesHeader({ t, canEdit, setShowSettings, setShowNewModal }: Not
{t('collab.notes.title')}
</h3>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
{canEdit && <button onClick={() => setShowSettings(true)} title={t('collab.notes.categorySettings') || 'Categories'}
{canEdit && <button onClick={() => setShowSettings(true)} title={t('collab.notes.categorySettings')}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 8, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', transition: 'color 0.12s' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
@@ -329,18 +330,7 @@ function CollabNotesGrid(S: NotesState) {
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
{sortedNotes.length === 0 ? (
/* ── Empty state ── */
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
padding: '48px 20px', textAlign: 'center', height: '100%',
}}>
<Pencil size={36} color="var(--text-faint)" style={{ marginBottom: 12 }} />
<div style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4, fontFamily: FONT }}>
{t('collab.notes.empty')}
</div>
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', fontFamily: FONT }}>
{t('collab.notes.emptyDesc') || 'Create a note to get started'}
</div>
</div>
<EmptyState scene="notes" title={t('collab.notes.empty')} />
) : (
/* ── Notes grid — 2 columns ── */
<div style={{
@@ -536,7 +526,8 @@ export default function CollabNotes(props: CollabNotesProps) {
<ConfirmDialog
isOpen={pendingDeleteNoteId !== null}
onClose={() => setPendingDeleteNoteId(null)}
onConfirm={() => { if (pendingDeleteNoteId !== null) handleDeleteNote(pendingDeleteNoteId) }}
// Hand the promise back so the dialog absorbs the rethrow of a failed DELETE.
onConfirm={() => (pendingDeleteNoteId !== null ? handleDeleteNote(pendingDeleteNoteId) : undefined)}
title={t('collab.notes.confirmDeleteTitle')}
message={t('collab.notes.confirmDeleteBody')}
/>
@@ -0,0 +1,301 @@
// FE-W5CNF-001 to FE-W5CNF-021
// NoteFormModal takes everything it needs as props, so the tests drive it
// directly rather than through CollabNotes.
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { useAuthStore } from '../../store/authStore'
import { useTripStore } from '../../store/tripStore'
import { usePermissionsStore } from '../../store/permissionsStore'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { buildUser, buildTrip } from '../../../tests/helpers/factories'
import { NoteFormModal } from './CollabNotesFormModal'
import type { CollabNote, NoteFile } from './CollabNotes.types'
const identity = (key: string) => key
function buildAttachment(overrides: Partial<NoteFile> = {}): NoteFile {
return {
id: 1,
filename: 'stored.png',
original_name: 'photo.png',
mime_type: 'image/png',
url: '/uploads/collab/photo.png',
...overrides,
}
}
function buildNote(overrides: Partial<CollabNote> = {}): CollabNote {
return {
id: 42,
trip_id: 1,
title: 'Existing note',
content: 'Existing content',
category: 'Food',
website: 'https://example.com',
pinned: false,
color: '#6366f1',
username: 'tester',
avatar_url: null,
avatar: null,
user_id: 1,
created_at: '2025-06-01T10:00:00.000Z',
attachments: [],
...overrides,
} as CollabNote
}
interface ModalOverrides {
note?: CollabNote | null
onClose?: () => void
onSubmit?: (data: Record<string, unknown>) => Promise<void>
onDeleteFile?: (noteId: number, fileId: number) => Promise<void>
existingCategories?: string[]
categoryColors?: Record<string, string>
t?: (key: string) => string
}
function renderModal(overrides: ModalOverrides = {}) {
const onSubmit = overrides.onSubmit ?? vi.fn(async () => {})
const onClose = overrides.onClose ?? vi.fn(() => {})
render(
<NoteFormModal
note={overrides.note ?? null}
tripId={1}
onClose={onClose}
onSubmit={onSubmit as unknown as React.ComponentProps<typeof NoteFormModal>['onSubmit']}
onDeleteFile={overrides.onDeleteFile}
existingCategories={overrides.existingCategories ?? []}
categoryColors={overrides.categoryColors as Record<string, string>}
getCategoryColor={(cat: string) => (cat === 'Food' ? '#ef4444' : '#6366f1')}
t={overrides.t ?? identity}
/>,
)
return { onSubmit, onClose }
}
beforeEach(() => {
resetAllStores()
seedStore(useAuthStore, { user: buildUser({ id: 1 }), isAuthenticated: true })
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 1 }) })
})
describe('NoteFormModal', () => {
it('FE-W5CNF-001: falls back to an empty color map when none is supplied', () => {
renderModal({ existingCategories: ['Food'] })
expect(screen.getByRole('button', { name: 'Food' })).toBeInTheDocument()
})
it('FE-W5CNF-002: submitting with a blank title does nothing', async () => {
const { onSubmit, onClose } = renderModal()
const form = document.querySelector('form')!
fireEvent.submit(form)
await waitFor(() => expect(onSubmit).not.toHaveBeenCalled())
expect(onClose).not.toHaveBeenCalled()
})
it('FE-W5CNF-003: a filled-in note is submitted trimmed and the modal closes', async () => {
const user = userEvent.setup()
const { onSubmit, onClose } = renderModal({ existingCategories: ['Food'] })
await user.type(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), ' Dinner ')
await user.type(screen.getByPlaceholderText('collab.notes.contentPlaceholder'), 'Book a table')
await user.type(screen.getByPlaceholderText('collab.notes.websitePlaceholder'), ' https://trek.test ')
await user.click(screen.getByRole('button', { name: 'collab.notes.create' }))
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
expect(onSubmit).toHaveBeenCalledWith({
title: 'Dinner',
content: 'Book a table',
category: 'Food',
color: '#ef4444',
website: 'https://trek.test',
_pendingFiles: [],
})
expect(onClose).toHaveBeenCalled()
})
it('FE-W5CNF-004: a note without a category submits null instead of an empty string', async () => {
const user = userEvent.setup()
const { onSubmit } = renderModal()
await user.type(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), 'Loose note')
await user.click(screen.getByRole('button', { name: 'collab.notes.create' }))
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ category: null, website: null }))
})
it('FE-W5CNF-005: a rejected submit keeps the modal open and re-enables the button', async () => {
const user = userEvent.setup()
const onSubmit = vi.fn(async () => { throw new Error('boom') })
const { onClose } = renderModal({ onSubmit })
await user.type(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), 'Fails')
const submit = screen.getByRole('button', { name: 'collab.notes.create' })
await user.click(submit)
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
expect(onClose).not.toHaveBeenCalled()
expect(submit).toBeEnabled()
})
it('FE-W5CNF-006: edit mode prefills the fields and uses the save label', () => {
renderModal({ note: buildNote() })
expect(screen.getByDisplayValue('Existing note')).toBeInTheDocument()
expect(screen.getByDisplayValue('Existing content')).toBeInTheDocument()
expect(screen.getByDisplayValue('https://example.com')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'collab.notes.save' })).toBeInTheDocument()
expect(screen.getByText('collab.notes.edit')).toBeInTheDocument()
})
it('FE-W5CNF-007: the close button calls back without submitting', async () => {
const user = userEvent.setup()
const { onClose, onSubmit } = renderModal()
const header = screen.getByText('collab.notes.new').parentElement!
await user.click(header.querySelector('button')!)
expect(onClose).toHaveBeenCalledTimes(1)
expect(onSubmit).not.toHaveBeenCalled()
})
it('FE-W5CNF-008: picking another category marks it active and submits it', async () => {
const user = userEvent.setup()
const { onSubmit } = renderModal({
existingCategories: ['Food'],
categoryColors: { Sights: '#6366f1' },
})
const sights = screen.getByRole('button', { name: 'Sights' })
expect(sights.style.background).toBe('transparent')
await user.click(sights)
expect(sights.style.background).toBe('rgba(99, 102, 241, 0.094)')
await user.type(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), 'Museum')
await user.click(screen.getByRole('button', { name: 'collab.notes.create' }))
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ category: 'Sights' }))
})
it('FE-W5CNF-009: pasting an image attaches it as a pending file', () => {
renderModal()
const file = new File(['x'], 'pasted.png', { type: 'image/png' })
fireEvent.paste(document.querySelector('form')!, {
clipboardData: { items: [{ type: 'image/png', getAsFile: () => file }] },
})
expect(screen.getByText('pasted.png')).toBeInTheDocument()
})
it('FE-W5CNF-010: pasting a PDF after a non-file item attaches the PDF', () => {
renderModal()
const file = new File(['%PDF'], 'itinerary.pdf', { type: 'application/pdf' })
fireEvent.paste(document.querySelector('form')!, {
clipboardData: {
items: [
{ type: 'text/plain', getAsFile: () => null },
{ type: 'application/pdf', getAsFile: () => file },
],
},
})
expect(screen.getByText('itinerary.pdf')).toBeInTheDocument()
})
it('FE-W5CNF-011: a paste whose item yields no file attaches nothing', () => {
renderModal()
fireEvent.paste(document.querySelector('form')!, {
clipboardData: { items: [{ type: 'image/png', getAsFile: () => null }] },
})
expect(screen.queryByText(/\.png$/)).not.toBeInTheDocument()
})
it('FE-W5CNF-012: a paste without clipboard items is ignored', () => {
renderModal()
fireEvent.paste(document.querySelector('form')!, { clipboardData: {} })
expect(screen.getByPlaceholderText('collab.notes.titlePlaceholder')).toBeInTheDocument()
})
it('FE-W5CNF-013: without upload rights the file section is hidden and paste is ignored', () => {
seedStore(usePermissionsStore, { permissions: { file_upload: 'admin' } })
renderModal()
expect(screen.queryByText('collab.notes.attachFiles')).not.toBeInTheDocument()
const file = new File(['x'], 'blocked.png', { type: 'image/png' })
fireEvent.paste(document.querySelector('form')!, {
clipboardData: { items: [{ type: 'image/png', getAsFile: () => file }] },
})
expect(screen.queryByText('blocked.png')).not.toBeInTheDocument()
})
it('FE-W5CNF-014: choosing files through the picker lists them and long names are truncated', () => {
renderModal()
const input = document.querySelector('input[type="file"]') as HTMLInputElement
const longName = 'an-extremely-long-attachment-name.pdf'
fireEvent.change(input, {
target: {
files: [
new File(['a'], 'short.png', { type: 'image/png' }),
new File(['b'], longName, { type: 'application/pdf' }),
],
},
})
expect(screen.getByText('short.png')).toBeInTheDocument()
expect(screen.getByText(`${longName.slice(0, 17)}...`)).toBeInTheDocument()
})
it('FE-W5CNF-015: an empty file selection changes nothing', () => {
renderModal()
const input = document.querySelector('input[type="file"]') as HTMLInputElement
fireEvent.change(input, { target: { files: [] } })
expect(screen.queryByText(/\.png$/)).not.toBeInTheDocument()
})
it('FE-W5CNF-016: a pending file can be removed again', async () => {
const user = userEvent.setup()
renderModal()
const input = document.querySelector('input[type="file"]') as HTMLInputElement
fireEvent.change(input, { target: { files: [new File(['a'], 'remove-me.png', { type: 'image/png' })] } })
const chip = screen.getByText('remove-me.png').closest('div')!
await user.click(chip.querySelector('button')!)
expect(screen.queryByText('remove-me.png')).not.toBeInTheDocument()
})
it('FE-W5CNF-017: the add button opens the hidden file picker', async () => {
const user = userEvent.setup()
renderModal()
const input = document.querySelector('input[type="file"]') as HTMLInputElement
const clickSpy = vi.spyOn(input, 'click').mockImplementation(() => {})
await user.click(screen.getByRole('button', { name: 'files.attach' }))
expect(clickSpy).toHaveBeenCalled()
clickSpy.mockRestore()
})
it('FE-W5CNF-018: the add button falls back to an English label', () => {
renderModal({ t: (key: string) => (key === 'files.attach' ? '' : key) })
expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument()
})
it('FE-W5CNF-019: deleting an existing attachment calls back and drops the chip', async () => {
const user = userEvent.setup()
const onDeleteFile = vi.fn(async () => {})
renderModal({
note: buildNote({
attachments: [
buildAttachment({ id: 7, original_name: 'a-really-long-attachment-name.pdf', mime_type: 'application/pdf' }),
],
}),
onDeleteFile,
})
const chip = screen.getByText('a-really-long-att...').closest('div')!
await user.click(chip.querySelector('button')!)
await waitFor(() => expect(onDeleteFile).toHaveBeenCalledWith(42, 7))
expect(screen.queryByText('a-really-long-att...')).not.toBeInTheDocument()
})
it('FE-W5CNF-020: an attachment without a delete handler stays in the list', async () => {
const user = userEvent.setup()
renderModal({ note: buildNote({ attachments: [buildAttachment({ id: 9 })] }) })
const chip = screen.getByText('photo.png').closest('div')!
await user.click(chip.querySelector('button')!)
expect(screen.getByText('photo.png')).toBeInTheDocument()
})
it('FE-W5CNF-021: an attachment without an original name renders an empty label', () => {
renderModal({
note: buildNote({
attachments: [buildAttachment({ id: 11, original_name: undefined as unknown as string, mime_type: 'text/plain' })],
}),
})
const section = screen.getByText('collab.notes.attachFiles').parentElement!
expect(section.querySelectorAll('img').length).toBe(0)
})
})
@@ -143,3 +143,125 @@ describe('CollabPanel', () => {
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
})
})
// FE-W5CPN-001 to FE-W5CPN-013
// The desktop branch picks a different layout for every combination of enabled
// collab features, so each combination gets its own case.
const allOff = { chat: false, notes: false, polls: false, whatsnext: false }
describe('CollabPanel feature combinations', () => {
beforeEach(() => {
originalInnerWidth = window.innerWidth
resetAllStores()
seedStore(useAuthStore, { user: buildUser() })
})
afterEach(() => {
Object.defineProperty(window, 'innerWidth', { value: originalInnerWidth, writable: true, configurable: true })
})
it('FE-W5CPN-001: renders nothing when every collab feature is disabled', () => {
setViewport(1280)
const { container } = render(<CollabPanel tripId={1} collabFeatures={allOff} />)
expect(container).toBeEmptyDOMElement()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('FE-W5CPN-002: chat alone fills the whole desktop panel', () => {
setViewport(1280)
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, chat: true }} />)
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
expect(screen.queryByTestId('collab-polls')).not.toBeInTheDocument()
expect(screen.queryByTestId('whats-next')).not.toBeInTheDocument()
})
it('FE-W5CPN-003: chat plus notes puts the notes card next to the chat column', () => {
setViewport(1280)
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, chat: true, notes: true }} />)
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
expect(screen.queryByTestId('collab-polls')).not.toBeInTheDocument()
})
it('FE-W5CPN-004: chat plus polls renders the polls card as the only right panel', () => {
setViewport(1280)
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, chat: true, polls: true }} />)
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
expect(screen.queryByTestId('whats-next')).not.toBeInTheDocument()
})
it("FE-W5CPN-005: chat plus what's next renders the widget as the only right panel", () => {
setViewport(1280)
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, chat: true, whatsnext: true }} />)
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
expect(screen.queryByTestId('collab-polls')).not.toBeInTheDocument()
})
it('FE-W5CPN-006: chat plus notes and polls stacks both right panels', () => {
setViewport(1280)
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, chat: true, notes: true, polls: true }} />)
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
expect(screen.queryByTestId('whats-next')).not.toBeInTheDocument()
})
it("FE-W5CPN-007: chat plus polls and what's next stacks those two", () => {
setViewport(1280)
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, chat: true, polls: true, whatsnext: true }} />)
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
})
it('FE-W5CPN-008: notes alone fills the panel when chat is off', () => {
setViewport(1280)
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, notes: true }} />)
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument()
})
it('FE-W5CPN-009: polls alone fills the panel when chat is off', () => {
setViewport(1280)
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, polls: true }} />)
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument()
})
it("FE-W5CPN-010: what's next alone fills the panel when chat is off", () => {
setViewport(1280)
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, whatsnext: true }} />)
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument()
})
it('FE-W5CPN-011: notes and polls share the width side by side when chat is off', () => {
setViewport(1280)
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, notes: true, polls: true }} />)
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument()
})
it("FE-W5CPN-012: polls and what's next share the width when chat is off", () => {
setViewport(1280)
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, polls: true, whatsnext: true }} />)
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
})
it('FE-W5CPN-013: mobile falls back to the first tab when the active one gets disabled', () => {
setViewport(375)
const { rerender } = render(<CollabPanel tripId={1} />)
fireEvent.click(screen.getByRole('button', { name: /polls/i }))
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
rerender(<CollabPanel tripId={1} collabFeatures={{ ...allOff, notes: true, whatsnext: true }} />)
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
expect(screen.queryByTestId('collab-polls')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /polls/i })).not.toBeInTheDocument()
})
})
+5 -5
View File
@@ -39,10 +39,10 @@ interface CollabPanelProps {
}
const ALL_TABS = [
{ id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', fallback: 'Chat', icon: MessageCircle },
{ id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', fallback: 'Notes', icon: StickyNote },
{ id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', fallback: 'Polls', icon: BarChart3 },
{ id: 'next', featureKey: 'whatsnext' as const, labelKey: 'collab.whatsNext.title', fallback: "What's Next", icon: Sparkles },
{ id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', icon: MessageCircle },
{ id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', icon: StickyNote },
{ id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', icon: BarChart3 },
{ id: 'next', featureKey: 'whatsnext' as const, labelKey: 'collab.whatsNext.title', icon: Sparkles },
]
export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }: CollabPanelProps) {
@@ -55,7 +55,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
const tabs = useMemo(() =>
ALL_TABS.filter(tab => features[tab.featureKey]).map(tab => ({
...tab,
label: t(tab.labelKey) || tab.fallback,
label: t(tab.labelKey),
})),
[features, t])
@@ -10,7 +10,7 @@ vi.mock('../../api/websocket', () => ({
removeListener: vi.fn(),
}));
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { render, screen, waitFor, fireEvent, act } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
@@ -30,7 +30,7 @@ const buildPoll = (overrides: Record<string, unknown> = {}) => ({
{ id: 1, text: 'Paris', label: 'Paris', voters: [] },
{ id: 2, text: 'Rome', label: 'Rome', voters: [] },
],
multi_choice: false,
multiple_choice: false,
is_closed: false,
deadline: null,
created_by: 1,
@@ -273,3 +273,432 @@ describe('CollabPolls', () => {
expect(optionInputs).toHaveLength(3);
});
});
// FE-W5CPL-001 to FE-W5CPL-028
// Covers the deadline maths, the voter chips, the error paths of every mutation
// and the WebSocket handler branches that the smoke tests above skip.
type AddToast = NonNullable<typeof window.__addToast>;
const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
/** A deadline `ms` in the future, with a small buffer so the clock can tick. */
const inFuture = (ms: number) => new Date(Date.now() + ms + 10_000).toISOString();
function servePolls(polls: unknown) {
server.use(http.get('/api/trips/1/collab/polls', () => HttpResponse.json(polls)));
}
/** Grabs the WS handler CollabPolls registered on mount. */
function wsHandler(): (msg: Record<string, unknown>) => void {
return (addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
}
describe('CollabPolls details', () => {
let addToast: ReturnType<typeof vi.fn<AddToast>>;
beforeEach(() => {
addToast = vi.fn<AddToast>(() => 0);
window.__addToast = addToast;
});
afterEach(() => {
delete window.__addToast;
});
it('FE-W5CPL-001: a deadline more than a day away is shown in days and hours', async () => {
servePolls({ polls: [buildPoll({ deadline: inFuture(2 * DAY + 3 * HOUR) })] });
render(<CollabPolls {...defaultProps} />);
expect(await screen.findByText('2d 3h')).toBeInTheDocument();
});
it('FE-W5CPL-002: a deadline within the day is shown in hours and minutes', async () => {
servePolls({ polls: [buildPoll({ deadline: inFuture(5 * HOUR + 30 * MINUTE) })] });
render(<CollabPolls {...defaultProps} />);
expect(await screen.findByText('5h 30m')).toBeInTheDocument();
});
it('FE-W5CPL-003: a deadline within the hour is shown in minutes', async () => {
servePolls({ polls: [buildPoll({ deadline: inFuture(45 * MINUTE) })] });
render(<CollabPolls {...defaultProps} />);
expect(await screen.findByText('45m')).toBeInTheDocument();
});
it('FE-W5CPL-004: a passed deadline closes the poll and drops the countdown', async () => {
servePolls({ polls: [buildPoll({ deadline: '2020-01-01T00:00:00.000Z' })] });
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Best destination?');
expect(screen.getByText(/closed/i)).toBeInTheDocument();
expect(screen.getByText('Paris').closest('button')).toBeDisabled();
});
it('FE-W5CPL-005: a poll served as a bare array is rendered', async () => {
servePolls([buildPoll({ question: 'Array shaped?' })]);
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Array shaped?');
});
it('FE-W5CPL-006: options without a voters array count as zero votes', async () => {
servePolls({ polls: [{ ...buildPoll(), options: [{ id: 1, text: 'Solo' }] }] });
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Solo');
expect(screen.getByText('0 votes')).toBeInTheDocument();
});
it('FE-W5CPL-007: a poll without an options array still renders its question', async () => {
servePolls({ polls: [{ ...buildPoll(), options: undefined }] });
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Best destination?');
expect(screen.queryByText('Paris')).not.toBeInTheDocument();
});
it('FE-W5CPL-008: plain string options are rendered as their own label', async () => {
servePolls({ polls: [{ ...buildPoll(), options: ['Yes', 'No'] }] });
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Yes');
expect(screen.getByText('No')).toBeInTheDocument();
});
it('FE-W5CPL-009: a multiple-choice poll shows the multi badge and a single vote label', async () => {
servePolls({
polls: [buildPoll({
multiple_choice: true,
options: [
{ id: 1, text: 'Paris', voters: [{ user_id: 9, username: 'bob', avatar_url: null }] },
{ id: 2, text: 'Rome', voters: [] },
],
})],
});
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Best destination?');
expect(screen.getByText('Multiple choice')).toBeInTheDocument();
expect(screen.getByText('1 vote')).toBeInTheDocument();
});
it('FE-W5CPL-010: once the user voted the results, avatars and tooltip appear', async () => {
servePolls({
polls: [buildPoll({
options: [
{
id: 1, text: 'Paris',
voters: [
{ user_id: 1, username: 'testuser', avatar_url: null },
{ user_id: 2, username: 'alice', avatar_url: '/uploads/avatars/alice.png' },
],
},
{ id: 2, text: 'Rome', voters: [] },
],
})],
});
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Paris');
expect(screen.getByText('100%')).toBeInTheDocument();
expect(screen.getByText('0%')).toBeInTheDocument();
expect(document.querySelector('img[src="/uploads/avatars/alice.png"]')).toBeInTheDocument();
const chip = screen.getByText('T');
fireEvent.mouseEnter(chip);
expect(await screen.findByText('testuser')).toBeInTheDocument();
fireEvent.mouseLeave(chip);
await waitFor(() => expect(screen.queryByText('testuser')).not.toBeInTheDocument());
});
it('FE-W5CPL-011: a voter without a username falls back to a question mark', async () => {
servePolls({
polls: [buildPoll({
is_closed: true,
options: [
{ id: 1, text: 'Paris', voters: [{ user_id: null, username: '', avatar_url: null }] },
{ id: 2, text: 'Rome', voters: [] },
],
})],
});
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Paris');
expect(screen.getByText('?')).toBeInTheDocument();
});
it('FE-W5CPL-012: hovering an open option scales it, a closed one stays put', async () => {
servePolls({ polls: [buildPoll({ id: 1 }), buildPoll({ id: 2, question: 'Done?', is_closed: true })] });
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Done?');
const [openOption, closedOption] = screen.getAllByText('Paris').map(el => el.closest('button')!);
fireEvent.mouseEnter(openOption);
expect(openOption.style.transform).toBe('scale(1.01)');
fireEvent.mouseLeave(openOption);
expect(openOption.style.transform).toBe('scale(1)');
fireEvent.mouseEnter(closedOption);
expect(closedOption.style.transform).toBe('');
});
it('FE-W5CPL-013: the closed section heading only appears next to active polls', async () => {
servePolls({
polls: [
buildPoll({ id: 1, question: 'Still open?' }),
buildPoll({ id: 2, question: 'Already done?', is_closed: true }),
],
});
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Still open?');
expect(screen.getByText('Already done?')).toBeInTheDocument();
// One "Closed" badge on the poll itself plus the section heading above it
expect(screen.getAllByText('Closed')).toHaveLength(2);
});
it('FE-W5CPL-014: closing a poll marks it closed and leaves the other one open', async () => {
let closeCalled = false;
servePolls({ polls: [buildPoll({ id: 5 }), buildPoll({ id: 6, question: 'Stays open?' })] });
server.use(
http.put('/api/trips/1/collab/polls/5/close', () => {
closeCalled = true;
return HttpResponse.json({ success: true });
}),
);
const user = userEvent.setup();
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Stays open?');
// Both action buttons highlight on hover
const closeBtn = screen.getAllByTitle('Close')[0];
fireEvent.mouseEnter(closeBtn);
expect(closeBtn.style.color).toBe('var(--text-primary)');
fireEvent.mouseLeave(closeBtn);
expect(closeBtn.style.color).toBe('var(--text-faint)');
const deleteBtn = screen.getAllByTitle('Delete')[0];
fireEvent.mouseEnter(deleteBtn);
expect(deleteBtn.style.color).toBe('rgb(239, 68, 68)');
fireEvent.mouseLeave(deleteBtn);
expect(deleteBtn.style.color).toBe('var(--text-faint)');
await user.click(closeBtn);
await waitFor(() => expect(closeCalled).toBe(true));
await waitFor(() => expect(screen.getAllByText('Closed')).toHaveLength(2));
expect(screen.getAllByTitle('Close')).toHaveLength(1);
});
it('FE-W5CPL-027: a failing poll request falls back to the empty state', async () => {
server.use(
http.get('/api/trips/1/collab/polls', () => new HttpResponse(null, { status: 500 })),
);
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
});
it('FE-W5CPL-028: a payload without a polls key yields an empty list', async () => {
servePolls({});
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
});
it('FE-W5CPL-015: a failing close shows an error and leaves the poll open', async () => {
servePolls({ polls: [buildPoll({ id: 5 })] });
server.use(
http.put('/api/trips/1/collab/polls/5/close', () => new HttpResponse(null, { status: 500 })),
);
const user = userEvent.setup();
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Best destination?');
await user.click(screen.getByTitle(/close/i));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
expect(screen.getByTitle(/close/i)).toBeInTheDocument();
});
it('FE-W5CPL-016: a failing delete shows an error and keeps the poll', async () => {
servePolls({ polls: [buildPoll({ id: 6 })] });
server.use(
http.delete('/api/trips/1/collab/polls/6', () => new HttpResponse(null, { status: 500 })),
);
const user = userEvent.setup();
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Best destination?');
await user.click(screen.getByTitle(/delete/i));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
expect(screen.getByText('Best destination?')).toBeInTheDocument();
});
it('FE-W5CPL-017: a failing vote shows an error and leaves the tally alone', async () => {
servePolls({ polls: [buildPoll({ id: 7 })] });
server.use(
http.post('/api/trips/1/collab/polls/7/vote', () => new HttpResponse(null, { status: 500 })),
);
const user = userEvent.setup();
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Paris');
await user.click(screen.getByText('Paris'));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
expect(screen.queryByText('100%')).not.toBeInTheDocument();
});
it('FE-W5CPL-018: an unwrapped vote response only replaces the poll that was voted on', async () => {
servePolls({ polls: [buildPoll({ id: 7 }), buildPoll({ id: 8, question: 'Untouched?' })] });
server.use(
http.post('/api/trips/1/collab/polls/7/vote', () =>
HttpResponse.json(buildPoll({
id: 7,
question: 'Voted!',
options: [
{ id: 1, text: 'Paris', voters: [{ user_id: 1, username: 'testuser', avatar_url: null }] },
{ id: 2, text: 'Rome', voters: [] },
],
})),
),
);
const user = userEvent.setup();
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Untouched?');
await user.click(screen.getAllByText('Paris')[0]);
await screen.findByText('Voted!');
expect(screen.getByText('Untouched?')).toBeInTheDocument();
});
it('FE-W5CPL-019: a failing create shows an error and keeps the modal open', async () => {
server.use(
http.post('/api/trips/1/collab/polls', () => new HttpResponse(null, { status: 500 })),
);
const user = userEvent.setup();
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
await user.click(screen.getByRole('button', { name: /new/i }));
await user.type(screen.getByPlaceholderText(/what should we do/i), 'Fails?');
const optionInputs = screen.getAllByPlaceholderText(/option/i);
await user.type(optionInputs[0], 'A');
await user.type(optionInputs[1], 'B');
await user.click(screen.getByRole('button', { name: /create|collab\.polls\.create/i }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
expect(screen.getByPlaceholderText(/what should we do/i)).toBeInTheDocument();
});
it('FE-W5CPL-020: creating a poll that is already in the list does not duplicate it', async () => {
servePolls({ polls: [buildPoll({ id: 12, question: 'Same poll' })] });
server.use(
http.post('/api/trips/1/collab/polls', () =>
HttpResponse.json(buildPoll({ id: 12, question: 'Same poll' })),
),
);
const user = userEvent.setup();
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Same poll');
await user.click(screen.getByRole('button', { name: /new/i }));
await user.type(screen.getByPlaceholderText(/what should we do/i), 'Same poll');
const optionInputs = screen.getAllByPlaceholderText(/option/i);
await user.type(optionInputs[0], 'A');
await user.type(optionInputs[1], 'B');
await user.click(screen.getByRole('button', { name: /create|collab\.polls\.create/i }));
await waitFor(() =>
expect(screen.queryByPlaceholderText(/what should we do/i)).not.toBeInTheDocument(),
);
expect(screen.getAllByText('Same poll')).toHaveLength(1);
});
it('FE-W5CPL-021: submitting the create form without enough options is a no-op', async () => {
let postCalled = false;
server.use(
http.post('/api/trips/1/collab/polls', () => {
postCalled = true;
return HttpResponse.json({ poll: buildPoll() });
}),
);
const user = userEvent.setup();
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
await user.click(screen.getByRole('button', { name: /new/i }));
await user.type(screen.getByPlaceholderText(/what should we do/i), 'Not enough');
fireEvent.submit(screen.getByPlaceholderText(/what should we do/i).closest('form')!);
await waitFor(() => expect(screen.getByPlaceholderText(/what should we do/i)).toBeInTheDocument());
expect(postCalled).toBe(false);
});
it('FE-W5CPL-022: an extra option can be removed again', async () => {
const user = userEvent.setup();
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
await user.click(screen.getByRole('button', { name: /new/i }));
await user.click(screen.getByText(/add option/i));
expect(screen.getAllByPlaceholderText(/option/i)).toHaveLength(3);
const thirdRow = screen.getAllByPlaceholderText(/option/i)[2].parentElement!;
await user.click(thirdRow.querySelector('button')!);
expect(screen.getAllByPlaceholderText(/option/i)).toHaveLength(2);
});
it('FE-W5CPL-023: the multi-choice toggle flips and is sent along on create', async () => {
let body: Record<string, unknown> | null = null;
server.use(
http.post('/api/trips/1/collab/polls', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ poll: buildPoll({ id: 30, question: 'Multi?' }) });
}),
);
const user = userEvent.setup();
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
await user.click(screen.getByRole('button', { name: /new/i }));
const toggle = screen.getByText(/multiple|multi/i).previousElementSibling as HTMLElement;
expect(toggle.style.background).toBe('var(--border-primary)');
await user.click(toggle);
expect(toggle.style.background).toBe('rgb(0, 122, 255)');
await user.type(screen.getByPlaceholderText(/what should we do/i), 'Multi?');
const optionInputs = screen.getAllByPlaceholderText(/option/i);
await user.type(optionInputs[0], 'A');
await user.type(optionInputs[1], 'B');
await user.click(screen.getByRole('button', { name: /create|collab\.polls\.create/i }));
await screen.findByText('Multi?');
expect(body).toMatchObject({ multiple_choice: true, options: ['A', 'B'] });
});
it('FE-W5CPL-024: WebSocket events without a type or a known id are ignored', async () => {
servePolls({ polls: [buildPoll({ id: 40 })] });
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Best destination?');
const handler = wsHandler();
await act(async () => {
handler({});
handler({ type: 'collab:poll:deleted' });
handler({ type: 'collab:poll:created', poll: { id: 40, question: 'Best destination?' } });
});
expect(screen.getAllByText('Best destination?')).toHaveLength(1);
});
it('FE-W5CPL-025: WebSocket vote and close events update only the matching poll', async () => {
servePolls({ polls: [buildPoll({ id: 41 }), buildPoll({ id: 42, question: 'Other poll' })] });
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Other poll');
const handler = wsHandler();
await act(async () => {
handler({ type: 'collab:poll:voted', poll: buildPoll({ id: 41, question: 'Voted live' }) });
});
expect(await screen.findByText('Voted live')).toBeInTheDocument();
expect(screen.getByText('Other poll')).toBeInTheDocument();
await act(async () => {
handler({ type: 'collab:poll:closed', poll: { id: 41 } });
});
await waitFor(() => expect(screen.getAllByText('Closed')).toHaveLength(2));
await act(async () => {
handler({ type: 'collab:poll:deleted', poll: { id: 42 } });
});
await waitFor(() => expect(screen.queryByText('Other poll')).not.toBeInTheDocument());
});
it('FE-W5CPL-026: a poll with a live deadline starts the countdown ticker', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
servePolls({ polls: [buildPoll({ deadline: inFuture(90 * MINUTE) })] });
const { unmount } = render(<CollabPolls {...defaultProps} />);
await screen.findByText('1h 30m');
await act(async () => { await vi.advanceTimersByTimeAsync(31_000); });
expect(screen.getByText('1h 29m')).toBeInTheDocument();
unmount();
expect(vi.getTimerCount()).toBe(0);
} finally {
vi.useRealTimers();
}
});
});
+17 -14
View File
@@ -6,6 +6,7 @@ import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import EmptyState from '../shared/EmptyState'
import ReactDOM from 'react-dom'
import type { User } from '../../types'
@@ -25,7 +26,7 @@ interface Poll {
id: number
question: string
options: PollOption[]
multi_choice: boolean
multiple_choice: boolean
is_closed: boolean
deadline: string | null
created_by: number
@@ -58,7 +59,7 @@ function totalVotes(poll) {
// ── Create Poll Modal ────────────────────────────────────────────────────────
interface CreatePollModalProps {
onClose: () => void
onCreate: (data: { question: string; options: string[]; multi_choice: boolean }) => Promise<void>
onCreate: (data: { question: string; options: string[]; multiple_choice: boolean }) => Promise<void>
t: (key: string) => string
}
@@ -79,7 +80,9 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
if (!canSubmit) return
setSubmitting(true)
try {
await onCreate({ question: question.trim(), options: options.filter(o => o.trim()), multi_choice: multiChoice })
// `multiple_choice` is the field the server reads (nest/collab/collab.service.ts);
// the old `multi_choice` name was silently dropped, losing desktop multi-choice.
await onCreate({ question: question.trim(), options: options.filter(o => o.trim()), multiple_choice: multiChoice })
onClose()
} catch {} finally { setSubmitting(false) }
}
@@ -95,7 +98,7 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
{/* Question */}
<div>
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{t('collab.polls.question')}</div>
<input autoFocus value={question} onChange={e => setQuestion(e.target.value)} placeholder={t('collab.polls.questionPlaceholder') || 'Ask a question...'} style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
<input autoFocus value={question} onChange={e => setQuestion(e.target.value)} placeholder={t('collab.polls.questionPlaceholder')} style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
</div>
{/* Options */}
@@ -205,6 +208,8 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
const isClosed = poll.is_closed || isExpired(poll.deadline)
const remaining = timeRemaining(poll.deadline)
const hasVoted = (poll.options || []).some(o => (o.voters || []).some(v => String(v.user_id) === String(currentUser.id)))
// Highest vote count across the options; 0 for a poll without options.
const topCount = (poll.options || []).reduce((max, o) => Math.max(max, o.voters?.length || 0), 0)
return (
<div style={{
@@ -231,7 +236,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
<Clock size={8} /> {remaining}
</span>
)}
{poll.multi_choice && (
{poll.multiple_choice && (
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 99 }}>
{t('collab.polls.multiChoice')}
</span>
@@ -268,10 +273,12 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
const count = opt.voters?.length || 0
const pct = total > 0 ? Math.round((count / total) * 100) : 0
const myVote = (opt.voters || []).some(v => String(v.user_id) === String(currentUser.id))
const isWinner = isClosed && count === Math.max(...(poll.options || []).map(o => o.voters?.length || 0)) && count > 0
const isWinner = isClosed && count > 0 && count === topCount
return (
<button key={idx} onClick={() => !isClosed && onVote(poll.id, idx)}
// React dispatches no mouse events on a disabled control, so the
// handlers below need no isClosed guard of their own.
<button key={idx} onClick={() => onVote(poll.id, idx)}
disabled={isClosed}
style={{
position: 'relative', display: 'flex', alignItems: 'center', gap: 8,
@@ -279,7 +286,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
background: 'var(--bg-secondary)', fontFamily: FONT, textAlign: 'left', width: '100%',
overflow: 'hidden', transition: 'transform 0.1s',
}}
onMouseEnter={e => { if (!isClosed) e.currentTarget.style.transform = 'scale(1.01)' }}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.01)' }}
onMouseLeave={e => e.currentTarget.style.transform = 'scale(1)'}
>
{/* Progress bar background */}
@@ -461,11 +468,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
{/* Content */}
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: '0 12px 12px' }}>
{polls.length === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '48px 20px', textAlign: 'center', height: '100%' }}>
<BarChart3 size={36} color="var(--text-faint)" strokeWidth={1.3} style={{ marginBottom: 12 }} />
<div style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>{t('collab.polls.empty')}</div>
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)' }}>{t('collab.polls.emptyHint')}</div>
</div>
<EmptyState scene="polls" title={t('collab.polls.empty')} />
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{activePolls.length > 0 && activePolls.map(poll => (
@@ -475,7 +478,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
<>
{activePolls.length > 0 && (
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, padding: '8px 0 2px' }}>
{t('collab.polls.closedSection') || 'Closed'}
{t('collab.polls.closedSection')}
</div>
)}
{closedPolls.map(poll => (
@@ -3,7 +3,8 @@ import { avatarSrc } from '../../utils/avatarSrc'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { MapPin, Clock, Calendar, Users, Sparkles } from 'lucide-react'
import { MapPin, Clock, Users, Sparkles } from 'lucide-react'
import EmptyState from '../shared/EmptyState'
function formatTime(timeStr, is12h) {
if (!timeStr) return ''
@@ -100,11 +101,7 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
{/* List */}
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: '8px 10px' }}>
{upcoming.length === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', padding: '48px 20px', textAlign: 'center' }}>
<Calendar size={36} color="var(--text-faint)" strokeWidth={1.3} style={{ marginBottom: 12 }} />
<div style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>{t('collab.whatsNext.empty')}</div>
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)' }}>{t('collab.whatsNext.emptyHint')}</div>
</div>
<EmptyState scene="guide" title={t('collab.whatsNext.empty')} />
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{upcoming.map((item, idx) => {
@@ -0,0 +1,363 @@
// FE-COMP-ADDPLACECOL-001 to FE-COMP-ADDPLACECOL-024
import React from 'react'
import type { Mock } from 'vitest'
import { http, HttpResponse } from 'msw'
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
import { server } from '../../../tests/helpers/msw/server'
import type { Category } from '@trek/shared'
import { useTranslation } from '../../i18n/TranslationContext'
import AddPlaceToCollectionModal from './AddPlaceToCollectionModal'
type Props = React.ComponentProps<typeof AddPlaceToCollectionModal>
function Harness(props: Omit<Props, 't'>): React.ReactElement {
const { t } = useTranslation()
return <AddPlaceToCollectionModal {...props} t={t} />
}
const categories: Category[] = [
{ id: 3, name: 'Food', color: '#f97316', icon: 'utensils' },
{ id: 4, name: 'Museum', color: '', icon: 'landmark' },
]
const mapsResult = {
name: 'Kissa Sakaiki',
address: 'Shibuya, Tokyo',
lat: 35.66,
lng: 139.7,
google_place_id: 'gp-1',
google_ftid: 'ft-1',
osm_id: 'osm-1',
website: 'https://kissa.example',
phone: '+81 3 0000 0000',
}
type AddToast = NonNullable<typeof window.__addToast>
let addToast: Mock<AddToast>
/** Bodies POSTed to the save endpoint, newest last. */
let savedBodies: Record<string, unknown>[]
function mockSearch(places: Record<string, unknown>[]): void {
server.use(http.post('/api/maps/search', () => HttpResponse.json({ places, source: 'nominatim' })))
}
function mockSave(response: Record<string, unknown> = { place: { id: 99 } }): void {
server.use(
http.post('/api/addons/collections/places', async ({ request }) => {
savedBodies.push((await request.json()) as Record<string, unknown>)
return HttpResponse.json(response)
}),
)
}
function setup(over: Partial<Omit<Props, 't'>> = {}) {
const props: Omit<Props, 't'> = {
isOpen: true,
collectionId: 7,
collectionName: 'Tokyo 2026',
categories,
onClose: vi.fn(),
onAdded: vi.fn(),
...over,
}
const view = render(<Harness {...props} />)
return { ...view, props }
}
/** Fills the one field the save button waits on. */
function typeName(value: string): void {
fireEvent.change(screen.getByPlaceholderText('Name'), { target: { value } })
}
describe('AddPlaceToCollectionModal', () => {
beforeEach(() => {
addToast = vi.fn<AddToast>(() => 0)
window.__addToast = addToast
savedBodies = []
mockSave()
})
afterEach(() => {
delete window.__addToast
})
it('FE-COMP-ADDPLACECOL-001: a closed modal renders nothing', () => {
setup({ isOpen: false })
expect(screen.queryByRole('heading', { name: 'Add a place' })).not.toBeInTheDocument()
})
it('FE-COMP-ADDPLACECOL-002: renders the search, form fields and status chips', () => {
setup()
expect(screen.getByRole('heading', { name: 'Add a place' })).toBeInTheDocument()
expect(screen.getByPlaceholderText('Search for a place…')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Street, City, Country')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Latitude (e.g. 48.8566)')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Idea/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Want to go/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Visited/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Add$/ })).toBeDisabled()
})
it('FE-COMP-ADDPLACECOL-003: the search button is inert until something is typed', () => {
setup()
expect(screen.getByRole('button', { name: /Search/ })).toBeDisabled()
fireEvent.change(screen.getByPlaceholderText('Search for a place…'), { target: { value: 'kissa' } })
expect(screen.getByRole('button', { name: /Search/ })).not.toBeDisabled()
})
it('FE-COMP-ADDPLACECOL-004: Enter runs the search and lists the results', async () => {
mockSearch([mapsResult, { name: 'No address place' }])
setup()
const input = screen.getByPlaceholderText('Search for a place…')
fireEvent.change(input, { target: { value: 'kissa' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(await screen.findByText('Kissa Sakaiki')).toBeInTheDocument()
expect(screen.getByText('Shibuya, Tokyo')).toBeInTheDocument()
expect(screen.getByText('No address place')).toBeInTheDocument()
})
it('FE-COMP-ADDPLACECOL-005: picking a result fills name, address and coordinates', async () => {
mockSearch([mapsResult])
setup()
fireEvent.change(screen.getByPlaceholderText('Search for a place…'), { target: { value: 'kissa' } })
fireEvent.click(screen.getByRole('button', { name: /Search/ }))
fireEvent.click(await screen.findByRole('button', { name: /Kissa Sakaiki/ }))
expect(screen.getByPlaceholderText('Name')).toHaveValue('Kissa Sakaiki')
expect(screen.getByPlaceholderText('Street, City, Country')).toHaveValue('Shibuya, Tokyo')
expect(screen.getByPlaceholderText('Latitude (e.g. 48.8566)')).toHaveValue('35.66')
expect(screen.getByPlaceholderText('Longitude (e.g. 2.3522)')).toHaveValue('139.7')
// The dropdown collapses once a result is taken.
expect(screen.queryByText('Shibuya, Tokyo')).not.toBeInTheDocument()
})
it('FE-COMP-ADDPLACECOL-006: a result without name or coordinates leaves those fields blank', async () => {
mockSearch([{ address: 'Somewhere' }])
setup()
fireEvent.change(screen.getByPlaceholderText('Search for a place…'), { target: { value: 'x' } })
fireEvent.click(screen.getByRole('button', { name: /Search/ }))
fireEvent.click(await screen.findByText('Somewhere'))
expect(screen.getByPlaceholderText('Name')).toHaveValue('')
expect(screen.getByPlaceholderText('Latitude (e.g. 48.8566)')).toHaveValue('')
// The query keeps what the user typed when the result carries no name.
expect(screen.getByPlaceholderText('Search for a place…')).toHaveValue('x')
})
it('FE-COMP-ADDPLACECOL-007: the result dropdown can be dismissed', async () => {
mockSearch([mapsResult])
setup()
fireEvent.change(screen.getByPlaceholderText('Search for a place…'), { target: { value: 'kissa' } })
fireEvent.click(screen.getByRole('button', { name: /Search/ }))
await screen.findByText('Kissa Sakaiki')
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(screen.queryByText('Kissa Sakaiki')).not.toBeInTheDocument()
})
it('FE-COMP-ADDPLACECOL-008: a failing search toasts the provider error', async () => {
server.use(http.post('/api/maps/search', () => HttpResponse.json({ error: 'Places API disabled' }, { status: 500 })))
setup()
fireEvent.change(screen.getByPlaceholderText('Search for a place…'), { target: { value: 'kissa' } })
fireEvent.click(screen.getByRole('button', { name: /Search/ }))
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Places API disabled', 'error', undefined))
})
it('FE-COMP-ADDPLACECOL-009: a search response without places yields no dropdown', async () => {
server.use(http.post('/api/maps/search', () => HttpResponse.json({ source: 'nominatim' })))
setup()
fireEvent.change(screen.getByPlaceholderText('Search for a place…'), { target: { value: 'kissa' } })
fireEvent.click(screen.getByRole('button', { name: /Search/ }))
await waitFor(() => expect(screen.getByRole('button', { name: /Search/ })).not.toBeDisabled())
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument()
})
it('FE-COMP-ADDPLACECOL-010: saving posts the manually typed place with nulls for what is empty', async () => {
const { props } = setup()
typeName(' Manual spot ')
fireEvent.click(screen.getByRole('button', { name: /Add$/ }))
await waitFor(() => expect(savedBodies).toHaveLength(1))
expect(savedBodies[0]).toMatchObject({
collection_id: 7,
name: 'Manual spot',
address: null,
lat: null,
lng: null,
google_place_id: null,
osm_id: null,
website: null,
category_id: null,
description: null,
links: [],
status: 'idea',
force: true,
})
expect(addToast).toHaveBeenCalledWith('Added to Tokyo 2026', 'success', undefined)
expect(props.onAdded).toHaveBeenCalledTimes(1)
// The form clears so another place can be added straight away.
await waitFor(() => expect(screen.getByPlaceholderText('Name')).toHaveValue(''))
})
it('FE-COMP-ADDPLACECOL-011: provenance from a picked result rides along with the save', async () => {
mockSearch([mapsResult])
setup()
fireEvent.change(screen.getByPlaceholderText('Search for a place…'), { target: { value: 'kissa' } })
fireEvent.click(screen.getByRole('button', { name: /Search/ }))
fireEvent.click(await screen.findByRole('button', { name: /Kissa Sakaiki/ }))
fireEvent.click(screen.getByRole('button', { name: /Add$/ }))
await waitFor(() => expect(savedBodies).toHaveLength(1))
expect(savedBodies[0]).toMatchObject({
name: 'Kissa Sakaiki',
address: 'Shibuya, Tokyo',
lat: 35.66,
lng: 139.7,
google_place_id: 'gp-1',
google_ftid: 'ft-1',
osm_id: 'osm-1',
website: 'https://kissa.example',
phone: '+81 3 0000 0000',
})
})
it('FE-COMP-ADDPLACECOL-012: a duplicate response warns instead of reporting a new place', async () => {
mockSave({ duplicate: true })
const { props } = setup()
typeName('Manual spot')
fireEvent.click(screen.getByRole('button', { name: /Add$/ }))
await waitFor(() => expect(addToast).toHaveBeenCalledWith('This place is already in the list', 'info', undefined))
expect(props.onAdded).not.toHaveBeenCalled()
})
it('FE-COMP-ADDPLACECOL-013: a failing save surfaces the server error and keeps the form', async () => {
server.use(http.post('/api/addons/collections/places', () => HttpResponse.json({ error: 'List is full' }, { status: 400 })))
const { props } = setup()
typeName('Manual spot')
fireEvent.click(screen.getByRole('button', { name: /Add$/ }))
await waitFor(() => expect(addToast).toHaveBeenCalledWith('List is full', 'error', undefined))
expect(props.onAdded).not.toHaveBeenCalled()
expect(screen.getByPlaceholderText('Name')).toHaveValue('Manual spot')
})
it('FE-COMP-ADDPLACECOL-014: an address and coordinates typed by hand are kept, blanks stay null', async () => {
setup()
typeName('GPS only')
fireEvent.change(screen.getByPlaceholderText('Street, City, Country'), { target: { value: ' 5 Rue Cler, Paris ' } })
fireEvent.change(screen.getByPlaceholderText('Latitude (e.g. 48.8566)'), { target: { value: '48.8566' } })
fireEvent.click(screen.getByRole('button', { name: /Add$/ }))
await waitFor(() => expect(savedBodies).toHaveLength(1))
expect(savedBodies[0]).toMatchObject({ address: '5 Rue Cler, Paris', lat: 48.8566, lng: null })
})
it('FE-COMP-ADDPLACECOL-015: pasting a coordinate pair splits it across both fields', () => {
setup()
const lat = screen.getByPlaceholderText('Latitude (e.g. 48.8566)')
fireEvent.paste(lat, { clipboardData: { getData: () => ' 48.8566, 2.3522 ' } })
expect(lat).toHaveValue('48.8566')
expect(screen.getByPlaceholderText('Longitude (e.g. 2.3522)')).toHaveValue('2.3522')
})
it('FE-COMP-ADDPLACECOL-015b: the pair is split the same way when pasted into longitude', () => {
setup()
const lng = screen.getByPlaceholderText('Longitude (e.g. 2.3522)')
fireEvent.paste(lng, { clipboardData: { getData: () => '48.8566, 2.3522' } })
expect(screen.getByPlaceholderText('Latitude (e.g. 48.8566)')).toHaveValue('48.8566')
expect(lng).toHaveValue('2.3522')
})
it('FE-COMP-ADDPLACECOL-016: a paste that is not a coordinate pair is left to the input', () => {
setup()
const lat = screen.getByPlaceholderText('Latitude (e.g. 48.8566)')
fireEvent.paste(lat, { clipboardData: { getData: () => 'Eiffel Tower' } })
expect(lat).toHaveValue('')
expect(screen.getByPlaceholderText('Longitude (e.g. 2.3522)')).toHaveValue('')
})
it('FE-COMP-ADDPLACECOL-017: the chosen status is saved', async () => {
setup()
typeName('Manual spot')
fireEvent.click(screen.getByRole('button', { name: /Visited/ }))
fireEvent.click(screen.getByRole('button', { name: /Add$/ }))
await waitFor(() => expect(savedBodies).toHaveLength(1))
expect(savedBodies[0]).toMatchObject({ status: 'visited' })
})
it('FE-COMP-ADDPLACECOL-018: a category can be picked and cleared again', async () => {
setup()
typeName('Manual spot')
fireEvent.click(screen.getByRole('button', { name: /Food/ }))
fireEvent.click(screen.getByRole('button', { name: /Museum/ }))
fireEvent.click(screen.getByRole('button', { name: /Add$/ }))
await waitFor(() => expect(savedBodies).toHaveLength(1))
expect(savedBodies[0]).toMatchObject({ category_id: 4 })
fireEvent.click(screen.getByRole('button', { name: 'No category' }))
typeName('Second spot')
fireEvent.click(screen.getByRole('button', { name: /Add$/ }))
await waitFor(() => expect(savedBodies).toHaveLength(2))
expect(savedBodies[1]).toMatchObject({ category_id: null })
})
it('FE-COMP-ADDPLACECOL-019: without categories the category row is not rendered', () => {
setup({ categories: [] })
expect(screen.queryByText('Category')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'No category' })).not.toBeInTheDocument()
})
it('FE-COMP-ADDPLACECOL-020: links are normalised and blank rows dropped', async () => {
setup()
typeName('Manual spot')
fireEvent.click(screen.getByRole('button', { name: /Add link/ }))
fireEvent.click(screen.getByRole('button', { name: /Add link/ }))
const urls = screen.getAllByPlaceholderText('https://…')
fireEvent.change(screen.getAllByPlaceholderText('Label')[0], { target: { value: ' Menu ' } })
fireEvent.change(urls[0], { target: { value: 'kissa.example/menu' } })
fireEvent.click(screen.getByRole('button', { name: /Add$/ }))
await waitFor(() => expect(savedBodies).toHaveLength(1))
expect(savedBodies[0].links).toEqual([{ label: 'Menu', url: 'https://kissa.example/menu' }])
})
it('FE-COMP-ADDPLACECOL-021: a link row can be removed again', () => {
setup()
fireEvent.click(screen.getByRole('button', { name: /Add link/ }))
expect(screen.getByPlaceholderText('https://…')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
expect(screen.queryByPlaceholderText('https://…')).not.toBeInTheDocument()
})
it('FE-COMP-ADDPLACECOL-022: a description renders a live markdown preview and is trimmed on save', async () => {
setup()
typeName('Manual spot')
fireEvent.change(screen.getByPlaceholderText('Add a description…'), { target: { value: ' **Great** coffee ' } })
expect(screen.getByText('Great')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /Add$/ }))
await waitFor(() => expect(savedBodies).toHaveLength(1))
expect(savedBodies[0]).toMatchObject({ description: '**Great** coffee' })
})
it('FE-COMP-ADDPLACECOL-023: closing the modal resets the form for the next open', () => {
const { rerender, props } = setup()
typeName('Manual spot')
fireEvent.change(screen.getByPlaceholderText('Add a description…'), { target: { value: 'notes' } })
rerender(<Harness {...props} isOpen={false} />)
rerender(<Harness {...props} isOpen />)
expect(screen.getByPlaceholderText('Name')).toHaveValue('')
expect(screen.getByPlaceholderText('Add a description…')).toHaveValue('')
})
it('FE-COMP-ADDPLACECOL-024: Cancel closes without saving', () => {
const { props } = setup()
typeName('Manual spot')
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(props.onClose).toHaveBeenCalledTimes(1)
expect(savedBodies).toHaveLength(0)
})
})
@@ -4,6 +4,7 @@ import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { Search, MapPin, Plus, Loader2, Link2, Trash2, Check, X } from 'lucide-react'
import Modal from '../shared/Modal'
import { NumericInput } from '../shared/NumericInput'
import MarkdownToolbar from '../Journey/MarkdownToolbar'
import { mapsApi } from '../../api/client'
import { collectionsApi } from '../../api/collections'
@@ -44,6 +45,11 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
// The picked location (address/coords/ids) plus the editable fields.
const [picked, setPicked] = useState<MapsPlace | null>(null)
const [name, setName] = useState('')
// Address + coordinates: prefilled from a picked result, but also directly
// typeable so a place can be added by GPS without searching (#1435).
const [address, setAddress] = useState('')
const [lat, setLat] = useState('')
const [lng, setLng] = useState('')
const [categoryId, setCategoryId] = useState<number | null>(null)
const [description, setDescription] = useState('')
const [links, setLinks] = useState<CollectionLink[]>([])
@@ -51,7 +57,7 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
const [saving, setSaving] = useState(false)
const descRef = useRef<HTMLTextAreaElement>(null)
const reset = () => { setQuery(''); setResults([]); setPicked(null); setName(''); setCategoryId(null); setDescription(''); setLinks([]); setStatus('idea') }
const reset = () => { setQuery(''); setResults([]); setPicked(null); setName(''); setAddress(''); setLat(''); setLng(''); setCategoryId(null); setDescription(''); setLinks([]); setStatus('idea') }
useEffect(() => { if (!isOpen) reset() }, [isOpen])
const search = async () => {
@@ -67,21 +73,31 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
}
}
const pick = (r: MapsPlace) => { setPicked(r); setName(str(r.name) ?? ''); setResults([]); setQuery(str(r.name) ?? query) }
const pick = (r: MapsPlace) => {
setPicked(r)
setName(str(r.name) ?? '')
setAddress(str(r.address) ?? '')
const la = num(r.lat); const lo = num(r.lng)
setLat(la != null ? String(la) : '')
setLng(lo != null ? String(lo) : '')
setResults([]); setQuery(str(r.name) ?? query)
}
const setLink = (i: number, patch: Partial<CollectionLink>) => setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
const save = async () => {
const cleanName = name.trim()
if (!cleanName) return
const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url)
const latNum = lat.trim() ? Number(lat) : NaN
const lngNum = lng.trim() ? Number(lng) : NaN
setSaving(true)
try {
const res = await collectionsApi.savePlace({
collection_id: collectionId,
name: cleanName,
address: (picked && str(picked.address)) ?? null,
lat: (picked && num(picked.lat)) ?? null,
lng: (picked && num(picked.lng)) ?? null,
address: address.trim() || null,
lat: Number.isFinite(latNum) ? latNum : null,
lng: Number.isFinite(lngNum) ? lngNum : null,
google_place_id: (picked && str(picked.google_place_id)) ?? null,
google_ftid: (picked && str(picked.google_ftid)) ?? null,
osm_id: (picked && str(picked.osm_id)) ?? null,
@@ -103,7 +119,12 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
}
}
const address = picked ? str(picked.address) : undefined
const coordPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
const text = e.clipboardData.getData('text').trim()
const match = text.match(/^(-?\d+\.?\d*)\s*[,;\s]\s*(-?\d+\.?\d*)$/)
if (match) { e.preventDefault(); setLat(match[1]); setLng(match[2]) }
}
const coordInputClass = 'w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent'
return (
<Modal
@@ -163,7 +184,16 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
<div>
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('common.name')}</label>
<input value={name} onChange={e => setName(e.target.value)} placeholder={t('common.name')} className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent" />
{address && <div className="flex items-center gap-1.5 mt-1.5 text-[12px] text-content-faint"><MapPin size={12} /> {address}</div>}
</div>
{/* Address + coordinates — editable so a place can be added by GPS alone */}
<div>
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('places.formAddress')}</label>
<input value={address} onChange={e => setAddress(e.target.value)} placeholder={t('places.formAddressPlaceholder')} className={coordInputClass} />
<div className="grid grid-cols-2 gap-2 mt-2">
<NumericInput mode="signed" value={lat} onValueChange={setLat} onPaste={coordPaste} placeholder={t('places.formLat')} className={coordInputClass} />
<NumericInput mode="signed" value={lng} onValueChange={setLng} onPaste={coordPaste} placeholder={t('places.formLng')} className={coordInputClass} />
</div>
</div>
{/* Status */}
@@ -0,0 +1,127 @@
// FE-COMP-BULKLABEL-001 to FE-COMP-BULKLABEL-009
import React from 'react';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import type { CollectionLabel } from '@trek/shared';
import { useTranslation } from '../../i18n/TranslationContext';
import BulkAssignLabelModal from './BulkAssignLabelModal';
type ModalProps = Omit<React.ComponentProps<typeof BulkAssignLabelModal>, 't'>;
function Harness(props: ModalProps): React.ReactElement {
const { t } = useTranslation();
return <BulkAssignLabelModal {...props} t={t} />;
}
const berlin: CollectionLabel = { id: 1, collection_id: 10, name: 'Berlin', color: '#0ea5e9' };
const food: CollectionLabel = { id: 2, collection_id: 10, name: 'Food', color: null };
function renderModal(overrides: Partial<ModalProps> = {}) {
const props: ModalProps = {
isOpen: true,
labels: [berlin, food],
count: 4,
onAssign: vi.fn(async () => {}),
onManage: vi.fn(),
onClose: vi.fn(),
...overrides,
};
render(<Harness {...props} />);
return props;
}
describe('BulkAssignLabelModal', () => {
it('FE-COMP-BULKLABEL-001: titles the modal with the selection count and lists every label', () => {
renderModal({ count: 4 });
expect(screen.getByRole('heading', { name: 'Add labels to 4 places' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Berlin' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Food' })).toBeInTheDocument();
});
it('FE-COMP-BULKLABEL-002: with no labels it explains why and offers the manager instead', async () => {
const user = userEvent.setup();
const props = renderModal({ labels: [] });
expect(screen.getByText('Create a label first to group places in this list.')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Assign label' })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /Manage labels/ }));
expect(props.onManage).toHaveBeenCalledTimes(1);
});
it('FE-COMP-BULKLABEL-003: the assign button is disabled until at least one label is picked', async () => {
const user = userEvent.setup();
renderModal();
const assign = screen.getByRole('button', { name: /^Assign label$/ });
expect(assign).toBeDisabled();
await user.click(screen.getByRole('button', { name: 'Berlin' }));
expect(assign).toBeEnabled();
});
it('FE-COMP-BULKLABEL-004: picking a label marks the row selected, clicking again unpicks it', async () => {
const user = userEvent.setup();
renderModal();
const berlinRow = screen.getByRole('button', { name: 'Berlin' });
await user.click(berlinRow);
expect(berlinRow).toHaveClass('border-accent');
await user.click(berlinRow);
expect(berlinRow).not.toHaveClass('border-accent');
expect(screen.getByRole('button', { name: /^Assign label$/ })).toBeDisabled();
});
it('FE-COMP-BULKLABEL-005: assigning sends every picked id in click order and clears the selection', async () => {
const user = userEvent.setup();
const props = renderModal();
await user.click(screen.getByRole('button', { name: 'Food' }));
await user.click(screen.getByRole('button', { name: 'Berlin' }));
await user.click(screen.getByRole('button', { name: /^Assign label$/ }));
await waitFor(() => expect(props.onAssign).toHaveBeenCalledWith([2, 1]));
// Selection resets so the modal is ready for the next batch.
await waitFor(() => expect(screen.getByRole('button', { name: /^Assign label$/ })).toBeDisabled());
expect(screen.getByRole('button', { name: 'Berlin' })).not.toHaveClass('border-accent');
});
it('FE-COMP-BULKLABEL-006: a second click while the assign is pending is ignored', async () => {
const user = userEvent.setup();
const onAssign = vi.fn(() => new Promise<void>(() => {}));
renderModal({ onAssign });
await user.click(screen.getByRole('button', { name: 'Berlin' }));
const assign = screen.getByRole('button', { name: /^Assign label$/ });
await user.click(assign);
await user.click(assign);
expect(onAssign).toHaveBeenCalledTimes(1);
expect(assign.querySelector('.animate-spin')).not.toBeNull();
expect(assign).toBeDisabled();
});
it('FE-COMP-BULKLABEL-007: Cancel closes without assigning anything', async () => {
const user = userEvent.setup();
const props = renderModal();
await user.click(screen.getByRole('button', { name: 'Berlin' }));
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(props.onClose).toHaveBeenCalledTimes(1);
expect(props.onAssign).not.toHaveBeenCalled();
});
it('FE-COMP-BULKLABEL-008: the footer manage shortcut opens the label manager', async () => {
const user = userEvent.setup();
const props = renderModal();
await user.click(screen.getByRole('button', { name: /Manage labels/ }));
expect(props.onManage).toHaveBeenCalledTimes(1);
});
it('FE-COMP-BULKLABEL-009: renders nothing while closed', () => {
renderModal({ isOpen: false });
expect(screen.queryByRole('heading', { name: /Add labels to/ })).not.toBeInTheDocument();
});
});
@@ -27,8 +27,14 @@ function makeProps(overrides: Partial<HarnessProps> = {}): HarnessProps {
counts: { all: 3, idea: 1, want: 1, visited: 1 },
categoryFilter: 'all',
categoryOptions: CATEGORY_OPTIONS,
ratingFilter: 'all',
sortMode: 'default',
onStatusFilter: vi.fn(),
onCategoryFilter: vi.fn(),
onRatingFilter: vi.fn(),
onSortMode: vi.fn(),
canAddPlace: false,
onAddPlace: vi.fn(),
showLabels: false,
labelOptions: [],
labelFilter: [],
@@ -49,9 +55,9 @@ beforeEach(() => {
describe('CollectionFilterBar', () => {
it('FE-COMP-COLFILTERBAR-001: renders the status dropdown showing the current "All" filter', () => {
render(<Harness {...makeProps()} />);
// Both dropdown triggers currently read "All" (status=all, category=all).
// With a category present there are exactly two "All" triggers: status + category.
expect(screen.getAllByRole('button', { name: 'All' })).toHaveLength(2);
// Three dropdown triggers read "All" (status=all, category=all, rating=all):
// status + category + the #1435 rating filter.
expect(screen.getAllByRole('button', { name: 'All' })).toHaveLength(3);
});
it('FE-COMP-COLFILTERBAR-002: opening the status dropdown reveals the status options', async () => {
@@ -84,15 +90,15 @@ describe('CollectionFilterBar', () => {
it('FE-COMP-COLFILTERBAR-004: the category dropdown is present when categoryOptions is non-empty', () => {
render(<Harness {...makeProps()} />);
// Two dropdown triggers = status + category.
// Three dropdown triggers = status + category + rating.
const triggers = screen.getAllByRole('button', { name: 'All' });
expect(triggers).toHaveLength(2);
expect(triggers).toHaveLength(3);
});
it('FE-COMP-COLFILTERBAR-005: the category dropdown is hidden when categoryOptions is empty', () => {
render(<Harness {...makeProps({ categoryOptions: [] })} />);
// Only the status dropdown remains.
expect(screen.getAllByRole('button', { name: 'All' })).toHaveLength(1);
// The status + rating dropdowns remain (the rating filter always shows).
expect(screen.getAllByRole('button', { name: 'All' })).toHaveLength(2);
});
it('FE-COMP-COLFILTERBAR-006: clicking a category option calls onCategoryFilter with the category id', async () => {
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'
import { ChevronDown, Check, Layers, Tag, Tags, CheckSquare } from 'lucide-react'
import type { StatusFilter } from '../../store/collectionStore'
import { ChevronDown, Check, Layers, Tag, Tags, CheckSquare, Star, Plus, ArrowDownUp } from 'lucide-react'
import type { StatusFilter, CollectionSortMode } from '../../store/collectionStore'
import type { TranslationFn } from '../../types'
import { getCategoryIcon } from '../shared/categoryIcons'
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
@@ -67,8 +67,15 @@ interface CollectionFilterBarProps {
counts: Record<StatusFilter, number>
categoryFilter: number | 'all'
categoryOptions: CategoryOption[]
ratingFilter: number | 'all'
sortMode: CollectionSortMode
onStatusFilter: (f: StatusFilter) => void
onCategoryFilter: (f: number | 'all') => void
onRatingFilter: (f: number | 'all') => void
onSortMode: (m: CollectionSortMode) => void
// Add a place to the current list — leads the row when the list is editable.
canAddPlace: boolean
onAddPlace: () => void
// Per-collection labels (hidden on the "All saved" union).
showLabels: boolean
labelOptions: LabelOption[]
@@ -88,7 +95,9 @@ interface CollectionFilterBarProps {
* Custom compact dropdowns so they barely take any space.
*/
export default function CollectionFilterBar({
statusFilter, counts, categoryFilter, categoryOptions, onStatusFilter, onCategoryFilter,
statusFilter, counts, categoryFilter, categoryOptions, ratingFilter, sortMode,
onStatusFilter, onCategoryFilter, onRatingFilter, onSortMode,
canAddPlace, onAddPlace,
showLabels, labelOptions, labelFilter, onLabelFilter, canManageLabels, onManageLabels,
showSelect, selectMode, onToggleSelect, t,
}: CollectionFilterBarProps): React.ReactElement {
@@ -109,12 +118,35 @@ export default function CollectionFilterBar({
}),
]
// Minimum-average-rating filter (#1435): All, then ≥5…≥1 stars.
const ratingOpts: Opt[] = [
{ key: 'all', label: t('common.all') },
...[5, 4, 3, 2, 1].map(n => ({
key: n,
label: `${n}+`,
icon: <Star size={13} color="#facc15" fill="#facc15" />,
})),
]
// Display order: the saved order, or alphabetical by name.
const sortOpts: Opt[] = [
{ key: 'default', label: t('collections.sort.default') },
{ key: 'name_asc', label: t('collections.sort.nameAsc') },
]
return (
<div className="col-filterbar">
{canAddPlace && (
<button type="button" onClick={onAddPlace} className="col-filter-btn col-filter-add" aria-label={t('collections.addPlace')} title={t('collections.addPlace')}>
<Plus size={15} />
</button>
)}
<Dropdown current={statusFilter} options={statusOpts} onSelect={k => onStatusFilter(k as StatusFilter)} lead={<Layers size={13} />} />
{categoryOptions.length > 0 && (
<Dropdown current={categoryFilter} options={catOpts} onSelect={k => onCategoryFilter(k as number | 'all')} lead={<Tag size={13} />} />
)}
<Dropdown current={ratingFilter} options={ratingOpts} onSelect={k => onRatingFilter(k as number | 'all')} lead={<Star size={13} />} />
<Dropdown current={sortMode} options={sortOpts} onSelect={k => onSortMode(k as CollectionSortMode)} lead={<ArrowDownUp size={13} />} />
{showSelect && (
<button type="button" onClick={onToggleSelect} className={`col-filter-btn col-filter-select${selectMode ? ' open' : ''}`} aria-pressed={selectMode}>
<CheckSquare size={14} /> <span className="col-filter-lbl">{t('collections.select')}</span>
@@ -140,7 +172,7 @@ export default function CollectionFilterBar({
)
})}
{canManageLabels && (
<button type="button" className="col-labelchip col-labelchip-manage" onClick={onManageLabels} title={t('collections.labels.manage')}>
<button type="button" className="col-filter-btn col-filter-addlabel" onClick={onManageLabels} title={t('collections.labels.manage')}>
<Tags size={13} />
<span className="col-filter-lbl">{labelOptions.length ? t('collections.labels.manage') : t('collections.labels.add')}</span>
</button>
@@ -0,0 +1,163 @@
// FE-COMP-COLHERO-001 to FE-COMP-COLHERO-011
import React from 'react';
import { render, screen, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import type { CollectionMember } from '@trek/shared';
import { useTranslation } from '../../i18n/TranslationContext';
import CollectionHero from './CollectionHero';
type HeroProps = Omit<React.ComponentProps<typeof CollectionHero>, 't'>;
function Harness(props: HeroProps): React.ReactElement {
const { t } = useTranslation();
return <CollectionHero {...props} t={t} />;
}
function member(over: Partial<CollectionMember> = {}): CollectionMember {
return { user_id: 1, username: 'ada lovelace', status: 'accepted', ...over } as CollectionMember;
}
function renderHero(overrides: Partial<HeroProps> = {}) {
const props: HeroProps = {
eyebrow: 'Private list',
title: 'Weekend in Rome',
color: '#ef4444',
members: [],
canShare: true,
isOwner: true,
canEdit: true,
onEdit: vi.fn(),
shareMemberCount: 0,
onShare: vi.fn(),
...overrides,
};
render(<Harness {...props} />);
return props;
}
describe('CollectionHero', () => {
it('FE-COMP-COLHERO-001: renders the eyebrow, the title and the colour wash', () => {
renderHero();
expect(screen.getByRole('heading', { name: 'Weekend in Rome' })).toBeInTheDocument();
expect(screen.getByText('Private list')).toBeInTheDocument();
// No cover image → the gradient background element stands in for it.
expect(document.querySelector('.col-hero-bg')).not.toBeNull();
expect(document.querySelector('.col-hero-img')).toBeNull();
expect(document.querySelector<HTMLElement>('.col-hero')?.style.getPropertyValue('--hero-color')).toBe('#ef4444');
});
it('FE-COMP-COLHERO-002: a cover image replaces the gradient and adds the tint layer', () => {
renderHero({ coverImage: '/uploads/covers/rome.jpg' });
expect(document.querySelector<HTMLImageElement>('.col-hero-img')?.getAttribute('src')).toBe('/uploads/covers/rome.jpg');
expect(document.querySelector('.col-hero-tint')).not.toBeNull();
expect(document.querySelector('.col-hero-bg')).toBeNull();
});
it('FE-COMP-COLHERO-003: the description renders only when present', () => {
const { unmount } = render(<Harness {...{
eyebrow: 'x', title: 'y', color: '#000', members: [], canShare: false, isOwner: false,
canEdit: false, onEdit: vi.fn(), shareMemberCount: 0, onShare: vi.fn(),
}} />);
expect(document.querySelector('.col-hero-desc')).toBeNull();
unmount();
renderHero({ description: 'Three days of pasta' });
expect(screen.getByText('Three days of pasta')).toBeInTheDocument();
});
it('FE-COMP-COLHERO-004: a single member does not produce an avatar stack', () => {
renderHero({ members: [member({ user_id: 1, is_owner: true })] });
expect(document.querySelector('.members')).toBeNull();
});
it('FE-COMP-COLHERO-005: two or more accepted members render initials avatars', () => {
renderHero({
members: [
member({ user_id: 1, username: 'ada lovelace', is_owner: true }),
member({ user_id: 2, username: 'grace' }),
member({ user_id: 3, username: ' ' }),
],
});
const stack = document.querySelector('.members');
expect(stack).not.toBeNull();
expect(within(stack as HTMLElement).getByText('AL')).toBeInTheDocument();
expect(within(stack as HTMLElement).getByText('G')).toBeInTheDocument();
// A blank username still gets a placeholder rather than an empty circle.
expect(within(stack as HTMLElement).getByText('?')).toBeInTheDocument();
});
it('FE-COMP-COLHERO-006: pending members are excluded, owners are kept regardless of status', () => {
renderHero({
members: [
member({ user_id: 1, username: 'ada', status: 'pending', is_owner: true }),
member({ user_id: 2, username: 'grace' }),
member({ user_id: 3, username: 'nobody', status: 'pending' }),
],
});
const stack = document.querySelector('.members') as HTMLElement;
expect(within(stack).getByText('A')).toBeInTheDocument();
expect(within(stack).getByText('G')).toBeInTheDocument();
expect(within(stack).queryByText('N')).not.toBeInTheDocument();
});
it('FE-COMP-COLHERO-007: an uploaded avatar renders as an image, and beyond five members a +N chip appears', () => {
const members = [1, 2, 3, 4, 5, 6, 7].map(i =>
member({ user_id: i, username: `user${i}`, avatar: i === 1 ? 'me.png' : null }),
);
renderHero({ members });
const stack = document.querySelector('.members') as HTMLElement;
expect(within(stack).getByRole('img', { name: 'user1' })).toHaveAttribute('src', '/uploads/avatars/me.png');
expect(within(stack).getByText('+2')).toBeInTheDocument();
});
it('FE-COMP-COLHERO-008: link chips show the label, or the bare host when unlabelled', async () => {
const user = userEvent.setup();
renderHero({
links: [
{ url: 'https://www.romeguide.example/food', label: 'Food guide' },
{ url: 'https://maps.example/pin' },
{ url: 'not-a-url' },
],
});
const labelled = screen.getByRole('link', { name: /Food guide/ });
expect(labelled).toHaveAttribute('href', 'https://www.romeguide.example/food');
expect(labelled).toHaveAttribute('target', '_blank');
// No label → hostname without the www. prefix.
expect(screen.getByRole('link', { name: /maps\.example/ })).toBeInTheDocument();
// Unparseable href → the raw string is shown rather than crashing.
expect(screen.getByRole('link', { name: /not-a-url/ })).toBeInTheDocument();
// The chip must not bubble a click up to the hero.
const onEdit = vi.fn();
await user.click(labelled);
expect(onEdit).not.toHaveBeenCalled();
});
it('FE-COMP-COLHERO-009: the Edit button renders only when canEdit and calls onEdit', async () => {
const user = userEvent.setup();
const props = renderHero({ canEdit: true });
await user.click(screen.getByRole('button', { name: 'Edit' }));
expect(props.onEdit).toHaveBeenCalledTimes(1);
renderHero({ canEdit: false, canShare: false });
expect(screen.getAllByRole('button', { name: 'Edit' })).toHaveLength(1);
});
it('FE-COMP-COLHERO-010: the owner sees a Share button carrying the member count badge', async () => {
const user = userEvent.setup();
const props = renderHero({ isOwner: true, canShare: true, shareMemberCount: 3 });
const share = screen.getByRole('button', { name: 'Share' });
expect(share).toHaveClass('has-count');
expect(within(share).getByText('3')).toBeInTheDocument();
await user.click(share);
expect(props.onShare).toHaveBeenCalledTimes(1);
});
it('FE-COMP-COLHERO-011: a non-owner sees a Shared button without a count badge', () => {
renderHero({ isOwner: false, canShare: true, shareMemberCount: 3 });
const share = screen.getByRole('button', { name: 'Shared' });
expect(share).not.toHaveClass('has-count');
expect(within(share).queryByText('3')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Share' })).not.toBeInTheDocument();
});
});
@@ -0,0 +1,208 @@
// FE-COMP-COLPICKER-001 to FE-COMP-COLPICKER-016
import React from 'react'
import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render'
import type { Collection, CollectionDetailResponse, CollectionPlace } from '@trek/shared'
import { collectionsApi } from '../../api/collections'
import { useAuthStore } from '../../store/authStore'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { buildUser } from '../../../tests/helpers/factories'
import { useTranslation } from '../../i18n/TranslationContext'
import CollectionPicker from './CollectionPicker'
type Props = React.ComponentProps<typeof CollectionPicker>
function Harness(props: Omit<Props, 't'>): React.ReactElement {
const { t } = useTranslation()
return <CollectionPicker {...props} t={t} />
}
const listA: Collection = { id: 1, owner_id: 1, name: 'Tokyo 2026', color: '#ec4899' }
const listB: Collection = { id: 2, owner_id: 1, name: 'Rome', color: null }
function place(over: Partial<CollectionPlace> & { id: number; collection_id: number; name: string }): CollectionPlace {
return { status: 'idea', ...over }
}
// Zebra Cafe sits far from the bias box, Alpha Bar right inside it — so the
// alphabetical and the proximity order are deliberately opposites.
const zebra = place({ id: 10, collection_id: 1, name: 'Zebra Cafe', address: 'Shibuya 1', lat: 35.6, lng: 139.7 })
const alpha = place({ id: 11, collection_id: 2, name: 'Alpha Bar', address: 'Trastevere 9', lat: 41.9, lng: 12.5, category: { id: 3, name: 'Bar', color: '#f59e0b', icon: 'beer' } })
const noCoords = place({ id: 12, collection_id: 2, name: 'Mystery Spot', status: 'visited' })
function detail(collection: Collection, places: CollectionPlace[]): CollectionDetailResponse {
return { collection, places }
}
function setup(over: Partial<Omit<Props, 't'>> = {}) {
const props: Omit<Props, 't'> = { onSelect: vi.fn(), ...over }
const view = render(<Harness {...props} />)
return { ...view, props }
}
describe('CollectionPicker', () => {
beforeEach(() => {
resetAllStores()
// Keeps PlaceAvatar from reaching for provider photos in jsdom.
seedStore(useAuthStore, { user: buildUser(), placesPhotosEnabled: false })
vi.spyOn(collectionsApi, 'list').mockResolvedValue({ collections: [listA, listB], incomingInvites: [] })
vi.spyOn(collectionsApi, 'get').mockImplementation(async (id: number) =>
id === 1 ? detail(listA, [zebra]) : detail(listB, [alpha, noCoords]),
)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('FE-COMP-COLPICKER-001: shows a spinner until the lists have loaded', async () => {
const { container } = setup()
expect(container.querySelector('.animate-spin')).toBeTruthy()
expect(await screen.findByText('Zebra Cafe')).toBeInTheDocument()
expect(container.querySelector('.animate-spin')).toBeFalsy()
})
it('FE-COMP-COLPICKER-002: merges the places of every list and shows their addresses', async () => {
setup()
expect(await screen.findByText('Zebra Cafe')).toBeInTheDocument()
expect(screen.getByText('Alpha Bar')).toBeInTheDocument()
expect(screen.getByText('Mystery Spot')).toBeInTheDocument()
expect(screen.getByText('Shibuya 1')).toBeInTheDocument()
expect(screen.getByText('Saved places')).toBeInTheDocument()
})
it('FE-COMP-COLPICKER-003: sorts alphabetically without a bias box', async () => {
setup()
await screen.findByText('Zebra Cafe')
const names = screen.getAllByRole('button', { name: /Alpha Bar|Mystery Spot|Zebra Cafe/ }).map(b => b.textContent)
expect(names).toEqual(['Alpha BarTrastevere 9', 'Mystery Spot', 'Zebra CafeShibuya 1'])
})
it('FE-COMP-COLPICKER-004: a bias box sorts by proximity and pushes coordinate-less places last', async () => {
setup({ bias: { low: { lat: 35.5, lng: 139.6 }, high: { lat: 35.7, lng: 139.8 } } })
await screen.findByText('Zebra Cafe')
const names = screen.getAllByRole('button', { name: /Alpha Bar|Mystery Spot|Zebra Cafe/ }).map(b => b.textContent)
expect(names).toEqual(['Zebra CafeShibuya 1', 'Alpha BarTrastevere 9', 'Mystery Spot'])
})
it('FE-COMP-COLPICKER-005: picking a place hands the whole record to onSelect', async () => {
const { props } = setup()
fireEvent.click(await screen.findByRole('button', { name: /Zebra Cafe/ }))
expect(props.onSelect).toHaveBeenCalledTimes(1)
expect(props.onSelect).toHaveBeenCalledWith(zebra)
})
it('FE-COMP-COLPICKER-006: the search box matches on name', async () => {
setup()
await screen.findByText('Zebra Cafe')
fireEvent.change(screen.getByPlaceholderText('Search your saved places'), { target: { value: 'zebra' } })
expect(screen.getByText('Zebra Cafe')).toBeInTheDocument()
expect(screen.queryByText('Alpha Bar')).not.toBeInTheDocument()
})
it('FE-COMP-COLPICKER-007: the search box also matches on address', async () => {
setup()
await screen.findByText('Zebra Cafe')
fireEvent.change(screen.getByPlaceholderText('Search your saved places'), { target: { value: 'trastevere' } })
expect(screen.getByText('Alpha Bar')).toBeInTheDocument()
expect(screen.queryByText('Zebra Cafe')).not.toBeInTheDocument()
})
it('FE-COMP-COLPICKER-008: a search with no hits falls back to the empty copy', async () => {
setup()
await screen.findByText('Zebra Cafe')
fireEvent.change(screen.getByPlaceholderText('Search your saved places'), { target: { value: 'nothing here' } })
expect(screen.getByText('No saved places to add')).toBeInTheDocument()
})
it('FE-COMP-COLPICKER-009: the list dropdown counts each list and filters to it', async () => {
setup()
await screen.findByText('Zebra Cafe')
fireEvent.click(screen.getByRole('button', { name: /All lists/ }))
const menu = screen.getByRole('listbox')
expect(within(menu).getByRole('option', { name: /All lists/ })).toHaveTextContent('3')
expect(within(menu).getByRole('option', { name: /Rome/ })).toHaveTextContent('2')
fireEvent.click(within(menu).getByRole('option', { name: /Tokyo 2026/ }))
expect(screen.getByText('Zebra Cafe')).toBeInTheDocument()
expect(screen.queryByText('Alpha Bar')).not.toBeInTheDocument()
// The trigger adopts the picked option and the menu closes.
expect(screen.getByRole('button', { name: /Tokyo 2026/ })).toBeInTheDocument()
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
})
it('FE-COMP-COLPICKER-010: the status dropdown filters by saved status', async () => {
setup()
await screen.findByText('Zebra Cafe')
fireEvent.click(screen.getByRole('button', { name: /^All$/ }))
fireEvent.click(within(screen.getByRole('listbox')).getByRole('option', { name: /Visited/ }))
expect(screen.getByText('Mystery Spot')).toBeInTheDocument()
expect(screen.queryByText('Zebra Cafe')).not.toBeInTheDocument()
expect(screen.queryByText('Alpha Bar')).not.toBeInTheDocument()
})
it('FE-COMP-COLPICKER-011: the selected option is flagged for assistive tech', async () => {
setup()
await screen.findByText('Zebra Cafe')
fireEvent.click(screen.getByRole('button', { name: /All lists/ }))
const menu = screen.getByRole('listbox')
expect(within(menu).getByRole('option', { name: /All lists/ })).toHaveAttribute('aria-selected', 'true')
expect(within(menu).getByRole('option', { name: /Rome/ })).toHaveAttribute('aria-selected', 'false')
})
it('FE-COMP-COLPICKER-012: a click outside closes an open dropdown', async () => {
setup()
await screen.findByText('Zebra Cafe')
const trigger = screen.getByRole('button', { name: /All lists/ })
fireEvent.click(trigger)
expect(trigger).toHaveAttribute('aria-expanded', 'true')
fireEvent.mouseDown(document.body)
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument())
})
it('FE-COMP-COLPICKER-013: a mousedown inside the dropdown keeps it open', async () => {
setup()
await screen.findByText('Zebra Cafe')
fireEvent.click(screen.getByRole('button', { name: /All lists/ }))
fireEvent.mouseDown(screen.getByRole('listbox'))
expect(screen.getByRole('listbox')).toBeInTheDocument()
})
it('FE-COMP-COLPICKER-014: Escape closes an open dropdown', async () => {
setup()
await screen.findByText('Zebra Cafe')
fireEvent.click(screen.getByRole('button', { name: /All lists/ }))
fireEvent.keyDown(document, { key: 'Escape' })
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument())
// A different key leaves it alone.
fireEvent.click(screen.getByRole('button', { name: /All lists/ }))
fireEvent.keyDown(document, { key: 'a' })
expect(screen.getByRole('listbox')).toBeInTheDocument()
})
it('FE-COMP-COLPICKER-015: a list whose detail request fails is skipped, the rest still load', async () => {
vi.mocked(collectionsApi.get).mockImplementation(async (id: number) => {
if (id === 1) throw new Error('403')
return detail(listB, [alpha])
})
setup()
expect(await screen.findByText('Alpha Bar')).toBeInTheDocument()
expect(screen.queryByText('Zebra Cafe')).not.toBeInTheDocument()
})
it('FE-COMP-COLPICKER-016: a failing list request degrades to the empty state without filters', async () => {
vi.mocked(collectionsApi.list).mockRejectedValue(new Error('offline'))
setup()
expect(await screen.findByText('No saved places to add')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /All lists/ })).not.toBeInTheDocument()
})
it('FE-COMP-COLPICKER-017: with no lists at all the filter row stays hidden', async () => {
vi.mocked(collectionsApi.list).mockResolvedValue({ collections: [], incomingInvites: [] })
setup()
expect(await screen.findByText('No saved places to add')).toBeInTheDocument()
expect(collectionsApi.get).not.toHaveBeenCalled()
expect(screen.queryByRole('button', { name: /All lists/ })).not.toBeInTheDocument()
})
})
@@ -31,6 +31,28 @@ function distanceTo(p: CollectionPlace, center: { lat: number; lng: number }): n
interface Opt { key: string | number; label: string; icon?: React.ReactNode; count?: number }
/** Detail requests fired at once while building the union keeps a user with
* many lists from opening the modal with a burst of parallel requests. */
const DETAIL_BATCH = 4
/** Union of every list's places, in list order, without duplicates. */
async function loadSavedPlaces(ids: number[]): Promise<CollectionPlace[]> {
const merged: CollectionPlace[] = []
const seen = new Set<number>()
for (let i = 0; i < ids.length; i += DETAIL_BATCH) {
const batch = await Promise.all(ids.slice(i, i + DETAIL_BATCH).map(id => collectionsApi.get(id).catch(() => null)))
for (const d of batch) {
if (!d) continue
for (const p of d.places) {
if (seen.has(p.id)) continue
seen.add(p.id)
merged.push(p)
}
}
}
return merged
}
/** Compact click-away dropdown (Tailwind — this panel lives outside .trek-dash). */
function FilterDropdown({ current, options, onSelect, lead }: {
current: string | number
@@ -93,13 +115,8 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
setLoading(true)
collectionsApi.list()
.then(async (res) => {
const detail = await Promise.all(res.collections.map(c => collectionsApi.get(c.id).catch(() => null)))
const merged = await loadSavedPlaces(res.collections.map(c => c.id))
if (cancelled) return
const merged: CollectionPlace[] = []
for (const d of detail) {
if (!d) continue
for (const p of d.places) merged.push(p)
}
setLists(res.collections.map(c => ({ id: c.id, name: c.name, color: c.color ?? null })))
setPlaces(merged)
})
@@ -1,13 +1,14 @@
// FE-COMP-COLDETAIL-001 to FE-COMP-COLDETAIL-010
// FE-COMP-COLDETAIL-001 to FE-COMP-COLDETAIL-035
import React from 'react';
import { render, screen } from '../../../tests/helpers/render';
import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser } from '../../../tests/helpers/factories';
import type { CollectionPlace } from '@trek/shared';
import type { CollectionLabel, CollectionPlace } from '@trek/shared';
import type { Category } from '../../types';
import { useTranslation } from '../../i18n/TranslationContext';
import CollectionPlaceDetail from './CollectionPlaceDetail';
@@ -33,6 +34,18 @@ const place: CollectionPlace = {
category: { id: 1, name: 'Food', color: '#f00', icon: null },
};
const CATEGORIES: Category[] = [
{ id: 1, name: 'Food', color: '#f00', icon: 'utensils' },
{ id: 2, name: 'Museums', color: '#00f', icon: 'landmark' },
];
const LABELS: CollectionLabel[] = [
{ id: 1, collection_id: 10, name: 'Berlin', color: '#0ea5e9' },
{ id: 2, collection_id: 10, name: 'Nightlife', color: null },
];
let addToast: ReturnType<typeof vi.fn>;
function renderDetail(overrides: Partial<Omit<DetailProps, 't'>> = {}) {
const props = {
place,
@@ -55,6 +68,8 @@ function renderDetail(overrides: Partial<Omit<DetailProps, 't'>> = {}) {
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { user: buildUser(), placesPhotosEnabled: false });
addToast = vi.fn();
window.__addToast = addToast as unknown as typeof window.__addToast;
// The detail sheet asks the maps provider for a cover photo on mount when a
// place carries no image of its own — stub it so nothing hits the network.
server.use(
@@ -64,6 +79,10 @@ beforeEach(() => {
);
});
afterEach(() => {
delete window.__addToast;
});
describe('CollectionPlaceDetail', () => {
it('FE-COMP-COLDETAIL-001: renders the place name, address and description', async () => {
renderDetail();
@@ -139,4 +158,335 @@ describe('CollectionPlaceDetail', () => {
await user.click(await screen.findByRole('button', { name: 'Copy to trip' }));
expect(props.onCopyToTrip).toHaveBeenCalledTimes(1);
});
// ── Custom cover image (#1136) ──────────────────────────────────────────────
it('FE-COMP-COLDETAIL-011: shows the cover upload control when canEdit && onUploadImage', async () => {
const user = userEvent.setup();
renderDetail({ canEdit: true, onUploadImage: vi.fn() });
const camera = await screen.findByRole('button', { name: 'Upload image' });
expect(camera).toBeInTheDocument();
// The camera button proxies the hidden file input.
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const clicked = vi.fn();
input.addEventListener('click', clicked);
await user.click(camera);
expect(clicked).toHaveBeenCalledTimes(1);
});
it('FE-COMP-COLDETAIL-012: hides the cover upload control when onUploadImage is not provided', async () => {
renderDetail({ canEdit: true });
// Wait for the mount photo effect to settle before asserting absence.
expect(await screen.findByRole('button', { name: 'Copy to trip' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Upload image' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Change image' })).not.toBeInTheDocument();
});
it('FE-COMP-COLDETAIL-013: removing a custom cover calls onSave with { image_url: null }', async () => {
const user = userEvent.setup();
const withImage: CollectionPlace = { ...place, image_url: '/uploads/places/mock.jpg' };
const props = renderDetail({ canEdit: true, onUploadImage: vi.fn(), place: withImage });
await user.click(await screen.findByRole('button', { name: 'Remove image' }));
expect(props.onSave).toHaveBeenCalledWith({ image_url: null });
});
it('FE-COMP-COLDETAIL-014: a failing cover removal surfaces the upload error', async () => {
const user = userEvent.setup();
const withImage: CollectionPlace = { ...place, image_url: '/uploads/places/mock.jpg' };
const onSave = vi.fn(() => Promise.reject({ response: { data: { error: 'Disk full' } } }));
renderDetail({ canEdit: true, onUploadImage: vi.fn(), place: withImage, onSave });
await user.click(await screen.findByRole('button', { name: 'Remove image' }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Disk full', 'error', undefined));
});
it('FE-COMP-COLDETAIL-015: picking a cover file hands the normalized file to onUploadImage', async () => {
const onUploadImage = vi.fn(async (_file: File) => {});
renderDetail({ canEdit: true, onUploadImage });
await screen.findByRole('button', { name: 'Upload image' });
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['x'], 'cover.png', { type: 'image/png' });
fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => expect(onUploadImage).toHaveBeenCalledTimes(1));
expect(onUploadImage.mock.calls[0][0]).toBe(file);
// The picker resets so re-selecting the same file fires again.
expect(input.value).toBe('');
});
it('FE-COMP-COLDETAIL-016: a cancelled file picker uploads nothing', async () => {
const onUploadImage = vi.fn(async () => {});
renderDetail({ canEdit: true, onUploadImage });
await screen.findByRole('button', { name: 'Upload image' });
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(input, { target: { files: [] } });
expect(onUploadImage).not.toHaveBeenCalled();
});
it('FE-COMP-COLDETAIL-017: a failing upload surfaces the error and clears the busy state', async () => {
const onUploadImage = vi.fn(() => Promise.reject(new Error('Upload rejected')));
renderDetail({ canEdit: true, onUploadImage });
await screen.findByRole('button', { name: 'Upload image' });
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(input, { target: { files: [new File(['x'], 'cover.png', { type: 'image/png' })] } });
// A rejection without a server payload falls back to the localized message.
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Could not upload image', 'error', undefined));
// The spinner is gone again, so the control accepts a retry.
await waitFor(() => expect(document.querySelector('.animate-spin')).toBeNull());
});
it('FE-COMP-COLDETAIL-018: the cover renders the fetched provider photo when the place has none', async () => {
server.use(
http.get('/api/maps/place-photo/:id', () =>
HttpResponse.json({ photoUrl: 'https://cdn.example/photo.jpg', attribution: null }),
),
);
renderDetail({ place: { ...place, google_place_id: 'gp-1' } });
await waitFor(() =>
expect(document.querySelector('.col-detail-cover img')).toHaveAttribute('src', 'https://cdn.example/photo.jpg'),
);
});
it('FE-COMP-COLDETAIL-018b: a place with its own image never asks the provider for a photo', async () => {
const photo = vi.fn();
server.use(http.get('/api/maps/place-photo/:id', () => { photo(); return HttpResponse.json({ photoUrl: null }); }));
renderDetail({ place: { ...place, image_url: '/uploads/places/mine.jpg', google_place_id: 'gp-1' } });
await screen.findByRole('heading', { name: 'Test Cafe' });
expect(document.querySelector('.col-detail-cover img')).toHaveAttribute('src', '/uploads/places/mine.jpg');
expect(photo).not.toHaveBeenCalled();
});
it('FE-COMP-COLDETAIL-019: the category chip and the assigned label chips render in read mode', async () => {
renderDetail({ labels: LABELS, place: { ...place, label_ids: [1] } });
expect(await screen.findByText('Food')).toBeInTheDocument();
expect(screen.getByText('Berlin')).toBeInTheDocument();
// Only the assigned label shows — the list's other labels stay hidden.
expect(screen.queryByText('Nightlife')).not.toBeInTheDocument();
});
it('FE-COMP-COLDETAIL-020: the description renders as markdown and links become chips', async () => {
renderDetail({
place: {
...place,
description: '# Heading\n\nsome **bold** text',
links: [{ url: 'https://www.example.com/menu', label: 'Menu' }, { url: 'https://tickets.example/x' }],
},
});
expect(await screen.findByRole('heading', { name: 'Heading' })).toBeInTheDocument();
expect(screen.getByText('bold').tagName).toBe('STRONG');
expect(screen.getByRole('link', { name: /Menu/ })).toHaveAttribute('href', 'https://www.example.com/menu');
// Unlabelled link falls back to the bare hostname.
expect(screen.getByRole('link', { name: /tickets\.example/ })).toBeInTheDocument();
});
it('FE-COMP-COLDETAIL-021: the rating control renders only when onRate is supplied', async () => {
const onRate = vi.fn(async () => {});
renderDetail({ onRate, place: { ...place, rating_avg: 4 } });
const stars = await screen.findByRole('radiogroup');
await userEvent.setup().click(within(stars).getByRole('radio', { name: '5' }));
expect(onRate).toHaveBeenCalledWith(5);
});
it('FE-COMP-COLDETAIL-022: edit mode exposes the category picker and switching category sticks', async () => {
const user = userEvent.setup();
renderDetail({ canEdit: true, categories: CATEGORIES, place: { ...place, category_id: 1 } });
await user.click(await screen.findByRole('button', { name: 'Edit' }));
expect(screen.getByText('Category')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Food$/ })).toHaveClass('on');
await user.click(screen.getByRole('button', { name: /^Museums$/ }));
expect(screen.getByRole('button', { name: /^Museums$/ })).toHaveClass('on');
await user.click(screen.getByRole('button', { name: 'No category' }));
expect(screen.getByRole('button', { name: 'No category' })).toHaveClass('on');
});
it('FE-COMP-COLDETAIL-023: saving from edit mode sends the whole patch and leaves edit mode', async () => {
const user = userEvent.setup();
const props = renderDetail({
canEdit: true,
categories: CATEGORIES,
labels: LABELS,
place: { ...place, lat: 52.5, lng: 13.4, label_ids: [1] },
});
await user.click(await screen.findByRole('button', { name: 'Edit' }));
const nameInput = screen.getByLabelText('List name');
await user.clear(nameInput);
await user.type(nameInput, ' Renamed Cafe ');
await user.click(screen.getByRole('button', { name: /^Museums$/ }));
await user.click(screen.getByRole('button', { name: /Nightlife/ }));
const desc = screen.getByPlaceholderText('Add a description…');
await user.clear(desc);
await user.type(desc, 'Great coffee');
await user.click(screen.getByRole('button', { name: /Save/ }));
await waitFor(() => expect(props.onSave).toHaveBeenCalled());
expect(props.onSave).toHaveBeenCalledWith({
name: 'Renamed Cafe',
description: 'Great coffee',
links: [{ label: undefined, url: 'https://x.com' }],
category_id: 2,
label_ids: [1, 2],
lat: 52.5,
lng: 13.4,
});
// Back in read mode.
expect(await screen.findByRole('button', { name: 'Edit' })).toBeInTheDocument();
});
it('FE-COMP-COLDETAIL-024: an empty name and description fall back to the stored name and null', async () => {
const user = userEvent.setup();
const props = renderDetail({ canEdit: true, place: { ...place, description: 'Nice spot', links: [] } });
await user.click(await screen.findByRole('button', { name: 'Edit' }));
await user.clear(screen.getByLabelText('List name'));
await user.clear(screen.getByPlaceholderText('Add a description…'));
await user.click(screen.getByRole('button', { name: /Save/ }));
await waitFor(() => expect(props.onSave).toHaveBeenCalled());
expect(props.onSave).toHaveBeenCalledWith(expect.objectContaining({
name: 'Test Cafe',
description: null,
lat: null,
lng: null,
}));
});
it('FE-COMP-COLDETAIL-025: a failed save toasts the server message and keeps the form open', async () => {
const user = userEvent.setup();
const onSave = vi.fn(() => Promise.reject({ response: { data: { error: 'Name taken' } } }));
renderDetail({ canEdit: true, onSave });
await user.click(await screen.findByRole('button', { name: 'Edit' }));
await user.click(screen.getByRole('button', { name: /Save/ }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Name taken', 'error', undefined));
expect(screen.getByLabelText('List name')).toBeInTheDocument();
});
it('FE-COMP-COLDETAIL-026: Cancel discards the edits and returns to read mode', async () => {
const user = userEvent.setup();
const props = renderDetail({ canEdit: true });
await user.click(await screen.findByRole('button', { name: 'Edit' }));
await user.type(screen.getByLabelText('List name'), ' Extra');
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(props.onSave).not.toHaveBeenCalled();
expect(screen.getByRole('heading', { name: 'Test Cafe' })).toBeInTheDocument();
});
it('FE-COMP-COLDETAIL-027: links can be added, relabelled and removed in edit mode', async () => {
const user = userEvent.setup();
const props = renderDetail({ canEdit: true, place: { ...place, links: [] } });
await user.click(await screen.findByRole('button', { name: 'Edit' }));
await user.click(screen.getByRole('button', { name: /Add link/ }));
await user.type(screen.getByPlaceholderText('Label'), 'Site');
await user.type(screen.getByPlaceholderText('https://…'), 'example.com/menu');
await user.click(screen.getByRole('button', { name: /Save/ }));
await waitFor(() => expect(props.onSave).toHaveBeenCalled());
// A bare host is normalized to an absolute https url before it is saved.
expect(props.onSave).toHaveBeenCalledWith(expect.objectContaining({
links: [{ label: 'Site', url: 'https://example.com/menu' }],
}));
});
it('FE-COMP-COLDETAIL-028: an emptied link row is dropped from the saved patch', async () => {
const user = userEvent.setup();
const props = renderDetail({ canEdit: true, place: { ...place, links: [{ url: 'https://x.com' }] } });
await user.click(await screen.findByRole('button', { name: 'Edit' }));
// Two rows: the existing link plus a blank one that never gets a url.
await user.click(screen.getByRole('button', { name: /Add link/ }));
expect(screen.getAllByPlaceholderText('https://…')).toHaveLength(2);
await user.click(screen.getAllByRole('button', { name: 'Delete' })[0]);
await user.click(screen.getByRole('button', { name: /Save/ }));
await waitFor(() => expect(props.onSave).toHaveBeenCalled());
expect(props.onSave).toHaveBeenCalledWith(expect.objectContaining({ links: [] }));
});
it('FE-COMP-COLDETAIL-029: pasting a "lat, lng" pair into the latitude field fills both inputs', async () => {
const user = userEvent.setup();
const props = renderDetail({ canEdit: true });
await user.click(await screen.findByRole('button', { name: 'Edit' }));
const latInput = screen.getByPlaceholderText('Latitude (e.g. 48.8566)');
fireEvent.paste(latInput, { clipboardData: { getData: () => ' 48.8566, 2.3522 ' } });
expect(latInput).toHaveValue('48.8566');
expect(screen.getByPlaceholderText('Longitude (e.g. 2.3522)')).toHaveValue('2.3522');
await user.click(screen.getByRole('button', { name: /Save/ }));
await waitFor(() => expect(props.onSave).toHaveBeenCalled());
expect(props.onSave).toHaveBeenCalledWith(expect.objectContaining({ lat: 48.8566, lng: 2.3522 }));
});
it('FE-COMP-COLDETAIL-030: a paste that is not a coordinate pair is left to the input', async () => {
const user = userEvent.setup();
renderDetail({ canEdit: true });
await user.click(await screen.findByRole('button', { name: 'Edit' }));
const latInput = screen.getByPlaceholderText('Latitude (e.g. 48.8566)');
fireEvent.paste(latInput, { clipboardData: { getData: () => 'somewhere nice' } });
expect(latInput).toHaveValue('');
expect(screen.getByPlaceholderText('Longitude (e.g. 2.3522)')).toHaveValue('');
});
it('FE-COMP-COLDETAIL-031: opening a different place resets the form back to read mode', async () => {
const user = userEvent.setup();
const props = renderDetail({ canEdit: true });
await user.click(await screen.findByRole('button', { name: 'Edit' }));
expect(screen.getByLabelText('List name')).toBeInTheDocument();
const other: CollectionPlace = { ...place, id: 2, name: 'Second Bar', description: null, links: [] };
render(<TranslatedDetail {...{ ...props, place: other }} />);
// The freshly mounted sheet shows the new place in read mode.
expect(await screen.findByRole('heading', { name: 'Second Bar' })).toBeInTheDocument();
});
it('FE-COMP-COLDETAIL-032: the labels field is omitted when the list defines none', async () => {
const user = userEvent.setup();
renderDetail({ canEdit: true, labels: [] });
await user.click(await screen.findByRole('button', { name: 'Edit' }));
expect(screen.getByText('Coordinates')).toBeInTheDocument();
expect(screen.queryByText('Labels')).not.toBeInTheDocument();
});
it('FE-COMP-COLDETAIL-033: the anchor rect docks the sheet over the given column', async () => {
renderDetail({ anchorRect: { left: 320, width: 420 } });
await screen.findByRole('heading', { name: 'Test Cafe' });
const sheet = document.querySelector('.col-detail') as HTMLElement;
expect(sheet).toHaveClass('docked');
expect(sheet.style.left).toBe('320px');
expect(sheet.style.width).toBe('420px');
});
it('FE-COMP-COLDETAIL-034: the header close button hands back to the caller', async () => {
const user = userEvent.setup();
const props = renderDetail();
await user.click(await screen.findByRole('button', { name: 'Close' }));
expect(props.onClose).toHaveBeenCalledTimes(1);
});
it('FE-COMP-COLDETAIL-035: the remove action fires onRemove for an admin', async () => {
const user = userEvent.setup();
const props = renderDetail({ canDelete: true });
await user.click(await screen.findByRole('button', { name: 'Remove from list' }));
expect(props.onRemove).toHaveBeenCalledTimes(1);
});
});
@@ -2,16 +2,20 @@ import React, { useEffect, useRef, useState } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag, Tags } from 'lucide-react'
import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag, Tags, Camera, Loader2 } from 'lucide-react'
import type { CollectionPlace, CollectionStatus, CollectionLink, CollectionLabel } from '@trek/shared'
import type { Category, TranslationFn } from '../../types'
import MarkdownToolbar from '../Journey/MarkdownToolbar'
import { NumericInput } from '../shared/NumericInput'
import { mapsApi } from '../../api/client'
import { entityGradient } from '../../utils/gradients'
import { getCategoryIcon } from '../shared/categoryIcons'
import { STATUS_META, STATUS_ORDER, normalizeLinkUrl } from '../../pages/collections/collectionsModel'
import { useToast } from '../shared/Toast'
import { getApiErrorMessage } from '../../types'
import { Tooltip } from '../shared/Tooltip'
import PlaceRating from '../shared/StarRating'
import { normalizeImageFile } from '../../utils/convertHeic'
import { getApiErrorMessage } from '../../utils/apiError'
function linkHost(url: string): string {
try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url }
@@ -28,9 +32,13 @@ interface CollectionPlaceDetailProps {
anchorRect?: { left: number; width: number } | null
onClose: () => void
onSetStatus: (status: CollectionStatus) => void
onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null; label_ids?: number[] }) => Promise<void>
onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null; label_ids?: number[]; image_url?: string | null; lat?: number | null; lng?: number | null }) => Promise<void>
/** Upload a custom cover image (#1136); enables the cover change/remove controls. */
onUploadImage?: (file: File) => Promise<void>
onCopyToTrip: () => void
onRemove: () => void
/** Cast/clear the current user's star vote (#1435); every member may vote. */
onRate?: (rating: number | null) => Promise<void> | void
t: TranslationFn
}
@@ -58,15 +66,19 @@ function StatusSegment({ status, onSet, t }: { status: CollectionStatus; onSet:
* is an always-live segmented control (auto-saves).
*/
export default function CollectionPlaceDetail({
place, canEdit, canDelete, categories, labels, anchorRect, onClose, onSetStatus, onSave, onCopyToTrip, onRemove, t,
place, canEdit, canDelete, categories, labels, anchorRect, onClose, onSetStatus, onSave, onUploadImage, onCopyToTrip, onRemove, onRate, t,
}: CollectionPlaceDetailProps): React.ReactElement {
const toast = useToast()
const [editing, setEditing] = useState(false)
const coverInputRef = useRef<HTMLInputElement>(null)
const [imgBusy, setImgBusy] = useState(false)
const [name, setName] = useState(place.name)
const [categoryId, setCategoryId] = useState<number | null>(place.category_id ?? null)
const [description, setDescription] = useState(place.description ?? '')
const [links, setLinks] = useState<CollectionLink[]>(place.links ?? [])
const [labelIds, setLabelIds] = useState<number[]>(place.label_ids ?? [])
const [lat, setLat] = useState(place.lat != null ? String(place.lat) : '')
const [lng, setLng] = useState(place.lng != null ? String(place.lng) : '')
const [saving, setSaving] = useState(false)
// A higher-res photo pulled from the maps provider when the place has none of
// its own — the list avatar's little thumbnail is too low-res for the cover.
@@ -81,6 +93,8 @@ export default function CollectionPlaceDetail({
setDescription(place.description ?? '')
setLinks(place.links ?? [])
setLabelIds(place.label_ids ?? [])
setLat(place.lat != null ? String(place.lat) : '')
setLng(place.lng != null ? String(place.lng) : '')
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [place.id])
@@ -99,16 +113,49 @@ export default function CollectionPlaceDetail({
}, [place.id])
const banner = place.image_url || fetchedPhoto
const handleCoverPick = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ''
if (!file || !onUploadImage) return
setImgBusy(true)
try {
await onUploadImage(await normalizeImageFile(file))
} catch (err) {
toast.error(getApiErrorMessage(err, t('places.imageUploadError')))
} finally {
setImgBusy(false)
}
}
const handleImageRemove = async () => {
setImgBusy(true)
try {
await onSave({ image_url: null })
} catch (err) {
toast.error(getApiErrorMessage(err, t('places.imageUploadError')))
} finally {
setImgBusy(false)
}
}
const setLink = (i: number, patch: Partial<CollectionLink>) => setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
const toggleLabel = (id: number) => setLabelIds(labelIds.includes(id) ? labelIds.filter(x => x !== id) : [...labelIds, id])
const resetForm = () => { setEditing(false); setName(place.name); setCategoryId(place.category_id ?? null); setDescription(place.description ?? ''); setLinks(place.links ?? []); setLabelIds(place.label_ids ?? []) }
const resetForm = () => { setEditing(false); setName(place.name); setCategoryId(place.category_id ?? null); setDescription(place.description ?? ''); setLinks(place.links ?? []); setLabelIds(place.label_ids ?? []); setLat(place.lat != null ? String(place.lat) : ''); setLng(place.lng != null ? String(place.lng) : '') }
const coordPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
const text = e.clipboardData.getData('text').trim()
const match = text.match(/^(-?\d+\.?\d*)\s*[,;\s]\s*(-?\d+\.?\d*)$/)
if (match) { e.preventDefault(); setLat(match[1]); setLng(match[2]) }
}
const assignedLabels = labels.filter(l => (place.label_ids ?? []).includes(l.id))
const save = async () => {
const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url)
const latNum = lat.trim() ? Number(lat) : NaN
const lngNum = lng.trim() ? Number(lng) : NaN
setSaving(true)
try {
await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId, label_ids: labelIds })
await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId, label_ids: labelIds, lat: Number.isFinite(latNum) ? latNum : null, lng: Number.isFinite(lngNum) ? lngNum : null })
setEditing(false)
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
@@ -131,6 +178,33 @@ export default function CollectionPlaceDetail({
</span>
)}
<button type="button" className="col-detail-close" onClick={onClose} aria-label={t('common.close')}><X size={16} /></button>
{canEdit && onUploadImage && (
<div style={{ position: 'absolute', top: 10, left: 10, display: 'flex', gap: 6, zIndex: 2 }}>
<Tooltip label={place.image_url ? t('places.changeImage') : t('places.uploadImage')} placement="bottom">
<button
type="button"
onClick={() => { if (!imgBusy) coverInputRef.current?.click() }}
aria-label={place.image_url ? t('places.changeImage') : t('places.uploadImage')}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 8, border: 'none', cursor: imgBusy ? 'default' : 'pointer', background: 'rgba(0,0,0,0.55)', color: '#fff', backdropFilter: 'blur(4px)' }}
>
{imgBusy ? <Loader2 size={15} className="animate-spin" /> : <Camera size={15} />}
</button>
</Tooltip>
{place.image_url && !imgBusy && (
<Tooltip label={t('places.removeImage')} placement="bottom">
<button
type="button"
onClick={handleImageRemove}
aria-label={t('places.removeImage')}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 8, border: 'none', cursor: 'pointer', background: 'rgba(0,0,0,0.55)', color: '#fff', backdropFilter: 'blur(4px)' }}
>
<Trash2 size={15} />
</button>
</Tooltip>
)}
<input ref={coverInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp,.heic,.heif" style={{ display: 'none' }} onChange={handleCoverPick} />
</div>
)}
<div className="col-detail-head">
{editing
? <input value={name} onChange={e => setName(e.target.value)} className="col-detail-name-input" autoFocus aria-label={t('collections.listName')} />
@@ -149,6 +223,13 @@ export default function CollectionPlaceDetail({
{/* Status — live for editors, read-only for viewers */}
<StatusSegment status={place.status} onSet={canEdit ? onSetStatus : () => {}} t={t} />
{/* Collaborative rating (#1435) — every member votes; the average shows. */}
{onRate && (
<div style={{ padding: '2px 0' }}>
<PlaceRating ratings={place.ratings ?? []} ratingAvg={place.rating_avg} onRate={onRate} />
</div>
)}
{editing ? (
<div className="col-detail-edit">
{/* Category */}
@@ -167,6 +248,14 @@ export default function CollectionPlaceDetail({
})}
</div>
</div>
{/* Coordinates */}
<div className="col-detail-field">
<div className="col-detail-label"><MapPin size={12} /> {t('collections.coordinates')}</div>
<div className="col-detail-link-row">
<NumericInput mode="signed" value={lat} onValueChange={setLat} onPaste={coordPaste} placeholder={t('places.formLat')} className="col-detail-input flex-1" />
<NumericInput mode="signed" value={lng} onValueChange={setLng} placeholder={t('places.formLng')} className="col-detail-input flex-1" />
</div>
</div>
{/* Labels */}
{labels.length > 0 && (
<div className="col-detail-field">
@@ -0,0 +1,220 @@
// FE-COMP-COPYTRIP-001 to FE-COMP-COPYTRIP-016
import React from 'react';
import { afterEach, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { tripsApi } from '../../api/client';
import { useTranslation } from '../../i18n/TranslationContext';
import CopyToTripModal from './CopyToTripModal';
type ModalProps = Omit<React.ComponentProps<typeof CopyToTripModal>, 't'>;
type CopyResult = { copied: number; skipped: { id: number; name: string }[] };
function Harness(props: ModalProps): React.ReactElement {
const { t } = useTranslation();
return <CopyToTripModal {...props} t={t} />;
}
const TRIPS = [
{ id: 1, title: 'Rome 2026', start_date: '2026-04-02', end_date: '2026-04-09', cover_image: '/uploads/covers/rome.jpg' },
{ id: 2, title: 'Tokyo', start_date: null, end_date: null, cover_image: null },
];
let addToast: ReturnType<typeof vi.fn>;
function renderModal(overrides: Partial<ModalProps> = {}) {
const props: ModalProps = {
isOpen: true,
onClose: vi.fn(),
placeIds: [7],
onCopy: vi.fn(async () => ({ copied: 1, skipped: [] })),
...overrides,
};
render(<Harness {...props} />);
return props;
}
beforeEach(() => {
addToast = vi.fn();
window.__addToast = addToast as unknown as typeof window.__addToast;
vi.spyOn(tripsApi, 'list').mockResolvedValue({ trips: TRIPS });
});
afterEach(() => {
vi.restoreAllMocks();
delete window.__addToast;
});
describe('CopyToTripModal', () => {
it('FE-COMP-COPYTRIP-001: lists the user trips with their date range once loaded', async () => {
renderModal();
expect(await screen.findByText('Rome 2026')).toBeInTheDocument();
const rome = screen.getByRole('button', { name: /Rome 2026/ });
expect(within(rome).getByText(/Apr 2 .*Apr 9/)).toBeInTheDocument();
// A trip without dates renders no date row at all.
const tokyo = screen.getByRole('button', { name: /Tokyo/ });
expect(tokyo.querySelector('.lucide-calendar-days')).toBeNull();
});
it('FE-COMP-COPYTRIP-015: a trips response landing after the modal closed is dropped', async () => {
const closed = <Harness isOpen onClose={vi.fn()} placeIds={[7]} onCopy={vi.fn(async () => ({ copied: 0, skipped: [] }))} />;
let resolve!: (v: { trips: typeof TRIPS }) => void;
const spy = vi.spyOn(tripsApi, 'list').mockReturnValue(new Promise(r => { resolve = r; }));
render(closed).unmount();
resolve({ trips: TRIPS });
await Promise.resolve();
expect(screen.queryByText('Rome 2026')).not.toBeInTheDocument();
// Same for a rejection — no "no trips" state is written into an unmounted tree.
let reject!: (e: Error) => void;
spy.mockReturnValue(new Promise((_, r) => { reject = r; }));
render(closed).unmount();
reject(new Error('offline'));
await Promise.resolve();
expect(screen.queryByText('No trips yet')).not.toBeInTheDocument();
});
it('FE-COMP-COPYTRIP-016: a trip without a title still renders and stays filterable', async () => {
vi.spyOn(tripsApi, 'list').mockResolvedValue({ trips: [{ id: 8, title: null }, ...TRIPS] });
renderModal();
await screen.findByText('Rome 2026');
fireEvent.change(screen.getByPlaceholderText('Search trips'), { target: { value: 'rome' } });
expect(screen.getByText('Rome 2026')).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: /Rome 2026|Tokyo/ })).toHaveLength(1);
});
it('FE-COMP-COPYTRIP-014: a one-sided date range falls back to the single date it has', async () => {
vi.spyOn(tripsApi, 'list').mockResolvedValue({
trips: [
{ id: 3, title: 'Open ended', start_date: '2026-04-02', end_date: null },
{ id: 4, title: 'Return only', start_date: null, end_date: '2026-04-09' },
],
});
renderModal();
await screen.findByText('Open ended');
expect(within(screen.getByRole('button', { name: /Open ended/ })).getByText(/Apr 2/)).toBeInTheDocument();
expect(within(screen.getByRole('button', { name: /Return only/ })).getByText(/Apr 9/)).toBeInTheDocument();
});
it('FE-COMP-COPYTRIP-002: a cover image replaces the pin placeholder', async () => {
renderModal();
await screen.findByText('Rome 2026');
const rome = screen.getByRole('button', { name: /Rome 2026/ });
expect(rome.querySelector('img')).toHaveAttribute('src', '/uploads/covers/rome.jpg');
expect(screen.getByRole('button', { name: /Tokyo/ }).querySelector('.lucide-map-pin')).not.toBeNull();
});
it('FE-COMP-COPYTRIP-003: the title is singular for one place and counted for a bulk copy', async () => {
const { unmount } = render(<Harness {...{ isOpen: true, onClose: vi.fn(), placeIds: [7], onCopy: vi.fn(async () => ({ copied: 1, skipped: [] })) }} />);
expect(await screen.findByRole('heading', { name: 'Copy to trip' })).toBeInTheDocument();
unmount();
renderModal({ placeIds: [7, 8, 9] });
expect(await screen.findByRole('heading', { name: 'Copy 3 to trip' })).toBeInTheDocument();
});
it('FE-COMP-COPYTRIP-004: shows the spinner until the trips arrive', async () => {
let resolve!: (v: { trips: typeof TRIPS }) => void;
vi.spyOn(tripsApi, 'list').mockReturnValue(new Promise(r => { resolve = r; }));
renderModal();
expect(document.querySelector('.animate-spin')).not.toBeNull();
resolve({ trips: TRIPS });
expect(await screen.findByText('Rome 2026')).toBeInTheDocument();
});
it('FE-COMP-COPYTRIP-005: an empty or failing trips response shows the no-trips copy', async () => {
vi.spyOn(tripsApi, 'list').mockResolvedValue({});
const { unmount } = render(<Harness {...{ isOpen: true, onClose: vi.fn(), placeIds: [7], onCopy: vi.fn(async () => ({ copied: 0, skipped: [] })) }} />);
expect(await screen.findByText('No trips yet')).toBeInTheDocument();
unmount();
vi.spyOn(tripsApi, 'list').mockRejectedValue(new Error('offline'));
renderModal();
expect(await screen.findByText('No trips yet')).toBeInTheDocument();
});
it('FE-COMP-COPYTRIP-006: requests nothing and renders nothing while closed', () => {
const spy = vi.spyOn(tripsApi, 'list').mockResolvedValue({ trips: TRIPS });
renderModal({ isOpen: false });
expect(spy).not.toHaveBeenCalled();
expect(screen.queryByRole('heading', { name: /Copy/ })).not.toBeInTheDocument();
});
it('FE-COMP-COPYTRIP-007: the search box filters trips by title, case-insensitively', async () => {
renderModal();
const search = await screen.findByPlaceholderText('Search trips');
fireEvent.change(search, { target: { value: 'rome' } });
expect(screen.getByText('Rome 2026')).toBeInTheDocument();
expect(screen.queryByText('Tokyo')).not.toBeInTheDocument();
fireEvent.change(search, { target: { value: 'nope' } });
expect(screen.getByText('No trips yet')).toBeInTheDocument();
});
it('FE-COMP-COPYTRIP-008: picking a trip copies, reports the count and closes', async () => {
const onCopy = vi.fn(async (): Promise<CopyResult> => ({ copied: 2, skipped: [] }));
const props = renderModal({ onCopy });
fireEvent.click(await screen.findByText('Rome 2026'));
await waitFor(() => expect(props.onClose).toHaveBeenCalled());
expect(onCopy).toHaveBeenCalledWith(1);
expect(addToast).toHaveBeenCalledWith('Copied 2 places', 'success', undefined);
});
it('FE-COMP-COPYTRIP-009: server-side duplicates are reported alongside the copied count', async () => {
const onCopy = vi.fn(async (): Promise<CopyResult> => ({ copied: 1, skipped: [{ id: 9, name: 'Colosseum' }] }));
renderModal({ onCopy });
fireEvent.click(await screen.findByText('Rome 2026'));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Skipped 1 duplicates', 'info', undefined));
expect(addToast).toHaveBeenCalledWith('Copied 1 places', 'success', undefined);
});
it('FE-COMP-COPYTRIP-010: a no-op copy says so instead of staying silent', async () => {
const onCopy = vi.fn(async (): Promise<CopyResult> => ({ copied: 0, skipped: [] }));
renderModal({ onCopy });
fireEvent.click(await screen.findByText('Rome 2026'));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Nothing to copy', 'info', undefined));
expect(addToast).toHaveBeenCalledTimes(1);
});
it('FE-COMP-COPYTRIP-011: a failed copy surfaces the server message and keeps the modal open', async () => {
const onCopy = vi.fn((): Promise<CopyResult> => Promise.reject({ response: { data: { error: 'Trip is locked' } } }));
const props = renderModal({ onCopy });
fireEvent.click(await screen.findByText('Rome 2026'));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Trip is locked', 'error', undefined));
expect(props.onClose).not.toHaveBeenCalled();
});
it('FE-COMP-COPYTRIP-012: a second click while a copy is running is ignored', async () => {
let resolve!: (v: CopyResult) => void;
const onCopy = vi.fn(() => new Promise<CopyResult>(r => { resolve = r; }));
const props = renderModal({ onCopy });
fireEvent.click(await screen.findByText('Rome 2026'));
fireEvent.click(screen.getByText('Tokyo'));
expect(onCopy).toHaveBeenCalledTimes(1);
expect(screen.getByRole('button', { name: /Rome 2026/ })).toBeDisabled();
resolve({ copied: 1, skipped: [] });
await waitFor(() => expect(props.onClose).toHaveBeenCalled());
});
it('FE-COMP-COPYTRIP-013: an empty selection never reaches the copy handler and Escape closes', async () => {
const user = userEvent.setup();
const props = renderModal({ placeIds: [] });
fireEvent.click(await screen.findByText('Rome 2026'));
expect(props.onCopy).not.toHaveBeenCalled();
await user.keyboard('{Escape}');
expect(props.onClose).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,172 @@
// FE-COMP-LABELMGR-001 to FE-COMP-LABELMGR-013
import React from 'react';
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import type { CollectionLabel } from '@trek/shared';
import { useTranslation } from '../../i18n/TranslationContext';
import LabelManager from './LabelManager';
type ManagerProps = Omit<React.ComponentProps<typeof LabelManager>, 't'>;
function Harness(props: ManagerProps): React.ReactElement {
const { t } = useTranslation();
return <LabelManager {...props} t={t} />;
}
const berlin: CollectionLabel = { id: 1, collection_id: 10, name: 'Berlin', color: '#0ea5e9' };
const food: CollectionLabel = { id: 2, collection_id: 10, name: 'Food', color: null };
function renderManager(overrides: Partial<ManagerProps> = {}) {
const props: ManagerProps = {
isOpen: true,
labels: [berlin, food],
onCreate: vi.fn(async () => {}),
onUpdate: vi.fn(async () => {}),
onDelete: vi.fn(async () => {}),
onClose: vi.fn(),
...overrides,
};
render(<Harness {...props} />);
return props;
}
/** The row whose rename input carries the given label name. */
function row(name: string): HTMLElement {
return screen.getByDisplayValue(name).closest('div') as HTMLElement;
}
describe('LabelManager', () => {
it('FE-COMP-LABELMGR-001: renders one editable row per label under the manage title', () => {
renderManager();
expect(screen.getByRole('heading', { name: 'Manage labels' })).toBeInTheDocument();
expect(screen.getByDisplayValue('Berlin')).toBeInTheDocument();
expect(screen.getByDisplayValue('Food')).toBeInTheDocument();
});
it('FE-COMP-LABELMGR-002: shows the empty copy and no rows when the list has no labels', () => {
renderManager({ labels: [] });
expect(screen.getByText('No labels yet')).toBeInTheDocument();
expect(screen.queryByLabelText('Label name')).not.toBeInTheDocument();
});
it('FE-COMP-LABELMGR-003: renders nothing at all while closed', () => {
renderManager({ isOpen: false });
expect(screen.queryByRole('heading', { name: 'Manage labels' })).not.toBeInTheDocument();
});
it('FE-COMP-LABELMGR-004: the Add button stays disabled until a name is typed', async () => {
const user = userEvent.setup();
renderManager();
const add = screen.getByRole('button', { name: /Add label/ });
expect(add).toBeDisabled();
await user.type(screen.getByPlaceholderText('e.g. Berlin'), 'Nightlife');
expect(add).toBeEnabled();
});
it('FE-COMP-LABELMGR-005: adding a label passes the trimmed name plus the picked colour and clears the form', async () => {
const user = userEvent.setup();
const props = renderManager();
await user.type(screen.getByPlaceholderText('e.g. Berlin'), ' Nightlife ');
await user.click(screen.getByRole('button', { name: /Add label/ }));
await waitFor(() => expect(props.onCreate).toHaveBeenCalledWith('Nightlife', '#6366f1'));
expect(screen.getByPlaceholderText('e.g. Berlin')).toHaveValue('');
});
it('FE-COMP-LABELMGR-006: picking a swatch in the create form changes the colour sent to onCreate', async () => {
const user = userEvent.setup();
const props = renderManager({ labels: [] });
// The only swatch row on screen belongs to the create form (no label rows).
await user.click(screen.getByRole('button', { name: '#10b981' }));
await user.type(screen.getByPlaceholderText('e.g. Berlin'), 'Nature');
await user.click(screen.getByRole('button', { name: /Add label/ }));
await waitFor(() => expect(props.onCreate).toHaveBeenCalledWith('Nature', '#10b981'));
});
it('FE-COMP-LABELMGR-007: Enter in the name field creates the label too', async () => {
const user = userEvent.setup();
const props = renderManager({ labels: [] });
await user.type(screen.getByPlaceholderText('e.g. Berlin'), 'Museums{Enter}');
await waitFor(() => expect(props.onCreate).toHaveBeenCalledWith('Museums', '#6366f1'));
});
it('FE-COMP-LABELMGR-008: a whitespace-only name never reaches onCreate', async () => {
const user = userEvent.setup();
const props = renderManager({ labels: [] });
await user.type(screen.getByPlaceholderText('e.g. Berlin'), ' {Enter}');
expect(props.onCreate).not.toHaveBeenCalled();
});
it('FE-COMP-LABELMGR-009: a second submit while the first create is still pending is ignored', async () => {
const user = userEvent.setup();
const onCreate = vi.fn(() => new Promise<void>(() => {}));
renderManager({ labels: [], onCreate });
const input = screen.getByPlaceholderText('e.g. Berlin');
await user.type(input, 'Slow{Enter}');
await user.type(input, '{Enter}');
expect(onCreate).toHaveBeenCalledTimes(1);
// The button shows the pending spinner while the promise is open.
expect(screen.getByRole('button', { name: /Add label/ }).querySelector('.animate-spin')).not.toBeNull();
});
it('FE-COMP-LABELMGR-010: renaming a label commits the trimmed name on blur', async () => {
const user = userEvent.setup();
const props = renderManager();
const input = screen.getByDisplayValue('Berlin');
await user.clear(input);
await user.type(input, 'Berlin Mitte');
await user.tab();
await waitFor(() => expect(props.onUpdate).toHaveBeenCalledWith(1, { name: 'Berlin Mitte' }));
});
it('FE-COMP-LABELMGR-011: Enter commits the rename, an unchanged or empty name reverts instead', async () => {
const user = userEvent.setup();
const props = renderManager();
const input = screen.getByDisplayValue('Food');
await user.type(input, ' Trucks{Enter}');
await waitFor(() => expect(props.onUpdate).toHaveBeenCalledWith(2, { name: 'Food Trucks' }));
expect(props.onUpdate).toHaveBeenCalledTimes(1);
// Focus + blur without a change must not fire a second update.
const berlinInput = screen.getByDisplayValue('Berlin');
await user.click(berlinInput);
await user.tab();
expect(props.onUpdate).toHaveBeenCalledTimes(1);
// Emptying the field restores the stored name rather than saving a blank one.
await user.clear(berlinInput);
await user.tab();
expect(screen.getByDisplayValue('Berlin')).toBeInTheDocument();
expect(props.onUpdate).toHaveBeenCalledTimes(1);
});
it('FE-COMP-LABELMGR-012: recolouring a row saves the new colour immediately', async () => {
const user = userEvent.setup();
const props = renderManager();
await user.click(within(row('Berlin')).getByRole('button', { name: '#ef4444' }));
await waitFor(() => expect(props.onUpdate).toHaveBeenCalledWith(1, { color: '#ef4444' }));
});
it('FE-COMP-LABELMGR-013: the row delete button passes the label id, the modal close hands back', async () => {
const user = userEvent.setup();
const props = renderManager();
await user.click(within(row('Food')).getByRole('button', { name: 'Delete' }));
expect(props.onDelete).toHaveBeenCalledWith(2);
await user.keyboard('{Escape}');
expect(props.onClose).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,348 @@
// FE-COMP-LISTEDITOR-001 to FE-COMP-LISTEDITOR-025
import React from 'react'
import type { Mock } from 'vitest'
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
import type { Collection } from '@trek/shared'
import { useCollectionStore } from '../../store/collectionStore'
import { tripsApi } from '../../api/client'
import { useTranslation } from '../../i18n/TranslationContext'
import ListEditorModal from './ListEditorModal'
// The modal takes `t` as a prop, so a harness forwards the real English
// translator from the provider the render helper mounts.
type Props = React.ComponentProps<typeof ListEditorModal>
function Harness(props: Omit<Props, 't'>): React.ReactElement {
const { t } = useTranslation()
return <ListEditorModal {...props} t={t} />
}
const existing: Collection = {
id: 42,
owner_id: 1,
name: 'Tokyo 2026',
color: '#ec4899',
description: 'Ramen shortlist',
links: [{ label: 'Guide', url: 'https://guide.example' }],
cover_image: '/uploads/covers/tokyo.jpg',
is_owner: true,
}
type CollectionStore = ReturnType<typeof useCollectionStore.getState>
type AddToast = NonNullable<typeof window.__addToast>
const initialCollectionState = useCollectionStore.getState()
let actions: {
createCollection: Mock<CollectionStore['createCollection']>
updateCollection: Mock<CollectionStore['updateCollection']>
uploadCover: Mock<CollectionStore['uploadCover']>
}
let addToast: Mock<AddToast>
const realCreateObjectURL = URL.createObjectURL
const realRevokeObjectURL = URL.revokeObjectURL
function stubObjectUrl(value: unknown, revoke: unknown): void {
Object.defineProperty(URL, 'createObjectURL', { writable: true, configurable: true, value })
Object.defineProperty(URL, 'revokeObjectURL', { writable: true, configurable: true, value: revoke })
}
function setup(over: Partial<Omit<Props, 't'>> = {}) {
const props: Omit<Props, 't'> = {
target: 'new',
onClose: vi.fn(),
onCreated: vi.fn(),
onRequestDelete: vi.fn(),
...over,
}
const view = render(<Harness {...props} />)
return { ...view, props }
}
describe('ListEditorModal', () => {
beforeEach(() => {
useCollectionStore.setState(initialCollectionState, true)
actions = {
createCollection: vi.fn<CollectionStore['createCollection']>(async () => ({ id: 77, owner_id: 1, name: 'Fresh' })),
updateCollection: vi.fn<CollectionStore['updateCollection']>(async () => undefined),
uploadCover: vi.fn<CollectionStore['uploadCover']>(async () => undefined),
}
useCollectionStore.setState(actions)
addToast = vi.fn<AddToast>(() => 0)
window.__addToast = addToast
// Node's URL.createObjectURL rejects a jsdom File, so the preview blob is stubbed.
stubObjectUrl(vi.fn(() => 'blob:cover-preview'), vi.fn())
vi.spyOn(tripsApi, 'searchCoverImages').mockResolvedValue({
photos: [
{ id: 'p1', url: 'https://img/full1.jpg', thumb: 'https://img/thumb1.jpg', description: 'A street', photographer: 'Rin' },
{ id: 'p2', url: 'https://img/full2.jpg', thumb: 'https://img/thumb2.jpg', description: null, photographer: null },
],
})
})
afterEach(() => {
vi.restoreAllMocks()
delete window.__addToast
stubObjectUrl(realCreateObjectURL, realRevokeObjectURL)
})
it('FE-COMP-LISTEDITOR-001: renders nothing while the target is null', () => {
setup({ target: null })
expect(screen.queryByRole('heading', { name: /New list|Edit list/ })).not.toBeInTheDocument()
})
it('FE-COMP-LISTEDITOR-002: create mode shows the New list title and a Create action', () => {
setup()
expect(screen.getByRole('heading', { name: 'New list' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled()
expect(screen.queryByRole('button', { name: /Delete list/ })).not.toBeInTheDocument()
})
it('FE-COMP-LISTEDITOR-003: edit mode seeds name, description, links and the cover preview', () => {
setup({ target: existing })
expect(screen.getByRole('heading', { name: 'Edit list' })).toBeInTheDocument()
expect(screen.getByDisplayValue('Tokyo 2026')).toBeInTheDocument()
expect(screen.getByDisplayValue('Ramen shortlist')).toBeInTheDocument()
expect(screen.getByDisplayValue('Guide')).toBeInTheDocument()
expect(screen.getByDisplayValue('https://guide.example')).toBeInTheDocument()
// A seeded cover flips the button label and paints the image.
expect(screen.getByRole('button', { name: 'Change cover' })).toBeInTheDocument()
expect(document.querySelector('img[src="/uploads/covers/tokyo.jpg"]')).toBeTruthy()
})
it('FE-COMP-LISTEDITOR-004: the owner gets a Delete list action that closes and hands off the id', () => {
const { props } = setup({ target: existing })
fireEvent.click(screen.getByRole('button', { name: /Delete list/ }))
expect(props.onClose).toHaveBeenCalledTimes(1)
expect(props.onRequestDelete).toHaveBeenCalledWith(42)
})
it('FE-COMP-LISTEDITOR-005: a non-owner editor sees no Delete list action', () => {
setup({ target: { ...existing, is_owner: false } })
expect(screen.queryByRole('button', { name: /Delete list/ })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
})
it('FE-COMP-LISTEDITOR-006: creating posts the trimmed payload and reports the new id', async () => {
const { props } = setup()
fireEvent.change(screen.getByPlaceholderText('e.g. Tokyo 2025'), { target: { value: ' Rome ' } })
fireEvent.change(screen.getByPlaceholderText('Add a description…'), { target: { value: ' gelato ' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
await waitFor(() => expect(actions.createCollection).toHaveBeenCalledTimes(1))
expect(actions.createCollection).toHaveBeenCalledWith({
name: 'Rome',
color: '#6366f1',
description: 'gelato',
links: [],
})
expect(props.onCreated).toHaveBeenCalledWith(77)
expect(props.onClose).toHaveBeenCalledTimes(1)
})
it('FE-COMP-LISTEDITOR-007: an empty description is sent as null', async () => {
setup()
fireEvent.change(screen.getByPlaceholderText('e.g. Tokyo 2025'), { target: { value: 'Rome' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
await waitFor(() => expect(actions.createCollection).toHaveBeenCalled())
expect(actions.createCollection.mock.calls[0][0].description).toBeNull()
})
it('FE-COMP-LISTEDITOR-008: editing patches the existing list and never calls onCreated', async () => {
const { props } = setup({ target: existing })
fireEvent.change(screen.getByDisplayValue('Tokyo 2026'), { target: { value: 'Tokyo 2027' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(actions.updateCollection).toHaveBeenCalledTimes(1))
expect(actions.updateCollection).toHaveBeenCalledWith(42, expect.objectContaining({ name: 'Tokyo 2027', color: '#ec4899' }))
expect(actions.createCollection).not.toHaveBeenCalled()
expect(props.onCreated).not.toHaveBeenCalled()
expect(props.onClose).toHaveBeenCalledTimes(1)
})
it('FE-COMP-LISTEDITOR-009: Enter in the name field saves', async () => {
setup()
const nameInput = screen.getByPlaceholderText('e.g. Tokyo 2025')
fireEvent.change(nameInput, { target: { value: 'Lisbon' } })
fireEvent.keyDown(nameInput, { key: 'Enter' })
await waitFor(() => expect(actions.createCollection).toHaveBeenCalled())
})
it('FE-COMP-LISTEDITOR-010: Enter on a blank name does nothing', () => {
setup()
fireEvent.keyDown(screen.getByPlaceholderText('e.g. Tokyo 2025'), { key: 'Enter' })
expect(actions.createCollection).not.toHaveBeenCalled()
})
it('FE-COMP-LISTEDITOR-011: picking a swatch changes the saved colour', async () => {
setup()
fireEvent.change(screen.getByPlaceholderText('e.g. Tokyo 2025'), { target: { value: 'Oslo' } })
fireEvent.click(screen.getByRole('button', { name: '#22c55e' }))
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
await waitFor(() => expect(actions.createCollection).toHaveBeenCalled())
expect(actions.createCollection.mock.calls[0][0].color).toBe('#22c55e')
})
it('FE-COMP-LISTEDITOR-012: links are normalised, labelled and blank rows are dropped', async () => {
setup()
fireEvent.change(screen.getByPlaceholderText('e.g. Tokyo 2025'), { target: { value: 'Oslo' } })
fireEvent.click(screen.getByRole('button', { name: /Add link/ }))
fireEvent.click(screen.getByRole('button', { name: /Add link/ }))
const urls = screen.getAllByPlaceholderText('https://…')
const labels = screen.getAllByPlaceholderText('Label')
fireEvent.change(labels[0], { target: { value: ' Menu ' } })
fireEvent.change(urls[0], { target: { value: 'example.com/menu' } })
// The second row stays blank and must not reach the payload.
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
await waitFor(() => expect(actions.createCollection).toHaveBeenCalled())
expect(actions.createCollection.mock.calls[0][0].links).toEqual([
{ label: 'Menu', url: 'https://example.com/menu' },
])
})
it('FE-COMP-LISTEDITOR-013: a link row can be removed again', () => {
setup({ target: existing })
expect(screen.getByDisplayValue('https://guide.example')).toBeInTheDocument()
fireEvent.click(screen.getAllByRole('button', { name: 'Delete' })[0])
expect(screen.queryByDisplayValue('https://guide.example')).not.toBeInTheDocument()
})
it('FE-COMP-LISTEDITOR-014: choosing a file shows a preview and uploads it after the create', async () => {
const file = new File(['x'], 'cover.png', { type: 'image/png' })
const { container } = setup()
fireEvent.change(screen.getByPlaceholderText('e.g. Tokyo 2025'), { target: { value: 'Oslo' } })
const fileInput = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement
fireEvent.change(fileInput, { target: { files: [file] } })
expect(screen.getByRole('button', { name: 'Change cover' })).toBeInTheDocument()
expect(document.querySelector('img[src="blob:cover-preview"]')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
await waitFor(() => expect(actions.uploadCover).toHaveBeenCalledWith(77, file))
})
it('FE-COMP-LISTEDITOR-015: the cover tile opens the file picker, and an empty pick leaves the cover alone', () => {
const { container } = setup()
const fileInput = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement
const openPicker = vi.fn()
fileInput.addEventListener('click', openPicker)
fireEvent.click(screen.getByRole('button', { name: 'Add cover' }))
expect(openPicker).toHaveBeenCalledTimes(1)
fireEvent.change(fileInput, { target: { files: [] } })
expect(screen.getByRole('button', { name: 'Add cover' })).toBeInTheDocument()
})
it('FE-COMP-LISTEDITOR-016: the Unsplash search renders results and the picked photo becomes cover_image', async () => {
setup()
fireEvent.change(screen.getByPlaceholderText('e.g. Tokyo 2025'), { target: { value: 'Oslo' } })
fireEvent.change(screen.getByPlaceholderText('Search destination photos'), { target: { value: 'fjord' } })
fireEvent.click(screen.getByRole('button', { name: /Search Unsplash/ }))
expect(await screen.findByRole('button', { name: 'Rin' })).toBeInTheDocument()
expect(tripsApi.searchCoverImages).toHaveBeenCalledWith('fjord')
// A photo without a photographer falls back to the generic label.
fireEvent.click(screen.getByRole('button', { name: 'Unsplash' }))
expect(document.querySelector('img[src="https://img/full2.jpg"]')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
await waitFor(() => expect(actions.createCollection).toHaveBeenCalled())
expect(actions.createCollection.mock.calls[0][0].cover_image).toBe('https://img/full2.jpg')
expect(actions.uploadCover).not.toHaveBeenCalled()
})
it('FE-COMP-LISTEDITOR-017: an empty query falls back to the list name, Enter triggers the search', async () => {
setup()
const query = screen.getByPlaceholderText('Search destination photos')
fireEvent.change(screen.getByPlaceholderText('e.g. Tokyo 2025'), { target: { value: 'Bergen' } })
fireEvent.keyDown(query, { key: 'Enter' })
await waitFor(() => expect(tripsApi.searchCoverImages).toHaveBeenCalledWith('Bergen'))
})
it('FE-COMP-LISTEDITOR-018: with neither a query nor a name the search button stays disabled', () => {
setup()
expect(screen.getByRole('button', { name: /Search Unsplash/ })).toBeDisabled()
})
it('FE-COMP-LISTEDITOR-019: a failing cover search clears the result grid', async () => {
vi.mocked(tripsApi.searchCoverImages).mockRejectedValueOnce(new Error('offline'))
setup()
fireEvent.change(screen.getByPlaceholderText('Search destination photos'), { target: { value: 'fjord' } })
fireEvent.click(screen.getByRole('button', { name: /Search Unsplash/ }))
await waitFor(() => expect(tripsApi.searchCoverImages).toHaveBeenCalled())
expect(screen.queryByRole('button', { name: 'Rin' })).not.toBeInTheDocument()
})
it('FE-COMP-LISTEDITOR-020: an uploaded file wins over a previously picked Unsplash photo', async () => {
const file = new File(['x'], 'cover.png', { type: 'image/png' })
const { container } = setup()
fireEvent.change(screen.getByPlaceholderText('e.g. Tokyo 2025'), { target: { value: 'Oslo' } })
fireEvent.change(screen.getByPlaceholderText('Search destination photos'), { target: { value: 'fjord' } })
fireEvent.click(screen.getByRole('button', { name: /Search Unsplash/ }))
fireEvent.click(await screen.findByRole('button', { name: 'Rin' }))
const fileInput = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement
fireEvent.change(fileInput, { target: { files: [file] } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
await waitFor(() => expect(actions.createCollection).toHaveBeenCalled())
expect(actions.createCollection.mock.calls[0][0].cover_image).toBeUndefined()
await waitFor(() => expect(actions.uploadCover).toHaveBeenCalledWith(77, file))
})
it('FE-COMP-LISTEDITOR-021: a failed cover upload toasts the server message and the retry updates instead of re-creating', async () => {
actions.uploadCover.mockRejectedValueOnce({ response: { data: { error: 'Cover too large' } } })
const file = new File(['x'], 'cover.png', { type: 'image/png' })
const { container, props } = setup()
fireEvent.change(screen.getByPlaceholderText('e.g. Tokyo 2025'), { target: { value: 'Oslo' } })
fireEvent.change(container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement, { target: { files: [file] } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Cover too large', 'error', undefined))
expect(props.onClose).not.toHaveBeenCalled()
// Retry: the id from the first (successful) create is reused.
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
await waitFor(() => expect(actions.updateCollection).toHaveBeenCalledWith(77, expect.objectContaining({ name: 'Oslo' })))
expect(actions.createCollection).toHaveBeenCalledTimes(1)
})
it('FE-COMP-LISTEDITOR-022: switching the target reseeds the form', () => {
const { rerender, props } = setup({ target: existing })
fireEvent.change(screen.getByDisplayValue('Tokyo 2026'), { target: { value: 'scratch' } })
rerender(<Harness {...props} target="new" />)
expect(screen.queryByDisplayValue('scratch')).not.toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'New list' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add cover' })).toBeInTheDocument()
})
it('FE-COMP-LISTEDITOR-023: a create that fails outright keeps the modal open and reports the error', async () => {
actions.createCollection.mockRejectedValueOnce(new Error('boom'))
const { props } = setup()
fireEvent.change(screen.getByPlaceholderText('e.g. Tokyo 2025'), { target: { value: 'Oslo' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
// A plain failure has no server-provided message, so the translated fallback shows.
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined))
expect(props.onClose).not.toHaveBeenCalled()
// The save button releases again so the user can retry.
await waitFor(() => expect(screen.getByRole('button', { name: 'Create' })).not.toBeDisabled())
})
it('FE-COMP-LISTEDITOR-024: unmounting revokes the last preview blob', () => {
const revoke = vi.fn()
stubObjectUrl(vi.fn(() => 'blob:cover-preview'), revoke)
const file = new File(['x'], 'cover.png', { type: 'image/png' })
const { container, unmount } = setup()
fireEvent.change(container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement, { target: { files: [file] } })
unmount()
expect(revoke).toHaveBeenCalledWith('blob:cover-preview')
})
it('FE-COMP-LISTEDITOR-025: Cancel closes without touching the store', () => {
const { props } = setup({ target: existing })
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(props.onClose).toHaveBeenCalledTimes(1)
expect(actions.updateCollection).not.toHaveBeenCalled()
})
})
@@ -4,7 +4,7 @@ import Modal from '../shared/Modal'
import { useCollectionStore } from '../../store/collectionStore'
import { useToast } from '../shared/Toast'
import { tripsApi } from '../../api/client'
import { getApiErrorMessage } from '../../types'
import { getApiErrorMessage } from '../../utils/apiError'
import { normalizeLinkUrl } from '../../pages/collections/collectionsModel'
import type { TranslationFn } from '../../types'
import type { Collection, CollectionLink } from '@trek/shared'
@@ -0,0 +1,130 @@
// FE-COMP-LISTSRAIL-001 to FE-COMP-LISTSRAIL-010
import React from 'react';
import { render, screen, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import type { Collection } from '@trek/shared';
import { useTranslation } from '../../i18n/TranslationContext';
import { ALL_SAVED } from '../../store/collectionStore';
import type { IncomingCollectionInvite } from '../../store/collectionStore';
import ListsRail from './ListsRail';
// The rail takes `t` as a prop, so a harness forwards the real English translator
// from the provider the render helper mounts.
type RailProps = Omit<React.ComponentProps<typeof ListsRail>, 't'>;
function Harness(props: RailProps): React.ReactElement {
const { t } = useTranslation();
return <ListsRail {...props} t={t} />;
}
const rome: Collection = { id: 11, owner_id: 1, name: 'Weekend in Rome', color: '#ef4444', place_count: 3 };
const tokyo: Collection = { id: 22, owner_id: 1, name: 'Tokyo Food Tour', color: null };
const shared: Collection = { id: 33, owner_id: 9, name: 'Family trip', color: '#22c55e', place_count: 7, is_owner: false };
const invite: IncomingCollectionInvite = {
collection_id: 44,
name: 'Iceland ideas',
from: { id: 5, username: 'kim' },
};
function renderRail(overrides: Partial<RailProps> = {}) {
const props: RailProps = {
ownedLists: [rome, tokyo],
sharedLists: [],
activeId: rome.id,
incomingInvites: [],
onSelect: vi.fn(),
onNewList: vi.fn(),
onAcceptInvite: vi.fn(),
onDeclineInvite: vi.fn(),
...overrides,
};
render(<Harness {...props} />);
return props;
}
describe('ListsRail', () => {
it('FE-COMP-LISTSRAIL-001: renders the new-list action, the All saved row and every owned list', () => {
renderRail();
expect(screen.getByRole('button', { name: 'New list' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'All saved' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Weekend in Rome/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Tokyo Food Tour/ })).toBeInTheDocument();
});
it('FE-COMP-LISTSRAIL-002: shows the place count per list and falls back to 0 when absent', () => {
renderRail();
expect(within(screen.getByRole('button', { name: /Weekend in Rome/ })).getByText('3')).toBeInTheDocument();
// tokyo carries no place_count — the rail renders 0 rather than an empty slot.
expect(within(screen.getByRole('button', { name: /Tokyo Food Tour/ })).getByText('0')).toBeInTheDocument();
});
it('FE-COMP-LISTSRAIL-003: marks only the active list row with the "on" class', () => {
renderRail({ activeId: tokyo.id });
expect(screen.getByRole('button', { name: /Tokyo Food Tour/ })).toHaveClass('on');
expect(screen.getByRole('button', { name: /Weekend in Rome/ })).not.toHaveClass('on');
expect(screen.getByRole('button', { name: 'All saved' })).not.toHaveClass('on');
});
it('FE-COMP-LISTSRAIL-004: marks the All saved row active when activeId is the sentinel', () => {
renderRail({ activeId: ALL_SAVED });
expect(screen.getByRole('button', { name: 'All saved' })).toHaveClass('on');
expect(screen.getByRole('button', { name: /Weekend in Rome/ })).not.toHaveClass('on');
});
it('FE-COMP-LISTSRAIL-005: clicking a list row selects it by id, All saved selects the sentinel', async () => {
const user = userEvent.setup();
const props = renderRail();
await user.click(screen.getByRole('button', { name: /Tokyo Food Tour/ }));
expect(props.onSelect).toHaveBeenCalledWith(22);
await user.click(screen.getByRole('button', { name: 'All saved' }));
expect(props.onSelect).toHaveBeenCalledWith(ALL_SAVED);
});
it('FE-COMP-LISTSRAIL-006: the new-list button calls onNewList', async () => {
const user = userEvent.setup();
const props = renderRail();
await user.click(screen.getByRole('button', { name: 'New list' }));
expect(props.onNewList).toHaveBeenCalledTimes(1);
});
it('FE-COMP-LISTSRAIL-007: renders a Shared section with its lists only when there are shared lists', () => {
const { unmount } = render(<Harness {...{
ownedLists: [rome], sharedLists: [], activeId: null, incomingInvites: [],
onSelect: vi.fn(), onNewList: vi.fn(), onAcceptInvite: vi.fn(), onDeclineInvite: vi.fn(),
}} />);
expect(screen.queryByText('Shared')).not.toBeInTheDocument();
unmount();
renderRail({ sharedLists: [shared] });
expect(screen.getByText('Shared')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Family trip/ })).toBeInTheDocument();
});
it('FE-COMP-LISTSRAIL-008: renders the invites block with the sender name and a count badge', () => {
renderRail({ incomingInvites: [invite] });
expect(screen.getByText('Iceland ideas')).toBeInTheDocument();
expect(screen.getByText('from kim')).toBeInTheDocument();
expect(screen.getByText('Invites').querySelector('.badge')?.textContent).toBe('1');
});
it('FE-COMP-LISTSRAIL-009: accepting and declining an invite pass the collection id', async () => {
const user = userEvent.setup();
const props = renderRail({ incomingInvites: [invite] });
await user.click(screen.getByRole('button', { name: 'Accept' }));
expect(props.onAcceptInvite).toHaveBeenCalledWith(44);
await user.click(screen.getByRole('button', { name: 'Decline' }));
expect(props.onDeclineInvite).toHaveBeenCalledWith(44);
});
it('FE-COMP-LISTSRAIL-010: with no owned lists, no separator and no invite block render', () => {
renderRail({ ownedLists: [], activeId: ALL_SAVED });
expect(document.querySelector('.col-rail-sep')).toBeNull();
expect(screen.queryByText('Invites')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'All saved' })).toBeInTheDocument();
});
});
@@ -0,0 +1,266 @@
// FE-COMP-MSAVESHEET-001 to FE-COMP-MSAVESHEET-016
import React from 'react';
import { afterEach, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render';
import type { Collection, CollectionListResponse, CollectionMembership, CollectionSaveResult } from '@trek/shared';
import { collectionsApi } from '../../api/collections';
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore';
import type { SaveToCollectionTarget } from '../../store/saveToCollectionStore';
import MSaveToCollectionSheet from './MSaveToCollectionSheet';
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return { ...actual, useNavigate: () => mockNavigate };
});
function list(over: Partial<Collection>): Collection {
return { id: 1, owner_id: 7, name: 'List', color: null, place_count: 0, ...over } as Collection;
}
const FAVORITES = list({ id: 1, name: 'Favorites', color: '#ef4444', place_count: 4 });
const WISHLIST = list({ id: 2, name: 'Wishlist' });
const SHARED = list({ id: 3, name: 'Team ideas', is_owner: false });
const listResponse = (collections: Collection[]): CollectionListResponse => ({ collections, incomingInvites: [] });
const EMPTY_MEMBERSHIP: CollectionMembership = { saved: false, lists: [] };
const TARGET: SaveToCollectionTarget = {
name: 'Colosseum',
source_trip_id: 5,
source_place_id: 42,
lat: 41.89,
lng: 12.49,
google_place_id: 'gp-1',
google_ftid: 'ft-1',
};
let addToast: ReturnType<typeof vi.fn>;
function openFor(target: SaveToCollectionTarget = TARGET) {
useSaveToCollectionStore.setState({ target, version: 0 });
}
beforeEach(() => {
addToast = vi.fn();
window.__addToast = addToast as unknown as typeof window.__addToast;
mockNavigate.mockClear();
useSaveToCollectionStore.setState({ target: null, version: 0 });
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([FAVORITES, WISHLIST]));
vi.spyOn(collectionsApi, 'membership').mockResolvedValue(EMPTY_MEMBERSHIP);
});
afterEach(() => {
vi.restoreAllMocks();
useSaveToCollectionStore.setState({ target: null, version: 0 });
delete window.__addToast;
});
describe('MSaveToCollectionSheet', () => {
it('FE-COMP-MSAVESHEET-001: stays closed and silent while no target is set', () => {
render(<MSaveToCollectionSheet />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(collectionsApi.list).not.toHaveBeenCalled();
});
it('FE-COMP-MSAVESHEET-002: opens a labelled sheet showing the target name and the lists', async () => {
openFor();
render(<MSaveToCollectionSheet />);
expect(await screen.findByText('Favorites')).toBeInTheDocument();
expect(screen.getByRole('dialog', { name: 'Save to list' })).toBeInTheDocument();
expect(screen.getByText('Colosseum')).toBeInTheDocument();
expect(screen.getByText('4 places')).toBeInTheDocument();
expect(screen.getByText('0 places')).toBeInTheDocument();
});
it('FE-COMP-MSAVESHEET-003: queries membership with the maps identity of the target', async () => {
openFor();
render(<MSaveToCollectionSheet />);
await screen.findByText('Favorites');
expect(collectionsApi.membership).toHaveBeenCalledWith({
google_place_id: 'gp-1',
google_ftid: 'ft-1',
name: 'Colosseum',
lat: 41.89,
lng: 12.49,
});
});
it('FE-COMP-MSAVESHEET-004: shows the spinner until the lists arrive', async () => {
let resolve!: (v: CollectionListResponse) => void;
vi.spyOn(collectionsApi, 'list').mockReturnValue(new Promise(r => { resolve = r; }));
openFor();
render(<MSaveToCollectionSheet />);
expect(document.querySelector('.animate-spin')).not.toBeNull();
resolve(listResponse([FAVORITES]));
expect(await screen.findByText('Favorites')).toBeInTheDocument();
});
it('FE-COMP-MSAVESHEET-005: a failing list request shows the empty state and its create shortcut', async () => {
vi.spyOn(collectionsApi, 'list').mockRejectedValue(new Error('offline'));
openFor();
render(<MSaveToCollectionSheet />);
expect(await screen.findByText('Create a list first to save places.')).toBeInTheDocument();
// With no lists the sheet drops its footer bar entirely.
expect(screen.queryByRole('button', { name: 'View' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /New list/ }));
expect(mockNavigate).toHaveBeenCalledWith('/collections');
expect(useSaveToCollectionStore.getState().target).toBeNull();
});
it('FE-COMP-MSAVESHEET-006: lists already holding the place are marked saved', async () => {
vi.spyOn(collectionsApi, 'membership').mockResolvedValue({
saved: true,
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900 }],
});
openFor();
render(<MSaveToCollectionSheet />);
const favorites = await screen.findByRole('button', { name: /Favorites/ });
expect(favorites.querySelector('.lucide-bookmark-check')).not.toBeNull();
expect(screen.getByRole('button', { name: /Wishlist/ }).querySelector('.lucide-bookmark-check')).toBeNull();
});
it('FE-COMP-MSAVESHEET-007: a failing membership lookup degrades to "saved nowhere"', async () => {
vi.spyOn(collectionsApi, 'membership').mockRejectedValue(new Error('offline'));
openFor();
render(<MSaveToCollectionSheet />);
const favorites = await screen.findByRole('button', { name: /Favorites/ });
expect(favorites.querySelector('.lucide-bookmark-check')).toBeNull();
});
it('FE-COMP-MSAVESHEET-008: a shared list carries the SHARED badge', async () => {
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([FAVORITES, SHARED]));
openFor();
render(<MSaveToCollectionSheet />);
const shared = await screen.findByRole('button', { name: /Team ideas/ });
expect(within(shared).getByText('Shared')).toBeInTheDocument();
});
it('FE-COMP-MSAVESHEET-009: tapping an unsaved list saves the target and bumps the version', async () => {
const save = vi.spyOn(collectionsApi, 'savePlace').mockResolvedValue({});
openFor();
render(<MSaveToCollectionSheet />);
fireEvent.click(await screen.findByRole('button', { name: /Wishlist/ }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Added to Wishlist', 'success', undefined));
expect(save).toHaveBeenCalledWith(expect.objectContaining({
collection_id: 2,
source_trip_id: 5,
source_place_id: 42,
name: 'Colosseum',
lat: 41.89,
lng: 12.49,
address: null,
force: true,
}));
expect(useSaveToCollectionStore.getState().version).toBe(1);
});
it('FE-COMP-MSAVESHEET-010: tapping a saved list removes that place instead', async () => {
vi.spyOn(collectionsApi, 'membership').mockResolvedValue({
saved: true,
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900 }],
});
const del = vi.spyOn(collectionsApi, 'deletePlace').mockResolvedValue({});
openFor();
render(<MSaveToCollectionSheet />);
fireEvent.click(await screen.findByRole('button', { name: /Favorites/ }));
await waitFor(() => expect(del).toHaveBeenCalledWith(900));
expect(addToast).toHaveBeenCalledWith('Removed from Favorites', 'success', undefined);
});
it('FE-COMP-MSAVESHEET-011: a failing save surfaces the server message and leaves the version alone', async () => {
vi.spyOn(collectionsApi, 'savePlace').mockRejectedValue({ response: { data: { error: 'List is full' } } });
openFor();
render(<MSaveToCollectionSheet />);
fireEvent.click(await screen.findByRole('button', { name: /Wishlist/ }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('List is full', 'error', undefined));
expect(useSaveToCollectionStore.getState().version).toBe(0);
});
it('FE-COMP-MSAVESHEET-012: every row locks while one save runs, so a second tap is dropped', async () => {
let resolve!: (v: CollectionSaveResult) => void;
const save = vi.spyOn(collectionsApi, 'savePlace').mockReturnValue(new Promise(r => { resolve = r; }));
openFor();
render(<MSaveToCollectionSheet />);
const wishlist = await screen.findByRole('button', { name: /Wishlist/ });
fireEvent.click(wishlist);
const favorites = screen.getByRole('button', { name: /Favorites/ });
expect(wishlist).toBeDisabled();
expect(favorites).toBeDisabled();
fireEvent.click(favorites);
expect(save).toHaveBeenCalledTimes(1);
resolve({});
await waitFor(() => expect(useSaveToCollectionStore.getState().version).toBe(1));
});
it('FE-COMP-MSAVESHEET-015: a membership refresh that fails after a save falls back to "saved nowhere"', async () => {
vi.spyOn(collectionsApi, 'membership')
.mockResolvedValueOnce(EMPTY_MEMBERSHIP)
.mockRejectedValue(new Error('offline'));
vi.spyOn(collectionsApi, 'savePlace').mockResolvedValue({});
openFor();
render(<MSaveToCollectionSheet />);
fireEvent.click(await screen.findByRole('button', { name: /Wishlist/ }));
await waitFor(() => expect(useSaveToCollectionStore.getState().version).toBe(1));
expect(screen.getByRole('button', { name: /Wishlist/ }).querySelector('.lucide-bookmark-check')).toBeNull();
});
it('FE-COMP-MSAVESHEET-016: a response landing after the sheet closed is dropped', async () => {
let resolve!: (v: CollectionListResponse) => void;
vi.spyOn(collectionsApi, 'list').mockReturnValue(new Promise(r => { resolve = r; }));
openFor();
const { unmount } = render(<MSaveToCollectionSheet />);
unmount();
resolve(listResponse([FAVORITES]));
await Promise.resolve();
expect(screen.queryByText('Favorites')).not.toBeInTheDocument();
});
it('FE-COMP-MSAVESHEET-013: the footer View navigates to the collections page', async () => {
openFor();
render(<MSaveToCollectionSheet />);
await screen.findByText('Favorites');
fireEvent.click(screen.getByRole('button', { name: 'View' }));
expect(mockNavigate).toHaveBeenCalledWith('/collections');
expect(useSaveToCollectionStore.getState().target).toBeNull();
});
it('FE-COMP-MSAVESHEET-014: both the header X and the footer Close dismiss without navigating', async () => {
openFor();
const { unmount } = render(<MSaveToCollectionSheet />);
await screen.findByText('Favorites');
// Header icon button first in the DOM, footer button last.
const closes = screen.getAllByRole('button', { name: 'Close' });
expect(closes).toHaveLength(2);
fireEvent.click(closes[closes.length - 1]);
expect(useSaveToCollectionStore.getState().target).toBeNull();
unmount();
openFor();
render(<MSaveToCollectionSheet />);
await screen.findByText('Favorites');
fireEvent.click(screen.getAllByRole('button', { name: 'Close' })[0]);
expect(useSaveToCollectionStore.getState().target).toBeNull();
expect(mockNavigate).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,223 @@
import { useEffect, useMemo, useState, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { Bookmark, BookmarkCheck, Check, Loader2, Plus, X } from 'lucide-react'
import MSheet from '../../mobile/components/MSheet'
import MIconBtn from '../../mobile/components/MIconBtn'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { collectionsApi } from '../../api/collections'
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'
import { getApiErrorMessage } from '../../utils/apiError'
import type { Collection, CollectionMembership } from '@trek/shared'
/**
* Mobile counterpart of SaveToCollectionModal the same store-driven list
* picker (load lists + membership, toggle the place in/out of each), dressed in
* the mobile design language (MSheet card, m-* tokens) so it matches the place
* detail sheet. Rendered instead of the desktop modal on phones (see App.tsx).
*/
export default function MSaveToCollectionSheet() {
const target = useSaveToCollectionStore(s => s.target)
const close = useSaveToCollectionStore(s => s.close)
const bumpVersion = useSaveToCollectionStore(s => s.bumpVersion)
const { t } = useTranslation()
const toast = useToast()
const navigate = useNavigate()
const [lists, setLists] = useState<Collection[]>([])
const [membership, setMembership] = useState<CollectionMembership | null>(null)
const [loading, setLoading] = useState(false)
const [busyId, setBusyId] = useState<number | null>(null)
const membershipQuery = useMemo(() => {
if (!target) return null
return {
google_place_id: target.google_place_id ?? undefined,
google_ftid: target.google_ftid ?? undefined,
name: target.name,
lat: target.lat ?? undefined,
lng: target.lng ?? undefined,
}
}, [target])
const refreshMembership = useCallback(async () => {
if (!membershipQuery) return
try {
setMembership(await collectionsApi.membership(membershipQuery))
} catch {
setMembership({ saved: false, lists: [] })
}
}, [membershipQuery])
// Load lists + membership whenever the picker opens for a new target.
useEffect(() => {
if (!target) return
let cancelled = false
setLoading(true)
setMembership(null)
Promise.all([
collectionsApi.list().catch(() => ({ collections: [], incomingInvites: [] })),
membershipQuery
? collectionsApi.membership(membershipQuery).catch(() => ({ saved: false, lists: [] as CollectionMembership['lists'] }))
: Promise.resolve({ saved: false, lists: [] as CollectionMembership['lists'] }),
])
.then(([listRes, m]) => {
if (cancelled) return
setLists(listRes.collections)
setMembership(m)
})
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [target])
const savedByCollection = new Map<number, number>()
for (const l of membership?.lists ?? []) savedByCollection.set(l.collection_id, l.place_id)
const handleToggle = async (list: Collection) => {
if (busyId != null || !target) return
const savedPlaceId = savedByCollection.get(list.id)
setBusyId(list.id)
try {
if (savedPlaceId != null) {
await collectionsApi.deletePlace(savedPlaceId)
toast.success(t('collections.removedFromList', { name: list.name }))
} else {
await collectionsApi.savePlace({
collection_id: list.id,
source_trip_id: target.source_trip_id ?? null,
source_place_id: target.source_place_id ?? null,
name: target.name,
description: target.description ?? null,
lat: target.lat ?? null,
lng: target.lng ?? null,
address: target.address ?? null,
category_id: target.category_id ?? null,
price: target.price ?? null,
currency: target.currency ?? null,
notes: target.notes ?? null,
image_url: target.image_url ?? null,
google_place_id: target.google_place_id ?? null,
google_ftid: target.google_ftid ?? null,
osm_id: target.osm_id ?? null,
website: target.website ?? null,
phone: target.phone ?? null,
force: true,
})
toast.success(t('collections.addedToList', { name: list.name }))
}
await refreshMembership()
bumpVersion()
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setBusyId(null)
}
}
return (
<MSheet open={!!target} onClose={close} variant="card" material="glass" ariaLabel={t('collections.pickList')}>
{/* Header — mirrors the place detail sheet: icon + title + target name + close */}
<div className="flex-none px-[18px] pt-4">
<div className="flex items-start gap-3">
<span className="flex h-[42px] w-[42px] flex-none items-center justify-center rounded-[14px] bg-[color:var(--m-ic)] text-m-muted">
<Bookmark size={18} strokeWidth={2} />
</span>
<div className="min-w-0 flex-1">
<div className="text-[1rem] font-bold leading-snug">{t('collections.pickList')}</div>
{target?.name && (
<div className="mt-[2px] truncate font-geist text-[0.6875rem] text-m-muted">{target.name}</div>
)}
</div>
<MIconBtn variant="neutral" size={34} onClick={close} ariaLabel={t('common.close')}>
<X size={15} strokeWidth={2.2} />
</MIconBtn>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-[14px] pb-[16px] pt-2">
{loading ? (
<div className="flex items-center justify-center py-10 text-m-faint">
<Loader2 size={20} className="animate-spin" />
</div>
) : lists.length === 0 ? (
<div className="flex flex-col items-center px-4 py-10 text-center">
<span className="mb-3 flex h-11 w-11 items-center justify-center rounded-2xl bg-[color:var(--m-ic)] text-m-faint">
<Bookmark size={20} strokeWidth={2} />
</span>
<p className="mb-3 font-geist text-[0.75rem] text-m-faint">{t('collections.noListsYet')}</p>
<button
type="button"
onClick={() => { close(); navigate('/collections') }}
className="inline-flex items-center gap-1.5 rounded-full bg-m-act px-4 py-[9px] text-[0.75rem] font-semibold text-m-actfg"
>
<Plus size={14} strokeWidth={2.2} /> {t('collections.newList')}
</button>
</div>
) : (
lists.map(list => {
const saved = savedByCollection.has(list.id)
const busy = busyId === list.id
return (
<button
key={list.id}
type="button"
onClick={() => handleToggle(list)}
disabled={busyId != null}
className={`mt-2 flex w-full items-center gap-[11px] rounded-[14px] border px-3 py-[10px] text-left disabled:opacity-60 ${
saved
? 'border-[color:var(--m-act)] bg-[color:var(--m-inner)]'
: 'border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)]'
}`}
>
<span
className="flex h-9 w-9 flex-none items-center justify-center rounded-xl text-white"
style={{ background: list.color || '#6366f1' }}
>
<Bookmark size={15} strokeWidth={2} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[0.8125rem] font-semibold text-m-ink">{list.name}</span>
<span className="mt-px block font-geist text-[0.65625rem] text-m-muted">
{t('collections.placeCount', { count: list.place_count ?? 0 })}
</span>
</span>
{list.is_owner === false && (
<span className="flex-none font-geist text-[0.5625rem] font-bold uppercase tracking-[.05em] text-m-faint">
{t('collections.shared')}
</span>
)}
<span
className={`flex h-[26px] w-[26px] flex-none items-center justify-center rounded-full ${
saved ? 'bg-m-act text-m-actfg' : 'border border-[color:var(--m-rowbr)] text-m-faint'
}`}
>
{busy ? <Loader2 size={14} className="animate-spin" /> : saved ? <BookmarkCheck size={14} strokeWidth={2} /> : <Check size={14} strokeWidth={2} />}
</span>
</button>
)
})
)}
</div>
{lists.length > 0 && (
<div className="flex flex-none items-center justify-between gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] py-3">
<button
type="button"
onClick={() => { close(); navigate('/collections') }}
className="text-[0.78125rem] font-semibold text-[color:var(--m-act)]"
>
{t('collections.viewInCollection')}
</button>
<button
type="button"
onClick={close}
className="rounded-full bg-[color:var(--m-ic)] px-4 py-[8px] text-[0.78125rem] font-semibold text-m-ink"
>
{t('common.close')}
</button>
</div>
)}
</MSheet>
)
}
@@ -0,0 +1,284 @@
// FE-COMP-SAVETOCOL-001 to FE-COMP-SAVETOCOL-016
import React from 'react';
import { afterEach, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render';
import type { Collection, CollectionListResponse, CollectionMembership, CollectionSaveResult } from '@trek/shared';
import { collectionsApi } from '../../api/collections';
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore';
import type { SaveToCollectionTarget } from '../../store/saveToCollectionStore';
import SaveToCollectionModal from './SaveToCollectionModal';
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return { ...actual, useNavigate: () => mockNavigate };
});
function list(over: Partial<Collection>): Collection {
return { id: 1, owner_id: 7, name: 'List', color: null, place_count: 0, ...over } as Collection;
}
const FAVORITES = list({ id: 1, name: 'Favorites', color: '#ef4444', place_count: 4 });
const WISHLIST = list({ id: 2, name: 'Wishlist' });
const SHARED = list({ id: 3, name: 'Team ideas', is_owner: false });
const listResponse = (collections: Collection[]): CollectionListResponse => ({ collections, incomingInvites: [] });
const EMPTY_MEMBERSHIP: CollectionMembership = { saved: false, lists: [] };
const TARGET: SaveToCollectionTarget = {
name: 'Colosseum',
source_trip_id: 5,
source_place_id: 42,
description: 'Ancient arena',
lat: 41.89,
lng: 12.49,
address: 'Rome',
category_id: 3,
price: 16,
currency: 'EUR',
notes: 'Book ahead',
image_url: '/uploads/places/colosseum.jpg',
google_place_id: 'gp-1',
google_ftid: 'ft-1',
osm_id: 'osm-1',
website: 'https://colosseo.example',
phone: '+39',
};
let addToast: ReturnType<typeof vi.fn>;
function openFor(target: SaveToCollectionTarget = TARGET) {
useSaveToCollectionStore.setState({ target, version: 0 });
}
beforeEach(() => {
addToast = vi.fn();
window.__addToast = addToast as unknown as typeof window.__addToast;
mockNavigate.mockClear();
useSaveToCollectionStore.setState({ target: null, version: 0 });
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([FAVORITES, WISHLIST]));
vi.spyOn(collectionsApi, 'membership').mockResolvedValue(EMPTY_MEMBERSHIP);
});
afterEach(() => {
vi.restoreAllMocks();
useSaveToCollectionStore.setState({ target: null, version: 0 });
delete window.__addToast;
});
describe('SaveToCollectionModal', () => {
it('FE-COMP-SAVETOCOL-001: renders nothing and calls no api while no target is set', () => {
render(<SaveToCollectionModal />);
expect(screen.queryByRole('heading', { name: 'Save to list' })).not.toBeInTheDocument();
expect(collectionsApi.list).not.toHaveBeenCalled();
});
it('FE-COMP-SAVETOCOL-002: shows the target name plus every list once loaded', async () => {
openFor();
render(<SaveToCollectionModal />);
expect(await screen.findByText('Favorites')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Save to list' })).toBeInTheDocument();
expect(screen.getByText('Colosseum')).toBeInTheDocument();
expect(screen.getByText('Wishlist')).toBeInTheDocument();
});
it('FE-COMP-SAVETOCOL-003: queries membership with the maps identity of the target', async () => {
openFor();
render(<SaveToCollectionModal />);
await screen.findByText('Favorites');
expect(collectionsApi.membership).toHaveBeenCalledWith({
google_place_id: 'gp-1',
google_ftid: 'ft-1',
name: 'Colosseum',
lat: 41.89,
lng: 12.49,
});
});
it('FE-COMP-SAVETOCOL-004: shows the spinner until the lists arrive', async () => {
let resolve!: (v: CollectionListResponse) => void;
vi.spyOn(collectionsApi, 'list').mockReturnValue(new Promise(r => { resolve = r; }));
openFor();
render(<SaveToCollectionModal />);
expect(document.querySelector('.animate-spin')).not.toBeNull();
resolve(listResponse([FAVORITES]));
expect(await screen.findByText('Favorites')).toBeInTheDocument();
});
it('FE-COMP-SAVETOCOL-005: a failing list request falls back to the empty state with a create shortcut', async () => {
vi.spyOn(collectionsApi, 'list').mockRejectedValue(new Error('offline'));
openFor();
render(<SaveToCollectionModal />);
expect(await screen.findByText('Create a list first to save places.')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /New list/ }));
expect(mockNavigate).toHaveBeenCalledWith('/collections');
expect(useSaveToCollectionStore.getState().target).toBeNull();
});
it('FE-COMP-SAVETOCOL-006: lists already holding the place are marked saved', async () => {
vi.spyOn(collectionsApi, 'membership').mockResolvedValue({
saved: true,
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900 }],
});
openFor();
render(<SaveToCollectionModal />);
const favorites = await screen.findByRole('button', { name: /Favorites/ });
expect(favorites).toHaveClass('border-accent');
expect(favorites.querySelector('.lucide-bookmark-check')).not.toBeNull();
expect(screen.getByRole('button', { name: /Wishlist/ })).not.toHaveClass('border-accent');
});
it('FE-COMP-SAVETOCOL-007: a failing membership lookup degrades to "saved nowhere"', async () => {
vi.spyOn(collectionsApi, 'membership').mockRejectedValue(new Error('offline'));
openFor();
render(<SaveToCollectionModal />);
const favorites = await screen.findByRole('button', { name: /Favorites/ });
expect(favorites).not.toHaveClass('border-accent');
});
it('FE-COMP-SAVETOCOL-008: a shared list carries the SHARED badge', async () => {
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([FAVORITES, SHARED]));
openFor();
render(<SaveToCollectionModal />);
const shared = await screen.findByRole('button', { name: /Team ideas/ });
expect(within(shared).getByText('Shared')).toBeInTheDocument();
expect(within(screen.getByRole('button', { name: /Favorites/ })).queryByText('Shared')).not.toBeInTheDocument();
});
it('FE-COMP-SAVETOCOL-009: picking an unsaved list forwards the whole target payload and bumps the version', async () => {
const save = vi.spyOn(collectionsApi, 'savePlace').mockResolvedValue({});
openFor();
render(<SaveToCollectionModal />);
fireEvent.click(await screen.findByRole('button', { name: /Wishlist/ }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Added to Wishlist', 'success', undefined));
expect(save).toHaveBeenCalledWith(expect.objectContaining({
collection_id: 2,
source_trip_id: 5,
source_place_id: 42,
name: 'Colosseum',
lat: 41.89,
lng: 12.49,
google_place_id: 'gp-1',
osm_id: 'osm-1',
website: 'https://colosseo.example',
force: true,
}));
expect(useSaveToCollectionStore.getState().version).toBe(1);
});
it('FE-COMP-SAVETOCOL-010: a sparse target sends explicit nulls rather than undefined', async () => {
const save = vi.spyOn(collectionsApi, 'savePlace').mockResolvedValue({});
openFor({ name: 'Nameless bar' });
render(<SaveToCollectionModal />);
fireEvent.click(await screen.findByRole('button', { name: /Wishlist/ }));
await waitFor(() => expect(save).toHaveBeenCalled());
expect(save).toHaveBeenCalledWith(expect.objectContaining({
collection_id: 2,
name: 'Nameless bar',
lat: null,
lng: null,
source_trip_id: null,
phone: null,
}));
});
it('FE-COMP-SAVETOCOL-011: picking a saved list removes that place instead', async () => {
vi.spyOn(collectionsApi, 'membership').mockResolvedValue({
saved: true,
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900 }],
});
const del = vi.spyOn(collectionsApi, 'deletePlace').mockResolvedValue({});
const save = vi.spyOn(collectionsApi, 'savePlace');
openFor();
render(<SaveToCollectionModal />);
fireEvent.click(await screen.findByRole('button', { name: /Favorites/ }));
await waitFor(() => expect(del).toHaveBeenCalledWith(900));
expect(save).not.toHaveBeenCalled();
expect(addToast).toHaveBeenCalledWith('Removed from Favorites', 'success', undefined);
});
it('FE-COMP-SAVETOCOL-012: a failing save surfaces the server message and leaves the version alone', async () => {
vi.spyOn(collectionsApi, 'savePlace').mockRejectedValue({ response: { data: { error: 'List is full' } } });
openFor();
render(<SaveToCollectionModal />);
fireEvent.click(await screen.findByRole('button', { name: /Wishlist/ }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('List is full', 'error', undefined));
expect(useSaveToCollectionStore.getState().version).toBe(0);
});
it('FE-COMP-SAVETOCOL-013: every row locks while a save runs', async () => {
let resolve!: (v: CollectionSaveResult) => void;
const save = vi.spyOn(collectionsApi, 'savePlace').mockReturnValue(new Promise(r => { resolve = r; }));
openFor();
render(<SaveToCollectionModal />);
const wishlist = await screen.findByRole('button', { name: /Wishlist/ });
fireEvent.click(wishlist);
expect(wishlist).toBeDisabled();
const favorites = screen.getByRole('button', { name: /Favorites/ });
expect(favorites).toBeDisabled();
fireEvent.click(favorites);
expect(save).toHaveBeenCalledTimes(1);
resolve({});
await waitFor(() => expect(useSaveToCollectionStore.getState().version).toBe(1));
});
it('FE-COMP-SAVETOCOL-015: a membership refresh that fails after a save falls back to "saved nowhere"', async () => {
vi.spyOn(collectionsApi, 'membership')
.mockResolvedValueOnce(EMPTY_MEMBERSHIP)
.mockRejectedValue(new Error('offline'));
vi.spyOn(collectionsApi, 'savePlace').mockResolvedValue({});
openFor();
render(<SaveToCollectionModal />);
fireEvent.click(await screen.findByRole('button', { name: /Wishlist/ }));
await waitFor(() => expect(useSaveToCollectionStore.getState().version).toBe(1));
expect(screen.getByRole('button', { name: /Wishlist/ })).not.toHaveClass('border-accent');
});
it('FE-COMP-SAVETOCOL-016: a response landing after the picker closed is dropped', async () => {
let resolve!: (v: CollectionListResponse) => void;
vi.spyOn(collectionsApi, 'list').mockReturnValue(new Promise(r => { resolve = r; }));
openFor();
const { unmount } = render(<SaveToCollectionModal />);
unmount();
resolve(listResponse([FAVORITES]));
await Promise.resolve();
expect(screen.queryByText('Favorites')).not.toBeInTheDocument();
});
it('FE-COMP-SAVETOCOL-014: the footer navigates to the collections page or just closes', async () => {
openFor();
const { unmount } = render(<SaveToCollectionModal />);
await screen.findByText('Favorites');
fireEvent.click(screen.getByRole('button', { name: 'View' }));
expect(mockNavigate).toHaveBeenCalledWith('/collections');
expect(useSaveToCollectionStore.getState().target).toBeNull();
unmount();
mockNavigate.mockClear();
openFor();
render(<SaveToCollectionModal />);
await screen.findByText('Favorites');
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(mockNavigate).not.toHaveBeenCalled();
expect(useSaveToCollectionStore.getState().target).toBeNull();
});
});
@@ -169,7 +169,7 @@ export default function SaveToCollectionModal(): React.ReactElement | null {
key={list.id}
type="button"
onClick={() => handleToggle(list)}
disabled={busy}
disabled={busyId != null}
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl border text-left transition-colors disabled:opacity-60 ${saved ? 'border-accent bg-accent-subtle' : 'border-edge bg-surface-card hover:bg-surface-hover'}`}
>
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: list.color || 'var(--accent)' }} />
@@ -0,0 +1,212 @@
// FE-COMP-SAVETRIPPL-001 to FE-COMP-SAVETRIPPL-015
import React from 'react';
import { afterEach, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import type { Collection, CollectionListResponse } from '@trek/shared';
import { collectionsApi } from '../../api/collections';
import SaveTripPlacesToListModal from './SaveTripPlacesToListModal';
type ModalProps = React.ComponentProps<typeof SaveTripPlacesToListModal>;
type SaveManyResult = { copied: number; skipped: { id: number; name: string }[] };
function list(over: Partial<Collection>): Collection {
return { id: 1, owner_id: 7, name: 'List', color: null, place_count: 0, ...over } as Collection;
}
const FAVORITES = list({ id: 1, name: 'Favorites', color: '#ef4444', place_count: 4 });
const WISHLIST = list({ id: 2, name: 'Wishlist' });
const listResponse = (collections: Collection[]): CollectionListResponse => ({ collections, incomingInvites: [] });
let addToast: ReturnType<typeof vi.fn>;
function renderModal(overrides: Partial<ModalProps> = {}) {
const props: ModalProps = {
isOpen: true,
tripId: 5,
placeIds: [11, 12],
onClose: vi.fn(),
onDone: vi.fn(),
...overrides,
};
render(<SaveTripPlacesToListModal {...props} />);
return props;
}
beforeEach(() => {
addToast = vi.fn();
window.__addToast = addToast as unknown as typeof window.__addToast;
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([FAVORITES, WISHLIST]));
});
afterEach(() => {
vi.restoreAllMocks();
delete window.__addToast;
});
describe('SaveTripPlacesToListModal', () => {
it('FE-COMP-SAVETRIPPL-001: titles the modal with the selection count and lists the writable lists', async () => {
renderModal({ placeIds: [11, 12, 13] });
expect(await screen.findByText('Favorites')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Save 3 to a list' })).toBeInTheDocument();
expect(screen.getByText('4 places')).toBeInTheDocument();
expect(screen.getByText('0 places')).toBeInTheDocument();
expect(screen.getByText('Duplicates are skipped automatically')).toBeInTheDocument();
});
it('FE-COMP-SAVETRIPPL-002: lists shared with the user are dropped — they cannot be written to', async () => {
vi.spyOn(collectionsApi, 'list').mockResolvedValue(
listResponse([FAVORITES, list({ id: 3, name: 'Team ideas', is_owner: false })]),
);
renderModal();
expect(await screen.findByText('Favorites')).toBeInTheDocument();
expect(screen.queryByText('Team ideas')).not.toBeInTheDocument();
});
it('FE-COMP-SAVETRIPPL-003: shows the spinner until the lists arrive', async () => {
let resolve!: (v: CollectionListResponse) => void;
vi.spyOn(collectionsApi, 'list').mockReturnValue(new Promise(r => { resolve = r; }));
renderModal();
expect(document.querySelector('.animate-spin')).not.toBeNull();
expect(screen.queryByText('You have no lists yet')).not.toBeInTheDocument();
resolve(listResponse([FAVORITES]));
expect(await screen.findByText('Favorites')).toBeInTheDocument();
});
it('FE-COMP-SAVETRIPPL-004: an absent or failing collections response falls back to the empty copy', async () => {
vi.spyOn(collectionsApi, 'list').mockResolvedValue({ incomingInvites: [] } as unknown as CollectionListResponse);
const { unmount } = render(<SaveTripPlacesToListModal isOpen tripId={5} placeIds={[11]} onClose={vi.fn()} onDone={vi.fn()} />);
expect(await screen.findByText('You have no lists yet')).toBeInTheDocument();
unmount();
vi.spyOn(collectionsApi, 'list').mockRejectedValue(new Error('offline'));
renderModal();
expect(await screen.findByText('You have no lists yet')).toBeInTheDocument();
});
it('FE-COMP-SAVETRIPPL-005: requests nothing and renders nothing while closed', () => {
const spy = vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([FAVORITES]));
renderModal({ isOpen: false });
expect(spy).not.toHaveBeenCalled();
expect(screen.queryByRole('heading', { name: /Save/ })).not.toBeInTheDocument();
});
it('FE-COMP-SAVETRIPPL-006: the search box appears above five lists and filters by name', async () => {
const many = [1, 2, 3, 4, 5, 6].map(id => list({ id, name: `List ${id}` }));
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse(many));
renderModal();
const search = await screen.findByPlaceholderText('Search lists');
fireEvent.change(search, { target: { value: 'list 4' } });
expect(screen.getByText('List 4')).toBeInTheDocument();
expect(screen.queryByText('List 5')).not.toBeInTheDocument();
fireEvent.change(search, { target: { value: 'nope' } });
expect(screen.getByText('You have no lists yet')).toBeInTheDocument();
});
it('FE-COMP-SAVETRIPPL-007: five lists or fewer need no search box', async () => {
renderModal();
await screen.findByText('Favorites');
expect(screen.queryByPlaceholderText('Search lists')).not.toBeInTheDocument();
});
it('FE-COMP-SAVETRIPPL-008: picking a list copies every selected place, reports it and closes', async () => {
const save = vi.spyOn(collectionsApi, 'saveFromTripMany').mockResolvedValue({ copied: 2, skipped: [] });
const props = renderModal();
fireEvent.click(await screen.findByText('Favorites'));
await waitFor(() => expect(props.onDone).toHaveBeenCalled());
expect(save).toHaveBeenCalledWith(1, 5, [11, 12]);
expect(addToast).toHaveBeenCalledWith('Saved 2 to Favorites', 'success', undefined);
expect(props.onClose).toHaveBeenCalled();
});
it('FE-COMP-SAVETRIPPL-009: server-side duplicates are reported separately', async () => {
vi.spyOn(collectionsApi, 'saveFromTripMany').mockResolvedValue({ copied: 1, skipped: [{ id: 12, name: 'Louvre' }] });
renderModal();
fireEvent.click(await screen.findByText('Favorites'));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Skipped 1 duplicates', 'info', undefined));
expect(addToast).toHaveBeenCalledWith('Saved 1 to Favorites', 'success', undefined);
});
it('FE-COMP-SAVETRIPPL-010: a no-op save says so instead of staying silent', async () => {
vi.spyOn(collectionsApi, 'saveFromTripMany').mockResolvedValue({ copied: 0, skipped: [] });
renderModal();
fireEvent.click(await screen.findByText('Favorites'));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Nothing to copy', 'info', undefined));
expect(addToast).toHaveBeenCalledTimes(1);
});
it('FE-COMP-SAVETRIPPL-011: a failed save surfaces the server message and keeps the modal open', async () => {
vi.spyOn(collectionsApi, 'saveFromTripMany').mockRejectedValue({ response: { data: { error: 'List is full' } } });
const props = renderModal();
fireEvent.click(await screen.findByText('Favorites'));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('List is full', 'error', undefined));
expect(props.onDone).not.toHaveBeenCalled();
expect(props.onClose).not.toHaveBeenCalled();
});
it('FE-COMP-SAVETRIPPL-012: every row locks while a save runs, and an empty selection never reaches the server', async () => {
let resolve!: (v: SaveManyResult) => void;
const save = vi.spyOn(collectionsApi, 'saveFromTripMany').mockReturnValue(new Promise(r => { resolve = r; }));
const props = renderModal();
const favorites = await screen.findByRole('button', { name: /Favorites/ });
fireEvent.click(favorites);
const wishlist = screen.getByRole('button', { name: /Wishlist/ });
expect(wishlist).toBeDisabled();
expect(within(favorites).queryByText('4 places')).toBeInTheDocument();
fireEvent.click(wishlist);
expect(save).toHaveBeenCalledTimes(1);
resolve({ copied: 1, skipped: [] });
await waitFor(() => expect(props.onDone).toHaveBeenCalled());
});
it('FE-COMP-SAVETRIPPL-014: a list response landing after the modal closed is dropped', async () => {
const closed = <SaveTripPlacesToListModal isOpen tripId={5} placeIds={[11]} onClose={vi.fn()} onDone={vi.fn()} />;
let resolve!: (v: CollectionListResponse) => void;
const spy = vi.spyOn(collectionsApi, 'list').mockReturnValue(new Promise(r => { resolve = r; }));
render(closed).unmount();
resolve(listResponse([FAVORITES]));
await Promise.resolve();
expect(screen.queryByText('Favorites')).not.toBeInTheDocument();
let reject!: (e: Error) => void;
spy.mockReturnValue(new Promise((_, r) => { reject = r; }));
render(closed).unmount();
reject(new Error('offline'));
await Promise.resolve();
expect(screen.queryByText('You have no lists yet')).not.toBeInTheDocument();
});
it('FE-COMP-SAVETRIPPL-015: a list the server sent without a count still renders a countable row', async () => {
vi.spyOn(collectionsApi, 'list').mockResolvedValue(
listResponse([{ id: 9, owner_id: 7, name: 'Untracked' } as Collection]),
);
renderModal();
const untracked = await screen.findByRole('button', { name: /Untracked/ });
expect(within(untracked).getByText('0 places')).toBeInTheDocument();
});
it('FE-COMP-SAVETRIPPL-013: an empty selection is a no-op, and Escape hands back to the caller', async () => {
const user = userEvent.setup();
const save = vi.spyOn(collectionsApi, 'saveFromTripMany');
const props = renderModal({ placeIds: [] });
fireEvent.click(await screen.findByText('Favorites'));
expect(save).not.toHaveBeenCalled();
await user.keyboard('{Escape}');
expect(props.onClose).toHaveBeenCalledTimes(1);
});
});
@@ -77,7 +77,7 @@ export default function SaveTripPlacesToListModal({ isOpen, tripId, placeIds, on
autoFocus
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('collections.copyToTripSearch')}
placeholder={t('collections.searchLists')}
className="w-full pl-8 pr-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
/>
</div>
@@ -0,0 +1,294 @@
// FE-COMP-COLSHARE-001 to FE-COMP-COLSHARE-025
import React from 'react'
import type { Mock } from 'vitest'
import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render'
import type { CollectionMember } from '@trek/shared'
import { collectionsApi } from '../../api/collections'
import { useCollectionStore } from '../../store/collectionStore'
import { useAuthStore } from '../../store/authStore'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { buildUser } from '../../../tests/helpers/factories'
import { useTranslation } from '../../i18n/TranslationContext'
import ShareCollectionModal from './ShareCollectionModal'
type Props = React.ComponentProps<typeof ShareCollectionModal>
function Harness(props: Omit<Props, 't'>): React.ReactElement {
const { t } = useTranslation()
return <ShareCollectionModal {...props} t={t} />
}
const OWNER: CollectionMember = { user_id: 1, username: 'maurice', email: 'm@example.com', status: 'accepted', is_owner: true }
const EDITOR: CollectionMember = { user_id: 2, username: 'julien', email: 'j@example.com', status: 'accepted', role: 'editor', avatar: 'jul.png' }
const VIEWER: CollectionMember = { user_id: 4, username: 'ada', email: 'a@example.com', status: 'accepted', role: 'viewer' }
const PENDING: CollectionMember = { user_id: 3, username: 'zoe', email: 'z@example.com', status: 'pending' }
type CollectionStore = ReturnType<typeof useCollectionStore.getState>
type AddToast = NonNullable<typeof window.__addToast>
const initialCollectionState = useCollectionStore.getState()
let actions: {
invite: Mock<CollectionStore['invite']>
cancelInvite: Mock<CollectionStore['cancelInvite']>
removeMember: Mock<CollectionStore['removeMember']>
setMemberRole: Mock<CollectionStore['setMemberRole']>
leave: Mock<CollectionStore['leave']>
}
let addToast: Mock<AddToast>
function setup(over: Partial<Omit<Props, 't'>> = {}) {
const props: Omit<Props, 't'> = {
isOpen: true,
collectionId: 7,
collectionName: 'Tokyo 2026',
isOwner: true,
members: [PENDING, EDITOR, OWNER],
onClose: vi.fn(),
onAfterLeave: vi.fn(),
...over,
}
const view = render(<Harness {...props} />)
return { ...view, props }
}
/** The roster row for a member the invite form carries the same role labels,
* so role assertions have to be scoped to the row they belong to. */
function memberRow(username: string): HTMLElement {
const nameCell = screen.getByText(username).parentElement as HTMLElement
return nameCell.parentElement as HTMLElement
}
describe('ShareCollectionModal', () => {
beforeEach(() => {
resetAllStores()
useCollectionStore.setState(initialCollectionState, true)
actions = {
invite: vi.fn<CollectionStore['invite']>(async () => undefined),
cancelInvite: vi.fn<CollectionStore['cancelInvite']>(async () => undefined),
removeMember: vi.fn<CollectionStore['removeMember']>(async () => undefined),
setMemberRole: vi.fn<CollectionStore['setMemberRole']>(async () => undefined),
leave: vi.fn<CollectionStore['leave']>(async () => undefined),
}
useCollectionStore.setState(actions)
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'maurice' }) })
addToast = vi.fn<AddToast>(() => 0)
window.__addToast = addToast
vi.spyOn(collectionsApi, 'availableUsers').mockResolvedValue({
users: [{ id: 9, username: 'nina' }, { id: 10, username: 'omar' }],
})
})
afterEach(() => {
vi.restoreAllMocks()
delete window.__addToast
})
it('FE-COMP-COLSHARE-001: a closed modal renders nothing and asks for no invitable users', () => {
setup({ isOpen: false })
expect(screen.queryByRole('heading', { name: /Share/ })).not.toBeInTheDocument()
expect(collectionsApi.availableUsers).not.toHaveBeenCalled()
})
it('FE-COMP-COLSHARE-002: the title names the list and the roster is counted', () => {
setup()
expect(screen.getByRole('heading', { name: 'Share “Tokyo 2026”' })).toBeInTheDocument()
expect(screen.getByText('Members')).toBeInTheDocument()
expect(screen.getByText('3')).toBeInTheDocument()
})
it('FE-COMP-COLSHARE-003: sorts owner first, then accepted members, then pending invites', () => {
setup({ members: [PENDING, VIEWER, EDITOR, OWNER] })
const names = screen.getAllByText(/^(maurice|julien|ada|zoe)/).map(n => n.textContent)
expect(names).toEqual(['maurice(you)', 'ada', 'julien', 'zoe'])
})
it('FE-COMP-COLSHARE-004: tags the owner, marks the signed-in user and hides a pending email', () => {
setup()
expect(screen.getByText('Owner')).toBeInTheDocument()
expect(screen.getByText('(you)')).toBeInTheDocument()
expect(screen.getByText('j@example.com')).toBeInTheDocument()
expect(screen.getByText('pending invite')).toBeInTheDocument()
expect(screen.queryByText('z@example.com')).not.toBeInTheDocument()
})
it('FE-COMP-COLSHARE-005: renders an uploaded avatar, otherwise the username initial', () => {
setup()
const avatars = document.querySelectorAll('img')
expect(avatars).toHaveLength(1)
expect(avatars[0]).toHaveAttribute('src', '/uploads/avatars/jul.png')
expect(screen.getByText('M')).toBeInTheDocument()
expect(screen.getByText('Z')).toBeInTheDocument()
})
it('FE-COMP-COLSHARE-006: a member without a username still gets a placeholder initial', () => {
setup({ members: [OWNER, { ...EDITOR, username: '', avatar: undefined }] })
expect(screen.getByText('?')).toBeInTheDocument()
})
it('FE-COMP-COLSHARE-007: the owner changes a member role through the role select', async () => {
setup({ members: [OWNER, EDITOR] })
// The select shows the member's current role; open it and pick another.
fireEvent.click(within(memberRow('julien')).getByRole('button', { name: 'Editor' }))
fireEvent.click(screen.getByRole('button', { name: 'Admin' }))
await waitFor(() => expect(actions.setMemberRole).toHaveBeenCalledWith(7, 2, 'admin'))
})
it('FE-COMP-COLSHARE-008: a member with no explicit role defaults to editor in the select', () => {
setup({ members: [OWNER, { ...EDITOR, role: undefined }] })
expect(within(memberRow('julien')).getByRole('button', { name: 'Editor' })).toBeInTheDocument()
})
it('FE-COMP-COLSHARE-009: a failing role change surfaces the server message', async () => {
actions.setMemberRole.mockRejectedValue({ response: { data: { error: 'Not allowed' } } })
setup({ members: [OWNER, EDITOR] })
fireEvent.click(within(memberRow('julien')).getByRole('button', { name: 'Editor' }))
fireEvent.click(screen.getByRole('button', { name: 'Viewer' }))
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Not allowed', 'error', undefined))
})
it('FE-COMP-COLSHARE-010: the owner removes an accepted member and cancels a pending invite', async () => {
setup()
fireEvent.click(screen.getByRole('button', { name: 'Remove' }))
await waitFor(() => expect(actions.removeMember).toHaveBeenCalledWith(7, 2))
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
await waitFor(() => expect(actions.cancelInvite).toHaveBeenCalledWith(7, 3))
})
it('FE-COMP-COLSHARE-011: a second remove is ignored while the first is still running', async () => {
let release!: () => void
actions.removeMember.mockReturnValue(new Promise<void>(r => { release = r }))
setup({ members: [OWNER, EDITOR, VIEWER] })
const [first, second] = screen.getAllByRole('button', { name: 'Remove' })
fireEvent.click(first)
fireEvent.click(second)
expect(actions.removeMember).toHaveBeenCalledTimes(1)
expect(first).toBeDisabled()
release()
await waitFor(() => expect(first).not.toBeDisabled())
})
it('FE-COMP-COLSHARE-012: a failing cancel reports the fallback error', async () => {
actions.cancelInvite.mockRejectedValue({})
setup()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined))
})
it('FE-COMP-COLSHARE-013: a failing remove reports the fallback error', async () => {
actions.removeMember.mockRejectedValue({})
setup()
fireEvent.click(screen.getByRole('button', { name: 'Remove' }))
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined))
})
it('FE-COMP-COLSHARE-014: the owner picks a user and role, then sends the invite', async () => {
setup({ members: [OWNER] })
expect(await screen.findByRole('button', { name: /Send invite/ })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: 'Select a user' }))
fireEvent.click(screen.getByRole('button', { name: 'omar' }))
fireEvent.click(screen.getByRole('button', { name: 'Editor' }))
fireEvent.click(screen.getByRole('button', { name: 'Admin' }))
fireEvent.click(screen.getByRole('button', { name: /Send invite/ }))
await waitFor(() => expect(actions.invite).toHaveBeenCalledWith(7, 10, 'admin'))
expect(addToast).toHaveBeenCalledWith('Invite sent', 'success', undefined)
// The picker falls back to its placeholder for the next invite.
await waitFor(() => expect(screen.getByRole('button', { name: 'Select a user' })).toBeInTheDocument())
})
it('FE-COMP-COLSHARE-015: editor is the preselected invite role', async () => {
setup({ members: [OWNER] })
fireEvent.click(await screen.findByRole('button', { name: 'Select a user' }))
fireEvent.click(screen.getByRole('button', { name: 'nina' }))
fireEvent.click(screen.getByRole('button', { name: /Send invite/ }))
await waitFor(() => expect(actions.invite).toHaveBeenCalledWith(7, 9, 'editor'))
})
it('FE-COMP-COLSHARE-016: a failing invite keeps the selection and reports the error', async () => {
actions.invite.mockRejectedValue(new Error('boom'))
setup({ members: [OWNER] })
fireEvent.click(await screen.findByRole('button', { name: 'Select a user' }))
fireEvent.click(screen.getByRole('button', { name: 'nina' }))
fireEvent.click(screen.getByRole('button', { name: /Send invite/ }))
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Could not send invite', 'error', undefined))
expect(screen.getByRole('button', { name: 'nina' })).toBeInTheDocument()
})
it('FE-COMP-COLSHARE-017: with nobody left to invite the owner is told so', async () => {
vi.mocked(collectionsApi.availableUsers).mockResolvedValue({ users: [] })
setup()
expect(await screen.findByText('No users available to invite.')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Send invite/ })).not.toBeInTheDocument()
})
it('FE-COMP-COLSHARE-018: a failing lookup degrades to the same empty state', async () => {
vi.mocked(collectionsApi.availableUsers).mockRejectedValue(new Error('offline'))
setup()
expect(await screen.findByText('No users available to invite.')).toBeInTheDocument()
})
it('FE-COMP-COLSHARE-019: members see a read-only roster, no invite form and no row actions', async () => {
setup({ isOwner: false, members: [OWNER, EDITOR, VIEWER] })
expect(screen.getByText('Only the list owner can invite or remove people.')).toBeInTheDocument()
expect(screen.getByText('Editor')).toBeInTheDocument()
expect(screen.getByText('Viewer')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Send invite/ })).not.toBeInTheDocument()
await waitFor(() => expect(collectionsApi.availableUsers).not.toHaveBeenCalled())
})
it('FE-COMP-COLSHARE-020: a member without a role reads as editor in the read-only badge', () => {
setup({ isOwner: false, members: [OWNER, { ...EDITOR, role: undefined }] })
expect(screen.getByText('Editor')).toBeInTheDocument()
})
it('FE-COMP-COLSHARE-021: leaving asks for confirmation first and can be backed out of', () => {
setup({ isOwner: false })
fireEvent.click(screen.getByRole('button', { name: 'Leave list' }))
expect(screen.getByText('Leave this shared list? You will lose access until you are invited again.')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(actions.leave).not.toHaveBeenCalled()
expect(screen.queryByText('Leave this shared list? You will lose access until you are invited again.')).not.toBeInTheDocument()
})
it('FE-COMP-COLSHARE-022: a confirmed leave calls the store and hands back to the page', async () => {
const { props } = setup({ isOwner: false })
fireEvent.click(screen.getByRole('button', { name: 'Leave list' }))
fireEvent.click(screen.getAllByRole('button', { name: 'Leave list' })[0])
await waitFor(() => expect(actions.leave).toHaveBeenCalledWith(7))
expect(addToast).toHaveBeenCalledWith('You left the list', 'success', undefined)
expect(props.onAfterLeave).toHaveBeenCalledTimes(1)
})
it('FE-COMP-COLSHARE-023: a failing leave reports the error and keeps the user in the list', async () => {
actions.leave.mockRejectedValue({ response: { data: { error: 'Owners cannot leave' } } })
const { props } = setup({ isOwner: false })
fireEvent.click(screen.getByRole('button', { name: 'Leave list' }))
fireEvent.click(screen.getAllByRole('button', { name: 'Leave list' })[0])
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Owners cannot leave', 'error', undefined))
expect(props.onAfterLeave).not.toHaveBeenCalled()
})
it('FE-COMP-COLSHARE-024: closing resets the picker and the leave confirmation', async () => {
const { rerender, props } = setup({ isOwner: false })
fireEvent.click(screen.getByRole('button', { name: 'Leave list' }))
expect(screen.getByText('Leave this shared list? You will lose access until you are invited again.')).toBeInTheDocument()
rerender(<Harness {...props} isOwner={false} isOpen={false} />)
rerender(<Harness {...props} isOwner={false} isOpen />)
await waitFor(() =>
expect(screen.queryByText('Leave this shared list? You will lose access until you are invited again.')).not.toBeInTheDocument(),
)
})
it('FE-COMP-COLSHARE-025: the modal close button calls onClose', () => {
const { props } = setup()
// The Modal chrome's only unnamed control is its close button.
const header = screen.getByRole('heading', { name: 'Share “Tokyo 2026”' }).parentElement as HTMLElement
fireEvent.click(header.querySelector('button') as HTMLButtonElement)
expect(props.onClose).toHaveBeenCalledTimes(1)
})
})
@@ -114,6 +114,7 @@ export default function ShareCollectionModal({
}
const handleSetRole = async (userId: number, role: CollectionRole) => {
if (settingRoleId != null) return
setSettingRoleId(userId)
try {
await setMemberRole(collectionId, userId, role)
@@ -0,0 +1,86 @@
import React from 'react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { http, HttpResponse } from 'msw';
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render';
import { server } from '../../../tests/helpers/msw/server';
import CollectionsWidget from './CollectionsWidget';
// FE-COMP-COLWIDGET-001 onwards
function collections(list: unknown[]) {
server.use(http.get('/api/addons/collections', () => HttpResponse.json({ collections: list })));
}
const list = (over: Record<string, unknown> = {}) => ({
id: 1, name: 'Tokyo eats', color: '#ff0000', cover_image: null, place_count: 12, ...over,
});
beforeEach(() => {
collections([]);
});
describe('CollectionsWidget', () => {
it('FE-COMP-COLWIDGET-001: shows the empty hint once the fetch resolves', async () => {
render(<CollectionsWidget onOpen={() => {}} />);
expect(await screen.findByText('No saved places yet')).toBeInTheDocument();
expect(screen.getByText('Collections')).toBeInTheDocument();
});
it('FE-COMP-COLWIDGET-002: renders at most six badges with their counts', async () => {
collections([1, 2, 3, 4, 5, 6, 7].map(id => list({ id, name: `List ${id}`, place_count: id })));
render(<CollectionsWidget onOpen={() => {}} />);
expect(await screen.findByText('List 1')).toBeInTheDocument();
expect(screen.getByText('List 6')).toBeInTheDocument();
expect(screen.queryByText('List 7')).not.toBeInTheDocument();
});
it('FE-COMP-COLWIDGET-003: a list without a count renders a zero', async () => {
collections([list({ place_count: null })]);
render(<CollectionsWidget onOpen={() => {}} />);
await screen.findByText('Tokyo eats');
expect(screen.getByText('0')).toBeInTheDocument();
});
it('FE-COMP-COLWIDGET-004: a cover image replaces the gradient tile', async () => {
collections([list({ cover_image: '/uploads/collections/1.jpg' })]);
const { container } = render(<CollectionsWidget onOpen={() => {}} />);
await screen.findByText('Tokyo eats');
expect(container.querySelector('img.col-badge-media')).toHaveAttribute('src', '/uploads/collections/1.jpg');
});
it('FE-COMP-COLWIDGET-005: a list without a colour falls back to the entity gradient', async () => {
collections([list({ color: null })]);
const { container } = render(<CollectionsWidget onOpen={() => {}} />);
await screen.findByText('Tokyo eats');
const media = container.querySelector('div.col-badge-media') as HTMLElement;
expect(media.style.backgroundImage).toContain('gradient');
});
it('FE-COMP-COLWIDGET-006: the header arrow runs the onOpen callback', async () => {
const onOpen = vi.fn();
render(<CollectionsWidget onOpen={onOpen} />);
fireEvent.click(screen.getByRole('button', { name: 'Collections' }));
expect(onOpen).toHaveBeenCalledTimes(1);
});
it('FE-COMP-COLWIDGET-007: a failing fetch degrades to the empty hint', async () => {
server.use(http.get('/api/addons/collections', () => new HttpResponse(null, { status: 500 })));
render(<CollectionsWidget onOpen={() => {}} />);
expect(await screen.findByText('No saved places yet')).toBeInTheDocument();
});
it('FE-COMP-COLWIDGET-008: nothing renders while the fetch is still in flight', async () => {
render(<CollectionsWidget onOpen={() => {}} />);
expect(screen.queryByText('No saved places yet')).not.toBeInTheDocument();
await waitFor(() => expect(screen.getByText('No saved places yet')).toBeInTheDocument());
});
});
@@ -0,0 +1,111 @@
// FE-W4FMH-001 to FE-W4FMH-012
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { File as FileIcon, FileText, FileImage, FileVideo, Plane, Train, Bus, Car, CarTaxiFront, Bike, Ship, Sailboat, Route } from 'lucide-react'
const downloadFile = vi.fn(async (_url: string, _name: string) => {})
vi.mock('../../utils/fileDownload', () => ({ downloadFile: (url: string, name: string) => downloadFile(url, name) }))
import {
isImage, isVideo, isMedia, isMarkdown, getFileIcon, formatSize,
triggerDownload, formatDateWithLocale, transportIcon,
} from './FileManager.helpers'
beforeEach(() => {
downloadFile.mockReset()
downloadFile.mockResolvedValue(undefined)
})
describe('mime helpers', () => {
it('FE-W4FMH-001: detects images and videos', () => {
expect(isImage('image/png')).toBe(true)
expect(isImage('application/pdf')).toBe(false)
expect(isImage(null)).toBe(false)
expect(isVideo('video/mp4')).toBe(true)
expect(isVideo(undefined)).toBe(false)
})
it('FE-W4FMH-002: treats both images and videos as lightbox media', () => {
expect(isMedia('image/jpeg')).toBe(true)
expect(isMedia('video/quicktime')).toBe(true)
expect(isMedia('application/pdf')).toBe(false)
expect(isMedia(null)).toBe(false)
})
it('FE-W4FMH-003: detects markdown by extension first', () => {
expect(isMarkdown('application/octet-stream', 'NOTES.MD')).toBe(true)
expect(isMarkdown('', 'readme.markdown')).toBe(true)
expect(isMarkdown('text/markdown', 'blob')).toBe(true)
expect(isMarkdown('text/x-markdown', null)).toBe(true)
expect(isMarkdown('text/plain', 'notes.txt')).toBe(false)
expect(isMarkdown(null, null)).toBe(false)
})
})
describe('getFileIcon', () => {
it('FE-W4FMH-004: picks the icon matching the mime family', () => {
expect(getFileIcon('application/pdf')).toBe(FileText)
expect(getFileIcon('video/mp4')).toBe(FileVideo)
expect(getFileIcon('image/png')).toBe(FileImage)
expect(getFileIcon('application/zip')).toBe(FileIcon)
expect(getFileIcon(null)).toBe(FileIcon)
})
})
describe('formatSize', () => {
it('FE-W4FMH-005: renders bytes below a kilobyte', () => {
expect(formatSize(512)).toBe('512 B')
})
it('FE-W4FMH-006: renders kilobytes and megabytes with one decimal', () => {
expect(formatSize(2048)).toBe('2.0 KB')
expect(formatSize(1024 * 1024 * 3.25)).toBe('3.3 MB')
})
it('FE-W4FMH-007: renders nothing for a missing or zero size', () => {
expect(formatSize(0)).toBe('')
expect(formatSize(null)).toBe('')
expect(formatSize(undefined)).toBe('')
})
})
describe('triggerDownload', () => {
it('FE-W4FMH-008: forwards to the download helper', () => {
triggerDownload('/uploads/files/a.pdf', 'a.pdf')
expect(downloadFile).toHaveBeenCalledWith('/uploads/files/a.pdf', 'a.pdf')
})
it('FE-W4FMH-009: swallows a rejected download', async () => {
downloadFile.mockRejectedValue(new Error('offline'))
expect(() => triggerDownload('/x', 'x')).not.toThrow()
await Promise.resolve()
})
})
describe('formatDateWithLocale', () => {
it('FE-W4FMH-010: formats an ISO date in the given locale', () => {
expect(formatDateWithLocale('2026-06-15T10:00:00Z', 'en-GB')).toBe('15/06/2026')
})
it('FE-W4FMH-011: returns an empty string for a missing or unparseable date', () => {
expect(formatDateWithLocale(null, 'en-GB')).toBe('')
expect(formatDateWithLocale('', 'en-GB')).toBe('')
expect(formatDateWithLocale('2026-06-15', 'en_US')).toBe('')
})
})
describe('transportIcon', () => {
it('FE-W4FMH-012: maps every transport type, defaulting to the plane', () => {
expect(transportIcon('train')).toBe(Train)
expect(transportIcon('bus')).toBe(Bus)
expect(transportIcon('car')).toBe(Car)
expect(transportIcon('taxi')).toBe(CarTaxiFront)
expect(transportIcon('bicycle')).toBe(Bike)
expect(transportIcon('cruise')).toBe(Ship)
expect(transportIcon('ferry')).toBe(Sailboat)
expect(transportIcon('transport_other')).toBe(Route)
expect(transportIcon('flight')).toBe(Plane)
expect(transportIcon('')).toBe(Plane)
})
})
@@ -0,0 +1,410 @@
// FE-W5ASG-001 to FE-W5ASG-022
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
import type { Day, Place, Reservation, TripFile } from '../../types'
import type { FileManagerState } from './useFileManager'
const getLinks = vi.fn(async (_tripId: number, _fileId: number): Promise<{ links?: unknown[] }> => ({ links: [] }))
const addLink = vi.fn(async (_tripId: number, _fileId: number, _data: unknown) => ({}))
const removeLink = vi.fn(async (_tripId: number, _fileId: number, _linkId: number) => ({}))
vi.mock('../../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../api/client')>()
return {
...actual,
filesApi: {
...actual.filesApi,
getLinks: (tripId: number, fileId: number) => getLinks(tripId, fileId),
addLink: (tripId: number, fileId: number, data: unknown) => addLink(tripId, fileId, data),
removeLink: (tripId: number, fileId: number, linkId: number) => removeLink(tripId, fileId, linkId),
},
}
})
import { AssignModal } from './FileManagerAssignModal'
const setAssignFileId = vi.fn()
const handleAssign = vi.fn(async (_fileId: number, _data: unknown) => {})
const refreshFiles = vi.fn(async () => {})
const file = (overrides: Partial<TripFile> = {}) =>
({ id: 7, original_name: 'ticket.pdf', description: '', url: '/uploads/files/ticket.pdf', ...overrides }) as unknown as TripFile
const place = (id: number, name: string) => ({ id, name }) as unknown as Place
const reservation = (id: number, title: string, type: string) => ({ id, title, type }) as unknown as Reservation
const day = (overrides: Partial<Day> = {}) => ({ id: 100, day_number: 1, ...overrides }) as unknown as Day
function state(overrides: Partial<FileManagerState> = {}): FileManagerState {
return {
files: [file()],
assignFileId: 7,
setAssignFileId,
t: (key: string) => key,
days: [],
assignments: {},
places: [],
reservations: [],
tripId: 3,
handleAssign,
refreshFiles,
...overrides,
} as unknown as FileManagerState
}
beforeEach(() => {
vi.clearAllMocks()
getLinks.mockResolvedValue({ links: [] })
addLink.mockResolvedValue({})
removeLink.mockResolvedValue({})
})
describe('AssignModal shell', () => {
it('FE-W5ASG-001: portals the modal and names the selected file', () => {
const { container } = render(<AssignModal {...state()} />)
expect(container).toBeEmptyDOMElement()
expect(screen.getByText('files.assignTitle')).toBeInTheDocument()
expect(screen.getByText('ticket.pdf')).toBeInTheDocument()
})
it('FE-W5ASG-002: the backdrop closes the modal but the card swallows the click', () => {
render(<AssignModal {...state()} />)
const card = screen.getByText('files.assignTitle').closest('div[style*="border-radius: 16px"]') as HTMLElement
fireEvent.click(card)
expect(setAssignFileId).not.toHaveBeenCalled()
fireEvent.click(card.parentElement!)
expect(setAssignFileId).toHaveBeenCalledWith(null)
})
it('FE-W5ASG-003: the header close button clears the selection', () => {
render(<AssignModal {...state()} />)
fireEvent.click(screen.getAllByRole('button')[0])
expect(setAssignFileId).toHaveBeenCalledWith(null)
})
it('FE-W5ASG-034: the note label falls back to English when the key is missing', () => {
render(<AssignModal {...state({ t: ((key: string) => (key === 'files.noteLabel' ? '' : key)) as FileManagerState['t'] })} />)
expect(screen.getByText('Note')).toBeInTheDocument()
})
it('FE-W5ASG-004: an unknown file id leaves the header blank and the body empty', () => {
render(<AssignModal {...state({ assignFileId: 999, places: [place(1, 'Louvre')] })} />)
expect(screen.queryByText('ticket.pdf')).not.toBeInTheDocument()
expect(screen.queryByText('files.assignPlace')).not.toBeInTheDocument()
})
})
describe('AssignModal note field', () => {
it('FE-W5ASG-005: a changed note is persisted on blur', () => {
render(<AssignModal {...state()} />)
const input = screen.getByPlaceholderText('files.notePlaceholder')
fireEvent.blur(input, { target: { value: ' seat 14A ' } })
expect(handleAssign).toHaveBeenCalledWith(7, { description: 'seat 14A' })
})
it('FE-W5ASG-006: an unchanged note is not persisted', () => {
render(<AssignModal {...state({ files: [file({ description: 'seat 14A' })] })} />)
const input = screen.getByPlaceholderText('files.notePlaceholder')
fireEvent.blur(input, { target: { value: 'seat 14A' } })
expect(handleAssign).not.toHaveBeenCalled()
})
it('FE-W5ASG-007: Enter blurs the note field, other keys do not', () => {
render(<AssignModal {...state()} />)
const input = screen.getByPlaceholderText('files.notePlaceholder') as HTMLInputElement
const blur = vi.spyOn(input, 'blur')
fireEvent.keyDown(input, { key: 'a' })
expect(blur).not.toHaveBeenCalled()
fireEvent.keyDown(input, { key: 'Enter' })
expect(blur).toHaveBeenCalled()
})
})
describe('AssignModal place list', () => {
const places = [place(1, 'Louvre'), place(2, 'Eiffel Tower'), place(3, 'Sacré-Cœur')]
it('FE-W5ASG-008: groups places under their day and shows the date badge', () => {
render(<AssignModal {...state({
places,
days: [day({ id: 100, date: '2026-03-15', title: 'Museums' }), day({ id: 101, day_number: 2 })],
assignments: { '100': [{ place: { id: 1 } }, { place_id: 2 }] as never },
})} />)
expect(screen.getByText('Museums')).toBeInTheDocument()
expect(screen.getByText('2026-03-15')).toBeInTheDocument()
// day 2 has no places and is dropped entirely
expect(screen.queryByText('dayplan.dayN')).not.toBeInTheDocument()
expect(screen.getByText('files.unassigned')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Sacré-Cœur' })).toBeInTheDocument()
})
it('FE-W5ASG-009: a titled day without a date badges the day number', () => {
render(<AssignModal {...state({
places,
days: [day({ title: 'Museums' })],
assignments: { '100': [{ place: { id: 1 } }] as never },
})} />)
expect(screen.getAllByText('dayplan.dayN')).toHaveLength(1)
})
it('FE-W5ASG-010: an untitled day without a date gets no badge', () => {
render(<AssignModal {...state({
places,
days: [day()],
assignments: { '100': [{ place: { id: 1 } }] as never },
})} />)
// only the group heading itself, no badge next to it
expect(screen.getAllByText('dayplan.dayN')).toHaveLength(1)
})
it('FE-W5ASG-011: without day groups the unassigned heading is dropped', () => {
render(<AssignModal {...state({ places })} />)
expect(screen.getByText('files.assignPlace')).toBeInTheDocument()
expect(screen.queryByText('files.unassigned')).not.toBeInTheDocument()
})
it('FE-W5ASG-012: the first place assignment goes through handleAssign', () => {
render(<AssignModal {...state({ places })} />)
fireEvent.click(screen.getByRole('button', { name: 'Louvre' }))
expect(handleAssign).toHaveBeenCalledWith(7, { place_id: 1 })
expect(addLink).not.toHaveBeenCalled()
})
it('FE-W5ASG-013: a second place becomes an extra link', async () => {
render(<AssignModal {...state({ places, files: [file({ place_id: 1 })] })} />)
fireEvent.click(screen.getByRole('button', { name: 'Eiffel Tower' }))
await waitFor(() => expect(addLink).toHaveBeenCalledWith(3, 7, { place_id: 2 }))
expect(refreshFiles).toHaveBeenCalled()
})
it('FE-W5ASG-014: a failing link request is swallowed', async () => {
addLink.mockRejectedValueOnce(new Error('offline'))
render(<AssignModal {...state({ places, files: [file({ place_id: 1 })] })} />)
fireEvent.click(screen.getByRole('button', { name: 'Eiffel Tower' }))
await waitFor(() => expect(addLink).toHaveBeenCalled())
expect(refreshFiles).not.toHaveBeenCalled()
})
it('FE-W5ASG-015: clicking the primary place again unassigns it', () => {
render(<AssignModal {...state({ places, files: [file({ place_id: 1 })] })} />)
fireEvent.click(screen.getByRole('button', { name: 'Louvre' }))
expect(handleAssign).toHaveBeenCalledWith(7, { place_id: null })
})
it('FE-W5ASG-016: clicking a linked place removes just that link', async () => {
getLinks.mockResolvedValueOnce({ links: [{ id: 55, place_id: 2 }] })
render(<AssignModal {...state({ places, files: [file({ place_id: 1, linked_place_ids: [2] })] })} />)
fireEvent.click(screen.getByRole('button', { name: 'Eiffel Tower' }))
await waitFor(() => expect(removeLink).toHaveBeenCalledWith(3, 7, 55))
expect(refreshFiles).toHaveBeenCalled()
})
it('FE-W5ASG-017: a linked place with no matching link row still refreshes', async () => {
getLinks.mockResolvedValueOnce({})
render(<AssignModal {...state({ places, files: [file({ place_id: 1, linked_place_ids: [2] })] })} />)
fireEvent.click(screen.getByRole('button', { name: 'Eiffel Tower' }))
await waitFor(() => expect(refreshFiles).toHaveBeenCalled())
expect(removeLink).not.toHaveBeenCalled()
})
it('FE-W5ASG-018: a failing links lookup is swallowed', async () => {
getLinks.mockRejectedValueOnce(new Error('offline'))
render(<AssignModal {...state({ places, files: [file({ place_id: 1, linked_place_ids: [2] })] })} />)
fireEvent.click(screen.getByRole('button', { name: 'Eiffel Tower' }))
await waitFor(() => expect(getLinks).toHaveBeenCalled())
expect(refreshFiles).not.toHaveBeenCalled()
})
it('FE-W5ASG-019: rows keep the linked highlight on mouse-out, free rows do not', () => {
render(<AssignModal {...state({ places, files: [file({ place_id: 1 })] })} />)
const linked = screen.getByRole('button', { name: 'Louvre' })
const free = screen.getByRole('button', { name: 'Eiffel Tower' })
expect(linked.style.fontWeight).toBe('600')
fireEvent.mouseEnter(linked)
fireEvent.mouseLeave(linked)
expect(linked.style.background).toBe('var(--bg-hover)')
fireEvent.mouseEnter(free)
expect(free.style.background).toBe('var(--bg-hover)')
fireEvent.mouseLeave(free)
expect(free.style.background).toBe('transparent')
})
})
describe('AssignModal reservation list', () => {
const bookings = [reservation(10, 'Hotel Lutetia', 'hotel')]
const transports = [reservation(20, 'AF1234', 'flight'), reservation(21, 'TGV 8712', 'train')]
it('FE-W5ASG-020: splits bookings from transports and gives each its own icon', () => {
render(<AssignModal {...state({ reservations: [...bookings, ...transports] })} />)
const iconOf = (name: string) =>
screen.getByRole('button', { name }).querySelector('svg')?.getAttribute('class') ?? ''
expect(screen.getByText('files.assignBooking')).toBeInTheDocument()
expect(screen.getByText('files.assignTransport')).toBeInTheDocument()
expect(iconOf('Hotel Lutetia')).toMatch(/ticket/)
expect(iconOf('TGV 8712')).toMatch(/tram-front/)
expect(iconOf('AF1234')).toMatch(/plane/)
})
it('FE-W5ASG-021: a transport-only trip shows no booking heading', () => {
render(<AssignModal {...state({ reservations: transports })} />)
expect(screen.queryByText('files.assignBooking')).not.toBeInTheDocument()
expect(screen.getByText('files.assignTransport')).toBeInTheDocument()
})
it('FE-W5ASG-022: a booking-only trip shows no transport heading', () => {
render(<AssignModal {...state({ reservations: bookings })} />)
expect(screen.getByText('files.assignBooking')).toBeInTheDocument()
expect(screen.queryByText('files.assignTransport')).not.toBeInTheDocument()
})
it('FE-W5ASG-023: the first reservation assignment goes through handleAssign', () => {
render(<AssignModal {...state({ reservations: bookings })} />)
fireEvent.click(screen.getByRole('button', { name: 'Hotel Lutetia' }))
expect(handleAssign).toHaveBeenCalledWith(7, { reservation_id: 10 })
})
it('FE-W5ASG-024: a second reservation becomes an extra link', async () => {
render(<AssignModal {...state({ reservations: [...bookings, ...transports], files: [file({ reservation_id: 10 })] })} />)
fireEvent.click(screen.getByRole('button', { name: 'AF1234' }))
await waitFor(() => expect(addLink).toHaveBeenCalledWith(3, 7, { reservation_id: 20 }))
expect(refreshFiles).toHaveBeenCalled()
})
it('FE-W5ASG-025: a failing reservation link is swallowed', async () => {
addLink.mockRejectedValueOnce(new Error('offline'))
render(<AssignModal {...state({ reservations: [...bookings, ...transports], files: [file({ reservation_id: 10 })] })} />)
fireEvent.click(screen.getByRole('button', { name: 'AF1234' }))
await waitFor(() => expect(addLink).toHaveBeenCalled())
expect(refreshFiles).not.toHaveBeenCalled()
})
it('FE-W5ASG-026: clicking the primary reservation again unassigns it', () => {
render(<AssignModal {...state({ reservations: bookings, files: [file({ reservation_id: 10 })] })} />)
fireEvent.click(screen.getByRole('button', { name: 'Hotel Lutetia' }))
expect(handleAssign).toHaveBeenCalledWith(7, { reservation_id: null })
})
it('FE-W5ASG-027: clicking a linked reservation removes just that link', async () => {
getLinks.mockResolvedValueOnce({ links: [{ id: 66, reservation_id: 20 }] })
render(<AssignModal {...state({
reservations: [...bookings, ...transports],
files: [file({ reservation_id: 10, linked_reservation_ids: [20] })],
})} />)
fireEvent.click(screen.getByRole('button', { name: 'AF1234' }))
await waitFor(() => expect(removeLink).toHaveBeenCalledWith(3, 7, 66))
expect(refreshFiles).toHaveBeenCalled()
})
it('FE-W5ASG-028: a linked reservation with no matching link row still refreshes', async () => {
getLinks.mockResolvedValueOnce({})
render(<AssignModal {...state({
reservations: [...bookings, ...transports],
files: [file({ reservation_id: 10, linked_reservation_ids: [20] })],
})} />)
fireEvent.click(screen.getByRole('button', { name: 'AF1234' }))
await waitFor(() => expect(refreshFiles).toHaveBeenCalled())
expect(removeLink).not.toHaveBeenCalled()
})
it('FE-W5ASG-029: a failing reservation links lookup is swallowed', async () => {
getLinks.mockRejectedValueOnce(new Error('offline'))
render(<AssignModal {...state({
reservations: [...bookings, ...transports],
files: [file({ reservation_id: 10, linked_reservation_ids: [20] })],
})} />)
fireEvent.click(screen.getByRole('button', { name: 'AF1234' }))
await waitFor(() => expect(getLinks).toHaveBeenCalled())
expect(refreshFiles).not.toHaveBeenCalled()
})
it('FE-W5ASG-030: reservation rows keep the linked highlight on mouse-out', () => {
render(<AssignModal {...state({ reservations: [...bookings, ...transports], files: [file({ reservation_id: 10 })] })} />)
const linked = screen.getByRole('button', { name: 'Hotel Lutetia' })
const free = screen.getByRole('button', { name: 'TGV 8712' })
fireEvent.mouseEnter(linked)
fireEvent.mouseLeave(linked)
expect(linked.style.background).toBe('var(--bg-hover)')
fireEvent.mouseEnter(free)
expect(free.style.background).toBe('var(--bg-hover)')
fireEvent.mouseLeave(free)
expect(free.style.background).toBe('transparent')
})
})
describe('AssignModal split layout', () => {
it('FE-W5ASG-031: places and bookings sit side by side when both exist', () => {
const { baseElement } = render(<AssignModal {...state({
places: [place(1, 'Louvre')],
reservations: [reservation(10, 'Hotel Lutetia', 'hotel')],
})} />)
expect(baseElement.querySelector('.md\\:flex')).not.toBeNull()
expect(baseElement.querySelectorAll('.md\\:w-1\\/2')).toHaveLength(2)
})
it('FE-W5ASG-032: a places-only trip renders a single column', () => {
const { baseElement } = render(<AssignModal {...state({ places: [place(1, 'Louvre')] })} />)
expect(baseElement.querySelector('.md\\:flex')).toBeNull()
expect(screen.getByText('files.assignPlace')).toBeInTheDocument()
expect(screen.queryByText('files.assignBooking')).not.toBeInTheDocument()
})
it('FE-W5ASG-033: a bookings-only trip renders a single column', () => {
const { baseElement } = render(<AssignModal {...state({ reservations: [reservation(10, 'Hotel Lutetia', 'hotel')] })} />)
expect(baseElement.querySelector('.md\\:flex')).toBeNull()
expect(screen.getByText('files.assignBooking')).toBeInTheDocument()
expect(screen.queryByText('files.assignPlace')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,54 @@
// FE-W4AVC-001 to FE-W4AVC-006
import { describe, it, expect } from 'vitest'
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import { AvatarChip } from './FileManagerAvatarChip'
describe('AvatarChip', () => {
it('FE-W4AVC-001: falls back to the uppercased initial without an avatar', () => {
const { container } = render(<AvatarChip name="ada" />)
expect(container.firstElementChild).toHaveTextContent('A')
expect(container.querySelector('img')).toBeNull()
})
it('FE-W4AVC-002: renders the avatar image when one is given', () => {
const { container } = render(<AvatarChip name="ada" avatarUrl="/uploads/avatars/ada.png" />)
expect(container.querySelector('img')).toHaveAttribute('src', '/uploads/avatars/ada.png')
expect(container.firstElementChild).not.toHaveTextContent('A')
})
it('FE-W4AVC-003: sizes the chip and its glyph from the size prop', () => {
const { container } = render(<AvatarChip name="ada" size={30} />)
const chip = container.firstElementChild as HTMLElement
expect(chip.style.width).toBe('30px')
expect(chip.style.height).toBe('30px')
expect(chip.style.fontSize).toBe('12px')
})
it('FE-W4AVC-004: defaults to a 20px chip', () => {
const { container } = render(<AvatarChip name="ada" />)
expect((container.firstElementChild as HTMLElement).style.width).toBe('20px')
})
it('FE-W4AVC-005: hovering portals a name tooltip and leaving removes it', () => {
const { container } = render(<AvatarChip name="Ada Lovelace" />)
const chip = container.firstElementChild as HTMLElement
fireEvent.mouseEnter(chip)
const tip = screen.getByText('Ada Lovelace')
expect(tip).toBeInTheDocument()
expect(container.contains(tip)).toBe(false)
fireEvent.mouseLeave(chip)
expect(screen.queryByText('Ada Lovelace')).toBeNull()
})
it('FE-W4AVC-006: renders nothing in the chip for an empty name', () => {
const { container } = render(<AvatarChip name="" />)
expect(container.firstElementChild).toBeEmptyDOMElement()
})
})
@@ -1,8 +1,9 @@
import { Fragment } from 'react'
import { Upload, FileText, Star } from 'lucide-react'
import { Upload, Star } from 'lucide-react'
import type { FileManagerState } from './useFileManager'
import { FileRow } from './FileManagerRow'
import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions'
import EmptyState from '../shared/EmptyState'
export function FilesView(S: FileManagerState) {
const {
@@ -66,11 +67,7 @@ export function FilesView(S: FileManagerState) {
{/* File list */}
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 28px 16px' }} className="max-md:!px-4">
{filteredFiles.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
<FileText size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
<p style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.empty')}</p>
<p style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', margin: 0 }}>{t('files.emptyHint')}</p>
</div>
<EmptyState scene="files" title={t('files.empty')} />
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{filteredFiles.map(file => {
@@ -0,0 +1,248 @@
// FE-W4LBX-001 to FE-W4LBX-019
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { TripFile } from '../../types'
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
const getAuthUrl = vi.fn(async (url: string, _kind: string) => `${url}?token=abc`)
const openFile = vi.fn(async (_url: string, _name: string) => {})
const downloadFile = vi.fn(async (_url: string, _name: string) => {})
vi.mock('../../api/authUrl', () => ({ getAuthUrl: (url: string, kind: string) => getAuthUrl(url, kind) }))
vi.mock('../../utils/fileDownload', () => ({
openFile: (url: string, name: string) => openFile(url, name),
downloadFile: (url: string, name: string) => downloadFile(url, name),
}))
vi.mock('../Journey/VideoPlayer', () => ({
default: ({ src }: { src: string }) => <div data-testid="video" data-src={src} />,
}))
import { ImageLightbox } from './FileManagerImageLightbox'
type LightboxFile = TripFile & { url: string }
function file(overrides: Partial<LightboxFile> = {}): LightboxFile {
return { id: 1, original_name: 'beach.jpg', mime_type: 'image/jpeg', url: '/uploads/files/beach.jpg', ...overrides } as unknown as LightboxFile
}
const IMAGES = [
file({ id: 1, original_name: 'a.jpg', url: '/f/a.jpg' }),
file({ id: 2, original_name: 'b.jpg', url: '/f/b.jpg' }),
file({ id: 3, original_name: 'c.jpg', url: '/f/c.jpg' }),
]
beforeEach(() => {
getAuthUrl.mockReset()
getAuthUrl.mockImplementation(async (url: string) => `${url}?token=abc`)
openFile.mockReset()
openFile.mockResolvedValue(undefined)
downloadFile.mockReset()
downloadFile.mockResolvedValue(undefined)
})
describe('ImageLightbox', () => {
it('FE-W4LBX-001: renders nothing when the index points past the list', () => {
const { container } = render(<ImageLightbox files={[]} initialIndex={0} onClose={() => {}} />)
expect(container).toBeEmptyDOMElement()
})
it('FE-W4LBX-002: shows the file name and its position in the gallery', () => {
render(<ImageLightbox files={IMAGES} initialIndex={1} onClose={() => {}} />)
expect(screen.getByText('b.jpg', { exact: false })).toBeInTheDocument()
expect(screen.getByText('2 / 3')).toBeInTheDocument()
})
it('FE-W4LBX-003: mints a signed download url for the shown image', async () => {
const { container } = render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
await waitFor(() => expect(container.querySelector('img[alt="a.jpg"]')).not.toBeNull())
expect(getAuthUrl).toHaveBeenCalledWith('/f/a.jpg', 'download')
expect(container.querySelector('img[alt="a.jpg"]')).toHaveAttribute('src', '/f/a.jpg?token=abc')
})
it('FE-W4LBX-004: hides the previous arrow on the first and the next arrow on the last file', () => {
const first = render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
// header (3) + next arrow + 3 thumbnails
expect(first.container.querySelectorAll('.lucide-chevron-left')).toHaveLength(0)
expect(first.container.querySelectorAll('.lucide-chevron-right')).toHaveLength(1)
first.unmount()
const last = render(<ImageLightbox files={IMAGES} initialIndex={2} onClose={() => {}} />)
expect(last.container.querySelectorAll('.lucide-chevron-left')).toHaveLength(1)
expect(last.container.querySelectorAll('.lucide-chevron-right')).toHaveLength(0)
})
it('FE-W4LBX-005: the arrows page through the gallery', () => {
const { container } = render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
fireEvent.click(container.querySelector('.lucide-chevron-right')!.closest('button')!)
expect(screen.getByText('2 / 3')).toBeInTheDocument()
fireEvent.click(container.querySelector('.lucide-chevron-left')!.closest('button')!)
expect(screen.getByText('1 / 3')).toBeInTheDocument()
})
it('FE-W4LBX-006: the arrow keys page and Escape closes', () => {
const onClose = vi.fn()
render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={onClose} />)
fireEvent.keyDown(window, { key: 'ArrowRight' })
expect(screen.getByText('2 / 3')).toBeInTheDocument()
fireEvent.keyDown(window, { key: 'ArrowLeft' })
expect(screen.getByText('1 / 3')).toBeInTheDocument()
fireEvent.keyDown(window, { key: 'Escape' })
expect(onClose).toHaveBeenCalledOnce()
})
it('FE-W4LBX-007: paging never runs past either end', () => {
render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
fireEvent.keyDown(window, { key: 'ArrowLeft' })
expect(screen.getByText('1 / 3')).toBeInTheDocument()
fireEvent.keyDown(window, { key: 'ArrowRight' })
fireEvent.keyDown(window, { key: 'ArrowRight' })
fireEvent.keyDown(window, { key: 'ArrowRight' })
expect(screen.getByText('3 / 3')).toBeInTheDocument()
})
it('FE-W4LBX-008: a swipe pages in the swiped direction', () => {
const { container } = render(<ImageLightbox files={IMAGES} initialIndex={1} onClose={() => {}} />)
const root = container.firstElementChild as HTMLElement
fireEvent.touchStart(root, { touches: [{ clientX: 200 }] })
fireEvent.touchEnd(root, { changedTouches: [{ clientX: 100 }] })
expect(screen.getByText('3 / 3')).toBeInTheDocument()
fireEvent.touchStart(root, { touches: [{ clientX: 100 }] })
fireEvent.touchEnd(root, { changedTouches: [{ clientX: 220 }] })
expect(screen.getByText('2 / 3')).toBeInTheDocument()
})
it('FE-W4LBX-009: a short swipe is ignored', () => {
const { container } = render(<ImageLightbox files={IMAGES} initialIndex={1} onClose={() => {}} />)
const root = container.firstElementChild as HTMLElement
fireEvent.touchStart(root, { touches: [{ clientX: 200 }] })
fireEvent.touchEnd(root, { changedTouches: [{ clientX: 180 }] })
expect(screen.getByText('2 / 3')).toBeInTheDocument()
})
it('FE-W4LBX-010: a touch end without a start is ignored', () => {
const { container } = render(<ImageLightbox files={IMAGES} initialIndex={1} onClose={() => {}} />)
fireEvent.touchEnd(container.firstElementChild!, { changedTouches: [{ clientX: 0 }] })
expect(screen.getByText('2 / 3')).toBeInTheDocument()
})
it('FE-W4LBX-011: the header buttons open, download and close', () => {
const onClose = vi.fn()
render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={onClose} />)
fireEvent.click(screen.getByTitle(/open/i))
expect(openFile).toHaveBeenCalledWith('/f/a.jpg', 'a.jpg')
fireEvent.click(screen.getByTitle(/download/i))
expect(downloadFile).toHaveBeenCalledWith('/f/a.jpg', 'a.jpg')
fireEvent.click(screen.getAllByRole('button')[2])
expect(onClose).toHaveBeenCalledOnce()
})
it('FE-W4LBX-012: clicking the backdrop closes but clicking the image does not', async () => {
const onClose = vi.fn()
const { container } = render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={onClose} />)
await waitFor(() => expect(container.querySelector('img[alt="a.jpg"]')).not.toBeNull())
fireEvent.click(container.querySelector('img[alt="a.jpg"]')!)
expect(onClose).not.toHaveBeenCalled()
fireEvent.click(container.firstElementChild!)
expect(onClose).toHaveBeenCalledOnce()
})
it('FE-W4LBX-013: the thumbnail strip jumps to the picked file', async () => {
render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
// header: open + download + close, then the next arrow, then three thumbs.
const thumbs = screen.getAllByRole('button').slice(-3)
fireEvent.click(thumbs[2])
expect(screen.getByText('3 / 3')).toBeInTheDocument()
await waitFor(() => expect(getAuthUrl).toHaveBeenCalledWith('/f/c.jpg', 'download'))
})
it('FE-W4LBX-014: hides the strip for a single file', () => {
render(<ImageLightbox files={[IMAGES[0]]} initialIndex={0} onClose={() => {}} />)
expect(screen.getAllByRole('button')).toHaveLength(3)
})
it('FE-W4LBX-015: a video plays in the player and never mints a download token', () => {
const video = file({ id: 9, original_name: 'clip.mp4', mime_type: 'video/mp4', url: '/f/clip.mp4' })
const { container } = render(<ImageLightbox files={[video]} initialIndex={0} onClose={() => {}} />)
expect(screen.getByTestId('video')).toHaveAttribute('data-src', '/f/clip.mp4')
expect(getAuthUrl).not.toHaveBeenCalled()
expect(container.querySelector('img')).toBeNull()
})
it('FE-W4LBX-016: a video thumbnail is a play glyph, not an image request', async () => {
const files = [IMAGES[0], file({ id: 9, original_name: 'clip.mp4', mime_type: 'video/mp4', url: '/f/clip.mp4' })]
const { container } = render(<ImageLightbox files={files} initialIndex={0} onClose={() => {}} />)
await waitFor(() => expect(getAuthUrl).toHaveBeenCalledWith('/f/a.jpg', 'download'))
expect(getAuthUrl).not.toHaveBeenCalledWith('/f/clip.mp4', 'download')
expect(container.querySelector('.lucide-play')).not.toBeNull()
})
it('FE-W4LBX-017: clicking the video wrapper does not close the lightbox', () => {
const onClose = vi.fn()
render(<ImageLightbox files={[file({ mime_type: 'video/mp4' })]} initialIndex={0} onClose={onClose} />)
fireEvent.click(screen.getByTestId('video').parentElement!)
expect(onClose).not.toHaveBeenCalled()
})
it('FE-W4LBX-018: Escape reaches the current onClose after a re-render', () => {
const first = vi.fn()
const second = vi.fn()
const { rerender } = render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={first} />)
rerender(<ImageLightbox files={IMAGES} initialIndex={0} onClose={second} />)
fireEvent.keyDown(window, { key: 'Escape' })
expect(second).toHaveBeenCalledOnce()
expect(first).not.toHaveBeenCalled()
})
it('FE-W4LBX-019: the strip mints thumbnail tokens only for thumbs in view', async () => {
// Every thumbnail costs its own one-shot token, so an off-screen thumb must
// stay quiet. The default observer stub never intersects.
const { unmount } = render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
await waitFor(() => expect(getAuthUrl).toHaveBeenCalledWith('/f/a.jpg', 'download'))
expect(getAuthUrl).toHaveBeenCalledTimes(1)
unmount()
getAuthUrl.mockClear()
const original = globalThis.IntersectionObserver
globalThis.IntersectionObserver = class {
constructor(private cb: IntersectionObserverCallback) {}
observe() { this.cb([{ isIntersecting: true } as IntersectionObserverEntry], this as unknown as IntersectionObserver) }
disconnect() {}
unobserve() {}
takeRecords() { return [] }
} as unknown as typeof IntersectionObserver
try {
render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
await waitFor(() => expect(getAuthUrl).toHaveBeenCalledWith('/f/c.jpg', 'download'))
} finally {
globalThis.IntersectionObserver = original
}
})
})
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { ExternalLink, Download, X, ChevronLeft, ChevronRight, Play } from 'lucide-react'
import { useTranslation } from '../../i18n'
import type { TripFile } from '../../types'
@@ -41,7 +41,7 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [])
}, [onClose])
if (!file) return null
@@ -128,11 +128,30 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
function ThumbImg({ file, active, onClick }: { file: TripFile & { url: string }; active: boolean; onClick: () => void }) {
const fileIsVideo = isVideo(file.mime_type)
const [src, setSrc] = useState('')
const [visible, setVisible] = useState(false)
const ref = useRef<HTMLButtonElement>(null)
// Each thumbnail costs its own one-shot download token, so the strip only
// mints them for the thumbs that actually scroll into view.
useEffect(() => {
const el = ref.current
if (!el || typeof IntersectionObserver !== 'function') { setVisible(true); return }
const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { setVisible(true); io.disconnect() } }, { rootMargin: '200px' })
io.observe(el)
return () => io.disconnect()
}, [])
// Videos have no stored thumbnail and can't render as an <img>; show a play
// placeholder and don't mint a download token for them (#823).
useEffect(() => { if (!fileIsVideo) getAuthUrl(file.url, 'download').then(setSrc) }, [file.url, fileIsVideo])
useEffect(() => {
if (!visible || fileIsVideo) return
let current = true
getAuthUrl(file.url, 'download').then(u => { if (current) setSrc(u) })
return () => { current = false }
}, [file.url, fileIsVideo, visible])
return (
<button onClick={onClick} style={{
<button ref={ref} onClick={onClick} style={{
width: 48, height: 48, borderRadius: 6, overflow: 'hidden', border: active ? '2px solid #fff' : '2px solid transparent',
opacity: active ? 1 : 0.5, cursor: 'pointer', padding: 0, background: '#111', flexShrink: 0, transition: 'opacity 0.15s',
display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'rgba(255,255,255,0.7)',
@@ -0,0 +1,190 @@
// FE-W4FPM-001 to FE-W4FPM-014
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { TripFile } from '../../types'
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
import type { FileManagerState } from './useFileManager'
const openFile = vi.fn(async (_url: string, _name: string) => {})
const downloadFile = vi.fn(async (_url: string, _name: string) => {})
vi.mock('../../utils/fileDownload', () => ({
openFile: (url: string, name: string) => openFile(url, name),
downloadFile: (url: string, name: string) => downloadFile(url, name),
}))
import { PdfPreviewModal } from './FileManagerPdfPreviewModal'
import { MarkdownPreviewModal } from './FileManagerMarkdownPreviewModal'
const toast = { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }
const setPreviewFile = vi.fn()
function state(overrides: Partial<FileManagerState> = {}): FileManagerState {
return {
previewFile: { id: 1, original_name: 'ticket.pdf', url: '/uploads/files/ticket.pdf' } as unknown as TripFile,
previewFileUrl: '/uploads/files/ticket.pdf?token=abc',
setPreviewFile,
toast,
t: (key: string) => key,
...overrides,
} as unknown as FileManagerState
}
beforeEach(() => {
openFile.mockReset()
openFile.mockResolvedValue(undefined)
downloadFile.mockReset()
downloadFile.mockResolvedValue(undefined)
setPreviewFile.mockClear()
Object.values(toast).forEach(f => f.mockClear())
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('PdfPreviewModal', () => {
it('FE-W4FPM-001: portals the viewer with the signed url and the file name', () => {
const { container, baseElement } = render(<PdfPreviewModal {...state()} />)
expect(container).toBeEmptyDOMElement()
expect(screen.getByText('ticket.pdf')).toBeInTheDocument()
const object = baseElement.querySelector('object') as HTMLObjectElement
expect(object).toHaveAttribute('data', '/uploads/files/ticket.pdf?token=abc#view=FitH')
expect(object).toHaveAttribute('type', 'application/pdf')
})
it('FE-W4FPM-002: leaves the viewer source unset while no signed url exists yet', () => {
const { baseElement } = render(<PdfPreviewModal {...state({ previewFileUrl: null })} />)
expect(baseElement.querySelector('object')).not.toHaveAttribute('data')
})
it('FE-W4FPM-003: the open-in-tab button uses the unsigned url', () => {
render(<PdfPreviewModal {...state()} />)
fireEvent.click(screen.getByRole('button', { name: /openTab/ }))
expect(openFile).toHaveBeenCalledWith('/uploads/files/ticket.pdf', 'ticket.pdf')
})
it('FE-W4FPM-004: a failed open toasts the error', async () => {
openFile.mockRejectedValue(new Error('blocked'))
render(<PdfPreviewModal {...state()} />)
fireEvent.click(screen.getByRole('button', { name: /openTab/ }))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('files.openError'))
})
it('FE-W4FPM-005: the download button hands the file to the download helper', () => {
render(<PdfPreviewModal {...state()} />)
fireEvent.click(screen.getByRole('button', { name: 'files.download' }))
expect(downloadFile).toHaveBeenCalledWith('/uploads/files/ticket.pdf', 'ticket.pdf')
})
it('FE-W4FPM-006: the close button and the backdrop clear the preview, the card does not', () => {
const { baseElement } = render(<PdfPreviewModal {...state()} />)
const backdrop = baseElement.querySelector('div[style*="rgba(0, 0, 0, 0.85)"]') as HTMLElement
fireEvent.click(screen.getAllByRole('button')[2])
expect(setPreviewFile).toHaveBeenCalledWith(null)
setPreviewFile.mockClear()
fireEvent.click(backdrop.firstElementChild!)
expect(setPreviewFile).not.toHaveBeenCalled()
fireEvent.click(backdrop)
expect(setPreviewFile).toHaveBeenCalledWith(null)
})
it('FE-W4FPM-007: the no-plugin fallback also opens the file', () => {
render(<PdfPreviewModal {...state()} />)
fireEvent.click(screen.getByRole('button', { name: 'files.downloadPdf' }))
expect(openFile).toHaveBeenCalledWith('/uploads/files/ticket.pdf', 'ticket.pdf')
})
it('FE-W4FPM-014: the toolbar buttons brighten on hover and dim again', () => {
render(<PdfPreviewModal {...state()} />)
for (const button of screen.getAllByRole('button').slice(0, 3)) {
fireEvent.mouseEnter(button)
expect(button.style.color).toBe('var(--text-primary)')
fireEvent.mouseLeave(button)
expect(button.style.color).toBe(button === screen.getAllByRole('button')[2] ? 'var(--text-faint)' : 'var(--text-muted)')
}
})
})
describe('MarkdownPreviewModal', () => {
const mdState = (overrides: Partial<FileManagerState> = {}) => state({
previewFile: { id: 2, original_name: 'notes.md', url: '/uploads/files/notes.md' } as unknown as TripFile,
previewFileUrl: '/uploads/files/notes.md?token=abc',
...overrides,
})
it('FE-W4FPM-008: fetches the markdown with credentials and renders it', async () => {
const fetchMock = vi.fn(async () => ({ ok: true, text: async () => '# Packing\n\nBring a **towel**.' }))
vi.stubGlobal('fetch', fetchMock)
render(<MarkdownPreviewModal {...mdState()} />)
expect(fetchMock).toHaveBeenCalledWith('/uploads/files/notes.md?token=abc', { credentials: 'include' })
expect(await screen.findByRole('heading', { name: 'Packing' })).toBeInTheDocument()
expect(screen.getByText('towel')).toBeInTheDocument()
})
it('FE-W4FPM-009: shows the error copy when the fetch fails', async () => {
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline') }))
render(<MarkdownPreviewModal {...mdState()} />)
expect(await screen.findByText('files.openError')).toBeInTheDocument()
})
it('FE-W4FPM-010: shows the error copy on a non-ok response', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, text: async () => '' })))
render(<MarkdownPreviewModal {...mdState()} />)
expect(await screen.findByText('files.openError')).toBeInTheDocument()
})
it('FE-W4FPM-011: skips the fetch until a signed url exists', () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
render(<MarkdownPreviewModal {...mdState({ previewFileUrl: null })} />)
expect(fetchMock).not.toHaveBeenCalled()
expect(screen.getByText('notes.md')).toBeInTheDocument()
})
it('FE-W4FPM-012: the header buttons open, download and close', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, text: async () => 'hi' })))
render(<MarkdownPreviewModal {...mdState()} />)
fireEvent.click(screen.getByRole('button', { name: /openTab/ }))
expect(openFile).toHaveBeenCalledWith('/uploads/files/notes.md', 'notes.md')
fireEvent.click(screen.getByRole('button', { name: 'files.download' }))
expect(downloadFile).toHaveBeenCalledWith('/uploads/files/notes.md', 'notes.md')
fireEvent.click(screen.getAllByRole('button')[2])
expect(setPreviewFile).toHaveBeenCalledWith(null)
await waitFor(() => expect(screen.getByText('hi')).toBeInTheDocument())
})
it('FE-W4FPM-013: a failed open toasts the error', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, text: async () => '' })))
openFile.mockRejectedValue(new Error('blocked'))
render(<MarkdownPreviewModal {...mdState()} />)
fireEvent.click(screen.getByRole('button', { name: /openTab/ }))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('files.openError'))
})
})
@@ -54,6 +54,7 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
setShowTrash(v => !v)
}, [showTrash, loadTrash])
// onUpdate doubles as the "files changed" signal towards the parent; the arguments carry no payload.
const refreshFiles = useCallback(async () => {
if (onUpdate) onUpdate(0, {} as any)
}, [onUpdate])
@@ -0,0 +1,381 @@
// FE-NOFEAR-BCN-001 to FE-NOFEAR-BCN-019
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen, act, fireEvent } from '../../../tests/helpers/render'
import NoFearBeacon from './NoFearBeacon'
vi.mock('./NoFearShow', () => ({
default: ({ onClose }: { onClose: () => void }) => (
<div role="dialog" aria-label="show stub">
<button type="button" onClick={onClose}>close show</button>
</div>
),
}))
const DISMISS_KEY = 'trek.fourzero.dismissed'
const POINT_COUNT = 24
interface StrokeRecord {
style: string
width: number
}
interface FakeCtx {
strokeStyle: string
lineWidth: number
fillStyle: string
globalCompositeOperation: string
setTransform: ReturnType<typeof vi.fn>
clearRect: ReturnType<typeof vi.fn>
beginPath: ReturnType<typeof vi.fn>
moveTo: ReturnType<typeof vi.fn>
lineTo: ReturnType<typeof vi.fn>
stroke: ReturnType<typeof vi.fn>
arc: ReturnType<typeof vi.fn>
fill: ReturnType<typeof vi.fn>
strokes: StrokeRecord[]
}
interface FakeObserver {
cb: ResizeObserverCallback
observe: ReturnType<typeof vi.fn>
unobserve: ReturnType<typeof vi.fn>
disconnect: ReturnType<typeof vi.fn>
}
let frames: FrameRequestCallback[] = []
let cancelSpy: ReturnType<typeof vi.fn>
let ctx: FakeCtx | null = null
let observers: FakeObserver[] = []
let randomQueue: number[] = []
const originalGetContext = HTMLCanvasElement.prototype.getContext
const OriginalResizeObserver = globalThis.ResizeObserver
function makeCtx(): FakeCtx {
const c: FakeCtx = {
strokeStyle: '',
lineWidth: 0,
fillStyle: '',
globalCompositeOperation: 'source-over',
setTransform: vi.fn(),
clearRect: vi.fn(),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(() => { c.strokes.push({ style: c.strokeStyle, width: c.lineWidth }) }),
arc: vi.fn(),
fill: vi.fn(),
strokes: [],
}
return c
}
class FakeResizeObserver {
observe = vi.fn()
unobserve = vi.fn()
disconnect = vi.fn()
constructor(public cb: ResizeObserverCallback) { observers.push(this) }
}
/** Runs the pending rAF callback with the given timestamp (ms). */
function frame(ms: number): void {
const pending = frames
frames = []
act(() => { pending.forEach(cb => cb(ms)) })
}
function resetDrawCounters(): void {
ctx?.moveTo.mockClear()
ctx?.lineTo.mockClear()
ctx?.arc.mockClear()
ctx?.fill.mockClear()
if (ctx) ctx.strokes.length = 0
}
function setReducedMotion(reduce: boolean): void {
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: reduce,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}))
}
beforeEach(() => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
// Well inside the release window so the beacon is not retired by the calendar.
vi.setSystemTime(new Date('2026-08-01T09:00:00'))
frames = []
observers = []
cancelSpy = vi.fn()
ctx = makeCtx()
randomQueue = [0]
let randomIdx = 0
vi.spyOn(Math, 'random').mockImplementation(() => {
const v = randomQueue[randomIdx % randomQueue.length]
randomIdx++
return v
})
vi.stubGlobal('requestAnimationFrame', vi.fn((cb: FrameRequestCallback) => {
frames.push(cb)
return frames.length
}))
vi.stubGlobal('cancelAnimationFrame', cancelSpy)
globalThis.ResizeObserver = FakeResizeObserver as unknown as typeof ResizeObserver
HTMLCanvasElement.prototype.getContext = vi.fn(() => ctx) as unknown as typeof originalGetContext
Object.defineProperty(HTMLCanvasElement.prototype, 'clientWidth', { configurable: true, get: () => 800 })
Object.defineProperty(HTMLCanvasElement.prototype, 'clientHeight', { configurable: true, get: () => 400 })
Object.defineProperty(window, 'devicePixelRatio', { configurable: true, writable: true, value: 1 })
setReducedMotion(false)
})
afterEach(() => {
HTMLCanvasElement.prototype.getContext = originalGetContext
Reflect.deleteProperty(HTMLCanvasElement.prototype, 'clientWidth')
Reflect.deleteProperty(HTMLCanvasElement.prototype, 'clientHeight')
globalThis.ResizeObserver = OriginalResizeObserver
vi.unstubAllGlobals()
vi.restoreAllMocks()
vi.useRealTimers()
})
describe('NoFearBeacon', () => {
it('FE-NOFEAR-BCN-001: renders the trigger card with title, subtitle and retirement badge', () => {
render(<NoFearBeacon />)
const play = screen.getByRole('button', { name: 'Press play.' })
expect(play).toHaveTextContent('NO FEAR')
expect(play).toHaveTextContent('A sign for an open world.')
expect(screen.getByText('Shown until Aug 23')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument()
})
it('FE-NOFEAR-BCN-002: renders nothing once the release window has passed', () => {
vi.setSystemTime(new Date('2026-08-24T00:00:01'))
const { container } = render(<NoFearBeacon />)
expect(container).toBeEmptyDOMElement()
expect(screen.queryByText('NO FEAR')).toBeNull()
})
it('FE-NOFEAR-BCN-003: stays hidden when it was dismissed before', () => {
localStorage.setItem(DISMISS_KEY, '1')
const { container } = render(<NoFearBeacon />)
expect(container).toBeEmptyDOMElement()
})
it('FE-NOFEAR-BCN-004: still renders when localStorage cannot be read', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation((key: string) => {
if (key === DISMISS_KEY) throw new Error('storage blocked')
return null
})
render(<NoFearBeacon />)
expect(screen.getByText('NO FEAR')).toBeInTheDocument()
})
it('FE-NOFEAR-BCN-005: needs two clicks to retire the moment and persists the choice', () => {
render(<NoFearBeacon />)
const dismiss = screen.getByRole('button', { name: 'Dismiss' })
fireEvent.click(dismiss)
expect(dismiss).toHaveTextContent('Hide for good?')
expect(dismiss).toHaveClass('fz-beacon-dismiss-confirm')
expect(screen.getByText('NO FEAR')).toBeInTheDocument()
fireEvent.click(dismiss)
expect(screen.queryByText('NO FEAR')).toBeNull()
expect(localStorage.getItem(DISMISS_KEY)).toBe('1')
})
it('FE-NOFEAR-BCN-006: disarms the confirm state when the pointer leaves', () => {
render(<NoFearBeacon />)
const dismiss = screen.getByRole('button', { name: 'Dismiss' })
fireEvent.click(dismiss)
expect(dismiss).toHaveTextContent('Hide for good?')
fireEvent.mouseLeave(dismiss)
expect(dismiss).not.toHaveTextContent('Hide for good?')
expect(dismiss).not.toHaveClass('fz-beacon-dismiss-confirm')
})
it('FE-NOFEAR-BCN-007: disarms the confirm state on blur', () => {
render(<NoFearBeacon />)
const dismiss = screen.getByRole('button', { name: 'Dismiss' })
fireEvent.click(dismiss)
fireEvent.blur(dismiss)
expect(dismiss).not.toHaveTextContent('Hide for good?')
})
it('FE-NOFEAR-BCN-008: hides for the session when the choice cannot be persisted', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('quota') })
render(<NoFearBeacon />)
const dismiss = screen.getByRole('button', { name: 'Dismiss' })
fireEvent.click(dismiss)
fireEvent.click(dismiss)
expect(screen.queryByText('NO FEAR')).toBeNull()
})
it('FE-NOFEAR-BCN-009: opens the show on play and closes it again', async () => {
render(<NoFearBeacon />)
expect(screen.queryByRole('dialog')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Press play.' }))
await act(async () => { await Promise.resolve() })
expect(screen.getByRole('dialog', { name: 'show stub' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'close show' }))
expect(screen.queryByRole('dialog')).toBeNull()
expect(screen.getByText('NO FEAR')).toBeInTheDocument()
})
it('FE-NOFEAR-BCN-010: sizes the teaser canvas by capped device pixel ratio', () => {
Object.defineProperty(window, 'devicePixelRatio', { configurable: true, writable: true, value: 3 })
render(<NoFearBeacon />)
const canvas = document.querySelector<HTMLCanvasElement>('canvas.fz-beacon-canvas')
expect(canvas?.width).toBe(1200)
expect(canvas?.height).toBe(600)
expect(ctx?.setTransform).toHaveBeenCalledWith(1.5, 0, 0, 1.5, 0, 0)
expect(observers[0].observe).toHaveBeenCalledWith(canvas)
})
it('FE-NOFEAR-BCN-011: re-fits the canvas when the observer reports a resize', () => {
render(<NoFearBeacon />)
expect(ctx?.setTransform).toHaveBeenCalledTimes(1)
act(() => { observers[0].cb([], observers[0] as unknown as ResizeObserver) })
expect(ctx?.setTransform).toHaveBeenCalledTimes(2)
})
it('FE-NOFEAR-BCN-012: breathes every light twice per frame and keeps animating', () => {
render(<NoFearBeacon />)
frame(0)
expect(ctx?.clearRect).toHaveBeenCalledWith(0, 0, 800, 400)
expect(ctx?.arc).toHaveBeenCalledTimes(POINT_COUNT * 2)
expect(ctx?.fill).toHaveBeenCalledTimes(POINT_COUNT * 2)
expect(ctx?.moveTo).not.toHaveBeenCalled()
expect(ctx?.globalCompositeOperation).toBe('source-over')
expect(frames).toHaveLength(1)
})
it('FE-NOFEAR-BCN-013: spawns a golden arc between two distant lights and grows it', () => {
// a = point 0, first b throwaway, then a far point so the search breaks at once.
randomQueue = [0, 0, 0.9]
render(<NoFearBeacon />)
frame(0)
resetDrawCounters()
frame(2000)
expect(ctx?.moveTo).toHaveBeenCalledTimes(1)
// Two passes per arc: wide warm halo, then the bright core.
expect(ctx?.strokes).toEqual([
{ style: 'rgba(255, 180, 95, 0.12)', width: 3.4 },
{ style: 'rgba(255, 208, 130, 0.55)', width: 1.1 },
])
const partial = ctx?.lineTo.mock.calls.length ?? 0
expect(partial).toBeGreaterThan(0)
expect(partial).toBeLessThan(22)
resetDrawCounters()
frame(3600)
expect(ctx?.lineTo).toHaveBeenCalledTimes(22)
})
it('FE-NOFEAR-BCN-014: fades an arc out and drops it once it is older than 4.5s', () => {
randomQueue = [0, 0, 0.9]
render(<NoFearBeacon />)
frame(0)
frame(2000)
// 3.75s old: half faded, drawn behind the arc that just spawned.
resetDrawCounters()
frame(5750)
expect(ctx?.moveTo).toHaveBeenCalledTimes(2)
expect(ctx?.strokes.slice(2)).toEqual([
{ style: 'rgba(255, 180, 95, 0.06)', width: 3.4 },
{ style: 'rgba(255, 208, 130, 0.275)', width: 1.1 },
])
// Past 4.5s it is dropped and only the younger arc remains.
resetDrawCounters()
frame(7000)
expect(ctx?.moveTo).toHaveBeenCalledTimes(1)
})
it('FE-NOFEAR-BCN-015: gives up looking for a distant partner after six tries', () => {
// Every draw returns the same index, so no candidate is ever far enough away.
randomQueue = [0]
render(<NoFearBeacon />)
frame(0)
resetDrawCounters()
frame(2000)
expect(ctx?.moveTo).toHaveBeenCalledTimes(1)
expect(Math.random).toHaveBeenCalledTimes(8)
})
it('FE-NOFEAR-BCN-016: paints a single frame and stops when motion is reduced', () => {
setReducedMotion(true)
render(<NoFearBeacon />)
expect(frames).toHaveLength(1)
frame(0)
expect(ctx?.fill).toHaveBeenCalledTimes(POINT_COUNT * 2)
expect(frames).toHaveLength(0)
})
it('FE-NOFEAR-BCN-017: skips the teaser entirely without a 2d context', () => {
ctx = null
render(<NoFearBeacon />)
expect(frames).toHaveLength(0)
expect(observers).toHaveLength(0)
expect(screen.getByText('NO FEAR')).toBeInTheDocument()
})
it('FE-NOFEAR-BCN-018: cancels the frame and disconnects the observer on unmount', () => {
const { unmount } = render(<NoFearBeacon />)
unmount()
expect(cancelSpy).toHaveBeenCalled()
expect(observers[0].disconnect).toHaveBeenCalledTimes(1)
})
it('FE-NOFEAR-BCN-019: falls back to a pixel ratio of 1 when the browser reports none', () => {
Object.defineProperty(window, 'devicePixelRatio', { configurable: true, writable: true, value: 0 })
render(<NoFearBeacon />)
expect(document.querySelector<HTMLCanvasElement>('canvas.fz-beacon-canvas')?.width).toBe(800)
expect(ctx?.setTransform).toHaveBeenCalledWith(1, 0, 0, 1, 0, 0)
})
})
@@ -0,0 +1,180 @@
import { lazy, Suspense, useEffect, useRef, useState } from 'react'
import { X } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { noFearChrome, noFearCopy } from './noFearLines'
import './fourzero.css'
// TREK 4.0.0 release moment — remove together with the FourZero folder.
const NoFearShow = lazy(() => import('./NoFearShow'))
// The moment retires itself. Last day it shows: 2026-08-23 (local midnight after
// it ends the run). After that the beacon renders nothing even if the folder is
// still in the build — so self-hosters who don't update past this version still
// see it disappear on time. The end date holds regardless of updates.
const VISIBLE_UNTIL = new Date('2026-08-24T00:00:00').getTime()
const DISMISS_KEY = 'trek.fourzero.dismissed'
// Abstract night-world for the teaser: light points loosely shaped like the
// inhabited continents (relative coords), between which golden arcs travel.
const TEASER_POINTS: [number, number][] = [
[0.46, 0.28], [0.5, 0.22], [0.53, 0.3], [0.48, 0.38], [0.55, 0.42], [0.44, 0.5],
[0.58, 0.55], [0.52, 0.62], [0.62, 0.3], [0.68, 0.4], [0.74, 0.32], [0.8, 0.45],
[0.84, 0.58], [0.88, 0.36], [0.2, 0.3], [0.14, 0.4], [0.24, 0.52], [0.3, 0.66],
[0.26, 0.78], [0.34, 0.35], [0.9, 0.72], [0.66, 0.68], [0.1, 0.26], [0.4, 0.72],
]
interface TeaserArc { a: number; b: number; born: number }
/** The living background of the beacon: pulsing lights, traveling golden arcs. */
function TeaserCanvas() {
const ref = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const cv = ref.current
const c = cv?.getContext('2d')
if (!cv || !c) return
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const dpr = Math.min(window.devicePixelRatio || 1, 1.5)
const fit = () => {
cv.width = Math.round(cv.clientWidth * dpr)
cv.height = Math.round(cv.clientHeight * dpr)
c.setTransform(dpr, 0, 0, dpr, 0, 0)
}
fit()
const ro = new ResizeObserver(fit)
ro.observe(cv)
const arcs: TeaserArc[] = []
let last = 0
let raf = 0
const draw = (now: number) => {
const t = now / 1000
const w = cv.clientWidth
const h = cv.clientHeight
c.clearRect(0, 0, w, h)
// A new arc every ~1.6s between two far-apart lights.
if (t - last > 1.6 && arcs.length < 7) {
last = t
const a = Math.floor(Math.random() * TEASER_POINTS.length)
let b = Math.floor(Math.random() * TEASER_POINTS.length)
for (let tries = 0; tries < 6; tries++) {
b = Math.floor(Math.random() * TEASER_POINTS.length)
if (Math.abs(TEASER_POINTS[a][0] - TEASER_POINTS[b][0]) > 0.22) break
}
arcs.push({ a, b, born: t })
}
c.globalCompositeOperation = 'lighter'
// Arcs: grow 1.4s, glow, fade until 4.5s.
for (let i = arcs.length - 1; i >= 0; i--) {
const arc = arcs[i]
const age = t - arc.born
if (age > 4.5) { arcs.splice(i, 1); continue }
const grow = Math.min(age / 1.4, 1)
const fade = age < 3 ? 1 : 1 - (age - 3) / 1.5
const [ax, ay] = TEASER_POINTS[arc.a]
const [bx, by] = TEASER_POINTS[arc.b]
const x1 = ax * w; const y1 = ay * h
const x2 = bx * w; const y2 = by * h
const mx = (x1 + x2) / 2
const my = (y1 + y2) / 2 - Math.min(Math.abs(x2 - x1) * 0.35, h * 0.34)
c.beginPath()
const steps = 22
const eased = 1 - (1 - grow) ** 3
const upto = Math.floor(steps * eased)
const pt = (q: number): [number, number] => [
(1 - q) * (1 - q) * x1 + 2 * (1 - q) * q * mx + q * q * x2,
(1 - q) * (1 - q) * y1 + 2 * (1 - q) * q * my + q * q * y2,
]
for (let s = 0; s <= upto; s++) {
const [ix, iy] = pt(s / steps)
if (s === 0) c.moveTo(ix, iy)
else c.lineTo(ix, iy)
}
if (eased < 1) {
const [tx, ty] = pt(eased)
c.lineTo(tx, ty)
}
c.strokeStyle = `rgba(255, 180, 95, ${0.12 * fade})`
c.lineWidth = 3.4
c.stroke()
c.strokeStyle = `rgba(255, 208, 130, ${0.55 * fade})`
c.lineWidth = 1.1
c.stroke()
}
// Lights: soft breathing points.
for (let i = 0; i < TEASER_POINTS.length; i++) {
const [px, py] = TEASER_POINTS[i]
const breathe = 0.55 + 0.45 * Math.sin(t * 1.4 + i * 1.7)
c.fillStyle = `rgba(255, 214, 150, ${0.5 * breathe})`
c.beginPath()
c.arc(px * w, py * h, 1.4 + breathe * 0.8, 0, Math.PI * 2)
c.fill()
c.fillStyle = `rgba(255, 190, 110, ${0.1 * breathe})`
c.beginPath()
c.arc(px * w, py * h, 6 + breathe * 3, 0, Math.PI * 2)
c.fill()
}
c.globalCompositeOperation = 'source-over'
if (!reduced) raf = requestAnimationFrame(draw)
}
raf = requestAnimationFrame(draw)
return () => { cancelAnimationFrame(raf); ro.disconnect() }
}, [])
return <canvas ref={ref} className="fz-beacon-canvas" aria-hidden />
}
/**
* The dashboard trigger for the 4.0.0 "KEINE ANGST" show: a dark cinematic card
* with a living web of golden travel arcs (desktop only). One click starts the
* show. Core colors ride inline so no dashboard widget CSS can wash them out.
*/
export default function NoFearBeacon() {
const { language } = useTranslation()
const copy = noFearCopy(language)
const chrome = noFearChrome(language)
const [open, setOpen] = useState(false)
const [confirming, setConfirming] = useState(false)
const [dismissed, setDismissed] = useState(() => {
try { return localStorage.getItem(DISMISS_KEY) === '1' } catch { return false }
})
// Past the end date, or dismissed for good — nothing renders.
if (dismissed || Date.now() >= VISIBLE_UNTIL) return null
const dismiss = () => {
try { localStorage.setItem(DISMISS_KEY, '1') } catch { /* private mode — hide for this session at least */ }
setDismissed(true)
}
return (
<>
<div className="fz-beacon" style={{ background: '#07080d', color: '#f5f2ea' }}>
<TeaserCanvas />
<div className="fz-beacon-glow" aria-hidden />
<button type="button" className="fz-beacon-play" aria-label={copy.beaconCta} onClick={() => setOpen(true)}>
<span className="fz-beacon-title">{copy.beaconTitle}</span>
<span className="fz-beacon-sub" style={{ color: 'rgba(245, 240, 225, 0.75)' }}>{copy.beaconSub}</span>
</button>
{/* Top-right X: two-step so a stray click can't retire the moment for good.
Dismissing is permanent (localStorage) only an update brings it back. */}
<button
type="button"
className={`fz-beacon-dismiss ${confirming ? 'fz-beacon-dismiss-confirm' : ''}`}
aria-label={chrome.dismiss}
title={chrome.dismiss}
onClick={() => (confirming ? dismiss() : setConfirming(true))}
onMouseLeave={() => setConfirming(false)}
onBlur={() => setConfirming(false)}
>
{confirming ? chrome.confirm : <X size={15} />}
</button>
{/* White retirement badge: viewable until Aug 23. */}
<span className="fz-beacon-until">{chrome.until}</span>
</div>
{open && (
<Suspense fallback={null}>
<NoFearShow onClose={() => setOpen(false)} />
</Suspense>
)}
</>
)
}
@@ -0,0 +1,662 @@
// FE-NOFEAR-SHOW-001 to FE-NOFEAR-SHOW-035
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen, act } from '../../../tests/helpers/render'
import apiClient, { placesApi, tripsApi } from '../../api/client'
import NoFearShow from './NoFearShow'
interface AudioStub {
start: ReturnType<typeof vi.fn>
setMuted: ReturnType<typeof vi.fn>
resume: ReturnType<typeof vi.fn>
setSuspended: ReturnType<typeof vi.fn>
swell: ReturnType<typeof vi.fn>
impact: ReturnType<typeof vi.fn>
setAct: ReturnType<typeof vi.fn>
dispose: ReturnType<typeof vi.fn>
}
interface SceneStub {
layout: ReturnType<typeof vi.fn>
load: ReturnType<typeof vi.fn>
setPersonalPlaces: ReturnType<typeof vi.fn>
draw: ReturnType<typeof vi.fn>
}
interface AssemblyStub {
init: ReturnType<typeof vi.fn>
draw: ReturnType<typeof vi.fn>
isReady: ReturnType<typeof vi.fn>
}
// Audio, scene and the finale's particle assembly are replaced wholesale: this
// suite is about the React shell (acts, clock, portal, teardown), not WebAudio
// or canvas painting, both of which have their own unit tests.
const stubs = vi.hoisted(() => {
const audio: AudioStub[] = []
const scene: SceneStub[] = []
const assembly: AssemblyStub[] = []
class FakeAudio {
start = vi.fn()
setMuted = vi.fn()
resume = vi.fn()
setSuspended = vi.fn()
swell = vi.fn()
impact = vi.fn()
setAct = vi.fn()
dispose = vi.fn()
constructor() { audio.push(this) }
}
class FakeScene {
layout = vi.fn()
load = vi.fn()
setPersonalPlaces = vi.fn()
draw = vi.fn()
constructor() { scene.push(this) }
}
class FakeAssembly {
init = vi.fn()
draw = vi.fn()
isReady = vi.fn()
constructor() { assembly.push(this) }
}
return { audio, scene, assembly, FakeAudio, FakeScene, FakeAssembly }
})
vi.mock('./noFearAudio', () => ({ NoFearAudio: stubs.FakeAudio }))
vi.mock('./noFearScene', () => ({ NoFearScene: stubs.FakeScene }))
vi.mock('./noFearAssembly', () => ({ TextAssembly: stubs.FakeAssembly }))
const LINES = {
afraid: 'They want you to be afraid.',
ofTheStranger: 'Afraid of the stranger. Afraid of everything you dont know.',
fearTool: 'Because fear closes borders — first on maps, then in minds.',
hateTrade: 'Fear is their tool. Hatred is their trade.',
butYouTraveled: 'But you have traveled.',
tables: 'You have eaten at foreign tables. Slept under foreign roofs. Laughed with strangers.',
notAnOpinion: 'Racism is not an opinion. Fascism is not an alternative.',
everyDot: 'Every one of these lights is a table where someone was welcome.',
yourPlaces: 'This — this was you.',
}
let nowMs = 0
let frames: FrameRequestCallback[] = []
let cancelSpy: ReturnType<typeof vi.fn>
let ctxStub: { setTransform: ReturnType<typeof vi.fn> } | null = null
const originalGetContext = HTMLCanvasElement.prototype.getContext
const audio = () => stubs.audio[0]
const scene = () => stubs.scene[0]
/** Runs the pending rAF callback at the given show time (seconds). */
function frame(seconds: number): void {
nowMs = seconds * 1000
const pending = frames
frames = []
act(() => { pending.forEach(cb => cb(nowMs)) })
}
async function flush(): Promise<void> {
await act(async () => {
for (let i = 0; i < 12; i++) await Promise.resolve()
})
}
function canvas(): HTMLCanvasElement {
const el = document.querySelector<HTMLCanvasElement>('canvas.fz-canvas')
if (!el) throw new Error('show canvas missing')
return el
}
/** The scene state handed to the canvas on the most recent frame. */
function lastSceneState(): { opacity: number; particles: number } {
const calls = scene().draw.mock.calls
return calls[calls.length - 1][1] as { opacity: number; particles: number }
}
/** Visible line text, normalised across the sentence-by-sentence reveal. */
function lineText(): string {
const el = document.querySelector('.fz-line:not(.fz-line-ghost)')
return (el?.textContent ?? '').replace(/\s+/g, ' ').trim()
}
function setReducedMotion(reduce: boolean): void {
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: reduce,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}))
}
beforeEach(() => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
nowMs = 0
frames = []
stubs.audio.length = 0
stubs.scene.length = 0
stubs.assembly.length = 0
cancelSpy = vi.fn()
ctxStub = { setTransform: vi.fn() }
vi.spyOn(performance, 'now').mockImplementation(() => nowMs)
vi.stubGlobal('requestAnimationFrame', vi.fn((cb: FrameRequestCallback) => {
frames.push(cb)
return frames.length
}))
vi.stubGlobal('cancelAnimationFrame', cancelSpy)
HTMLCanvasElement.prototype.getContext = vi.fn(() => ctxStub) as unknown as typeof originalGetContext
Object.defineProperty(HTMLCanvasElement.prototype, 'clientWidth', { configurable: true, get: () => 800 })
Object.defineProperty(HTMLCanvasElement.prototype, 'clientHeight', { configurable: true, get: () => 600 })
Object.defineProperty(window, 'devicePixelRatio', { configurable: true, writable: true, value: 1 })
setReducedMotion(false)
// No traveler data by default — the generic show.
vi.spyOn(tripsApi, 'list').mockResolvedValue([])
vi.spyOn(placesApi, 'list').mockResolvedValue([])
vi.spyOn(apiClient, 'get').mockRejectedValue(new Error('no atlas'))
})
afterEach(() => {
HTMLCanvasElement.prototype.getContext = originalGetContext
Reflect.deleteProperty(HTMLCanvasElement.prototype, 'clientWidth')
Reflect.deleteProperty(HTMLCanvasElement.prototype, 'clientHeight')
vi.unstubAllGlobals()
vi.restoreAllMocks()
vi.useRealTimers()
document.body.classList.remove('fz-show-open')
})
describe('NoFearShow', () => {
it('FE-NOFEAR-SHOW-001: portals a labelled dialog into the body and locks the page chrome', () => {
render(<NoFearShow onClose={vi.fn()} />)
const dialog = screen.getByRole('dialog', { name: 'NO FEAR' })
expect(dialog).toHaveAttribute('aria-modal', 'true')
expect(dialog.parentElement).toBe(document.body)
expect(document.body).toHaveClass('fz-show-open')
})
it('FE-NOFEAR-SHOW-002: boots audio and scene and starts the soundtrack', () => {
render(<NoFearShow onClose={vi.fn()} />)
expect(stubs.audio).toHaveLength(1)
expect(stubs.scene).toHaveLength(1)
expect(audio().start).toHaveBeenCalledTimes(1)
expect(scene().layout).toHaveBeenCalledWith(800, 600)
})
it('FE-NOFEAR-SHOW-003: sizes the canvas by capped device pixel ratio', () => {
Object.defineProperty(window, 'devicePixelRatio', { configurable: true, writable: true, value: 4 })
render(<NoFearShow onClose={vi.fn()} />)
expect(canvas().width).toBe(1400)
expect(canvas().height).toBe(1050)
expect(ctxStub?.setTransform).toHaveBeenCalledWith(1.75, 0, 0, 1.75, 0, 0)
})
it('FE-NOFEAR-SHOW-004: loads the scene with a live abort signal that aborts on unmount', () => {
const { unmount } = render(<NoFearShow onClose={vi.fn()} />)
const signal = scene().load.mock.calls[0][2] as AbortSignal
expect(scene().load).toHaveBeenCalledWith(800, 600, signal)
expect(signal.aborted).toBe(false)
unmount()
expect(signal.aborted).toBe(true)
})
it('FE-NOFEAR-SHOW-005: coalesces a resize storm into a single re-fit', () => {
render(<NoFearShow onClose={vi.fn()} />)
expect(scene().layout).toHaveBeenCalledTimes(1)
act(() => {
for (let i = 0; i < 20; i++) window.dispatchEvent(new Event('resize'))
})
// layout() rebakes every static layer, so nothing happens while the drag runs
expect(scene().layout).toHaveBeenCalledTimes(1)
act(() => { vi.advanceTimersByTime(150) })
expect(scene().layout).toHaveBeenCalledTimes(2)
expect(scene().layout).toHaveBeenLastCalledWith(800, 600)
})
it('FE-NOFEAR-SHOW-006: shows no line before the first cue', () => {
render(<NoFearShow onClose={vi.fn()} />)
frame(0.2)
expect(document.querySelector('.fz-line')).toBeNull()
expect(audio().setAct).not.toHaveBeenCalled()
})
it('FE-NOFEAR-SHOW-007: opens on the fear act with the first line', () => {
render(<NoFearShow onClose={vi.fn()} />)
frame(0.9)
expect(lineText()).toBe(LINES.afraid)
expect(audio().setAct).toHaveBeenCalledWith('fear')
expect(scene().draw).toHaveBeenCalled()
})
it('FE-NOFEAR-SHOW-008: decays the replaced fear line into a per-character ghost', () => {
render(<NoFearShow onClose={vi.fn()} />)
frame(0.9)
expect(document.querySelector('.fz-line-ghost')).toBeNull()
frame(6.1)
expect(lineText()).toBe(LINES.ofTheStranger)
const ghost = document.querySelector('.fz-line-ghost')
expect(ghost).not.toBeNull()
expect(ghost?.querySelectorAll('.fz-char-decay')).toHaveLength(LINES.afraid.length)
// Once its letters have decayed the ghost retires itself.
act(() => { vi.advanceTimersByTime(1400) })
expect(document.querySelector('.fz-line-ghost')).toBeNull()
frame(12.1)
const ghostText = document.querySelector('.fz-line-ghost')?.textContent ?? ''
expect(ghostText.replace(/\u00a0/g, ' ')).toBe(LINES.ofTheStranger)
})
it('FE-NOFEAR-SHOW-009: raises the red vignette while the borders burn', () => {
render(<NoFearShow onClose={vi.fn()} />)
frame(12.1)
expect(lineText()).toBe(LINES.fearTool)
expect(audio().setAct).toHaveBeenLastCalledWith('dread')
expect(document.querySelector('.fz-vignette')).not.toBeNull()
frame(18.6)
expect(lineText()).toBe(LINES.hateTrade)
expect(document.querySelector('.fz-vignette')).not.toBeNull()
frame(26.1)
expect(document.querySelector('.fz-vignette')).toBeNull()
})
it('FE-NOFEAR-SHOW-010: strobes the staccato words and hides the line inside a flash window', () => {
render(<NoFearShow onClose={vi.fn()} />)
frame(18.6)
expect(lineText()).toBe(LINES.hateTrade)
frame(22.5)
expect(screen.getByText('FEAR.')).toBeInTheDocument()
expect(document.querySelector('.fz-line')).toBeNull()
frame(23.8)
expect(screen.getByText('HATRED.')).toBeInTheDocument()
frame(25.2)
expect(screen.getByText('WALLS.')).toBeInTheDocument()
frame(25.8)
expect(document.querySelector('.fz-flash')).toBeNull()
expect(lineText()).toBe(LINES.hateTrade)
})
it('FE-NOFEAR-SHOW-011: cuts to silence with no line at all', () => {
render(<NoFearShow onClose={vi.fn()} />)
frame(18.6)
frame(26.2)
expect(audio().setAct).toHaveBeenLastCalledWith('silence')
expect(document.querySelector('.fz-line:not(.fz-line-ghost)')).toBeNull()
})
it('FE-NOFEAR-SHOW-012: swells into the soft pivot line', () => {
render(<NoFearShow onClose={vi.fn()} />)
frame(26.2)
frame(28.6)
expect(lineText()).toBe(LINES.butYouTraveled)
expect(document.querySelector('.fz-line-soft')).not.toBeNull()
expect(audio().swell).toHaveBeenCalledTimes(1)
})
it('FE-NOFEAR-SHOW-013: reveals hope-act lines sentence by sentence', () => {
render(<NoFearShow onClose={vi.fn()} />)
frame(34.1)
expect(audio().setAct).toHaveBeenLastCalledWith('hope')
expect(lineText()).toBe(LINES.tables)
const segments = document.querySelectorAll('.fz-seg')
expect(segments).toHaveLength(3)
expect(segments[0].textContent?.trim()).toBe('You have eaten at foreign tables.')
expect((segments[2] as HTMLElement).style.animationDelay).toBe('1.9s')
})
it('FE-NOFEAR-SHOW-014: renders the hard line unsegmented and punches the audio', () => {
render(<NoFearShow onClose={vi.fn()} />)
frame(34.1)
frame(64.1)
expect(lineText()).toBe(LINES.notAnOpinion)
expect(document.querySelector('.fz-line-hard')).not.toBeNull()
expect(document.querySelectorAll('.fz-seg')).toHaveLength(0)
expect(audio().impact).toHaveBeenCalledWith(1)
})
it('FE-NOFEAR-SHOW-015: falls back to the generic line when the traveler has no data', async () => {
render(<NoFearShow onClose={vi.fn()} />)
await flush()
frame(55.6)
expect(lineText()).toBe(LINES.everyDot)
expect(audio().impact).toHaveBeenCalledWith(0.35)
frame(58.9)
expect(lineText()).toBe(LINES.everyDot)
expect(scene().setPersonalPlaces).not.toHaveBeenCalled()
})
it('FE-NOFEAR-SHOW-016: feeds the traveler places into the scene and states the country count', async () => {
vi.mocked(tripsApi.list).mockResolvedValue([{ id: 1 }, { id: 2 }])
vi.mocked(placesApi.list).mockResolvedValue([
{ lat: 48.1, lng: 11.5 },
{ lat: 52.5, lng: 13.4 },
{ lat: null, lng: 9.9 },
])
vi.mocked(apiClient.get).mockResolvedValue({ data: { stats: { totalCountries: 9 } } })
render(<NoFearShow onClose={vi.fn()} />)
await flush()
expect(scene().setPersonalPlaces).toHaveBeenCalledWith([
{ lat: 48.1, lng: 11.5 },
{ lat: 52.5, lng: 13.4 },
{ lat: 48.1, lng: 11.5 },
{ lat: 52.5, lng: 13.4 },
])
frame(55.6)
expect(lineText()).toBe(LINES.yourPlaces)
frame(58.9)
expect(lineText()).toBe('4 places. 9 countries. And not once did the world hurt you.')
})
it('FE-NOFEAR-SHOW-017: falls back to trip counts when Atlas reports no countries', async () => {
vi.mocked(tripsApi.list).mockResolvedValue([{ id: 1 }, { id: 2 }, { id: 3 }])
vi.mocked(placesApi.list).mockResolvedValue([{ lat: 1, lng: 2 }])
vi.mocked(apiClient.get).mockResolvedValue({ data: { stats: { totalCountries: 0 } } })
render(<NoFearShow onClose={vi.fn()} />)
await flush()
frame(58.9)
expect(lineText()).toBe('3 places. 3 journeys. And not once did the world hurt you.')
})
it('FE-NOFEAR-SHOW-018: falls back to trip counts when the Atlas request fails', async () => {
vi.mocked(tripsApi.list).mockResolvedValue([{ id: 7 }])
vi.mocked(placesApi.list).mockResolvedValue([
{ lat: 1, lng: 2 },
{ lat: 3, lng: 4 },
{ lat: 5, lng: 6 },
])
render(<NoFearShow onClose={vi.fn()} />)
await flush()
frame(58.9)
expect(lineText()).toBe('3 places. 1 journeys. And not once did the world hurt you.')
})
it('FE-NOFEAR-SHOW-019: ignores a trip whose places fail to load and skips thin data sets', async () => {
vi.mocked(tripsApi.list).mockResolvedValue([{ id: 1 }, { id: 2 }])
vi.mocked(placesApi.list)
.mockResolvedValueOnce([{ lat: 1, lng: 2 }])
.mockRejectedValueOnce(new Error('boom'))
render(<NoFearShow onClose={vi.fn()} />)
await flush()
expect(scene().setPersonalPlaces).not.toHaveBeenCalled()
})
it('FE-NOFEAR-SHOW-020: survives a failing trip list', async () => {
vi.mocked(tripsApi.list).mockRejectedValue(new Error('offline'))
render(<NoFearShow onClose={vi.fn()} />)
await flush()
frame(58.9)
expect(lineText()).toBe(LINES.everyDot)
expect(scene().setPersonalPlaces).not.toHaveBeenCalled()
})
it('FE-NOFEAR-SHOW-021: ignores a non-array trip response', async () => {
vi.mocked(tripsApi.list).mockResolvedValue({ trips: [] })
render(<NoFearShow onClose={vi.fn()} />)
await flush()
expect(placesApi.list).not.toHaveBeenCalled()
expect(scene().setPersonalPlaces).not.toHaveBeenCalled()
})
it('FE-NOFEAR-SHOW-022: drops late place results after unmount', async () => {
let releasePlaces: (value: { lat: number; lng: number }[]) => void = () => {}
vi.mocked(tripsApi.list).mockResolvedValue([{ id: 1 }])
vi.mocked(placesApi.list).mockReturnValue(new Promise(resolve => { releasePlaces = resolve }))
const { unmount } = render(<NoFearShow onClose={vi.fn()} />)
await flush()
unmount()
releasePlaces([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }, { lat: 5, lng: 6 }])
await flush()
expect(scene().setPersonalPlaces).not.toHaveBeenCalled()
})
it('FE-NOFEAR-SHOW-023: pauses the clock while the tab is hidden', () => {
render(<NoFearShow onClose={vi.fn()} />)
frame(10)
expect(lineText()).toBe(LINES.ofTheStranger)
Object.defineProperty(document, 'hidden', { configurable: true, get: () => true })
act(() => { document.dispatchEvent(new Event('visibilitychange')) })
expect(audio().setSuspended).toHaveBeenCalledWith(true)
nowMs = 30_000
Object.defineProperty(document, 'hidden', { configurable: true, get: () => false })
act(() => { document.dispatchEvent(new Event('visibilitychange')) })
expect(audio().setSuspended).toHaveBeenLastCalledWith(false)
// 21s of wall clock passed, but the show only advanced 1s.
frame(31)
expect(lineText()).toBe(LINES.ofTheStranger)
Reflect.deleteProperty(document, 'hidden')
})
it('FE-NOFEAR-SHOW-024: resumes audio on any pointer gesture', () => {
render(<NoFearShow onClose={vi.fn()} />)
act(() => { window.dispatchEvent(new Event('pointerdown')) })
expect(audio().resume).toHaveBeenCalledTimes(1)
})
it('FE-NOFEAR-SHOW-025: closes on Escape and ignores other keys', () => {
const onClose = vi.fn()
render(<NoFearShow onClose={onClose} />)
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })) })
expect(onClose).not.toHaveBeenCalled()
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) })
expect(onClose).toHaveBeenCalledTimes(1)
})
it('FE-NOFEAR-SHOW-026: closes through the chrome button', () => {
const onClose = vi.fn()
render(<NoFearShow onClose={onClose} />)
act(() => { screen.getByRole('button', { name: 'Carry it on' }).click() })
expect(onClose).toHaveBeenCalledTimes(1)
})
it('FE-NOFEAR-SHOW-027: toggles mute and relabels the button', () => {
render(<NoFearShow onClose={vi.fn()} />)
act(() => { screen.getByRole('button', { name: 'Sound off' }).click() })
expect(audio().setMuted).toHaveBeenCalledWith(true)
act(() => { screen.getByRole('button', { name: 'Sound on' }).click() })
expect(audio().setMuted).toHaveBeenLastCalledWith(false)
})
it('FE-NOFEAR-SHOW-028: skip jumps straight to the anthem and retires the skip button', () => {
render(<NoFearShow onClose={vi.fn()} />)
frame(5)
expect(lineText()).toBe(LINES.afraid)
act(() => { screen.getByRole('button', { name: 'Skip' }).click() })
expect(audio().setAct).toHaveBeenLastCalledWith('anthem')
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('NO FEAR')
expect(screen.queryByRole('button', { name: 'Skip' })).toBeNull()
expect(document.querySelector('.fz-line')).toBeNull()
})
it('FE-NOFEAR-SHOW-029: closes the show with the anthem cascade in every other language', () => {
render(<NoFearShow onClose={vi.fn()} />)
frame(71.2)
expect(audio().setAct).toHaveBeenLastCalledWith('anthem')
expect(audio().impact).toHaveBeenLastCalledWith(0.8)
const cascade = document.querySelectorAll('.fz-cascade-item')
expect(cascade).toHaveLength(22)
expect(cascade[0].textContent).toBe('Keine Angst')
expect(screen.queryByText('No fear')).toBeNull()
})
it('FE-NOFEAR-SHOW-030: condenses the anthem title out of one lazily built particle assembly', () => {
vi.spyOn(HTMLHeadingElement.prototype, 'getBoundingClientRect').mockReturnValue({
left: 100, top: 200, width: 300, height: 60,
} as unknown as DOMRect)
render(<NoFearShow onClose={vi.fn()} />)
// The title only exists from the frame after the anthem state flipped.
frame(71.2)
expect(stubs.assembly).toHaveLength(0)
frame(72)
expect(stubs.assembly).toHaveLength(1)
expect(stubs.assembly[0].init).toHaveBeenCalledWith(
'NO FEAR',
expect.any(String),
{ left: 100, top: 200, width: 300, height: 60 },
800,
600,
)
const [drawCtx, progress, fade, drawT] = stubs.assembly[0].draw.mock.calls[0] as [unknown, number, number, number]
expect(drawCtx).toBe(ctxStub)
expect(progress).toBeCloseTo(0.1875, 6)
expect(fade).toBe(0)
expect(drawT).toBe(72)
expect(screen.getByRole('heading', { level: 1 })).toHaveClass('fz-word-hidden')
frame(76)
expect(stubs.assembly).toHaveLength(1)
expect(screen.getByRole('heading', { level: 1 })).not.toHaveClass('fz-word-hidden')
// Targets are absolute screen coordinates, so a resize invalidates them and
// the next frame re-samples the moved title.
act(() => { window.dispatchEvent(new Event('resize')) })
act(() => { vi.advanceTimersByTime(150) })
frame(77)
expect(stubs.assembly).toHaveLength(2)
})
it('FE-NOFEAR-SHOW-031: tears down chrome, listeners and audio on unmount', () => {
const { unmount } = render(<NoFearShow onClose={vi.fn()} />)
const onClose = vi.fn()
const layoutCalls = scene().layout.mock.calls.length
unmount()
expect(document.body).not.toHaveClass('fz-show-open')
expect(audio().dispose).toHaveBeenCalledTimes(1)
expect(cancelSpy).toHaveBeenCalled()
window.dispatchEvent(new Event('resize'))
window.dispatchEvent(new Event('pointerdown'))
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
expect(scene().layout.mock.calls.length).toBe(layoutCalls)
expect(audio().resume).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
})
it('FE-NOFEAR-SHOW-032: reduced motion renders the finale as one static frame without audio', () => {
setReducedMotion(true)
render(<NoFearShow onClose={vi.fn()} />)
expect(audio().start).not.toHaveBeenCalled()
expect(audio().setAct).toHaveBeenCalledWith('anthem')
expect(requestAnimationFrame).not.toHaveBeenCalled()
expect(scene().draw).toHaveBeenCalledTimes(1)
const [, state, t] = scene().draw.mock.calls[0] as [unknown, { particles: number; opacity: number }, number]
expect(state.particles).toBe(0)
expect(state.opacity).toBe(1)
expect(t).toBe(79)
expect(screen.getByRole('heading', { level: 1 })).not.toHaveClass('fz-word-assembled')
})
it('FE-NOFEAR-SHOW-033: still runs the show when the canvas has no 2d context', () => {
ctxStub = null
render(<NoFearShow onClose={vi.fn()} />)
frame(12.1)
expect(lineText()).toBe(LINES.fearTool)
expect(scene().draw).not.toHaveBeenCalled()
expect(stubs.assembly).toHaveLength(0)
})
it('FE-NOFEAR-SHOW-034: dims the world through the blackout and brings it back with the hope act', () => {
render(<NoFearShow onClose={vi.fn()} />)
frame(20)
expect(lastSceneState().opacity).toBe(1)
frame(27)
expect(lastSceneState().opacity).toBe(0)
frame(31)
expect(lastSceneState().opacity).toBeGreaterThan(0.8)
expect(lastSceneState().opacity).toBeLessThan(1)
frame(32)
expect(lastSceneState().opacity).toBe(1)
})
it('FE-NOFEAR-SHOW-035: falls back to a pixel ratio of 1 when the browser reports none', () => {
Object.defineProperty(window, 'devicePixelRatio', { configurable: true, writable: true, value: 0 })
render(<NoFearShow onClose={vi.fn()} />)
expect(canvas().width).toBe(800)
expect(ctxStub?.setTransform).toHaveBeenCalledWith(1, 0, 0, 1, 0, 0)
})
})

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