Compare commits

..

147 Commits

Author SHA1 Message Date
Maurice abe6f12890 fix(admin): use TREK's CustomSelect for the invite trip picker
The trip binding dropdown in the admin create-invite dialog was a native
<select>; swap it for the shared CustomSelect so it matches the rest of the UI
(and searchable once there are many trips).
2026-07-02 15:26:48 +02:00
Maurice d546e79500 fix(join): extract JoinTripPage state into a useJoinTrip hook
The page container held useState/useEffect directly, tripping the CI
page-pattern check. Move the token preview + accept logic into a co-located
useJoinTrip() hook; the page is now a thin presentational shell.
2026-07-02 13:47:36 +02:00
Maurice 80e7c2b3bb feat(trips): trip invite links + optional trip binding on admin invites
Trip invite links (#1143): each trip can have one rotating invite link in its
Share panel. An existing, logged-in user who opens /join/<token> is added to
the trip as a member; an anonymous visitor is sent to the login page and
returned to the invite afterwards — there is no registration from this link.
Reading, rotating or disabling the link all require the share_manage permission.

Admin invite trip binding (#1402): the admin create-invite dialog can now bind
a registration invite to a trip. Someone who registers via that link is
auto-added to the trip as a member (password and OIDC paths), inside the same
atomic step that consumes the invite.

Adds a trip_invite_tokens table and a nullable invite_tokens.trip_id, a shared
owner-safe/idempotent add-by-id helper, the manage + join endpoints, the
JoinTripPage and Share-panel section, the admin trip picker, and the new i18n
keys across every locale. Wiki updated.

Discussion #1143
2026-07-02 13:21:56 +02:00
Maurice bfc857d246 feat(oidc): use the picture claim as avatar when none is uploaded
When a user signs in via OIDC and hasn't uploaded a custom avatar, their
`picture` claim is now used as their avatar. The users.avatar column holds
either an uploaded file name or an absolute https URL from the claim, and a
single resolver on each side (server avatarUrl, client avatarSrc) renders
both. An uploaded avatar always wins and is never overwritten; the picture
refreshes on each login otherwise. Only https URLs are stored, matching the
image CSP.

All the scattered /uploads/avatars/ builders now go through the resolvers,
which also fixes collection member avatars that were rendering a bare file
name.

Discussion #1399
2026-07-02 11:22:40 +02:00
Maurice 05269f0710 feat(planner): show a day's route distances inline on mobile
Seeing the driving/walking distances between a day's places on mobile
meant tapping the day (which closes the plan sheet), reopening it, then
tapping Route. Now the per-day Route button in the mobile footer toggles
that day's leg distances in place, so the sheet stays open and you get the
distances between places without selecting the day first.

The leg computation runs for every route-toggled day instead of only the
selected one, and the leg/hotel-bookend maps are nested per day so several
toggled days can't overwrite each other's segments. Desktop is unchanged.

Discussion #1374
2026-07-02 10:39:10 +02:00
Maurice 5152a5849f feat(planner): shorten the map-open button labels to "Google Maps" / "OpenStreetMap" 2026-07-01 21:39:44 +02:00
Maurice 1fdc19712f feat(planner): add an "Open in OpenStreetMap" button to the place inspector
Next to the existing "Open in Google Maps" action, add an OpenStreetMap button that
opens the place on openstreetmap.org (a marker at its coordinates, or a name search
when it has none) — the same map source TREK already renders, and a jumping-off point
for OSM-based apps like OrganicMaps / CoMaps. Requested in discussion #880.

Strings across all 22 locales; a unit test for the URL builder.
2026-07-01 21:39:44 +02:00
Maurice a83ecc4ffb fix: resolve a batch of reported bugs (planner, budget, atlas, bookings, mobile)
- #1394 planner: two transports on one day no longer draw a phantom airport→airport
  road route between them (a run is only a drive when it holds a real place); mirrored
  in the map hook and the sidebar's leg list, with a regression test.
- #1392 planner: the per-day Route button now points the selection at the tapped day
  before toggling, so on mobile it computes that day's route instead of the previously
  selected one, and only the selected day's button reads as active.
- #1372 planner: the "open in Google Maps" route now includes the day's hotel bookends,
  matching the drawn map route.
- #1375 planner: a multi-day accommodation no longer thrashes the plan scroll — the
  auto-scroll lock keys on the selection identity, not the per-day row.
- #1377 planner: the reset-orientation compass is now shown on small screens too.
- #1382 budget: settlement nets in the trip's canonical currency and converts to the
  display currency once, so balances no longer drift with live FX and no phantom
  third-party micro-flows appear (identity, hence unchanged, when they're the same).
- #1366 atlas: countries reached only by a transport booking (no lodging/place) now
  count as visited, on both the dashboard and the Atlas page.
- #1383 bookings: a hotel linked to an accommodation shows only its day-range, not a
  duplicate stamped date row, and the range stays correct after an edit.
- #1353 bookings: any non-hotel reservation can now link an existing trip place/activity.
- #1390 i18n: fix the Polish word for "buddies" (Towarzysze → Współpodróżnicy).
- #1265 planner: drag-and-drop of places now works on touch devices via a polyfill.
2026-07-01 21:16:19 +02:00
jubnl 60cbd20327 fix: back-merge v3.1.4 hotfixes into dev (#1371)
Port the three main-only fixes onto dev's (post-rewrite) architecture:
- fix(backups): prevent recursion when the backup path sits inside the backed-up dir
- fix(share): convert budget items to the viewer's base currency instead of a flat EUR
- fix(files): surface the descriptive server error for unsupported upload types (#1363)

Cherry-picked from 819aa793 on main; the SharedTripPage and useTripPlanner
conflicts were resolved to keep dev's font-scaling and full import set while
taking the fixes' currency conversion and translateApiError wiring.
2026-07-01 20:23:31 +02:00
mauriceboe 3686792ce8 chore(i18n): backfill datepicker keys for sv + vi locales added on dev 2026-07-01 18:27:36 +02:00
Gio Cettuzzi 2af292bd64 fix(date-picker): add missing locale file and fix let-to-const lint error
- Add missing common.datepicker.* keys to overlooked locale file
- Change reassigned `let` to `const` where value is not mutated
  to satisfy lint rules
2026-07-01 18:27:36 +02:00
Gio Cettuzzi 33fcbba414 fix(date-picker): locale-aware keyboard input parsing and i18n control labels
- Replace fixed-order date parser with locale-aware implementation
  using Intl.DateTimeFormat.formatToParts to detect field order;
  adds swap fallback for unambiguous inputs (day > 12) to handle
  locale mismatches gracefully
- Pre-fill keyboard input with locale-formatted numeric date
  (e.g. 14.06.2026) instead of raw ISO value
- Replace all hardcoded English aria-labels and titles with t()
  calls; add new keys under common.datepicker.* namespace across
  all locale files
- Update FE-COMP-DATEPICKER-013 to use unambiguous day value (> 12)
  to avoid locale-dependent test failures
2026-07-01 18:27:36 +02:00
Gio Cettuzzi 3011cf6c2f feat(date-picker): add month/year drill-down navigation and keyboard input trigger
- Add three-level calendar view (days → months → years) via clickable
  header label, allowing fast navigation to distant dates without
  repeated arrow clicks
- Replace double-click text input affordance with a visible keyboard
  icon button; compact/borderless variants show the icon in the
  calendar footer
- Pre-fill text input with locale-aware numeric date (DD.MM.YYYY)
  when a value is already selected
- Add aria-label and aria-pressed to all interactive calendar elements
  for screen reader support
- Update existing tests to reflect new two-button trigger layout
- Add FE-COMP-DATEPICKER-018 through 027 covering drill-down
  view transitions, prev/next behaviour per view, aria-pressed
  state, and keyboard icon trigger
2026-07-01 18:27:36 +02:00
Maurice 7d87d87eec docs(wiki): add Collections addon page (#1081)
New wiki/Collections.md in the style of the other addon pages (lists, status,
categories, adding/bulk-adding places, place detail, filters + bulk actions,
fusion sharing with member roles, dashboard widget). Add it to the Addons
overview table + the sidebar navigation.
2026-07-01 18:02:44 +02:00
Maurice 8014264b21 test(collections): client component tests + select in All saved, off the map (#1081)
- Add client tests for the new collections UI (80 tests): collectionsModel (incl.
  the undefined-entry guards that fix the white-screen regression), StatusBadge,
  CollectionFilterBar, CollectionList, CollectionPlaceDetail (permission gating),
  MoveToListModal.
- Offer the select toggle in "All saved" too (server enforces per-place rights).
- Drop the now-duplicate select button from the map controls (it lives in the
  filter row).
2026-07-01 18:02:44 +02:00
Maurice baf156a36b style(collections): widen the share modal (#1081) 2026-07-01 18:02:44 +02:00
Maurice 59134ae38e style(collections): custom dropdown for the permission role pickers (#1081)
Swap the two native <select> role pickers in the share modal (invite + per-member)
for the app's CustomSelect (portal dropdown, size sm) so they match the rest of
the UI instead of the browser's native control.
2026-07-01 18:02:44 +02:00
Maurice 6cdcc82d9c feat(collections): bulk-add selected trip places to a list (#1081)
Add a "Save to collection" action to the trip place list's selection bar (next to
bulk category + delete): it opens a list picker and copies every selected place
into the chosen list in one request, instead of one-by-one from each place.

- Server: saveFromTripPlaces (one access check + one WS notify), POST
  places/from-trip-many; dedups by name/coords, skips missing ids, honours force.
- Client: saveFromTripMany api + SaveTripPlacesToListModal; the selection-bar
  button is gated on the collections addon being enabled.
- Copy count / skipped-duplicates toast; strings in all 22 locales.
- Service + controller tests for the bulk path.
2026-07-01 18:02:44 +02:00
Maurice 1e0feb93dd feat(collections): per-member permission roles on shared lists (#1081)
The owner now assigns each member a role — viewer (read + copy-to-trip only),
editor (default: add + edit places) or admin (full incl. delete). The owner is
always full. Existing members default to editor via migration 152, so nothing
regresses.

- Server: role column on collection_members (migration 152 + schema); roleOf +
  assertCanEdit (save/update/status/list-meta) + assertCanDelete (delete) layered
  on assertAccess; sendInvite takes a role; new setMemberRole (owner-only) +
  POST members/role; members payload carries each role.
- Client: Share modal gains a role picker on invite and a per-member role select
  for the owner (read-only role badge for others); the page hides add / edit /
  status / move / delete for roles that can't perform them (server still enforces).
- Roles in all 22 locales; service + controller tests for the new gating.
2026-07-01 18:02:44 +02:00
Maurice f2972481fe fix(collections): mobile polish — touch targets, safe-areas, overflow (#1081)
From a mobile UX audit of the collections page:
- Detail sheet: the read-mode footer no longer clips "Remove from list" (it wraps,
  drops the growing spacer) and clears the home indicator (safe-area padding,
  84dvh instead of 84vh).
- Bigger touch targets on phones (≥40px): view toggle, filter dropdowns, select-bar
  buttons, detail close/actions, drawer rail rows, and the interactive status badge
  (enlarged tap area via a pseudo-element, look unchanged).
- Select action bar breaks its bulk actions onto their own line instead of
  stranding them behind a growing spacer.
- Lists drawer honours device safe-areas and gets an explicit close button.
- Page honours the top safe-area and goes full-width on phones (drop the 95vw cap);
  filter popovers cap their width so long category names don't overflow.
- Add-place: Cancel/Add pinned in the modal footer (reachable without scrolling),
  status pills wrap.
- Drop dead hero mobile CSS left over from the hero refactor.
2026-07-01 18:02:44 +02:00
Maurice b9da2494d4 test(collections): unit-test the nest controller (branch coverage) (#1081)
The collections nest module had no controller test, dragging src/nest/** branch
coverage below the 80% gate. Cover the controller's branches: reorder/deleteMany
payload validation, owner-gated invite/cancel/remove/available-users, invite +
accept error surfacing, the cover demo-mode + no-file guards, and the x-socket-id
forwarding on the mutating endpoints.
2026-07-01 18:02:44 +02:00
Maurice fd5258129a fix(collections): saved-places picker height + list filter, all-saved first-load (#1081)
- Trip "Saved places" picker: drop the fixed 360px cap so the list fills the
  panel instead of stopping half-way, and add list + status filter dropdowns
  (filter by which collection the place is saved in).
- "All saved" showed nothing on first open: setActive(ALL_SAVED) unioned the
  lists from the store, but on first load those aren't fetched yet (loadAll still
  running). Load them first when empty so the union isn't blank.
2026-07-01 18:02:44 +02:00
Maurice 56fee19d64 style(dashboard): accent follows the user's theme instead of a fixed orange (#1081)
The .trek-dash scope (dashboard, collections, vacay, atlas) hardcoded an orange
accent, ignoring the appearance theme. Drop the override so --accent inherits the
theme tokens (index.css): monochrome black/white by default, coloured per
data-scheme / custom accent. --accent-ink/-soft now map onto --accent-on/-subtle,
and accent-filled elements use --accent-text for legible text on any scheme.
Category colours are set explicitly per element and stay untouched.
2026-07-01 18:02:44 +02:00
Maurice 6be8a67c82 feat(collections): select toolbar — select-all, move/duplicate to another list (#1081)
- The select toggle now sits at the right of the filter row (same height as the
  status/category dropdowns) instead of the top toolbar.
- Select mode gains a "select all / deselect all" toggle and shows even with
  nothing selected yet.
- Selected places can be moved or duplicated into another of your lists via a
  target-list picker (move re-points collection_id; duplicate re-saves the place
  data, carrying description / category / notes / etc.).
- New strings across all 22 locales.
2026-07-01 18:02:44 +02:00
Maurice 3198fd35eb fix(collections): white screen when editing a place (undefined in places) (#1081)
updatePlace wrote `res.place` into the places list, but the endpoint returns the
updated place directly (not wrapped in { place }, unlike savePlace) — so an
`undefined` slipped into the list and the category-filter's presentCategories()
crashed on `undefined.category_id`, blanking the whole page. The WS echo used to
mask it by refetching; excluding the editor's own socket exposed it.

- Read the updated place directly and guard against a falsy response.
- Fix the api return types to match (updatePlace/setStatus return the place).
- Harden filterPlaces / statusCounts / presentCategories / mappablePlaces against
  a stray undefined entry so a single bad row can never white-screen the page.
2026-07-01 18:02:44 +02:00
Maurice ef3f043e00 fix(collections): stop the address pin from shrinking on long addresses (#1081)
The little map pin in front of a place's address sits in a flex row with the
address text but had no flex-shrink guard, so a long address squeezed the icon
smaller. Pin the SVG to its size.
2026-07-01 18:02:44 +02:00
Maurice 6a3e6ae083 fix(collections): copy-to-trip labels + Unsplash cover search (#1081)
- Copy-to-trip modal showed blank rows: trips are keyed by `title`, not `name`,
  so nothing rendered. Read `title` and add the trip's date range under it.
- List editor gains an Unsplash cover search (same source as trip creation) next
  to the upload button; picking a photo sets it as the list cover.
- Add-place result rows: pin keeps a hard min width so a long address can't
  squeeze it.
2026-07-01 18:02:44 +02:00
Maurice b8ceb081fa feat(collections): hero edit/share row, place photos, edit-echo fix, wider page (#1081)
- Editing a place (category, status, …) no longer reloads the view: the mutating
  client's own socket is now excluded from the WS broadcast (x-socket-id threaded
  through save/update/status/delete + list update/cover), so the optimistic update
  stands on its own instead of being chased by an echoed refetch.
- Detail sheet pulls a higher-res cover photo from the maps provider when the
  place has no image of its own (the avatar thumbnail was too low-res).
- Hero: Share moved onto the title row (no more empty top band) with an Edit
  button beside it; editing/deleting a list now happens there. The list rail drops
  its per-row kebab entirely (and with it the janky open animation).
- The list editor can delete the collection from its footer (owner only).
- Wider, screen-relative page (max-width min(2100px, 95vw)).
- List rows: the place avatar no longer shrinks when the address is long.
2026-07-01 18:02:44 +02:00
Maurice 0c484959ce feat(collections): filters, add-place popup, category badges, map-click hardening (#1081)
- Map: markers no longer rebuild on every unrelated re-render (memoised the
  mappable list + only update the hero size when it really changes), the floating
  controls bar is click-through except its buttons, and the collection map runs
  with the hover tooltip off. Together these stop a marker click from landing on
  a mid-rebuild element / the tooltip so the pick actually registers.
- Filters moved out of the hero into a compact status + category dropdown row
  above the places (custom dropdowns); the hero no longer carries the stat chips.
- Add-place is a single popup now: search fills the location, and name / status /
  category / description / links are all editable together before saving.
- Category shown as a badge top-left on the detail cover and next to the status
  in each list row (divided by a hairline).
- Slimmer hero: shorter, tighter spacing, links tucked into the eyebrow row
  instead of their own line.
2026-07-01 18:02:44 +02:00
Maurice f97e284592 feat(collections): detail redesign + categories, close-on-map, highlight fix (#1081)
- Rebuild the place detail as a clean, opaque, sectioned sheet (cover → meta →
  status segment → description → links) with a proper footer action bar — the
  loose "white lower half" is gone.
- Assign a place to a central (admin-defined) category, both in the detail edit
  and when adding a place; categories are fetched once for the page.
- Add-place now sets category + description (markdown) + links + status in the
  same step, closer to the trip's place form.
- Switching to the full-map view now closes the (list-docked) detail.
- Fix the selected-row highlight: it was clipped by the column's overflow — use
  an inset ring and only clip during the map-collapse animation; a picked row
  now scrolls into view above the detail sheet.
- New category strings across all 22 locales.
2026-07-01 18:02:44 +02:00
Maurice 0910c90e7f fix(collections): detail-sheet, edit-refresh, map + rail polish (#1081)
- Editing a place (status, description, title, …) no longer reloads the view or
  closes the detail: the WS echo now refreshes via loadCollection, which keeps
  the current selection + select-mode instead of setActive resetting them.
- Place detail: docks over the list column on the desktop split (measured rect)
  instead of centred over the map, and the card is now opaque (was too see-through).
- Map: click a marker in full-map view to drop back to the split; picking a place
  scrolls its list row into view; the select toggle is disabled in full-map view;
  the floating controls are one non-overlapping top bar and less transparent.
- Hero: drop the New-list button (it's already in the rail).
- Rail: the kebab is always visible (easy to hit); menu is Edit + Delete only
  (colour moved into the editor); "Rename" → "Edit".
- Add-place: pick a result, then set description (markdown) / links / status
  before saving, all in one step.
- Share modal: member roster as cards with clearer role badges + a count.
2026-07-01 18:02:44 +02:00
Maurice de4077d461 fix(collections): review follow-ups on the B–E work (#1081)
- Block the list-cover upload in demo mode (mirror the trips cover endpoint).
- Restrict list/place links to http(s) (schema) and normalise scheme-less URLs
  to https:// on save, so a bare "booking.com" no longer resolves as a relative
  SPA route (and javascript:/data: hrefs are rejected).
- Place detail: surface save errors with a toast instead of silently swallowing
  a 400 and leaving the sheet stuck in edit mode.
- List editor: don't create a duplicate list when a retry follows a cover-upload
  failure (reuse the created id); revoke the cover preview object URL.
- Map controls: one top bar (left toggle/select, right add/search) so they can't
  overlap on a narrow split map — the search shrinks instead.
- Dashboard list badge: full-opacity colour wash so the name stays legible over
  bright covers.
2026-07-01 18:02:44 +02:00
Maurice 68e101ed37 feat(collections): list details, place detail sheet, add-place, fusion kick (#1081)
Dashboard widget (B): the collections tool now shows the user's LISTS as
compact colour-washed badges (cover image tinted with the list colour, or a
gradient) that jump to the list — one list() call, no N+1.

List details (C): lists gain a description, a custom cover image (uploaded to
/uploads/covers, tinted with the list colour in the hero) and links. A shared
ListEditorModal handles both create and edit; the hero shows the description +
link chips. New `links` JSON column on collections + collection_places
(migration 151) with parse/serialize in the service; a POST :id/cover upload
endpoint mirroring trips; cover-file cleanup path-confined locally.

Place detail (D): clicking a place opens a bottom sheet (no backdrop, so the
map stays visible) — status cycle, copy-to-trip, remove, and an edit mode with
a markdown description + links editor (collectionsApi.updatePlace, now wired
via a store action). A "+" next to the search adds a place to the list via the
maps search.

Fusion + fixes (E): the owner can now remove an accepted member (kick) — new
removeMember service/route/store + a button in ShareCollectionModal, with a
collections:removed WS bounce. findMembership no longer matches on name alone
(coordinate proximity required, killing "Starbucks everywhere" false positives).
loadCollection swallows a 403/404 after a leave/remove so the URL sync can't
throw uncaught. Grid remnants gone; the map select toggle moved onto the map.

New strings across all 22 locales; i18n parity strict passes.
2026-07-01 18:02:44 +02:00
Maurice 25b8d28574 feat(collections): list+map default, map-only toggle, deselect + tooltip fixes (#1081)
- Drop the grid/tile view. The list view is now the default and, on wide
  screens, a list + persistent map split; a top-left control on the map
  collapses the list to a full-width map (and back), animating smoothly (the
  map stays mounted and is nudged to re-layout during the transition). The
  place search moves onto the map (top-right); mobile keeps a list/map toggle.
- Let a place be deselected again: clicking it once more, clicking the map
  background, or picking another all toggle the selection (collections map now
  wires onMapClick).
- Fix the stuck hover tooltip: selecting a place swaps its marker's DOM node so
  the browser never fires mouseout/mouseleave, orphaning the fixed-position
  tooltip (it hung on screen and drifted with scroll). Both map stacks now clear
  the hover on selection change and on scroll.
- Remove CollectionGrid + PlaceCover; add hero eyebrow + map control strings.
2026-07-01 18:02:44 +02:00
Maurice 74cace7c8f feat(collections): list+map split, taller rail, list-menu popover fix (#1081)
- List view splits into a scrollable list + a sticky map on wide screens;
  clicking a place pans/highlights it on the map (single selectedPlaceId, no
  inspector over the map). Narrow screens keep the single-column list.
- Keep the list rail at least as tall as the hero (measure the hero via a
  small useElementSize hook and feed its height as the rail's min-height).
- List row kebab menu: portal the menu/colour popover to the body so the
  rail's overflow + backdrop-filter can't clip it ("renders only in the
  module"), and fade the place count on hover so the kebab stops overlapping it.
2026-07-01 18:02:44 +02:00
Maurice 4e478722c3 feat(collections): redesign the page on the dashboard glass language (#1081)
Rebuilds the /collections page from the functional placeholder into the
dashboard's glass visual language (light + dark):

- A colour-washed hero per list: eyebrow + member avatars, big title, and
  stat chips (All / Idea / Want / Visited) that double as the status filter.
- A sticky glass list rail (owned + shared + invites) with a mobile drawer.
- Gradient/photo cover place cards modelled on the trip cards, via a new
  rectangular PlaceCover (photoService-backed, gradient fallback) + a shared
  gradients util. List and map views restyled to match.
- Status pill rendered as a role=button span so it survives the .trek-dash
  button reset and can nest inside the card; the share member-count badge is
  now owner-only.
- New hero eyebrow strings across all locales.
2026-07-01 18:02:44 +02:00
Maurice 5f9da5b72f feat(collections): fusion sharing UI + dashboard widget + per-user toggle (#1081)
- ShareCollectionModal: the owner manages a list's members and invites users
  (available-users picker → invite, cancel pending); a member can leave a shared
  list. The incoming accept/decline surface stays in the lists rail. A Share
  button is added to the collections header for owners (and members, to reach
  Leave).
- CollectionsWidget: a dashboard glass card after the currency widget showing the
  saved count and the most recent saved places, double-gated by the admin addon
  and a new per-user appearance flag.
- Appearance: a 'collections' dashboard-widget flag (desktop + mobile defaults)
  wired into the appearance settings, surviving normalize.
- Sharing + settings strings across all 22 locales (parity strict passes).
2026-07-01 18:02:44 +02:00
Maurice 783332cc0e feat(collections): /collections page, entry points and i18n (#1081)
Adds the client side of the Collections addon:
- A distinct /collections page (Atlas pattern, page/hook split) gated behind the
  addon: a multi-list rail, a Grid (default) / List / Map view switch, the
  idea/want/visited status with a one-tap badge, search and status filters, and
  considered empty states. Store + hook + model + websocket wiring; the place
  detail reuses the trip place inspector via a mode guard.
- Entry points: a "Save to Collection" button next to Open-in-Google-Maps in the
  place inspector (and the two sidebar context menus), a "Copy to trip" modal,
  and a desktop-only two-column add-place picker (mobile keeps the single-column
  form).
- The collection namespace and the new keys across all 22 locales.
2026-07-01 18:02:44 +02:00
Maurice ec8509f2de feat(collections): backend for the Overall Places addon (#1081)
Adds the Collections addon backend: a server-wide-per-user library of saved
places, independent of any trip, with multiple named lists, an idea/want/visited
status, and Vacay-style fusion invitations to share a list with other users.

- Data: collection / collection_members / collection_places / collection_place_tags
  tables (+ migration and baseline schema). Saved places carry the owner plus a
  nullable saved_by so a member deleting their account can't drop shared content.
- Service: list + place CRUD with owner-or-accepted-member visibility, dedup,
  status, save-from-trip and copy-to-trip (reusing the trip copy column list),
  and the full fusion-invitation state machine mirrored from vacay (send / accept
  / decline / cancel / leave) with a websocket broadcast and an invite
  notification. Deleting a list snapshots its members and notifies them.
- NestJS module + addon guard (404 before auth), registered in the app module.
- Widens the place photo cache reference check to count collection places so the
  nightly sweep no longer evicts photos a saved place still uses.
- collection_invite notification wired across all 22 locales.
2026-07-01 18:02:44 +02:00
Maurice 37ef3a9d18 fix(map): match the GL place hover tooltip to the Leaflet map (#1385)
The MapLibre/Mapbox hover showed an anchored popup with a large photo
thumbnail, completely unlike the Leaflet map's slim, cursor-following
name/category/address card. Drop the anchored photo popup for places and
render the same cursor-following overlay the Leaflet map uses (no photo,
matching fonts/padding/shadow), so the two maps hover identically.
2026-06-30 19:34:07 +02:00
Maurice a54503f26d feat(map): group GL place markers into clusters on zoom-out (#1385)
MapLibre/Mapbox showed every place as its own rich HTML marker with no
grouping when zoomed out, unlike the Leaflet map. Feed the place points
through a clustered GeoJSON source: clustered points render as a dark
count bubble (click to zoom in and expand) while the rich HTML photo
markers are only drawn for the points the source reports as unclustered.
Always on, matching the Leaflet MarkerClusterGroup.
2026-06-30 19:12:43 +02:00
Maurice 4abb38b517 style(packing): small gap between the list and the luggage sidebar divider
The luggage sidebar's left border sat flush against the right-hand category
card. Add a little left margin so the divider has minimal breathing room.
2026-06-30 19:12:43 +02:00
Maurice 743b724cbc feat(packing): three-tier sharing — personal, shared-with-people, common pool (#858)
Rework the private-packing flag into a full sharing model. Every item is now
Common (the group pool — where all existing items live, so nothing breaks),
Personal (private to its owner) or Shared with specific people (it shows up on
those travelers' own lists, marked "by <bringer>"). is_private discriminates
restricted from common; a new packing_item_recipients table holds who a shared
item covers, and packing_item_contributors records "I can bring that too"
pledges on Common items.

The panel gains a Gemeinsam / Meine Liste view switch, each item a sharing
control (owner sets the tier + the people it covers), and Common items can be
co-brought or cloned onto your personal list. Visibility is enforced server-side
in listItems and the WebSocket broadcasts are scoped to exactly who can see an
item across every tier transition. All 22 locales stay in parity.
2026-06-30 19:12:43 +02:00
Maurice 04f2ec72c6 feat(trips): guest members for accountless participants (#1362, #1291)
Add "guest" trip participants — people without a Trek account who can still be
assigned to costs, packing, to-dos and day-plan activities. A guest is a
credential-less users row (is_guest=1) joined into trip_members, so it is
assignable everywhere a real member is, with the cost-splitting, settlement,
packing and assignment paths working unchanged.

Guests are firewalled from everything account-related: they can never sign in
(password, OIDC and reset lookups skip them), never appear in the global user
directory, the member-add picker or admin user management, are never resolved as
notification recipients, can't be invited to another trip, and can't be made
owner. The trip owner manages guests from the share dialog in a dedicated,
clearly-labelled section (add / rename / remove), and guests carry a "Guest"
badge wherever members are picked. All 22 locales stay in parity.
2026-06-30 15:03:57 +02:00
Maurice 7673aa52f2 fix(packing): drop the always-true guard in the row drag handler (#969)
The onDragOver guard `drag.isDragging || true` is a constant condition (eslint
no-constant-condition). The handler is already gated by canDrag, so run the
drag-over logic directly, matching the to-do row.
2026-06-30 15:03:57 +02:00
Maurice e56a901d82 i18n: translate the booking link field across all locales (#935)
Fan out reservations.urlLabel / reservations.urlPlaceholder to the remaining
locales so the dedicated booking URL field is localised everywhere.
2026-06-30 15:03:57 +02:00
Maurice 641711322e feat(trips): transfer trip ownership to a member (#973)
Add POST /api/trips/:id/transfer so the owner can hand a trip to one of its
existing members. The swap runs in a transaction: the new owner takes
trips.user_id and the former owner is kept on as a regular member, so nobody
loses access. The endpoint is owner-only, writes a trip.transfer_ownership
audit entry and broadcasts the refreshed trip. The members modal gains a
"Make owner" action, shown only to the current owner.
2026-06-30 15:03:57 +02:00
Maurice fac2393388 feat(lists): reorder packing/to-do lists and private packing items (#969, #858)
Add drag-to-reorder to the packing and to-do lists, mirroring the budget
panel's native HTML5 drag pattern. A drag within a filtered/grouped view is
mapped back onto the global order so untouched items keep their place, and the
order persists optimistically via the existing reorder endpoints.

Packing items can now be marked private (#858): a private item is visible only
to its owner. createItem/bulkImport stamp the owner, listItems filters by the
viewer, and the WebSocket broadcasts are scoped to the owner so a private item
never reaches another member's screen — including the public/private toggle
transitions. Owners get a lock toggle and a private indicator on their items.
2026-06-30 15:03:57 +02:00
Maurice 7eac5a5a02 feat(files): render uploaded Markdown files inline (#1345)
Markdown (.md/.markdown) is now an allowed upload type and opens in a rendered preview in the file manager instead of just downloading. Reuses the existing react-markdown stack with rehype-sanitize (these are untrusted uploads, so output is sanitized) and detects markdown by extension first since browsers send unreliable MIME for .md.
2026-06-30 15:03:57 +02:00
Maurice d6ed7a60a0 feat(bookings): add a dedicated URL field to reservations (#935)
Bookings get a first-class url column (migration) instead of users pasting links into notes. It's editable in the booking modal and rendered as a clickable link on the reservation card. The reservation request schemas are open passthroughs, so only the entity schema + service SQL enumerate it.
2026-06-30 15:03:57 +02:00
Maurice ad64df42ed test(video): cover the new upload-handler branches
Add controller tests for the gallery-video route (success / no-video / not-allowed / cleanup-on-reject), the per-asset media_types loops (gallery + entry, batch + single), and the file-manager per-type cap + unlink-on-rejection — restoring branch coverage on src/nest above the 80% gate.
2026-06-30 12:27:28 +02:00
Maurice 4af35b162e test(video): update gallery accept selector + complete fileService mocks
The gallery upload input now accepts image/*,video/* — update the two JourneyDetailPage selectors that matched the old value. The files/journey e2e suites mock fileService and were missing the new MAX_VIDEO_SIZE / isVideoExtension / isVideoMime exports, which broke module load.
2026-06-30 12:27:28 +02:00
Maurice 20c1858b23 fix(video): harden upload handling and fix video playback edge cases
Security: the gallery-video poster is now always stored as .jpg instead of the client-supplied extension, so a poster declared image/* but named x.html / x.js can't be written with that extension and served inline same-origin; local gallery files are also served with X-Content-Type-Options: nosniff.

Robustness: rejected/unauthorised uploads no longer orphan their bytes on disk (the gallery-video and file-manager handlers unlink before throwing); the file-manager per-type size cap is keyed on the extension like the filter, so a real video labelled application/octet-stream isn't wrongly rejected. UX: the file-manager thumbnail strip shows a play placeholder for video instead of a broken image; shared (public) journeys now return media_type and play videos with a play badge; and a poster-less video shows a neutral tile instead of a broken thumbnail.
2026-06-30 12:27:28 +02:00
Maurice e986c9ab27 feat(video): upload and play videos in the trip file manager
The file manager (which already attaches files to a place/activity) now accepts video uploads up to the larger video cap — other types stay at the document limit — and the lightbox plays them with the Plyr player over the plain same-origin download URL, so cookie auth and HTTP Range both work. Videos are excluded from the offline blob prefetch so one clip can't evict a trip's documents.
2026-06-30 12:27:28 +02:00
Maurice 61ffdb553e test(photos): assert the forwarded Range arg on the original stream
Follow-up to the Range-aware photo proxy.
2026-06-30 12:27:28 +02:00
Maurice 1abc9b2bc7 feat(video): link and stream Immich videos in the journey gallery
Immich timeline and album listings no longer filter out videos; each asset now carries its media type, which the provider picker forwards when linking. A linked video streams through Immich's transcoded /video/playback endpoint, and the asset proxy forwards the viewer's Range header (and passes 206/Content-Range back) so the player can seek. Synology video stays excluded until its stream API is verified.

Adds media_type/media_types to the provider-photos request contract.
2026-06-30 12:27:28 +02:00
Maurice 8713443665 feat(video): use Plyr for the gallery video player
Swaps the bare <video> element for a Plyr-wrapped player so playback controls match a consistent, cleaner skin. The instance is created per source and destroyed on unmount, so the lightbox stops playback when you navigate away.
2026-06-30 12:27:28 +02:00
Maurice c92c02e1b8 feat(video): play local gallery videos in the journey gallery
Picking a video in the journey gallery now captures a poster frame + duration in the browser and uploads the raw clip; the grid shows the poster with a play badge and the lightbox plays it with a native video player (HTTP Range seeking). Images keep their existing HEIC-normalised path. No server-side transcoding.

Server media_type work was committed separately.
2026-06-30 12:27:28 +02:00
Maurice 993d9bf713 feat(video): media_type discriminator + local gallery video upload (server)
trek_photos gains a media_type column (migration) so the registry can hold video as well as images. A new POST :id/gallery/video endpoint accepts a video plus a client-captured poster (500 MB cap, video MIME/extension allowlist), stores the poster as the thumbnail, and the photo stream serves the poster for the thumbnail kind and the raw file (HTTP Range) for the original — without running the image thumbnailer on video bytes.
2026-06-30 12:27:28 +02:00
Maurice c7e4b2781b docs(wiki): document force-offline, selective storage and conflicts 2026-06-30 10:04:15 +02:00
Maurice a88cd772cf i18n(offline): offline settings strings across all locales 2026-06-30 10:04:15 +02:00
Maurice 98d11d4267 feat(offline): Settings -> Offline controls and a status banner
The Offline tab gains a force-offline switch, a prepare-for-offline download with progress, per-trip and map-tile storage toggles, and a conflict resolver with a default strategy. The floating status pill now reflects forced-offline and unresolved conflicts.
2026-06-30 10:04:15 +02:00
Maurice 6707dac4a9 feat(offline): force-offline mode, selective sync and a conflict queue
A force-offline override routes every read to the cache and every write to the queue; preparing for offline downloads trip data, documents and map tiles up front and waits for them to finish. Map tiles and individual trips can be left out of the cache. Queued edits carry the version they were based on so the queue can surface server conflicts for a keep-mine / keep-theirs decision; chained offline edits to one entity no longer conflict with each other, and evicting a trip preserves its unsynced writes.
2026-06-30 10:04:15 +02:00
Maurice c552472b63 feat(offline): detect update conflicts on the server for places and packing
Update handlers accept an optional X-Base-Updated-At token and reject a stale overwrite with 409, returning the current server row. An absent token keeps the existing last-write-wins behaviour, so older clients are unaffected. packing_items gains an updated_at column (migration + stamped on every insert) so it can take part in conflict detection too.
2026-06-30 10:04:15 +02:00
Maurice 5fd66f4833 feat(map): include the day's route in the map fit (#1128)
Selecting a day already fits the map to that day's destinations; this also
folds the route polyline into the bounds. BoundsController fits the
destinations immediately, then re-fits once — when the day's route finishes
computing asynchronously — to destinations + the full route, so a route that
bulges past its stops (a detour or ferry) stays in view. One-shot per day
selection, so later route-profile toggles don't re-zoom.
2026-06-30 00:04:38 +02:00
Maurice 50609b078a feat(places): bulk "change category" from the selection toolbar
Closes the UI half of #1168: in the Places selection mode, a new tag button
before delete opens a category picker that applies one category (or "No
category") to every selected place in a single request. Adds a REST
/places/bulk-update endpoint reusing updatePlacesMany, an offline-aware repo +
store action that patches both the place pool and the day-assignment
projections, undo grouped by each place's prior category, and the i18n keys
across all locales.
2026-06-29 23:19:33 +02:00
Maurice 42b45dcd82 feat(dashboard): show the year on trip dates from other years
Trip dates only showed month + day, so trips from other years were ambiguous
(#1323). Dashboard cards and the boarding-pass hero now include the year, and
so does the shared formatDate (planner day headers etc.) — but only when it
isn't the current year, so this year's trips stay compact. Order and
punctuation follow the locale (EN "Sep 10, 2026", DE "10. Sep 2026").
2026-06-29 22:29:57 +02:00
Maurice 9dd9057b7b feat(mcp): add bulk_update_places tool
Apply the same field values to many places in one call instead of one
update_place per place — e.g. re-categorising 80 POIs at once. Adds the
updatePlacesMany service (one transaction, trip-scoped, partial patch
built on updatePlace) and the bulk_update_places MCP tool with the usual
demo/access/place_edit guards and a place:updated broadcast per place.
2026-06-29 22:29:57 +02:00
Maurice 23987c76bb harden calendar feeds: absolute URLs, real disable, folding, schema sync
- Resolve feed URLs against the request host when APP_URL is unset, so the
  webcal:// / Add-to-Google links work on a default install (not just behind a
  configured reverse proxy).
- Give the public link a real off switch: POST enables, PUT rotates, DELETE
  clears the token (feed_token = NULL). The subscribe dialog no longer mints a
  token just from being opened — the user opts in explicitly.
- Fold ICS content lines at 75 octets (UTF-8 safe) in exportICS, so download
  and feed both stay RFC 5545-compliant for long/non-ASCII summaries.
- Extract VEVENTs by structural line scan instead of a lazy END:VEVENT regex
  that user text could truncate.
- URL-encode the Google Calendar cid; mirror feed_token into schema.ts.
- Collapse the duplicated all-trips modal into the shared IcsSubscribeModal.
2026-06-29 21:53:06 +02:00
michael-bohr 7173e82fe8 feat(feeds): subscribable ICS calendar feeds for trips
Adds TripIt-style live calendar subscriptions alongside the existing one-time
.ics download. A trip (or all of a user's trips) exposes a secret, revocable
feed URL that Google/Apple/Outlook poll to stay in sync.

- Public read endpoints GET /api/feed/trip/:token.ics and /api/feed/user/:token.ics
  (no auth — the secret token is the credential), reusing the existing exportICS()
  generator and adding REFRESH-INTERVAL / X-PUBLISHED-TTL hints.
- JWT-guarded token endpoints to generate (lazy, idempotent) and regenerate/revoke
  per-trip and per-user feed tokens; tokens stored in nullable feed_token columns.
- All-trips feed excludes archived trips and trips ended >90 days ago.
- UI: ICS toolbar button becomes a Download/Subscribe menu; modal offers one-click
  "Add to Google Calendar" (render?cid=webcal://) and a webcal:// link for
  Apple/Outlook, plus copy-link fallbacks. All-trips feed reachable from dashboard.
- Feed base URL read from the existing APP_URL env var.

Purely additive: new endpoints + two nullable columns, no breaking changes.

Tests: server/tests/e2e/feeds.e2e.test.ts covers lazy token generate + idempotency,
regenerate-invalidates-old, 401/404 auth+access, public feed content-type + hint
injection, unknown-token 404, and the archived/>90-day all-trips exclusion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 21:53:06 +02:00
Maurice 72dfa2c60c docs(helm): clean up existingClaim notes
Strip stray zero-width characters from the persistence docs, move the PVC
note out of the ENCRYPTION_KEY usage block into its own Persistence section
in NOTES.txt, and document that persistence.enabled=false falls back to an
ephemeral emptyDir.
2026-06-29 21:00:25 +02:00
yael-tramier d19305bda4 fix(helm): emptyDir is used as a fallback when persistence is disabled. 2026-06-29 21:00:25 +02:00
yael-tramier 7aa2f6e4f2 feat(helm): Add existingClaim variable for custom PVC usage. 2026-06-29 21:00:25 +02:00
Maurice 3e64cb86a6 chore(i18n): sync Vietnamese with latest dev keys
Add the keys dev gained since this PR opened so the new vi locale keeps full
parity: the help namespace (wiki help center), settings appearance options,
costs split modes, dashboard Unsplash cover search, the insecure-cookie login
hint, nav.help and the admin group labels.
2026-06-29 20:48:51 +02:00
leeduc e4efcf0840 feat(i18n): add Vietnamese translations 2026-06-29 20:48:51 +02:00
Zorth Thorch e34f40b686 feat(costs): Splitwise-like cost splitting
Add per-payer and per-member custom split amounts with Equally, Custom and
Ticket split modes on top of the existing equal split, keep legacy "paid by"
expenses working, and document the modes in the Budget Tracking wiki page.
2026-06-29 20:34:24 +02:00
Maurice 3701ab6cad feat(auth): explain the plain-HTTP secure-cookie gotcha on login
When the server issues a Secure session cookie but the request arrived over
plain HTTP (the common LAN install over http://ip:3000), the browser drops
the cookie and the next request dead-ends on a bare "Access token required" —
the top source of avoidable install issues. The login response now flags this
exact case and the login page shows a localized box explaining the fix (use
HTTPS, or set COOKIE_SECURE=false) with a link to the Troubleshooting guide.
It only triggers in the real failure case, never for correct HTTPS setups.
2026-06-29 18:32:58 +02:00
Maurice e91f592f22 feat(help): embed the TREK wiki as an in-app help centre
Add a Help section (profile menu, /help) that renders the GitHub wiki inside
TREK. /api/help fetches the wiki markdown — the nav from _Sidebar.md, pages,
and proxied images — from GitHub and caches it (1h TTL, serves stale on
outage), so it auto-syncs on wiki edits with no redeploy and the client never
calls GitHub directly. The page is styled to match TREK with a section
sidebar, search and react-markdown; wiki [[links]] are rewritten to in-app
routes and HTML-comment placeholders are stripped. Page state lives in a
useHelp() hook per the page pattern. Adds nav.help and a help namespace
across all locales.
2026-06-29 18:32:58 +02:00
Maurice 1cc69fc22a refactor(admin): group the admin sidebar tabs into sections
The admin sidebar had 11 flat tabs. PageSidebar now supports optional group headings (backward-compatible; the Settings sidebar stays flat), and the admin tabs are grouped into Users, Configuration, Integrations and Maintenance. Group labels added across all locales.
2026-06-29 13:59:00 +02:00
Maurice 4d131db9af refactor(settings): rename the Display tab to General and group its settings
The Display tab became a catch-all once theming moved to its own Appearance tab, and its 'Display' label no longer fit. It is now 'General' (Allgemein) and split into 'Language & region' and 'Travel & map' sections. Tab labels and the new section titles are added across all locales.
2026-06-29 13:59:00 +02:00
Maurice f5d03e7213 chore(about): remove the monthly supporters section 2026-06-29 13:59:00 +02:00
Maurice 891171ce6c feat(appearance): mark the Readability section as experimental
Transparency-off, density and per-size typography are best-effort while the token migration is ongoing, so the section carries an Experimental badge. Adds the i18n key across all locales.
2026-06-29 13:59:00 +02:00
Maurice 720edce2ee fix(appearance): make the dashboard hero boarding-pass solid with transparency off 2026-06-29 13:59:00 +02:00
Maurice b27793f99a fix(appearance): shorten the Auto color-mode label to 'Auto' on mobile 2026-06-29 13:59:00 +02:00
Maurice 813db0ca6e feat(appearance): show per-size text controls inline with examples
The four size-class sliders (Large/Medium/Normal/Small) are now always visible instead of behind a disclosure, each with a live sample rendered at that size and an example of what it affects (e.g. Normal = place names/descriptions, Small = addresses/labels).
2026-06-29 13:59:00 +02:00
Maurice 741639edf0 feat(appearance): granular per-size text scaling with live preview
The text-size control now adjusts each size class (Large / Medium / Normal / Small) independently as well as all-at-once. Inline px sizes are mapped to a class by their value, so the per-class sliders reach real content; each class variable = global factor x its per-class factor (no double-scaling with the root font-size that handles rem text). The settings UI gains a live preview that resizes as you drag, and the four size sliders sit behind a clear toggle.
2026-06-29 13:59:00 +02:00
Maurice bb8f4d4e5e fix(appearance): keep i18n key parity and update the scaled-emoji test
Add the new appearance settings keys (widget group titles, sidebar/density hints) to every locale so the strict key-parity check passes, and update the single-emoji chat test to expect the now-scalable calc() font size.
2026-06-29 13:59:00 +02:00
Maurice fac043c691 fix(appearance): clearer widget settings, density hint, solid surfaces with transparency off
Dashboard widget settings are grouped by where they sit on the dashboard (below the hero / right sidebar / bottom of page); the right-sidebar master toggle now nests its individual widgets and greys them out when the sidebar is off, instead of a confusing flat list mixing the master with its children. Density gains an explanatory hint plus a real compact spacing effect. Transparency-off also solidifies the Atlas glass panels and tooltip, Leaflet zoom controls and GL popups — class-based surfaces via CSS, the Atlas inline panels via a noTransparency flag.
2026-06-29 13:59:00 +02:00
Maurice a3f395e5ac fix(appearance): scale inline px font sizes so text-size reaches all content
The global text-size control only set the root font-size, which scales rem-based text (navbar, menus) but not the dense inline px sizes used across the trip planner, budget, journey and panels — so place titles and addresses stayed fixed. applyAppearance now also exposes the factor as --fs-scale-text, and a codemod wraps inline numeric fontSize in calc(<px> * var(--fs-scale-text, 1)) across components and pages (map popups and PDF excluded). Sizes are byte-identical at 100%; the control now visibly resizes the actual content.
2026-06-29 13:59:00 +02:00
Maurice b6a414b79f chore(appearance): add theme:lint guard for hardcoded styles
A theme:lint script (modeled on i18n:parity) flags new inline color/fontSize literals and arbitrary-hex Tailwind classes that bypass the design tokens, so future code stays themeable. Map/PDF surfaces are exempt. The token taxonomy and the six theming rules are documented in src/theme/README.md.
2026-06-29 13:59:00 +02:00
Maurice 200108b76a feat(dashboard): per-device widget visibility with layout reflow
Dashboard widgets (currency, timezones, upcoming reservations, atlas and the stat tiles) can be shown or hidden independently on desktop and mobile from the appearance settings. The stat grid spreads its visible tiles to full width, and disabling the right sidebar collapses the layout to a single centered column.
2026-06-29 13:59:00 +02:00
Maurice a7334a9060 feat(settings): appearance settings tab
New Appearance tab with color mode (moved out of Display), color-scheme swatches, a custom accent picker with a live WCAG contrast hint, transparency and reduce-motion toggles, density, a global text-size slider with advanced per-tier controls, and per-device dashboard widget toggles. Edits preview live and commit on a short debounce. i18n keys added across all locales, translated for German.
2026-06-29 13:59:00 +02:00
Maurice 2cda779bc5 feat(appearance): token-driven theme engine with schemes and FOUC-safe boot
applyAppearance is the single writer of styling to the DOM (the .dark class plus data-scheme/-no-transparency/-density/-reduce-motion and the custom-accent/type-scale CSS vars). An external pre-paint /theme-boot.js replays a cached snapshot before first paint and complies with the production CSP (script-src 'self'), fixing the long-standing theme FOUC. Adds seven color schemes (incl. a true high-contrast that raises neutral contrast), a custom accent with auto-derived legible text, an extended token layer (accent variants, status/shadow/overlay/inverse), a scheme-gated legacy accent bridge, and a transparency-off layer. The default scheme sets no attributes, so existing users are unaffected.
2026-06-29 13:59:00 +02:00
Maurice 4742915389 feat(appearance): add per-user appearance config contract
Shared AppearanceConfig (color scheme, accent, transparency, per-tier type scale, density, reduce-motion and per-device dashboard widgets) stored as one JSON blob under the existing settings key. normalizeAppearance never throws, so a malformed/partial/future blob degrades to the neutral default and can never reach the DOM. No DB migration; the default reproduces today's look exactly.
2026-06-29 13:59:00 +02:00
Maurice d6bba454e0 fix(llm): stop the browser autofilling the LLM base URL (#1301)
The AI-parsing base URL and model inputs had no autoComplete, so a
browser password manager could drop the saved login email into the
base URL field. In the admin addon config onBlur then fired a model
lookup against e.g. "admin@trek.local", which the server rejected
with 400. Mark the base URL and model inputs as type=url /
autoComplete=off in both the admin addon config and the per-user
connection section.
2026-06-28 21:38:46 +02:00
Maurice 6f42e84183 fix(docker): keep server/reset-admin.js in the build context (#1339)
The Dockerfile copies server/reset-admin.js (the admin recovery
script), but .dockerignore also listed it, so it was stripped from
the build context and the image build failed with a not-found error.
Drop the ignore entry so the COPY resolves again.
2026-06-28 21:11:54 +02:00
Maurice cb3f9f0021 test(trips): cover the Unsplash cover download and search-race guard (#1277)
Adds unit coverage for saveUnsplashCover (host check, content-type
and size limits, download failure), the searchUnsplashPhotos error
and success paths, and the PUT handler internalising a hot-link.
Updates the existing PUT tests for the now-async handler.
2026-06-28 20:21:13 +02:00
Maurice f24d44b4a3 feat(trips): download chosen Unsplash covers into uploads (#1277)
Previously a selected Unsplash photo was stored as a remote
images.unsplash.com hot-link, so covers broke offline and on link
rot. The trip PUT handler now fetches the picked image through the
SSRF guard and saves it under uploads/covers, rewriting cover_image
to the local path (502 if the download fails). Also debounces the
cover search so a slow earlier request can no longer overwrite newer
results, drops a dead userId parameter, and reverts an unrelated
vite proxy change.
2026-06-28 20:21:13 +02:00
Azalea af90ba0911 [+] i18n 2026-06-28 20:21:13 +02:00
Azalea 8c941b52f9 [+] Unsplash 2026-06-28 20:21:13 +02:00
Maurice c7e8a5614d feat(mobile): make the bottom-nav "+" context-aware per trip tab (#1349)
On mobile the bottom-nav "+" always created a new place (except on the Costs tab,
where it added an expense). It now matches the active trip tab: Bookings adds a
reservation, Transports adds a transport, Costs adds an expense, and everything
else (Plan, plus tabs that have no create modal — Lists / Files / Collab) keeps
adding a place.

Follows the existing ?create=<intent> pattern: BottomNav.useCreateAction emits the
per-tab intent, and useTripPlanner consumes create=reservation|transport to open
the booking / transport modals (both already mounted at page level). Place and
expense were already wired; this just extends the mapping.

Tests: 4 new BottomNav cases (plan/bookings/transports/costs → correct intent +
navigate target); client tsc clean, full client suite green (2855).

Implements mauriceboe/TREK#1349
2026-06-28 16:26:16 +02:00
Maurice c10b9cc202 fix(map): keep the mobile GPS button above the day-detail panel (#1348)
On mobile the location (GPS) FAB sat at bottom: calc(var(--bottom-nav-h) + 12px),
which only clears the bottom nav. When a day is selected, DayDetailPanel slides
up over the map from bottom: navh+20 and spans nearly full width at z-index
10000, covering the button's band — so the button was hidden behind it.

DayDetailPanel now publishes its live measured height to a root CSS var
--day-panel-h (ResizeObserver, reset to 0 on unmount), and both map renderers
lift the button above the panel when it's open, reusing the hasDayDetail prop
they already receive:

  hasDayDetail
    ? calc(var(--bottom-nav-h) + 20px + var(--day-panel-h) + 12px)
    : calc(var(--bottom-nav-h) + 12px)

Applied to both the Leaflet (MapView) and GL (MapViewGL) renderers. When the
panel closes, hasDayDetail is false and the offset falls back to the bottom-nav
value. Desktop is unaffected — the button is mobile-only.

Tests: new DayDetailPanel case asserting --day-panel-h is published and reset on
unmount; client tsc clean, full client suite green (2851).
2026-06-28 14:54:31 +02:00
Maurice d1e024277f fix(pwa): stop unregistering the service worker on offline boot (#1346)
Opening the installed PWA offline showed Chrome's "no internet" page instead of
the cached app. On boot the axios response interceptor reacts to a failed
request with no response by probing /api/health; the probe collapsed "genuinely
offline" and "edge-proxy auth wall" into a single reachable=false, so the
interceptor unregistered the service worker and reloaded — straight into a dead
network. navigator.onLine is true on mobile while offline, so the existing guard
didn't help. This also defeated the offline data layer (withOfflineFallback,
authStore's offline branch), which runs later in the chain.

Fix: connectivity.probe() now returns a discriminated state
('online' | 'offline' | 'proxy-wall'). A fetch that throws, or navigator.onLine
false, is 'offline'; a cross-origin redirect (CF Access, via redirect:'manual'
→ opaqueredirect) or an HTML auth wall (Pangolin) is 'proxy-wall'. The
interceptor only tears down the SW on 'proxy-wall'; on plain offline it lets the
request reject so the cached shell + IndexedDB serve the app. CF Access /
Pangolin reauth still works — the proxy always presents a reachable redirect or
HTML wall, which the probe now detects positively.

Regression dates to v3.0.16 (#964), surfaced by the 3.1.0 rewrite.

Tests: 6 new connectivity cases (offline/online/proxy-wall discrimination);
client tsc clean, full client suite green (2850).
2026-06-28 12:48:27 +02:00
Maurice 172cff57a2 fix(airtrail): import departure/arrival times for manually-entered flights (#1336)
The mapper read only `departureScheduled`/`arrivalScheduled`, but those columns
are optional in AirTrail and stay null for manually-entered flights — where
`departure`/`arrival` are the only times set. So the import dropped the departure
clock (date-only) and the whole arrival (no date, no time), exactly as reported.

AirTrail's own rule is "use departure if available, otherwise fall back to
departureScheduled". Mirror that: prefer the scheduled instant, fall back to the
primary departure/arrival, in mapFlightToReservation, normalizeFlight, and the
sync hash. Hashing the resolved instant means flights already imported without a
scheduled time re-sync once and pick up their clock automatically; flights that
do have scheduled times are unaffected (no spurious re-sync).

Tests: 3 new mapper cases (fallback mapping, picker preview, hash tracking);
two existing cases that asserted the scheduled-only behaviour updated to the
"neither time set" case. Full server suite green (4085).
2026-06-28 12:12:48 +02:00
jufy111 0d6737726d Added focus to search places in placeFormModal 2026-06-28 11:53:42 +02:00
Maurice 6996a67670 fix(extract): don't let the day-clamp fallback break reservation resync (#1288)
This branch added a clamp-to-nearest-day fallback to resolveDayIdFromTime so an
imported booking whose exact date has no day row still lands on a day. After
rebasing onto dev, that collided with #1288's resyncReservationDays, which
relies on the original "null when no exact day" semantics to leave a booking
whose date now falls outside the range untouched — instead it snapped to an edge
day (TRIP-SVC-019 failed: expected day_id kept, got the clamped one).

Make clampToNearest an opt-in parameter (default true, preserving the import
behaviour for create/update) and have resyncReservationDays pass false, so
out-of-range bookings keep their day_id. Full server suite green (4082).
2026-06-28 11:53:19 +02:00
Maurice 84adc28684 fix(i18n): add Swedish translations for the AI booking-import settings
The Swedish (sv) locale landed on dev (#1325) after this branch added the
AI-parsing settings/reservation keys to the other locales, so sv was missing
them — strict i18n key parity failed after rebasing onto dev. Adds the 3
reservations.import.* and 17 settings.aiParsing/aiAlwaysRetry keys in sv.
2026-06-28 11:53:19 +02:00
Maurice f206fa6dff test(setup): stub websocket addListener/removeListener in the global mock
BackgroundTasksWidget (mounted globally in App) subscribes via addListener/removeListener from api/websocket, but the global test mock didn't export them, so every test that renders <App/> threw on mount. Add the two stubs. (Surfaced now that the page-pattern check passes and the client test step actually runs.)
2026-06-28 11:53:19 +02:00
Maurice c3b3c278b8 test(llm-parse): cover the extraction router, client factory and import jobs
The new LLM extraction router shipped with little branch coverage, dropping src/nest below the 80% gate. Add unit tests for routeExtraction (flights/single/union/error paths, deterministic booking-wide fill), the native Ollama format client, the provider factory, the local-router service path with its type-aware text cap, the flat->schema.org mapper's remaining reservation types, and the background import-jobs runner. Also remove the now-unused validate.ts (only its FlatLike type was still referenced; moved to flat-schemas).
2026-06-28 11:53:19 +02:00
Maurice d09a62fcc8 refactor(planner): move the import-review bridge effect into the page hook
TripPlannerPage held a useEffect (the background-import → review bridge), which trips the page-pattern check (pages must stay wiring containers). Move the effect and its store/IndexedDB wiring into useTripPlanner where the rest of the import-review state already lives.
2026-06-28 11:53:19 +02:00
Maurice f4b2143a59 feat(settings): use the shared custom dropdown for the AI parsing provider
Swap the native select for CustomSelect so the provider picker matches the rest of the app's styling (dark mode, portal dropdown).
2026-06-28 11:53:19 +02:00
Maurice 33f554b1bf fix(settings): show the Integrations tab when only AI parsing is enabled
hasIntegrations gated the tab on memories/mcp/airtrail only, so a user with just the llm_parsing addon enabled saw no Integrations tab and could not reach the AI parsing config. Include llmEnabled in the gate.
2026-06-28 11:53:19 +02:00
Maurice fc1f29bb29 feat(settings): let users set their own AI parsing model
Adds an "AI parsing" section under Settings -> Integrations where a user can choose the LLM provider, model, base URL, API key and multimodal option used for booking extraction. This per-user config applies when an admin has not configured an instance-wide model. Reuses the existing encrypted user settings: the API key is stored encrypted, never prefilled, and a blank field keeps the stored one. Adds settings.aiParsing.* across all 20 locales.
2026-06-28 11:53:19 +02:00
Maurice 01e5859564 chore(extract): recommend only Qwen3-8B (drop Qwen2.5 from the curated list)
Qwen3-8B is the identified default; the prior Qwen2.5 entries are no longer needed in the pull list.
2026-06-28 11:53:19 +02:00
Maurice 6a70f4fc41 fix(import): persist source files in IndexedDB so attach survives a reload
The source document was only kept in memory on the background task, so a page reload during the (now always-LLM ~25s) parse lost it and the booking saved without its file. Store the uploaded files in IndexedDB keyed by job id; the review loads them from there when the in-memory copy is gone, and a 1h TTL prunes abandoned imports.
2026-06-28 11:53:19 +02:00
Maurice 27fbc241e8 fix(import): preview the parsed cost as linked in the review modal
During the per-item import review the booking isn't saved yet, so the Costs section showed an empty 'Create expense' even though a linked cost will be created on save. Show the parsed price (amount + category) as the pending linked expense so the user can verify it up front. Reuses existing i18n keys.
2026-06-28 11:53:19 +02:00
Maurice 574c54c16c perf(extract): cap single-booking text tighter; require rental company
A long single-booking PDF (e.g. an 11-page rental voucher) spent ~200s on CPU prompt-eval at the 16k cap, though its data sits in the first ~2k. Cap non-flight docs at 6k (flights keep 16k for all legs). Also make the rental operator a required field so the car gets a real title.
2026-06-28 11:53:19 +02:00
Maurice 0cb0567d28 fix(import): refresh costs immediately after an imported booking is saved
The saving client gets no budget:created echo (X-Socket-Id) and the create response omits the linked budget item, so the booking's Costs section and the Costs tab stayed stale until a manual reload. Reload the budget items right after a create that carried a budget entry.
2026-06-28 11:53:19 +02:00
Maurice 76447f4a73 fix(extract): require the hotel address and ask for the rental company
After dropping the vendor templates, the model skipped the (often unlabeled) Expedia-style hotel address — making address a required schema field forces it to emit the street-address line, restoring the booking's location/place. Also hint the rental company so a car booking gets a real title instead of the generic fallback.
2026-06-28 11:53:19 +02:00
Maurice 55ff5c03dd refactor(extract): drop vendor templates, let the model drive with deterministic backfill
Now that a capable instruct model (Qwen3-8B, thinking off) reads name/address/dates/legs reliably across formats, the per-vendor template short-circuit distorted more than it fixed: brittle on layout variations and overriding the better model output. Remove the template layer; the model extracts the structure and Schicht 2 backfills the confirmation/total and takes the currency from the document's own symbol (correcting model misreads like ¥→$). Per-type prompts now also ask for address and price/currency.
2026-06-28 11:53:19 +02:00
Maurice 3277965426 feat(extract): recommend Qwen3-8B as the local extraction model
A/B against the prior default (qwen2.5:7b) on CPU showed Qwen3-8B is both faster and more accurate on tricky/multilingual booking docs (correct Airbnb year+price, correct DisneySea admission date), once thinking is disabled — which the router now does. Feature it as the recommended pull, keep qwen2.5:7b as the fallback.
2026-06-28 11:53:19 +02:00
Maurice d95d26e493 fix(extract): disable model thinking for grammar-constrained extraction
Hybrid/reasoning models (Qwen3 and similar) default to emitting reasoning tokens, which collide with Ollama's format-grammar constraint — on CPU this produced null/unparseable output and blew the latency budget (qwen3:8b: null or 300s timeouts vs ~20s with thinking off). Send think:false on the /api/chat call; Ollama ignores it for non-thinking models (verified on qwen2.5:7b), so it's safe and unlocks the stronger Qwen3 family.
2026-06-28 11:53:19 +02:00
Maurice 4abe96fe01 feat(import): attach the parsed source document to each booking
Keep the uploaded files on the background task and hand them to the review flow, so each reviewed booking pre-fills its Files with the document it was parsed from (uploaded with the booking on save). The two modals also adopt the shared resolveDayId helper.
2026-06-28 11:53:19 +02:00
Maurice 7bac753ff3 refactor(extract): dedupe currency/day helpers, drop redundant casts, support JPY vouchers
Code-audit clean-ups: share one normCurrency between the router and the templates, lift the duplicated nearest-day resolver into formatters.resolveDayId, drop two needless as-unknown-as casts at the fillBookingWideFields call sites, restore routeExtraction's doc comment, and give the broker template readable names. Plus recognise ¥/JPY and fall back to a standalone symbol amount, so a Klook-style voucher whose price sits far from any label still yields a cost.
2026-06-28 11:53:19 +02:00
Maurice 743397994e fix(import): refresh costs after a booking review so imported expenses appear without a reload
Imported bookings auto-create their linked budget items server-side, but the saving client suppresses its own budget:created echo, so the Costs list stayed stale until a manual reload. Reload the budget items when the review session ends.
2026-06-28 11:53:19 +02:00
Maurice 459426ed43 fix(import): resolve an imported transport's day from its parsed dates
A reviewed transport (e.g. a rental car) arrived with only its parsed pick-up/return dates and no day_id, so the modal kept just the time and saved a bare "HH:MM" with no date. Resolve start/end day from the parsed dates (exact match, else nearest trip day) so the booking lands on the right days.
2026-06-28 11:53:19 +02:00
Maurice b3fa87bdd6 fix(reservations): skip un-geocoded endpoints instead of failing the save
reservation_endpoints.lat/lng are NOT NULL, so saving a reviewed transport whose pick-up/return couldn't be geocoded threw a 500 and lost the whole booking (dates, linked cost). Skip those rows; the dates still persist on reservation_time/reservation_end_time.
2026-06-28 11:53:19 +02:00
Maurice 519dc3b0d8 fix(import): keep the parse-progress widget across a reload
Persist the background-import tasks (id/trip/status only) and re-fetch each job's status on mount, so a parse still running when the page reloads keeps its widget instead of vanishing; expired jobs (404) are dropped and a restored 'done' task re-fetches its items.
2026-06-28 11:53:19 +02:00
Maurice c1d61c98f0 fix(extract): backfill booking code/total and harden the reference match
Apply the deterministic confirmation-code and total fill to vendor-template results too (not just model output), and require the captured reference to contain a digit so a bare 'Confirmation'/'Reference' label no longer grabs the next prose word.
2026-06-28 11:53:19 +02:00
Maurice c7f5694f63 feat(extract): add Expedia and rental-broker booking templates
Pull the hotel/rental fields these vendors print in a stable text layout (name, address, stay/pickup dates, price, reference) deterministically, so the import stops depending on the local model for them. Handles German long/abbreviated months and English dates incl. 12-hour and comma forms.
2026-06-28 11:53:19 +02:00
Maurice d0b4052c5d fix(import): create linked costs and accommodations from reviewed bookings
Reviewing an imported booking saves it through the normal reservation
form, which dropped the parsed price (so no linked cost was created) and
only created the accommodation when both nights matched a trip day.
Carry the parsed price into a linked cost on save, and create the
accommodation from whichever day the check-in/out dates resolve to.
2026-06-28 11:53:19 +02:00
Maurice 1c81e8b959 feat(import): parse bookings in the background with a progress widget
Parsing a booking can take a while on a CPU host, so don't hold the
upload modal open for it. The async import endpoint returns a job id
right away; the parse runs server-side (one at a time per user) and
pushes progress over the user's WebSocket, and a small widget in the
bottom corner tracks it while the user keeps navigating and editing.
A finished job opens the per-item review from the widget.
2026-06-28 11:53:19 +02:00
Maurice 8f1c99a07a feat(extract): drive local parsing through a layered extraction router
The single-shot prompt was unreliable on multi-leg flights and longer
documents, and slow on a CPU host. For the local provider, run a small
router instead:

- deterministic vendor templates first, with no model call at all
- exactly one grammar-enforced call per document via Ollama's native
  `format` (flights as a flat array of legs, everything else as one flat
  reservation, the type picked from keywords or a union schema)
- booking-wide fields (booking reference, total price, the overnight
  arrival day) filled deterministically from the text afterwards, and
  dates coerced to ISO so a natural-language date can't slip through

Recommend qwen2.5 in the AI-parsing settings instead of NuExtract.
2026-06-28 11:53:19 +02:00
Maurice 5fdd4aa153 feat(import): review each parsed booking before it's saved
Instead of writing parsed items straight to the trip, the import opens the
normal edit modal pre-filled for each one, so you can check and fix it before
saving — useful when a model guesses a wrong date or address. Hotels gained an
editable address field; on save an existing place is matched by name, otherwise
the reviewed address is geocoded and a new place is created.
2026-06-28 11:53:19 +02:00
Maurice 22801938b5 fix(admin): tidy the AI parsing settings and recommend the 2B model
The provider picker is the shared CustomSelect now and the form is split into
clear sections rather than a flat stack of inputs. NuExtract 2.0 2B is the
recommended default — fastest on a CPU-only host and MIT licensed; the 4B
carries a non-commercial licence, so it's no longer flagged as recommended.
2026-06-28 11:53:19 +02:00
Maurice 8640100312 feat(extract): drive NuExtract with its native template
NuExtract isn't an instruct model — fed a plain chat prompt it just echoes the
schema back. Detect a NuExtract model by id and talk to it the way the model
cards document: the JSON template inlined in a single user message, no system
prompt, no json_schema, temperature 0. Its flat result is mapped back to the
same KiReservation shape the rest of the pipeline already uses, so nothing
downstream changes; every other model keeps the generic prompt.

Money is taken as a verbatim string and parsed locally (German "1.580,22 €"
otherwise comes back as 1.49772), a rental car's pickup/return ride the from/to
fields so a stray form label doesn't become the location, and a lodging with no
name falls back to its address instead of being dropped.
2026-06-28 11:53:19 +02:00
Maurice e666313865 fix(extract): refresh accommodations after a booking import
A freshly imported hotel links to an accommodation that lives outside the
trip store, so loadTrip alone left the reservation edit modal with blank
place/date fields. Reload the accommodations list once the import finishes.
2026-06-28 11:53:19 +02:00
Maurice aa72d527c9 feat(extract): create a linked cost from the booking price on import
When a confirmation carries a total price, record it as a real expense
linked to the reservation (in the matching Costs category) instead of
leaving the amount in metadata only. Gated on the Costs addon.
2026-06-28 11:53:19 +02:00
Maurice 684ac3b442 feat(extract): capture seat, class, platform, price + event venue contact
Request and map root-level seat/class/platform and a total price/currency into reservation metadata (shown on the card; price reuses the existing label). Read both the root and reservationFor and tolerate common field-name aliases (priceAmount, priceCurrencyISO4217Code, fareClass, ...) since models name these inconsistently. Also capture event/attraction venue telephone + url onto the auto-created place, matching lodging/restaurant.
2026-06-28 11:53:19 +02:00
Maurice f049229e25 perf(extract): cap LLM input at 4000 chars for CPU-only speed
On a GPU-less host the model's prompt-eval time scales with input length and dominates total latency. Booking details sit at the top of a confirmation, so capping the extracted text at 4000 chars (was 8000) roughly halves extraction time (~50s warm for a capable local 7B model) with no loss of fields on real hotel/rental confirmations. Tunable if a long multi-segment itinerary needs more.
2026-06-28 11:53:19 +02:00
Maurice 38565c3c6d feat(extract): fill transport/booking fields, geocode endpoints, assign days
- rental car: request+map dropoffLocation, emit pickup->return from/to endpoints, set a location string (G1/G2/G3). - geocode endpoints (stations/stops/terminals/rental desks) on confirm via Nominatim; mapper now emits coordless named endpoints and confirm persists only the geocoded ones (G6). - assign every dated booking to the nearest trip day so it still shows when slightly out of range, and keep hotel accommodation from vanishing when a check date misses (G5/G10). - fix bus mislabelled as train + add bus_number metadata (G7/G8), flag malformed boats (G9), accept root start/end time for events (G11). - raise the local-LLM timeout to 300s for CPU-only Ollama.
2026-06-28 11:53:19 +02:00
Maurice a1cbc11169 fix(extract): make AI imports reliable and fast on local models
client: the import call inherited the global 8s axios timeout and aborted long LLM extractions even though the server finished it; remove the timeout. server: raise the OpenAI-compatible LLM timeout 60s->180s (a cold Ollama model can take ~45s to first token). server: cap extracted text to 8000 chars before the LLM - multi-page T&C tails (30k+ chars) overflowed the context window, truncating the relevant head and making CPU inference crawl; booking details sit at the top.
2026-06-28 11:53:19 +02:00
Maurice b859ae8b00 fix(extract): auto-run the AI fallback when the addon is enabled
Booking import only fell back to the LLM when each user flipped an 'always retry with AI' toggle, so by default files kitinerary returned nothing for just failed. Run the fallback automatically whenever the AI Parsing addon is on (fallback-on-empty); drop the now-redundant per-user toggle and its setting.
2026-06-28 11:53:19 +02:00
jubnl ae14a6c860 feat(extract): extract data using LLM 2026-06-28 11:53:19 +02:00
Maurice 41c541828f fix(setup): warn when ADMIN_EMAIL/ADMIN_PASSWORD are ignored, ship reset-admin
The first-run seeder only applies ADMIN_EMAIL/ADMIN_PASSWORD on an empty
database and then silently ignores them. People add the vars after the first
boot, or pull a fresh image without clearing ./data, restart, and cannot log
in with no hint why (#1339). The default is a generated password (not the
.env.example placeholder), printed once in the first-run box. Now: warn loudly
when the vars are set but a user already exists, and warn on a partial
(one-of-two) config instead of quietly falling back.

Also ship the reset-admin recovery script in the image -- it was never COPYed in
despite the wiki referencing it. node server/reset-admin.js resets/creates
admin@trek.local with a generated password (RESET_ADMIN_EMAIL/RESET_ADMIN_PASSWORD
overridable), picks a free username so it cannot trip UNIQUE(username), and sets
must_change_password.
2026-06-28 11:10:40 +02:00
Maurice 37f1fff367 Merge main into dev after the v3.1.3 release 2026-06-28 10:59:53 +02:00
Maurice 0c1c534435 docs(wiki): document the snap Docker + no-new-privileges startup failure 2026-06-28 10:30:37 +02:00
612 changed files with 1379 additions and 32669 deletions
-1
View File
@@ -11,7 +11,6 @@ on:
- '.github/ISSUE_TEMPLATE/**'
- '.github/FUNDING.yml'
- '.github/PULL_REQUEST_TEMPLATE.md'
- 'plugin-sdk/**'
workflow_dispatch:
inputs:
bump:
-32
View File
@@ -1,32 +0,0 @@
name: Publish plugin-sdk to npm
# Publishes trek-plugin-sdk when a tag like `plugin-sdk-v1.2.0` is pushed.
# One-time setup: add an npm automation token as the repo secret NPM_TOKEN.
# The package's prepublishOnly hook builds + tests before publishing.
on:
push:
tags:
- 'plugin-sdk-v*'
permissions:
contents: read
jobs:
publish:
runs-on: ubuntu-latest
defaults:
run:
working-directory: plugin-sdk
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
# 22 matches the TREK server runtime and has node:sqlite, which the
# dev-server tests exercise.
node-version: 22
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
-136
View File
@@ -1,136 +0,0 @@
# Contributor Covenant 3.0 Code of Conduct
## Our Pledge
We pledge to make our community welcoming, safe, and equitable for all.
We are committed to fostering an environment that respects and promotes the dignity, rights, and contributions of all
individuals, regardless of characteristics including race, ethnicity, caste, color, age, physical characteristics,
neurodiversity, disability, sex or gender, gender identity or expression, sexual orientation, language, philosophy or
religion, national or social origin, socio-economic position, level of education, or other status. The same privileges
of participation are extended to everyone who participates in good faith and in accordance with this Covenant.
## Encouraged Behaviors
While acknowledging differences in social norms, we all strive to meet our community's expectations for positive
behavior. We also understand that our words and actions may be interpreted differently than we intend based on culture,
background, or native language.
With these considerations in mind, we agree to behave mindfully toward each other and act in ways that center our shared
values, including:
1. Respecting the **purpose of our community**, our activities, and our ways of gathering.
2. Engaging **kindly and honestly** with others.
3. Respecting **different viewpoints** and experiences.
4. **Taking responsibility** for our actions and contributions.
5. Gracefully giving and accepting **constructive feedback**.
6. Committing to **repairing harm** when it occurs.
7. Behaving in other ways that promote and sustain the **well-being of our community**.
## Restricted Behaviors
We agree to restrict the following behaviors in our community. Instances, threats, and promotion of these behaviors are
violations of this Code of Conduct.
1. **Harassment.** Violating explicitly expressed boundaries or engaging in unnecessary personal attention after any
clear request to stop.
2. **Character attacks.** Making insulting, demeaning, or pejorative comments directed at a community member or group of
people.
3. **Stereotyping or discrimination.** Characterizing anyones personality or behavior on the basis of immutable
identities or traits.
4. **Sexualization.** Behaving in a way that would generally be considered inappropriately intimate in the context or
purpose of the community.
5. **Violating confidentiality**. Sharing or acting on someone's personal or private information without their
permission.
6. **Endangerment.** Causing, encouraging, or threatening violence or other harm toward any person or group.
7. Behaving in other ways that **threaten the well-being** of our community.
### Other Restrictions
1. **Misleading identity.** Impersonating someone else for any reason, or pretending to be someone else to evade
enforcement actions.
2. **Failing to credit sources.** Not properly crediting the sources of content you contribute.
3. **Promotional materials**. Sharing marketing or other commercial content in a way that is outside the norms of the
community.
4. **Irresponsible communication.** Failing to responsibly present content which includes, links or describes any other
restricted behaviors.
## Reporting an Issue
Tensions can occur between community members even when they are trying their best to collaborate. Not every conflict
represents a code of conduct violation, and this Code of Conduct reinforces encouraged behaviors and norms that can help
avoid conflicts and minimize harm.
When an incident does occur, it is important to report it promptly. To report a possible violation, **send an email to
report@liketrek.com**.
Community Moderators take reports of violations seriously and will make every effort to respond in a timely manner. They
will investigate all reports of code of conduct violations, reviewing messages, logs, and recordings, or interviewing
witnesses and other participants. Community Moderators will keep investigation and enforcement actions as transparent as
possible while prioritizing safety and confidentiality. In order to honor these values, enforcement actions are carried
out in private with the involved parties, but communicating to the whole community may be part of a mutually agreed upon
resolution.
## Addressing and Repairing Harm
****
If an investigation by the Community Moderators finds that this Code of Conduct has been violated, the following
enforcement ladder may be used to determine how best to repair harm, based on the incident's impact on the individuals
involved and the community as a whole. Depending on the severity of a violation, lower rungs on the ladder may be
skipped.
1) Warning
1) Event: A violation involving a single incident or series of incidents.
2) Consequence: A private, written warning from the Community Moderators.
3) Repair: Examples of repair include a private written apology, acknowledgement of responsibility, and seeking
clarification on expectations.
2) Temporarily Limited Activities
1) Event: A repeated incidence of a violation that previously resulted in a warning, or the first incidence of a
more serious violation.
2) Consequence: A private, written warning with a time-limited cooldown period designed to underscore the
seriousness of the situation and give the community members involved time to process the incident. The cooldown
period may be limited to particular communication channels or interactions with particular community members.
3) Repair: Examples of repair may include making an apology, using the cooldown period to reflect on actions and
impact, and being thoughtful about re-entering community spaces after the period is over.
3) Temporary Suspension
1) Event: A pattern of repeated violation which the Community Moderators have tried to address with warnings, or a
single serious violation.
2) Consequence: A private written warning with conditions for return from suspension. In general, temporary
suspensions give the person being suspended time to reflect upon their behavior and possible corrective actions.
3) Repair: Examples of repair include respecting the spirit of the suspension, meeting the specified conditions for
return, and being thoughtful about how to reintegrate with the community when the suspension is lifted.
4) Permanent Ban
1) Event: A pattern of repeated code of conduct violations that other steps on the ladder have failed to resolve, or
a violation so serious that the Community Moderators determine there is no way to keep the community safe with
this person as a member.
2) Consequence: Access to all community spaces, tools, and communication channels is removed. In general, permanent
bans should be rarely used, should have strong reasoning behind them, and should only be resorted to if working
through other remedies has failed to change the behavior.
3) Repair: There is no possible repair in cases of this severity.
This enforcement ladder is intended as a guideline. It does not limit the ability of Community Managers to use their
discretion and judgment, in keeping with the best interests of our community.
## Scope
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing
the community in public or other spaces. Examples of representing our community include using an official email address,
posting via an official social media account, or acting as an appointed representative at an online or offline event.
## Attribution
This Code of Conduct is adapted from the Contributor Covenant, version 3.0, permanently available
at [https://www.contributor-covenant.org/version/3/0/](https://www.contributor-covenant.org/version/3/0/).
Contributor Covenant is stewarded by the Organization for Ethical Source and licensed under CC BY-SA 4.0. To view a copy
of this license,
visit [https://creativecommons.org/licenses/by-sa/4.0/](https://creativecommons.org/licenses/by-sa/4.0/)
For answers to common questions about Contributor Covenant, see the FAQ
at [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). Translations are provided
at [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). Additional
enforcement and community guideline resources can be found
at [https://www.contributor-covenant.org/resources](https://www.contributor-covenant.org/resources). The enforcement
ladder was inspired by the work of [Mozillas code of conduct team](https://github.com/mozilla/inclusion).
+2 -3
View File
@@ -49,12 +49,12 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
<div align="center">
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner · day plan & route" width="49%" /></a>
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner with 3D map" width="49%" /></a>
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Costs · expense splitting" width="49%" /></a>
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
<a href="docs/screenshots/collections.png"><img src="docs/screenshots/collections.png" alt="Collections · saved place lists" width="49%" /></a>
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Trip planner · day plan and route" width="49%" /></a>
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
</div>
@@ -428,7 +428,6 @@ Caddy handles TLS and WebSockets automatically.
| `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random |
| **Other** | | |
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
| `UNSPLASH_ACCESS_KEY` | Optional Unsplash Access Key for trip-cover and place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from. Get a free key at [unsplash.com/developers](https://unsplash.com/developers). Overrides any per-admin key set in Admin > Settings (where it can also be configured instead). | — |
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 3.2.1
version: 3.1.3
description: Minimal Helm chart for TREK app
appVersion: "3.2.1"
appVersion: "3.1.3"
-6
View File
@@ -63,12 +63,6 @@ spec:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: OIDC_CLIENT_SECRET
optional: true
- name: UNSPLASH_ACCESS_KEY
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: UNSPLASH_ACCESS_KEY
optional: true
volumeMounts:
- name: data
mountPath: /app/data
-6
View File
@@ -17,9 +17,6 @@ data:
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY | b64enc | quote }}
{{- end }}
{{- end }}
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
@@ -47,7 +44,4 @@ stringData:
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET }}
{{- end }}
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
{{- end }}
{{- end }}
-6
View File
@@ -92,12 +92,6 @@ secretEnv:
ADMIN_PASSWORD: ""
# OIDC client secret — set together with env.OIDC_ISSUER and env.OIDC_CLIENT_ID.
OIDC_CLIENT_SECRET: ""
# Optional Unsplash Access Key for trip-cover and place-image search.
# Without one, TREK uses Unsplash's unauthenticated endpoint, which some
# datacenter/VPS IPs (including many Kubernetes clusters) are blocked from.
# Get a free key at https://unsplash.com/developers. Can also be set per-admin
# in Admin > Settings; this value overrides that. Leave empty to disable.
UNSPLASH_ACCESS_KEY: ""
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
generateEncryptionKey: false
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "3.2.1",
"version": "3.1.3",
"private": true,
"type": "module",
"scripts": {
@@ -52,7 +52,6 @@
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"topojson-client": "^3.1.0",
"tz-lookup": "^6.1.25",
"zod": "^4.3.6",
"zustand": "^4.5.2"
},
-12
View File
@@ -4,8 +4,6 @@ import { useAuthStore } from './store/authStore'
import { useSettingsStore } from './store/settingsStore'
import { applyAppearance } from './theme/applyAppearance'
import { useAddonStore } from './store/addonStore'
import { usePluginStore } from './store/pluginStore'
import PluginPage from './pages/PluginPage'
import LoginPage from './pages/LoginPage'
import ForgotPasswordPage from './pages/ForgotPasswordPage'
import ResetPasswordPage from './pages/ResetPasswordPage'
@@ -113,7 +111,6 @@ export default function App() {
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore()
const { loadAddons } = useAddonStore()
const { loadPlugins } = usePluginStore()
useEffect(() => {
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) {
@@ -171,7 +168,6 @@ export default function App() {
if (isAuthenticated) {
loadSettings()
loadAddons()
loadPlugins()
}
}, [isAuthenticated])
@@ -288,14 +284,6 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="/plugins/:pluginId"
element={
<ProtectedRoute>
<PluginPage />
</ProtectedRoute>
}
/>
<Route
path="/vacay"
element={
+2 -56
View File
@@ -460,19 +460,6 @@ export const adminApi = {
updateOidc: (data: Record<string, unknown>) => apiClient.put('/admin/oidc', data).then(r => r.data),
addons: () => apiClient.get('/admin/addons').then(r => r.data),
updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
plugins: () => apiClient.get('/admin/plugins').then(r => r.data),
pluginBrowse: (refresh?: boolean) => apiClient.get('/admin/plugins/registry', { params: refresh ? { refresh: 1 } : undefined }).then(r => r.data),
pluginDetail: (id: string) => apiClient.get(`/admin/plugins/registry/${encodeURIComponent(id)}`).then(r => r.data),
pluginInstall: (id: string, opts?: { version?: string; constraint?: string; withDependencies?: boolean }) =>
apiClient.post('/admin/plugins/install', { id, ...opts }).then(r => r.data),
pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data),
pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data),
pluginUpdate: (id: string) => apiClient.post(`/admin/plugins/${id}/update`).then(r => r.data),
pluginUninstall: (id: string, deleteData: boolean) => apiClient.post(`/admin/plugins/${id}/uninstall`, { deleteData }).then(r => r.data),
pluginRescan: () => apiClient.post('/admin/plugins/rescan').then(r => r.data),
pluginUpload: (file: File) => { const fd = new FormData(); fd.append('file', file); return apiClient.post('/admin/plugins/upload', fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) },
pluginErrors: (id: string) => apiClient.get(`/admin/plugins/${id}/errors`).then(r => r.data),
pluginAudit: (id: string) => apiClient.get(`/admin/plugins/${id}/audit`).then(r => r.data),
// Local LLM (Ollama) management for the AI-parsing addon.
llmLocalModels: (baseUrl: string): Promise<{ models: { name: string; size: number }[] }> =>
apiClient.get('/admin/llm/local/models', { params: { baseUrl } }).then(r => r.data),
@@ -555,39 +542,6 @@ export const addonsApi = {
enabled: () => apiClient.get('/addons').then(r => r.data),
}
export const pluginsApi = {
// Active plugins the client renders (page nav entries, dashboard widgets).
active: () => apiClient.get('/plugins').then(r => r.data),
// Extra place info contributed by placeDetailProvider plugins (#1429). Fail-safe:
// the server skips any slow/failing provider, so this only ever adds rows.
placeDetails: (placeId: number) =>
apiClient.get(`/place-details/${placeId}`).then(r => r.data as { providers: Array<{ pluginId: string; items: Array<{ label: string; value?: string; url?: string }> }> }),
// Validation/warning contributions from warningProvider plugins (#1429). Fail-safe.
tripWarnings: (tripId: number) =>
apiClient.get(`/trip-warnings/${tripId}`).then(r => r.data as { warnings: Array<{ pluginId: string; level: 'info' | 'warning' | 'error'; message: string; dayId?: number; placeId?: number }> }),
// Call one of a plugin's own declared routes through the host proxy. `sub` is
// supplied by untrusted plugin code (the trekBridge forwards it verbatim), so it
// MUST stay inside the plugin's own /plugins/:id/ namespace. We resolve it with
// the URL parser — which normalizes `../`, encoded traversal and backslashes the
// same way the browser would before sending — and reject anything that escapes
// the prefix or points off-origin. Without this a plugin could send
// sub='/../../auth/me' and drive arbitrary authenticated /api routes as the user.
invoke: (id: string, sub: string, init?: { method?: string; body?: unknown }) => {
const prefix = `/api/plugins/${id}/`
let resolved: URL
try {
resolved = new URL(String(sub).replace(/^\/+/, ''), window.location.origin + prefix)
} catch {
return Promise.reject(new Error('invalid plugin route'))
}
if (resolved.origin !== window.location.origin || !resolved.pathname.startsWith(prefix)) {
return Promise.reject(new Error('plugin route escapes its namespace'))
}
const url = resolved.pathname.slice('/api'.length) + resolved.search
return apiClient.request({ url, method: init?.method || 'GET', data: init?.body }).then(r => r.data)
},
}
export const airtrailApi = {
getSettings: () => apiClient.get('/integrations/airtrail/settings').then(r => r.data),
saveSettings: (data: { url: string; apiKey?: string; allowInsecureTls?: boolean; writeEnabled?: boolean }) =>
@@ -702,8 +656,8 @@ export const budgetApi = {
setPayers: (tripId: number | string, id: number, payers: { user_id: number; amount: number }[]) => apiClient.put(`/trips/${tripId}/budget/${id}/payers`, { payers }).then(r => r.data),
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data),
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data),
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
@@ -859,14 +813,6 @@ export const shareApi = {
getSharedTrip: (token: string) => apiClient.get(`/shared/${token}`).then(r => r.data),
}
// Public transit routing (#1065) — Transitous/MOTIS proxied through the server.
export const transitApi = {
geocode: (q: string, opts?: { lang?: string; near?: string }) =>
apiClient.get('/transit/geocode', { params: { q, lang: opts?.lang, near: opts?.near } }).then(r => r.data),
plan: (params: { from: string; to: string; time?: string; arriveBy?: boolean; modes?: string; maxTransfers?: number }) =>
apiClient.get('/transit/plan', { params }).then(r => r.data),
}
// Trip invite links (#1143) — join a trip as an existing, logged-in user.
export const tripInviteApi = {
getLink: (tripId: number | string) => apiClient.get(`/trips/${tripId}/invite-link`).then(r => r.data),
-14
View File
@@ -18,9 +18,6 @@ import type {
CollectionStatus,
Collection,
CollectionPlace,
CollectionLabel,
CollectionLabelCreateRequest,
CollectionLabelUpdateRequest,
} from '@trek/shared'
const ax = apiClient
@@ -98,15 +95,4 @@ export const collectionsApi = {
ax.post(`${base}/members/remove`, { collection_id: collectionId, user_id: userId }).then((r: AxiosResponse) => r.data),
availableUsers: (id: number): Promise<{ users: { id: number; username: string }[] }> =>
ax.get(`${base}/${id}/available-users`).then((r: AxiosResponse) => r.data),
createLabel: (collectionId: number, name: string, color?: string): Promise<CollectionLabel> =>
ax.post(`${base}/labels`, { collection_id: collectionId, name, color } satisfies CollectionLabelCreateRequest).then((r: AxiosResponse) => r.data),
updateLabel: (labelId: number, body: CollectionLabelUpdateRequest): Promise<CollectionLabel> =>
ax.patch(`${base}/labels/${labelId}`, body satisfies CollectionLabelUpdateRequest).then((r: AxiosResponse) => r.data),
deleteLabel: (labelId: number): Promise<unknown> =>
ax.delete(`${base}/labels/${labelId}`).then((r: AxiosResponse) => r.data),
assignLabels: (labelIds: number[], placeIds: number[]): Promise<{ changed: number }> =>
ax.post(`${base}/labels/assign`, { label_ids: labelIds, place_ids: placeIds }).then((r: AxiosResponse) => r.data),
unassignLabels: (labelIds: number[], placeIds: number[]): Promise<{ changed: number }> =>
ax.post(`${base}/labels/unassign`, { label_ids: labelIds, place_ids: placeIds }).then((r: AxiosResponse) => r.data),
}
File diff suppressed because it is too large Load Diff
@@ -2,8 +2,8 @@ export const CURRENCIES = [
'EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK',
'TRY', 'THB', 'AUD', 'CAD', 'NZD', 'BRL', 'MXN', 'INR', 'IDR', 'MYR',
'PHP', 'SGD', 'KRW', 'CNY', 'HKD', 'TWD', 'ZAR', 'AED', 'SAR', 'ILS',
'EGP', 'MAD', 'HUF', 'RON', 'BGN', 'HRK', 'ISK', 'RUB', 'UAH', 'KGS',
'BDT', 'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS',
'EGP', 'MAD', 'HUF', 'RON', 'BGN', 'HRK', 'ISK', 'RUB', 'UAH', 'BDT',
'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS',
]
export const SYMBOLS: Record<string, string> = {
@@ -13,8 +13,8 @@ export const SYMBOLS: Record<string, string> = {
PHP: '₱', SGD: 'S$', KRW: '₩', CNY: '¥', HKD: 'HK$', TWD: 'NT$',
ZAR: 'R', AED: 'د.إ', SAR: '﷼', ILS: '₪', EGP: 'E£', MAD: 'MAD',
HUF: 'Ft', RON: 'lei', BGN: 'лв', HRK: 'kn', ISK: 'kr', RUB: '₽',
UAH: '₴', KGS: 'сом', BDT: '৳', LKR: 'Rs', VND: '₫', CLP: 'CL$',
COP: 'CO$', PEN: 'S/.', ARS: 'AR$',
UAH: '₴', BDT: '৳', LKR: 'Rs', VND: '₫', CLP: 'CL$', COP: 'CO$',
PEN: 'S/.', ARS: 'AR$',
}
export const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7']
@@ -4,7 +4,6 @@ import { http, HttpResponse } from 'msw'
import { server } from '../../../tests/helpers/msw/server'
import { useAuthStore } from '../../store/authStore'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { buildUser, buildTrip, buildBudgetItem } from '../../../tests/helpers/factories'
import CostsPanel from './CostsPanel'
@@ -165,28 +164,6 @@ describe('CostsPanel — settlements in the ledger', () => {
expect(screen.getByText('Unfinished')).toBeInTheDocument()
})
it('sums only unfinished expenses in the Outstanding amount card', async () => {
// Display in the trip's own currency so FX conversion is an identity — keeps the asserted sum deterministic.
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
const paid = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 60, payers: [{ user_id: 1, amount: 60, username: 'alice' }], members: [{ user_id: 1, username: 'alice', paid: 1 }] }
const unfinishedA = { ...buildBudgetItem({ trip_id: 1, category: 'lodging', name: 'Hotel' }), total_price: 90, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
const unfinishedB = { ...buildBudgetItem({ trip_id: 1, category: 'transport', name: 'Taxi' }), total_price: 30, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
const zero = { ...buildBudgetItem({ trip_id: 1, category: 'misc', name: 'Freebie' }), total_price: 0, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [paid, unfinishedA, unfinishedB, zero] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
)
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
// Footer only shows the count once unfinished expenses have loaded.
const foot = await screen.findByText('expenses need a payer')
expect(foot).toHaveTextContent('2 expenses need a payer') // the two payer-less, non-zero expenses
// Sum is 90 + 30 = 120 — the paid (60) and zero-total items are excluded.
// Sum is 90 + 30 = 120 — the paid (60) and zero-total items are excluded.
const card = screen.getByText('Outstanding amount').closest('div[style*="border-radius: 22"]')
expect(card).toHaveTextContent('120') // 120,00 € (locale separator), i.e. 90 + 30
})
it('records a recorded-total expense with nobody to split with (#1286)', async () => {
let posted: Record<string, unknown> | null = null
server.use(
+22 -99
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom'
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle } from 'lucide-react'
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2 } from 'lucide-react'
import { useTripStore } from '../../store/tripStore'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
@@ -131,8 +131,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const [settlement, setSettlement] = useState<SettlementData | null>(null)
const [filter, setFilter] = useState<'all' | 'mine' | 'owed'>('all')
const [search, setSearch] = useState('')
const [catFilter, setCatFilter] = useState('') // '' = all categories
const [dayFilter, setDayFilter] = useState('') // '' = all days, else YYYY-MM-DD
const [modalOpen, setModalOpen] = useState(false)
const [editing, setEditing] = useState<BudgetItem | null>(null)
const [editingSettlement, setEditingSettlement] = useState<Settlement | null>(null)
@@ -179,9 +177,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const myShare = shares[me] || 0
return convert(myShare, curOf(e))
}
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip
// total but stays out of settlements until who-paid is filled in.
const isUnfinished = (e: BudgetItem) => baseTotal(e) > 0 && (e.payers || []).filter(p => p.amount > 0).length === 0
const totals = useMemo(() => {
const totalSpend = budgetItems.reduce((a, e) => a + baseTotal(e), 0)
@@ -189,9 +184,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const myShare = budgetItems.reduce((a, e) => a + myShareOf(e), 0)
const owe = (settlement?.flows || []).filter(f => f.from.user_id === me).reduce((a, f) => a + f.amount, 0)
const owed = (settlement?.flows || []).filter(f => f.to.user_id === me).reduce((a, f) => a + f.amount, 0)
const outstanding = budgetItems.reduce((a, e) => (isUnfinished(e) ? a + baseTotal(e) : a), 0)
const outstandingCount = budgetItems.filter(isUnfinished).length
return { totalSpend, myPaid, myShare, owe, owed, outstanding, outstandingCount }
return { totalSpend, myPaid, myShare, owe, owed }
}, [budgetItems, settlement, me])
// ── filtering + day grouping ────────────────────────────────────────────
@@ -199,27 +192,21 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
let list = budgetItems.slice()
if (filter === 'mine') list = list.filter(e => myPaidOf(e) > 0)
if (filter === 'owed') list = list.filter(e => round2(myPaidOf(e) - myShareOf(e)) > 0)
// catMeta normalises legacy/free-text categories to the fixed keys, so the
// filter matches rows saved before the category rework too.
if (catFilter) list = list.filter(e => catMeta(e.category).key === catFilter)
if (dayFilter) list = list.filter(e => (e.expense_date || '') === dayFilter)
const q = search.trim().toLowerCase()
if (q) list = list.filter(e => e.name.toLowerCase().includes(q))
return list
}, [budgetItems, filter, search, catFilter, dayFilter, me])
}, [budgetItems, filter, search, me])
// Settlements ("payments") shown inline in the ledger. They have no name, so a
// text search hides them; they're excluded from the "owed" expense filter and,
// under "mine", only show transfers I'm part of.
const filteredSettlements = useMemo(() => {
// Payments carry no name or category, so a text/category filter hides them.
if (search.trim() || catFilter) return []
if (search.trim()) return []
if (filter === 'owed') return []
let list = settlement?.settlements || []
if (filter === 'mine') list = list.filter(s => s.from_user_id === me || s.to_user_id === me)
if (dayFilter) list = list.filter(s => (s.created_at || '').slice(0, 10) === dayFilter)
return list
}, [settlement, filter, search, catFilter, dayFilter, me])
}, [settlement, filter, search, me])
const dayGroups = useMemo(() => {
const entries: LedgerEntry[] = [
@@ -242,24 +229,10 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
return groups
}, [filtered, filteredSettlements, locale, t])
// ── filter dropdown options (category + single day) ──────────────────────
const categoryOptions = useMemo(() => [
{ value: '', label: t('costs.filter.allCategories') },
...COST_CATEGORY_LIST.map(c => ({ value: c.key, label: t(c.labelKey), icon: <c.Icon size={14} style={{ color: c.color }} /> })),
], [t])
const dayOptions = useMemo(() => {
const days = Array.from(new Set(budgetItems.map(e => e.expense_date).filter(Boolean) as string[])).sort((a, b) => b.localeCompare(a))
const fmtDay = (d: string) => {
try { return new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } catch { return d }
}
return [{ value: '', label: t('costs.filter.allDays') }, ...days.map(d => ({ value: d, label: fmtDay(d) }))]
}, [budgetItems, locale, t])
// ── settle actions ──────────────────────────────────────────────────────
const settleFlow = async (fromId: number, toId: number, amount: number) => {
try {
await budgetApi.createSettlement(tripId, { from_user_id: fromId, to_user_id: toId, amount, currency: base })
await budgetApi.createSettlement(tripId, { from_user_id: fromId, to_user_id: toId, amount })
loadSettlement()
} catch { toast.error(t('common.unknownError')) }
}
@@ -270,7 +243,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const flows = settlement?.flows || []
if (!flows.length) return
try {
for (const f of flows) await budgetApi.createSettlement(tripId, { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount, currency: base })
for (const f of flows) await budgetApi.createSettlement(tripId, { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount })
loadSettlement()
} catch { toast.error(t('common.unknownError')) }
}
@@ -310,29 +283,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
return <>{parts.map((p, i) => <span key={i} style={isBig(p) ? undefined : { fontSize: smallSize, fontWeight: 500, color: mutedColor }}>{p.value}</span>)}</>
}
// ── category + day filter controls (shared by both layouts) ──────────────
const filterControls = (
<>
<CustomSelect value={catFilter} onChange={v => setCatFilter(String(v))} options={categoryOptions} size="sm" style={{ minWidth: 148 }} />
<CustomSelect value={dayFilter} onChange={v => setDayFilter(String(v))} options={dayOptions} size="sm" searchable style={{ minWidth: 140 }} />
</>
)
// A prominent summary shown when a single day is selected: the day + its total.
const dayFilterTotal = dayFilter ? filtered.reduce((a, e) => a + baseTotal(e), 0) : 0
const dayFilterLabel = dayFilter
? (() => { try { return new Date(dayFilter + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' }) } catch { return dayFilter } })()
: ''
const dayBanner = dayFilter ? (
<div className={cardCls} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, borderRadius: 16, padding: '16px 20px', marginBottom: 16 }}>
<div style={{ minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.01em' }}>{dayFilterLabel}</div>
<div className="text-content-muted" style={{ marginTop: 3, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t('costs.expensesCount', { count: filtered.length })}</div>
</div>
<div className="text-content" style={{ fontSize: 'calc(26px * var(--fs-scale-title, 1))', fontWeight: 700, letterSpacing: '-0.02em', whiteSpace: 'nowrap' }}>{bigMoney(dayFilterTotal, 15, 'var(--text-muted)')}</div>
</div>
) : null
return (
<div className="costs-root" style={{ minHeight: '100%', background: 'var(--c-bg)', padding: isMobile ? '6px 14px 28px' : '40px 24px 48px' }}>
{isMobile ? <MobileBody /> : (
@@ -374,7 +324,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</div>
{/* ── Summary cards ── */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 36 }} className="costs-summary">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1.15fr', gap: 16, marginBottom: 36 }} className="costs-summary">
<SummaryCard label={t('costs.youOwe')} sub={t('costs.youOweSub')} amount={totals.owe} currency={base} locale={locale}
icon={<ArrowDown size={18} />} tone="owe"
foot={totals.owe > 0.01
@@ -385,11 +335,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
foot={totals.owed > 0.01
? <FlowPills ids={(settlement?.flows || []).filter(f => f.to.user_id === me).map(f => f.from.user_id)} lead={t('costs.from')} Avatar={Avatar} name={personName} />
: <span className="text-content-faint">{t('costs.nothingOwed')}</span>} />
<SummaryCard label={t('costs.outstanding')} sub={t('costs.outstandingSub')} amount={totals.outstanding} currency={base} locale={locale}
icon={<AlertCircle size={18} />} tone="unfinished"
foot={totals.outstandingCount > 0
? <span><b>{totals.outstandingCount}</b> {t('costs.outstandingItems')}</span>
: <span className="text-content-faint">{t('costs.allSettled')}</span>} />
<SummaryCard label={t('costs.totalSpend')} sub={t('costs.totalSpendSub')} amount={totals.totalSpend} currency={base} locale={locale}
icon={<BarChart3 size={18} />} tone="total"
foot={<span style={{ display: 'flex', gap: 16 }}><span>{t('costs.yourShare')} · <b>{fmt0(totals.myShare)}</b></span><span>{t('costs.youPaid')} · <b>{fmt0(totals.myPaid)}</b></span></span>} />
@@ -403,13 +348,12 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<h3 className="text-content" style={{ fontSize: 'calc(24px * var(--fs-scale-title, 1))', fontWeight: 600, letterSpacing: '-0.025em', margin: 0 }}>
{t('costs.expenses')}
</h3>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 6, borderRadius: 10, padding: '0 10px', height: 34 }}>
<Search size={15} className="text-content-faint" />
<input value={search} onChange={e => setSearch(e.target.value)} placeholder={t('costs.searchPlaceholder')}
className="text-content" style={{ border: 0, background: 'none', outline: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', width: 150, fontFamily: 'inherit' }} />
</div>
{filterControls}
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 9, padding: 3 }}>
{(['all', 'mine', 'owed'] as const).map(f => (
<button key={f} onClick={() => setFilter(f)}
@@ -422,7 +366,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</div>
</div>
{dayBanner}
{dayGroups.length === 0 ? (
<div className="text-content-faint" style={{ textAlign: 'center', padding: '60px 20px' }}>
{search ? t('costs.noMatch') : t('costs.emptyText')}
@@ -431,11 +374,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0)
return (
<div key={g.day} style={{ marginBottom: 22 }}>
{!dayFilter && (
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', margin: '0 0 10px 4px' }}>
{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t('costs.spent', { amount: fmt(dtot) })}</span>
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{g.entries.map(en => en.kind === 'expense'
? <ExpenseRow key={'e' + en.e.id} e={en.e} />
@@ -485,7 +426,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
)}
{(editingSettlement || addingPayment) && (
<SettlementModal tripId={tripId} people={people} me={me} editing={editingSettlement} currency={base}
<SettlementModal tripId={tripId} people={people} me={me} editing={editingSettlement}
onClose={() => { setEditingSettlement(null); setAddingPayment(false) }}
onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} />
)}
@@ -521,11 +462,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
.costs-root .text-content-faint { color: var(--c-ink3) !important; }
.costs-root .exp-actions { opacity: 1; }
@media (max-width: 1100px) {
.costs-root .costs-summary { grid-template-columns: 1fr 1fr !important; }
.costs-root .costs-grid { grid-template-columns: 1fr !important; }
}
@media (max-width: 640px) {
.costs-root .costs-summary { grid-template-columns: 1fr !important; }
.costs-root .costs-grid { grid-template-columns: 1fr !important; }
}
`}</style>
</div>
@@ -593,18 +531,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</div>
</div>
{/* Outstanding */}
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 11 }}>
<div style={{ width: 34, height: 34, borderRadius: 10, display: 'grid', placeItems: 'center', background: '#d9770622', color: '#d97706', flexShrink: 0 }}><AlertCircle size={17} /></div>
<div style={{ minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('costs.outstanding')}</div>
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))' }}>{t('costs.outstandingSub')}</div>
</div>
<div style={{ marginLeft: 'auto', fontSize: 'calc(27px * var(--fs-scale-title, 1))', fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, display: 'flex', alignItems: 'baseline', color: '#d97706' }}>{bigMoney(totals.outstanding, 16, 'var(--c-ink3)')}</div>
</div>
</div>
{/* Settle up */}
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 8 }}>
@@ -628,18 +554,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<button key={f} onClick={() => setFilter(f)} className={filter === f ? 'bg-surface-card text-content' : 'text-content-muted'} style={{ flex: 1, padding: '8px 6px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, borderRadius: 8, border: 0, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap' }}>{t('costs.filter.' + f)}</button>
))}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<CustomSelect value={catFilter} onChange={v => setCatFilter(String(v))} options={categoryOptions} size="sm" style={{ flex: 1, minWidth: 0 }} />
<CustomSelect value={dayFilter} onChange={v => setDayFilter(String(v))} options={dayOptions} size="sm" searchable style={{ flex: 1, minWidth: 0 }} />
</div>
{dayBanner}
{dayGroups.length === 0
? <div className="text-content-faint" style={{ textAlign: 'center', padding: '36px 16px', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{search ? t('costs.noMatch') : t('costs.emptyText')}</div>
: dayGroups.map(g => {
const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0)
return (
<div key={g.day} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{!dayFilter && <div className={labelCls} style={{ display: 'flex', alignItems: 'center', padding: '0 2px' }}>{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))' }}>{t('costs.spent', { amount: fmt(dtot) })}</span></div>}
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', padding: '0 2px' }}>{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))' }}>{t('costs.spent', { amount: fmt(dtot) })}</span></div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{g.entries.map(en => en.kind === 'expense'
? <ExpenseRow key={'e' + en.e.id} e={en.e} />
: <SettlementRow key={'s' + en.s.id} s={en.s} />)}</div>
@@ -670,19 +591,21 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const cur = curOf(e)
const payers = (e.payers || []).filter(p => p.amount > 0)
const net = round2(myPaidOf(e) - myShareOf(e))
const unfinished = isUnfinished(e)
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip
// total but stays out of settlements until who-paid is filled in.
const isUnfinished = baseTotal(e) > 0 && payers.length === 0
return (
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
<span style={{ position: 'relative', width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}>
<Icon size={21} />
{isMobile && unfinished && (
{isMobile && isUnfinished && (
<span title={t('costs.unfinishedHint')} style={{ position: 'absolute', bottom: -4, right: -4, width: 20, height: 20, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 800, lineHeight: 1, border: '2px solid var(--bg-card)' }}>!</span>
)}
</span>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}>
<span className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600 }}>{e.name}</span>
{unfinished && !isMobile && (
{isUnfinished && !isMobile && (
<span title={t('costs.unfinishedHint')} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px 2px 6px', borderRadius: 999, background: 'rgba(217,119,6,0.14)', color: '#d97706', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, flexShrink: 0 }}>
<span style={{ width: 14, height: 14, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 800 }}>!</span>
{t('costs.unfinished')}
@@ -809,9 +732,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
}
// ── pure subcomponents ─────────────────────────────────────────────────────
function SummaryCard({ label, sub, amount, currency, locale, icon, foot, tone }: { label: string; sub: string; amount: number; currency: string; locale: string; icon: React.ReactNode; foot: React.ReactNode; tone: 'owe' | 'owed' | 'total' | 'unfinished' }) {
function SummaryCard({ label, sub, amount, currency, locale, icon, foot, tone }: { label: string; sub: string; amount: number; currency: string; locale: string; icon: React.ReactNode; foot: React.ReactNode; tone: 'owe' | 'owed' | 'total' }) {
const total = tone === 'total'
const accent = tone === 'owe' ? '#dc2626' : tone === 'owed' ? '#16a34a' : tone === 'unfinished' ? '#d97706' : undefined
const accent = tone === 'owe' ? '#dc2626' : tone === 'owed' ? '#16a34a' : undefined
const muted = total ? 'rgba(255,255,255,0.55)' : 'var(--text-faint)'
// formatToParts keeps the design's "big integer + muted symbol/decimals" styling
// while letting Intl place the symbol and pick separators per locale + currency.
@@ -858,8 +781,8 @@ function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; A
// Add or edit a settle-up payment (from / to / amount). Reachable inline from the
// ledger row and from a manual "Add payment" button, so recording "I sent money to
// X" works the same whether or not there's an outstanding expense behind it.
function SettlementModal({ tripId, people, me, editing, currency, onClose, onSaved }: {
tripId: number; people: TripMember[]; me: number; editing: Settlement | null; currency: string; onClose: () => void; onSaved: () => void
function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: {
tripId: number; people: TripMember[]; me: number; editing: Settlement | null; onClose: () => void; onSaved: () => void
}) {
const { t } = useTranslation()
const toast = useToast()
@@ -876,7 +799,7 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav
const save = async () => {
if (!valid) return
setSaving(true)
const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency }
const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt }
try {
if (editing) await budgetApi.updateSettlement(tripId, editing.id, data)
else await budgetApi.createSettlement(tripId, data)
@@ -1,90 +0,0 @@
import React, { useState } from 'react'
import { Check, Loader2, Settings2, Tags } from 'lucide-react'
import Modal from '../shared/Modal'
import type { CollectionLabel } from '@trek/shared'
import type { TranslationFn } from '../../types'
interface BulkAssignLabelModalProps {
isOpen: boolean
labels: CollectionLabel[]
/** Number of selected places the labels will be added to. */
count: number
onAssign: (labelIds: number[]) => Promise<void> | void
/** Open the label manager to create labels first. */
onManage: () => void
onClose: () => void
t: TranslationFn
}
/**
* Pick one or more of the list's labels to add to every selected place. Additive
* it never removes labels a place already has. When the list has no labels yet,
* it points the user at the label manager instead.
*/
export default function BulkAssignLabelModal({ isOpen, labels, count, onAssign, onManage, onClose, t }: BulkAssignLabelModalProps): React.ReactElement {
const [picked, setPicked] = useState<number[]>([])
const [busy, setBusy] = useState(false)
const toggle = (id: number) => setPicked(picked.includes(id) ? picked.filter(x => x !== id) : [...picked, id])
const assign = async () => {
if (picked.length === 0 || busy) return
setBusy(true)
try {
await onAssign(picked)
setPicked([])
} finally {
setBusy(false)
}
}
return (
<Modal isOpen={isOpen} onClose={onClose} title={t('collections.labels.assignN', { count })} size="sm">
{labels.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-6 text-center">
<Tags size={26} className="text-content-faint" />
<p className="text-[13px] text-content-faint">{t('collections.labels.emptyHint')}</p>
<button type="button" onClick={onManage} className="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-edge text-[13px] text-content hover:bg-surface-hover">
<Settings2 size={14} /> {t('collections.labels.manage')}
</button>
</div>
) : (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1 max-h-[46vh] overflow-y-auto -mx-1 px-1">
{labels.map(l => {
const on = picked.includes(l.id)
return (
<button
key={l.id}
type="button"
onClick={() => toggle(l.id)}
className={`flex items-center gap-2.5 px-3 py-2.5 rounded-xl border text-left transition-colors ${on ? 'border-accent bg-accent/10' : 'border-edge bg-surface-card hover:bg-surface-hover'}`}
>
<span className="w-3.5 h-3.5 rounded-full shrink-0" style={{ background: l.color || '#6366f1' }} />
<span className="flex-1 min-w-0 text-[13px] font-medium text-content truncate">{l.name}</span>
{on && <Check size={15} className="text-accent shrink-0" />}
</button>
)
})}
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-edge">
<button type="button" onClick={onManage} className="mr-auto flex items-center gap-1.5 px-3 py-2 rounded-lg text-[13px] text-content-secondary hover:bg-surface-hover">
<Settings2 size={14} /> {t('collections.labels.manage')}
</button>
<button type="button" onClick={onClose} className="px-3 py-2 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover">
{t('common.cancel')}
</button>
<button
type="button"
onClick={assign}
disabled={picked.length === 0 || busy}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-accent text-white text-[13px] font-semibold hover:opacity-90 disabled:opacity-50"
>
{busy ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />} {t('collections.labels.assign')}
</button>
</div>
</div>
)}
</Modal>
)
}
@@ -29,12 +29,6 @@ function makeProps(overrides: Partial<HarnessProps> = {}): HarnessProps {
categoryOptions: CATEGORY_OPTIONS,
onStatusFilter: vi.fn(),
onCategoryFilter: vi.fn(),
showLabels: false,
labelOptions: [],
labelFilter: [],
onLabelFilter: vi.fn(),
canManageLabels: false,
onManageLabels: vi.fn(),
showSelect: true,
selectMode: false,
onToggleSelect: vi.fn(),
@@ -1,10 +1,10 @@
import React, { useEffect, useRef, useState } from 'react'
import { ChevronDown, Check, Layers, Tag, Tags, CheckSquare } from 'lucide-react'
import { ChevronDown, Check, Layers, Tag, CheckSquare } from 'lucide-react'
import type { StatusFilter } from '../../store/collectionStore'
import type { TranslationFn } from '../../types'
import { getCategoryIcon } from '../shared/categoryIcons'
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
import type { CategoryOption, LabelOption } from '../../pages/collections/collectionsModel'
import type { CategoryOption } from '../../pages/collections/collectionsModel'
interface Opt {
key: string | number
@@ -69,13 +69,6 @@ interface CollectionFilterBarProps {
categoryOptions: CategoryOption[]
onStatusFilter: (f: StatusFilter) => void
onCategoryFilter: (f: number | 'all') => void
// Per-collection labels (hidden on the "All saved" union).
showLabels: boolean
labelOptions: LabelOption[]
labelFilter: number[]
onLabelFilter: (ids: number[]) => void
canManageLabels: boolean
onManageLabels: () => void
showSelect: boolean
selectMode: boolean
onToggleSelect: () => void
@@ -89,7 +82,6 @@ interface CollectionFilterBarProps {
*/
export default function CollectionFilterBar({
statusFilter, counts, categoryFilter, categoryOptions, onStatusFilter, onCategoryFilter,
showLabels, labelOptions, labelFilter, onLabelFilter, canManageLabels, onManageLabels,
showSelect, selectMode, onToggleSelect, t,
}: CollectionFilterBarProps): React.ReactElement {
const statusOpts: Opt[] = [
@@ -120,33 +112,6 @@ export default function CollectionFilterBar({
<CheckSquare size={14} /> <span className="col-filter-lbl">{t('collections.select')}</span>
</button>
)}
{showLabels && (labelOptions.length > 0 || canManageLabels) && (
<div className="col-labelfilter">
{labelOptions.map(l => {
const on = labelFilter.includes(l.id)
return (
<button
key={l.id}
type="button"
className={`col-labelchip${on ? ' on' : ''}`}
style={{ ['--label' as string]: l.color ?? 'var(--accent)' }}
onClick={() => onLabelFilter(on ? labelFilter.filter(id => id !== l.id) : [...labelFilter, l.id])}
aria-pressed={on}
>
<span className="col-labelchip-dot" />
<span className="col-filter-lbl">{l.name}</span>
{l.count > 0 && <span className="col-filter-count">{l.count}</span>}
</button>
)
})}
{canManageLabels && (
<button type="button" className="col-labelchip col-labelchip-manage" 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>
)}
</div>
)}
</div>
)
}
@@ -65,7 +65,6 @@ function renderList(over: Partial<{
render(
<CollectionList
places={places}
labels={[]}
selectedPlaceId={over.selectedPlaceId ?? null}
selectMode={over.selectMode ?? false}
selectedIds={over.selectedIds ?? []}
@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useRef } from 'react'
import React, { useEffect, useRef } from 'react'
import { Check, MapPin } from 'lucide-react'
import type { CollectionPlace, CollectionStatus, CollectionLabel } from '@trek/shared'
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
import type { TranslationFn } from '../../types'
import PlaceAvatar from '../shared/PlaceAvatar'
import { getCategoryIcon } from '../shared/categoryIcons'
@@ -8,7 +8,6 @@ import StatusBadge from './StatusBadge'
interface CollectionListProps {
places: CollectionPlace[]
labels: CollectionLabel[]
selectedPlaceId: number | null
selectMode: boolean
selectedIds: number[]
@@ -24,9 +23,8 @@ interface CollectionListProps {
* open the place (or toggle it in select mode).
*/
export default function CollectionList({
places, labels, selectedPlaceId, selectMode, selectedIds, onOpenPlace, onStatusChange, onToggleSelect, t,
places, selectedPlaceId, selectMode, selectedIds, onOpenPlace, onStatusChange, onToggleSelect, t,
}: CollectionListProps): React.ReactElement {
const labelsById = useMemo(() => new Map(labels.map(l => [l.id, l])), [labels])
// Bring the selected row into view — e.g. when it was picked from the map.
const selectedRef = useRef<HTMLDivElement>(null)
useEffect(() => {
@@ -38,7 +36,6 @@ export default function CollectionList({
{places.map(place => {
const selected = selectedIds.includes(place.id)
const active = selectedPlaceId === place.id
const placeLabels = (place.label_ids ?? []).map(id => labelsById.get(id)).filter(Boolean) as CollectionLabel[]
return (
<div
key={place.id}
@@ -64,12 +61,6 @@ export default function CollectionList({
)}
</div>
<div className="col-lrow-end">
{placeLabels.slice(0, 2).map(l => (
<span key={l.id} className="col-lrow-label" style={{ ['--label' as string]: l.color || 'var(--accent)' }} title={l.name}>
<span className="col-labelchip-dot" /> {l.name}
</span>
))}
{placeLabels.length > 2 && <span className="col-lrow-label more" title={placeLabels.map(l => l.name).join(', ')}>+{placeLabels.length - 2}</span>}
{place.category?.name && (() => {
const CatIcon = getCategoryIcon(place.category.icon ?? undefined)
return (
@@ -39,7 +39,6 @@ function renderDetail(overrides: Partial<Omit<DetailProps, 't'>> = {}) {
canEdit: true,
canDelete: true,
categories: [],
labels: [],
anchorRect: null,
onClose: vi.fn(),
onSetStatus: vi.fn(),
@@ -2,8 +2,8 @@ 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 type { CollectionPlace, CollectionStatus, CollectionLink, CollectionLabel } from '@trek/shared'
import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag } from 'lucide-react'
import type { CollectionPlace, CollectionStatus, CollectionLink } from '@trek/shared'
import type { Category, TranslationFn } from '../../types'
import MarkdownToolbar from '../Journey/MarkdownToolbar'
import { mapsApi } from '../../api/client'
@@ -22,13 +22,11 @@ interface CollectionPlaceDetailProps {
canEdit: boolean
canDelete: boolean
categories: Category[]
/** The active list's custom labels, for the assign chips. */
labels: CollectionLabel[]
/** When set, dock the sheet over that column (desktop split) instead of centred. */
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 }) => Promise<void>
onCopyToTrip: () => void
onRemove: () => void
t: TranslationFn
@@ -58,7 +56,7 @@ 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, anchorRect, onClose, onSetStatus, onSave, onCopyToTrip, onRemove, t,
}: CollectionPlaceDetailProps): React.ReactElement {
const toast = useToast()
const [editing, setEditing] = useState(false)
@@ -66,7 +64,6 @@ export default function CollectionPlaceDetail({
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 [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.
@@ -80,7 +77,6 @@ export default function CollectionPlaceDetail({
setCategoryId(place.category_id ?? null)
setDescription(place.description ?? '')
setLinks(place.links ?? [])
setLabelIds(place.label_ids ?? [])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [place.id])
@@ -100,15 +96,13 @@ export default function CollectionPlaceDetail({
const banner = place.image_url || fetchedPhoto
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 assignedLabels = labels.filter(l => (place.label_ids ?? []).includes(l.id))
const resetForm = () => { setEditing(false); setName(place.name); setCategoryId(place.category_id ?? null); setDescription(place.description ?? ''); setLinks(place.links ?? []) }
const save = async () => {
const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url)
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 })
setEditing(false)
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
@@ -167,22 +161,6 @@ export default function CollectionPlaceDetail({
})}
</div>
</div>
{/* Labels */}
{labels.length > 0 && (
<div className="col-detail-field">
<div className="col-detail-label"><Tags size={12} /> {t('collections.labels.title')}</div>
<div className="col-detail-cats">
{labels.map(l => {
const on = labelIds.includes(l.id)
return (
<button key={l.id} type="button" onClick={() => toggleLabel(l.id)} className={`col-detail-cat${on ? ' on' : ''}`} style={{ ['--cat' as string]: l.color || '#6366f1' }}>
<span className="col-labelchip-dot" /> {l.name}
</button>
)
})}
</div>
</div>
)}
{/* Description */}
<div className="col-detail-field">
<div className="col-detail-label">{t('collections.description')}</div>
@@ -206,15 +184,6 @@ export default function CollectionPlaceDetail({
</div>
) : (
<>
{assignedLabels.length > 0 && (
<div className="col-detail-labels">
{assignedLabels.map(l => (
<span key={l.id} className="col-labelchip on static" style={{ ['--label' as string]: l.color || 'var(--accent)' }}>
<span className="col-labelchip-dot" /> {l.name}
</span>
))}
</div>
)}
{place.description && (
<div className="col-detail-md collab-note-md">
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description}</Markdown>
@@ -1,139 +0,0 @@
import React, { useState } from 'react'
import { Plus, Trash2, Loader2 } from 'lucide-react'
import Modal from '../shared/Modal'
import type { CollectionLabel, CollectionLabelUpdateRequest } from '@trek/shared'
import type { TranslationFn } from '../../types'
const SWATCHES = ['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#ec4899', '#8b5cf6', '#64748b']
interface LabelManagerProps {
isOpen: boolean
labels: CollectionLabel[]
onCreate: (name: string, color?: string) => Promise<void> | void
onUpdate: (labelId: number, body: CollectionLabelUpdateRequest) => Promise<void> | void
onDelete: (labelId: number) => Promise<void> | void
onClose: () => void
t: TranslationFn
}
/** Swatch row shared by the create form and each row's recolor control. */
function Swatches({ value, onPick }: { value: string; onPick: (c: string) => void }): React.ReactElement {
return (
<div className="flex items-center gap-1.5 flex-wrap">
{SWATCHES.map(c => (
<button
key={c}
type="button"
onClick={() => onPick(c)}
className={`w-5 h-5 rounded-full border transition-transform ${value.toLowerCase() === c ? 'border-content scale-110' : 'border-transparent'}`}
style={{ background: c }}
aria-label={c}
/>
))}
</div>
)
}
/** One existing label: inline rename (save on blur/Enter), recolor, delete. */
function LabelRow({ label, onUpdate, onDelete, t }: {
label: CollectionLabel
onUpdate: LabelManagerProps['onUpdate']
onDelete: LabelManagerProps['onDelete']
t: TranslationFn
}): React.ReactElement {
const [name, setName] = useState(label.name)
const [color, setColor] = useState(label.color || '#6366f1')
const [busy, setBusy] = useState(false)
const commitName = async () => {
const trimmed = name.trim()
if (!trimmed || trimmed === label.name) { setName(label.name); return }
setBusy(true)
try { await onUpdate(label.id, { name: trimmed }) } finally { setBusy(false) }
}
const pickColor = async (c: string) => {
setColor(c)
setBusy(true)
try { await onUpdate(label.id, { color: c }) } finally { setBusy(false) }
}
return (
<div className="flex items-center gap-2 px-2.5 py-2 rounded-xl border border-edge bg-surface-card">
<span className="w-3.5 h-3.5 rounded-full shrink-0" style={{ background: color }} />
<input
value={name}
onChange={e => setName(e.target.value)}
onBlur={commitName}
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
maxLength={60}
className="flex-1 min-w-0 bg-transparent text-[13px] text-content outline-none"
aria-label={t('collections.labels.name')}
/>
<Swatches value={color} onPick={pickColor} />
{busy && <Loader2 size={14} className="animate-spin text-content-faint shrink-0" />}
<button type="button" onClick={() => onDelete(label.id)} className="p-1 text-content-faint hover:text-danger shrink-0" aria-label={t('common.delete')}>
<Trash2 size={14} />
</button>
</div>
)
}
/**
* Manage a list's custom labels create, rename, recolor and delete. Available
* to any member who can edit the list; the labels are shared by the whole list.
*/
export default function LabelManager({ isOpen, labels, onCreate, onUpdate, onDelete, onClose, t }: LabelManagerProps): React.ReactElement {
const [newName, setNewName] = useState('')
const [newColor, setNewColor] = useState(SWATCHES[0])
const [adding, setAdding] = useState(false)
const add = async () => {
const trimmed = newName.trim()
if (!trimmed || adding) return
setAdding(true)
try {
await onCreate(trimmed, newColor)
setNewName('')
setNewColor(SWATCHES[0])
} finally {
setAdding(false)
}
}
return (
<Modal isOpen={isOpen} onClose={onClose} title={t('collections.labels.manage')} size="sm">
<div className="flex flex-col gap-3">
{labels.length === 0 ? (
<p className="text-center text-[13px] text-content-faint py-4">{t('collections.labels.empty')}</p>
) : (
<div className="flex flex-col gap-1.5 max-h-[46vh] overflow-y-auto -mx-1 px-1">
{labels.map(l => <LabelRow key={l.id} label={l} onUpdate={onUpdate} onDelete={onDelete} t={t} />)}
</div>
)}
<div className="flex flex-col gap-2 pt-3 border-t border-edge">
<div className="flex items-center gap-2">
<span className="w-3.5 h-3.5 rounded-full shrink-0" style={{ background: newColor }} />
<input
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') add() }}
maxLength={60}
placeholder={t('collections.labels.namePlaceholder')}
className="flex-1 min-w-0 px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
/>
<button
type="button"
onClick={add}
disabled={!newName.trim() || adding}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-accent text-white text-[13px] font-semibold hover:opacity-90 disabled:opacity-50 shrink-0"
>
{adding ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />} {t('collections.labels.add')}
</button>
</div>
<Swatches value={newColor} onPick={setNewColor} />
</div>
</div>
</Modal>
)
}
@@ -1 +1 @@
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'])
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'])
@@ -1,23 +0,0 @@
import { describe, it, expect } from 'vitest';
import { isWalletPass } from './FileManager.helpers';
describe('isWalletPass (#1447)', () => {
it('detects by extension when the mime is unreliable', () => {
// Browsers frequently send octet-stream / empty for .pkpass uploads
expect(isWalletPass('application/octet-stream', 'boarding.pkpass')).toBe(true);
expect(isWalletPass(null, 'multi.pkpasses')).toBe(true);
expect(isWalletPass('', 'CAPS.PKPASS')).toBe(true);
});
it('falls back to the wallet MIME types when there is no extension', () => {
expect(isWalletPass('application/vnd.apple.pkpass', 'pass')).toBe(true);
expect(isWalletPass('application/vnd.apple.pkpasses', null)).toBe(true);
});
it('is false for non-wallet files', () => {
expect(isWalletPass('application/pdf', 'report.pdf')).toBe(false);
expect(isWalletPass('image/png', 'photo.png')).toBe(false);
expect(isWalletPass('text/markdown', 'notes.md')).toBe(false);
expect(isWalletPass(null, null)).toBe(false);
});
});
@@ -26,18 +26,6 @@ export function isMarkdown(mimeType?: string | null, name?: string | null) {
return !!mimeType && (mimeType === 'text/markdown' || mimeType === 'text/x-markdown')
}
/**
* Apple Wallet pass (#1447). Detected by EXTENSION first browsers often send an
* empty / octet-stream MIME for .pkpass falling back to the wallet MIME types.
* Wallet passes must be downloaded so the OS hands them to Apple Wallet rather
* than rendered in the in-app PDF preview.
*/
export function isWalletPass(mimeType?: string | null, name?: string | null) {
const ext = (name || '').toLowerCase().split('.').pop()
if (ext === 'pkpass' || ext === 'pkpasses') return true
return !!mimeType && (mimeType === 'application/vnd.apple.pkpass' || mimeType === 'application/vnd.apple.pkpasses')
}
export function getFileIcon(mimeType?: string | null) {
if (!mimeType) return File
if (mimeType === 'application/pdf') return FileText
@@ -15,15 +15,6 @@ vi.mock('../../api/authUrl', () => ({
getAuthUrl: vi.fn().mockResolvedValue('http://localhost/signed-url'),
}));
// Mock the blob download/open helpers so we can assert wallet passes are
// downloaded (#1447) rather than opened in the in-app PDF preview.
vi.mock('../../utils/fileDownload', () => ({
openFile: vi.fn().mockResolvedValue(undefined),
downloadFile: vi.fn().mockResolvedValue(undefined),
}));
import { openFile as openFileInTab } from '../../utils/fileDownload';
// Markdown pipeline mocked to render its children verbatim (the unified/ESM
// pipeline is heavy in jsdom) — we only assert the markdown text reaches the modal.
vi.mock('react-markdown', () => ({
@@ -322,21 +313,6 @@ describe('FileManager', () => {
});
});
it('FE-COMP-FILEMANAGER-035: pkpass click downloads via blob helper, not the PDF preview (#1447)', async () => {
const files = [buildFile({ id: 1, mime_type: 'application/octet-stream', original_name: 'boarding.pkpass', url: '/uploads/trips/1/boarding.pkpass' })];
render(<FileManager {...defaultProps} files={files} />);
const user = userEvent.setup();
await user.click(screen.getByText('boarding.pkpass'));
// Blob helper is called with the file url + name — the OS hands it to Wallet
await waitFor(() => {
expect(openFileInTab).toHaveBeenCalledWith('/uploads/trips/1/boarding.pkpass', 'boarding.pkpass');
});
// No PDF preview modal — the filename appears only once (in the list row)
expect(screen.getAllByText('boarding.pkpass').length).toBe(1);
});
it('FE-COMP-FILEMANAGER-015: file with uploader name shows avatar chip initials', () => {
const files = [buildFile({ uploaded_by_name: 'Alice Smith' })];
render(<FileManager {...defaultProps} files={files} />);
@@ -7,8 +7,7 @@ import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../ty
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { getAuthUrl } from '../../api/authUrl'
import { isImage, isMedia, isWalletPass } from './FileManager.helpers'
import { openFile as openFileInTab } from '../../utils/fileDownload'
import { isImage, isMedia } from './FileManager.helpers'
export interface FileManagerProps {
files?: TripFile[]
@@ -192,10 +191,6 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
if (isMedia(file.mime_type)) {
const idx = mediaFiles.findIndex(f => f.id === file.id)
setLightboxIndex(idx >= 0 ? idx : 0)
} else if (isWalletPass(file.mime_type, file.original_name)) {
// Download so the OS hands the pass to Apple Wallet (#1447) rather than
// forcing it into the in-app PDF preview.
openFileInTab(file.url, file.original_name).catch(() => {})
} else {
setPreviewFile(file)
}
@@ -102,7 +102,7 @@ describe('BottomNav', () => {
const user = userEvent.setup();
sessionStorage.setItem('trip-tab-42', 'transports');
render(<BottomNav />, { initialEntries: ['/trips/42'] });
await user.click(screen.getByRole('button', { name: 'Transport' }));
await user.click(screen.getByRole('button', { name: 'Manual Transport' }));
expect(mockNavigate).toHaveBeenCalledWith('/trips/42?create=transport');
});
+3 -6
View File
@@ -4,9 +4,8 @@ import { Link, useNavigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { usePluginStore } from '../../store/pluginStore'
import { useTranslation } from '../../i18n'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark, Blocks } from 'lucide-react'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import InAppNotificationBell from './InAppNotificationBell.tsx'
@@ -53,7 +52,6 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
// Only show 'global' type addons in the navbar — 'integration' addons have no dedicated page
const globalAddons = allAddons.filter((a: Addon) => a.type === 'global' && a.enabled)
const pagePlugins = usePluginStore(s => s.plugins).filter(p => p.type === 'page')
useEffect(() => {
if (user) loadAddons()
@@ -137,7 +135,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
{/* Centred liquid-glass tab menu (design handoff). Absolutely positioned so
the left brand block and the right action cluster keep their layout. */}
{(globalAddons.length > 0 || pagePlugins.length > 0) && !tripTitle && (
{globalAddons.length > 0 && !tripTitle && (
<div
className="trek-nav-pill"
style={{
@@ -149,8 +147,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
}}
>
{[{ id: '__trips', path: '/dashboard', label: t('nav.myTrips'), Icon: Briefcase },
...globalAddons.map(a => ({ id: a.id, path: `/${a.id}`, label: getAddonName(a), Icon: ADDON_ICONS[a.icon] || CalendarDays })),
...pagePlugins.map(p => ({ id: `plugin:${p.id}`, path: `/plugins/${p.id}`, label: p.name, Icon: Blocks }))
...globalAddons.map(a => ({ id: a.id, path: `/${a.id}`, label: getAddonName(a), Icon: ADDON_ICONS[a.icon] || CalendarDays }))
].map(tab => {
const isActive = location.pathname === tab.path
return (
+1 -41
View File
@@ -29,9 +29,7 @@ vi.mock('react-leaflet', () => ({
>
<button
data-testid="marker-hover-trigger"
// A real mouseover never bubbles as a click to the marker, so the
// hover-simulation must not trigger the marker's click handler.
onClick={(e: any) => { e.stopPropagation(); eventHandlers?.mouseover?.({ originalEvent: { clientX: 100, clientY: 100 } }) }}
onClick={() => eventHandlers?.mouseover?.({ originalEvent: { clientX: 100, clientY: 100 } })}
/>
{children}
</div>
@@ -247,44 +245,6 @@ describe('MapView', () => {
expect(mapMock.fitBounds.mock.calls.length).toBeGreaterThan(afterFirst)
})
it('FE-COMP-MAPVIEW-021: clicking a marker clears the hover tooltip (#1404)', async () => {
const user = userEvent.setup()
const places = [buildMapPlace({ id: 3, name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945 })]
render(<MapView places={places} onMarkerClick={vi.fn()} />)
await user.click(screen.getByTestId('marker-hover-trigger'))
expect(screen.getByTestId('tooltip')).toBeTruthy()
// The recenter that follows the click moves the marker out from under the
// cursor — no mouseout will ever fire, so the click itself must clear.
fireEvent.click(screen.getByTestId('marker'))
expect(screen.queryByTestId('tooltip')).toBeNull()
})
it('FE-COMP-MAPVIEW-022: camera movement clears the tooltip and suppresses re-show until it ends (#1404)', async () => {
const user = userEvent.setup()
const places = [buildMapPlace({ id: 4, name: 'Louvre', lat: 48.86, lng: 2.337 })]
render(<MapView places={places} />)
await user.click(screen.getByTestId('marker-hover-trigger'))
expect(screen.getByTestId('tooltip')).toBeTruthy()
const findHandler = (event: string) =>
mapMock.on.mock.calls.find(c => c[0] === event)?.[1] as (() => void) | undefined
const start = findHandler('movestart zoomstart')
const end = findHandler('moveend zoomend')
expect(start).toBeTypeOf('function')
expect(end).toBeTypeOf('function')
fireEvent.click(screen.getByTestId('marker-hover-trigger')) // ensure hover is showing
start!()
await waitFor(() => expect(screen.queryByTestId('tooltip')).toBeNull())
// during the pan animation a mouseover must not re-show the card
fireEvent.click(screen.getByTestId('marker-hover-trigger'))
expect(screen.queryByTestId('tooltip')).toBeNull()
// once the move ends, hover works again
end!()
await user.click(screen.getByTestId('marker-hover-trigger'))
expect(screen.getByTestId('tooltip')).toBeTruthy()
})
it('FE-COMP-MAPVIEW-020: a day fit expands to include the route once it arrives (#1128)', async () => {
const L = ((await import('leaflet')).default) as unknown as { latLngBounds: ReturnType<typeof vi.fn> }
const dayPlaces = [
+5 -39
View File
@@ -9,7 +9,6 @@ import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
import { mapsApi } from '../../api/client'
import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import ReservationOverlay from './ReservationOverlay'
import { useTransportRoutes } from '../../hooks/useTransportRoutes'
import type { Reservation } from '../../types'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
@@ -140,23 +139,6 @@ function createPoiIcon(category: string) {
return icon
}
// Clears the hover tooltip the moment the camera starts moving and suppresses
// re-showing it until the move ends: after a click-recenter the marker slides
// away under a stationary cursor, so the browser never fires mouseout — and
// mouseover/mousemove during the pan animation would immediately re-set the
// tooltip we just cleared (#1404).
function CameraHoverGuard({ movingRef, onMoveStart }: { movingRef: { current: boolean }; onMoveStart: () => void }) {
const map = useMap()
useEffect(() => {
const start = () => { movingRef.current = true; onMoveStart() }
const end = () => { movingRef.current = false }
map.on('movestart zoomstart', start)
map.on('moveend zoomend', end)
return () => { map.off('movestart zoomstart', start); map.off('moveend zoomend', end) }
}, [map, movingRef, onMoveStart])
return null
}
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
// OSM places for the visible area.
function ViewportController({ onViewportChange }: { onViewportChange?: (b: { south: number; west: number; north: number; east: number }) => void }) {
@@ -446,7 +428,6 @@ export const MapView = memo(function MapView({
reservations = [] as Reservation[],
showReservationStats = false,
visibleConnectionIds = [] as number[],
showTransitRoutes = true,
onReservationClick,
pois = [] as Poi[],
onPoiClick,
@@ -464,13 +445,10 @@ export const MapView = memo(function MapView({
</Marker>
)), [pois, onPoiClick])
const visibleReservations = useMemo(() => {
const set = new Set(visibleConnectionIds || [])
// Transit journeys ride the route toggle — they are part of the computed
// day route, so hiding the route hides them too (#1065).
return reservations.filter((r: Reservation) => (r.type === 'transit' && showTransitRoutes) || set.has(r.id))
}, [reservations, visibleConnectionIds, showTransitRoutes])
// Real road geometry for car/bus/taxi/bicycle bookings (straight line until it loads/if it fails).
const transportRoutes = useTransportRoutes(visibleReservations)
if (!visibleConnectionIds || visibleConnectionIds.length === 0) return []
const set = new Set(visibleConnectionIds)
return reservations.filter((r: Reservation) => set.has(r.id))
}, [reservations, visibleConnectionIds])
// Dynamic padding: account for sidebars + bottom inspector + day detail panel
const paddingOpts = useMemo((): L.FitBoundsOptions => {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
@@ -485,10 +463,9 @@ export const MapView = memo(function MapView({
// Hover state for the single tooltip overlay (replaces per-marker <Tooltip>)
const [hoveredPlace, setHoveredPlace] = useState<any>(null)
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null)
const mapMovingRef = useRef(false)
const handleMarkerHover = useCallback((place: any, x: number, y: number) => {
if (hoverDisabled || mapMovingRef.current) return
if (hoverDisabled) return
setHoveredPlace(place)
setTooltipPos({ x, y })
}, [hoverDisabled])
@@ -512,18 +489,9 @@ export const MapView = memo(function MapView({
}, [hoveredPlace])
const handleMarkerClick = useCallback((id: number) => {
// Clear the hover card right away: the recenter that follows moves the
// marker out from under the cursor, so no mouseout will ever fire (#1404).
setHoveredPlace(null)
setTooltipPos(null)
onMarkerClick?.(id)
}, [onMarkerClick])
const clearHover = useCallback(() => {
setHoveredPlace(null)
setTooltipPos(null)
}, [])
// photoUrls: only base64 thumbs for smooth map zoom
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
@@ -674,7 +642,6 @@ export const MapView = memo(function MapView({
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} />
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
<CameraHoverGuard movingRef={mapMovingRef} onMoveStart={clearHover} />
<ViewportController onViewportChange={onViewportChange} />
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
@@ -715,7 +682,6 @@ export const MapView = memo(function MapView({
showConnections
showStats={showReservationStats}
onEndpointClick={onReservationClick}
roadRoutes={transportRoutes}
/>
{poiMarkers}
+2 -176
View File
@@ -6,10 +6,7 @@ import { resetAllStores } from '../../../tests/helpers/store'
import { buildPlace } from '../../../tests/helpers/factories'
import { useSettingsStore } from '../../store/settingsStore'
// Stable fake map so fitBounds call counts survive re-renders. The canvas
// container is a single element so listeners registered by the component are
// reachable from tests via dispatchEvent.
const glCanvasContainer = vi.hoisted(() => document.createElement('div'))
// Stable fake map so fitBounds call counts survive re-renders.
const glMap = vi.hoisted(() => ({
on: vi.fn(),
off: vi.fn(),
@@ -28,12 +25,10 @@ const glMap = vi.hoisted(() => ({
setLayoutProperty: vi.fn(),
getStyle: vi.fn().mockReturnValue({ layers: [] }),
isStyleLoaded: vi.fn().mockReturnValue(true),
getCanvasContainer: vi.fn(() => glCanvasContainer),
getCanvasContainer: vi.fn(() => document.createElement('div')),
getLayer: vi.fn().mockReturnValue(null),
queryRenderedFeatures: vi.fn().mockReturnValue([]),
querySourceFeatures: vi.fn().mockReturnValue([]),
unproject: vi.fn(() => ({ lng: 2.3522, lat: 48.8566 })),
getBounds: vi.fn(() => ({ getSouth: () => 0, getWest: () => 0, getNorth: () => 1, getEast: () => 1 })),
easeTo: vi.fn(),
}))
@@ -270,173 +265,4 @@ describe('MapViewGL', () => {
expect(glMap.addLayer).toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-place-clusters-circle' }))
expect(glMap.addLayer).toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-place-clusters-count' }))
})
function touchEvent(type: string, touches: Array<{ clientX: number; clientY: number }>) {
const ev = new Event(type, { bubbles: true })
Object.defineProperty(ev, 'touches', { value: touches })
return ev
}
it('FE-COMP-MAPVIEWGL-006: touch long-press opens Add-Place at the held position (#1398)', async () => {
vi.useFakeTimers()
try {
const onContext = vi.fn()
render(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} />)
await act(async () => {})
act(() => {
glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 30, clientY: 40 }]))
vi.advanceTimersByTime(650)
})
expect(onContext).toHaveBeenCalledTimes(1)
expect(onContext.mock.calls[0][0].latlng).toEqual({ lat: 48.8566, lng: 2.3522 })
} finally {
vi.useRealTimers()
}
})
it('FE-COMP-MAPVIEWGL-007: a moving finger (pan) cancels the long-press (#1398)', async () => {
vi.useFakeTimers()
try {
const onContext = vi.fn()
render(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} />)
await act(async () => {})
act(() => {
glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 30, clientY: 40 }]))
glCanvasContainer.dispatchEvent(touchEvent('touchmove', [{ clientX: 60, clientY: 90 }]))
vi.advanceTimersByTime(650)
})
expect(onContext).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('FE-COMP-MAPVIEWGL-008: a second finger (pinch) cancels the long-press (#1398)', async () => {
vi.useFakeTimers()
try {
const onContext = vi.fn()
render(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} />)
await act(async () => {})
act(() => {
glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 30, clientY: 40 }]))
glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 30, clientY: 40 }, { clientX: 80, clientY: 40 }]))
vi.advanceTimersByTime(650)
})
expect(onContext).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('FE-COMP-MAPVIEWGL-009: a plain right-click (map contextmenu event) opens Add-Place, deduped (#1398)', async () => {
const onContext = vi.fn()
render(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} />)
await act(async () => {})
const handler = glMap.on.mock.calls.find(c => c[0] === 'contextmenu')?.[1] as (e: unknown) => void
expect(handler).toBeTypeOf('function')
act(() => {
handler({ lngLat: { lat: 48.8566, lng: 2.3522 }, originalEvent: new MouseEvent('contextmenu') })
// Android long-press fires the native contextmenu on top of our timer —
// a second event inside the dedupe window must not open a second form.
handler({ lngLat: { lat: 48.8566, lng: 2.3522 }, originalEvent: new MouseEvent('contextmenu') })
})
expect(onContext).toHaveBeenCalledTimes(1)
expect(onContext.mock.calls[0][0].latlng).toEqual({ lat: 48.8566, lng: 2.3522 })
})
it('FE-COMP-MAPVIEWGL-012: a right-button rotate/pitch drag does not open Add-Place on release (#1398)', async () => {
const onContext = vi.fn()
render(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} />)
await act(async () => {})
const handler = glMap.on.mock.calls.find(c => c[0] === 'contextmenu')?.[1] as (e: unknown) => void
act(() => {
// mapbox-gl (unlike maplibre) still emits contextmenu after a right-drag
// on Windows — the movement guard must drop it.
glCanvasContainer.dispatchEvent(new MouseEvent('mousedown', { button: 2, clientX: 10, clientY: 10, bubbles: true }))
handler({ lngLat: { lat: 1, lng: 2 }, originalEvent: new MouseEvent('contextmenu', { clientX: 140, clientY: 90 }) })
})
expect(onContext).not.toHaveBeenCalled()
// ...while a stationary right-click still fires.
act(() => {
glCanvasContainer.dispatchEvent(new MouseEvent('mousedown', { button: 2, clientX: 10, clientY: 10, bubbles: true }))
handler({ lngLat: { lat: 1, lng: 2 }, originalEvent: new MouseEvent('contextmenu', { clientX: 11, clientY: 10 }) })
})
expect(onContext).toHaveBeenCalledTimes(1)
})
it('FE-COMP-MAPVIEWGL-013: a stale long-press suppression never swallows a later real tap (#1398)', async () => {
vi.useFakeTimers()
try {
const onContext = vi.fn()
const onMapClick = vi.fn()
render(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} onMapClick={onMapClick} />)
await act(async () => {})
// Long-press fires (arms the suppression), but no click follows.
act(() => {
glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 30, clientY: 40 }]))
vi.advanceTimersByTime(650)
})
expect(onContext).toHaveBeenCalledTimes(1)
// The NEXT gesture starts fresh: its tap must reach the map click handler.
const clickHandler = glMap.on.mock.calls.find(c => c[0] === 'click')?.[1] as (e: unknown) => void
act(() => {
glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 80, clientY: 90 }]))
glCanvasContainer.dispatchEvent(touchEvent('touchend', []))
clickHandler({ lngLat: { lat: 3, lng: 4 }, originalEvent: { target: glCanvasContainer } })
})
expect(onMapClick).toHaveBeenCalledWith({ latlng: { lat: 3, lng: 4 } })
} finally {
vi.useRealTimers()
}
})
it('FE-COMP-MAPVIEWGL-010: middle-click still opens Add-Place (#1398 regression guard)', async () => {
const onContext = vi.fn()
render(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} />)
await act(async () => {})
act(() => {
glCanvasContainer.dispatchEvent(new MouseEvent('mousedown', { button: 1, bubbles: true }))
})
expect(onContext).toHaveBeenCalledTimes(1)
})
it('FE-COMP-MAPVIEWGL-011: clicking a marker clears the hover card; movestart clears + suppresses it (#1404)', async () => {
// Markers are only reconciled once the style has loaded — fire 'load' like GL-005 does.
glMap.on.mockImplementation((event: string, handlerOrLayer: unknown) => {
if (event === 'load' && typeof handlerOrLayer === 'function') (handlerOrLayer as () => void)()
return glMap
})
const mapboxgl = (await import('mapbox-gl')).default
const places = [buildMapPlace({ id: 7, lat: 48.8584, lng: 2.2945, name: 'Tour Eiffel' })]
const { queryByTestId } = render(<MapViewGL places={places} fitKey={1} onMarkerClick={vi.fn()} />)
await act(async () => {})
const markerCall = (mapboxgl.Marker as unknown as ReturnType<typeof vi.fn>).mock.calls
.find(c => c[0]?.element)
expect(markerCall).toBeTruthy()
const el = markerCall![0].element as HTMLElement
// hover shows the card
act(() => { el.dispatchEvent(new MouseEvent('mouseenter', { clientX: 10, clientY: 10 })) })
expect(queryByTestId('tooltip')).toBeTruthy()
// click clears it (the flyTo that follows moves the marker away, no mouseleave will come)
act(() => { el.dispatchEvent(new MouseEvent('click', { bubbles: false })) })
expect(queryByTestId('tooltip')).toBeNull()
// hover again, then camera movement clears + suppresses
act(() => { el.dispatchEvent(new MouseEvent('mouseenter', { clientX: 10, clientY: 10 })) })
expect(queryByTestId('tooltip')).toBeTruthy()
const moveStart = glMap.on.mock.calls.find(c => c[0] === 'movestart')?.[1] as () => void
const moveEnds = glMap.on.mock.calls.filter(c => c[0] === 'moveend').map(c => c[1] as () => void)
act(() => { moveStart() })
expect(queryByTestId('tooltip')).toBeNull()
// while the camera is moving, a re-fired mouseenter must not bring it back
act(() => { el.dispatchEvent(new MouseEvent('mouseenter', { clientX: 10, clientY: 10 })) })
expect(queryByTestId('tooltip')).toBeNull()
// after the move ends, hover works again
act(() => { moveEnds.forEach(fn => fn()) })
act(() => { el.dispatchEvent(new MouseEvent('mouseenter', { clientX: 10, clientY: 10 })) })
expect(queryByTestId('tooltip')).toBeTruthy()
})
})
+17 -119
View File
@@ -11,7 +11,6 @@ import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
import { ReservationMapboxOverlay } from './reservationsMapbox'
import { useTransportRoutes } from '../../hooks/useTransportRoutes'
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders'
import LocationButton from './LocationButton'
import { useGeolocation } from '../../hooks/useGeolocation'
@@ -71,7 +70,7 @@ interface Props {
onMarkerClick?: (id: number) => void
hoverDisabled?: boolean
onMapClick?: (info: { latlng: { lat: number; lng: number } }) => void
onMapContextMenu?: ((e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent | TouchEvent }) => void) | null
onMapContextMenu?: ((e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => void) | null
center?: [number, number]
zoom?: number
fitKey?: number | null
@@ -82,7 +81,6 @@ interface Props {
hasDayDetail?: boolean
reservations?: Reservation[]
visibleConnectionIds?: number[]
showTransitRoutes?: boolean
showReservationStats?: boolean
onReservationClick?: (reservationId: number) => void
pois?: Poi[]
@@ -201,7 +199,6 @@ export function MapViewGL({
hasDayDetail = false,
reservations = [],
visibleConnectionIds = [],
showTransitRoutes = true,
showReservationStats = false,
onReservationClick,
pois = [],
@@ -215,7 +212,7 @@ export function MapViewGL({
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) === true
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) !== false
const mapLang = useSettingsStore(s => s.settings.language)
const isMapLibre = glProvider === 'maplibre-gl'
const gl = (isMapLibre ? maplibregl : mapboxgl) as any
@@ -229,10 +226,6 @@ export function MapViewGL({
const [hoverPlace, setHoverPlace] = useState<(Place & { category_color?: string | null; category_icon?: string | null; category_name?: string | null }) | null>(null)
const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null)
const hoverIdRef = useRef<number | null>(null)
// True while the camera is moving (flyTo after a click, pan, zoom). Marker
// elements get rebuilt during the move and re-fire mouseenter under a
// stationary cursor, which would re-show the card we just cleared (#1404).
const camMovingRef = useRef(false)
// Selecting a place rebuilds its marker element, so the browser never fires
// mouseleave on the removed node and the fixed-position hover card gets
@@ -453,13 +446,7 @@ export function MapViewGL({
setMapReady(true)
})
// Set by the long-press handler below: the touchend tap that follows a
// long-press must not count as a normal map click (#1398).
let suppressNextClick = false
map.on('click', (e) => {
// The tap that ends a long-press would otherwise land here and clear
// the selection right after the Add-Place form opened (#1398).
if (suppressNextClick) { suppressNextClick = false; return }
const t = e.originalEvent.target as HTMLElement
if (t.closest('.mapboxgl-marker, .maplibregl-marker')) return // markers handle their own click
// A click that lands on a cluster bubble is the cluster's to handle
@@ -480,52 +467,19 @@ export function MapViewGL({
}
map.on('moveend', emitViewport)
map.once('idle', emitViewport)
// Clear the hover card (and the anchored POI popup) as soon as the camera
// starts moving, and keep hover suppressed until it stops: the marker
// slides away under a stationary cursor, so mouseleave never fires (#1404).
const onCamStart = () => {
camMovingRef.current = true
hoverIdRef.current = null
setHoverPlace(null)
setHoverPos(null)
popupRef.current?.remove()
}
const onCamEnd = () => { camMovingRef.current = false }
map.on('movestart', onCamStart)
map.on('moveend', onCamEnd)
// "Add place here" on the GL map (#1398). Three routes into one handler:
// middle-click (the original binding), a plain right-click via the map's
// own contextmenu event — both GL libs suppress that event while the
// right-button rotate/pitch drag is active, so it can't fight the gesture,
// and it also covers Mac ctrl-click / two-finger tap — and a touch
// long-press, which neither GL lib synthesizes into contextmenu (Leaflet
// does, which is why the OSM map already worked on mobile).
// In the GL map the right mouse button is reserved for the
// built-in rotate/pitch gesture, so we bind the "add place" action
// to the middle mouse button (button === 1) instead.
const canvas = map.getCanvasContainer()
let lastContextFire = 0
const fireContext = (lngLat: { lat: number; lng: number }, originalEvent: MouseEvent | TouchEvent): boolean => {
// Android fires a native contextmenu for a long-press on top of our own
// timer — dedupe so the form doesn't open twice.
if (Date.now() - lastContextFire < 700) return false
lastContextFire = Date.now()
onClickRefs.current.context?.({ latlng: { lat: lngLat.lat, lng: lngLat.lng }, originalEvent })
return true
}
// MapLibre swallows the map contextmenu at the end of a right-button
// rotate/pitch drag, but mapbox-gl does NOT — and on Windows the DOM
// contextmenu arrives after mouseup, so every rotate would end by opening
// the Add-Place form. Track the right-button press position and drop a
// contextmenu whose pointer travelled like a drag rather than a click.
let rightDownAt: { x: number; y: number } | null = null
const onAuxDown = (ev: MouseEvent) => {
if (ev.button === 2) {
rightDownAt = { x: ev.clientX, y: ev.clientY }
return
}
if (ev.button !== 1) return
ev.preventDefault()
const rect = canvas.getBoundingClientRect()
const lngLat = map.unproject([ev.clientX - rect.left, ev.clientY - rect.top])
fireContext({ lat: lngLat.lat, lng: lngLat.lng }, ev)
onClickRefs.current.context?.({
latlng: { lat: lngLat.lat, lng: lngLat.lng },
originalEvent: ev,
})
}
// Also suppress the browser's native auxclick menu on middle-click.
const onAuxClick = (ev: MouseEvent) => {
@@ -533,49 +487,6 @@ export function MapViewGL({
}
canvas.addEventListener('mousedown', onAuxDown)
canvas.addEventListener('auxclick', onAuxClick)
map.on('contextmenu', (e: { lngLat: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
const down = rightDownAt
rightDownAt = null
if (down && Math.hypot(e.originalEvent.clientX - down.x, e.originalEvent.clientY - down.y) > 5) return
fireContext(e.lngLat, e.originalEvent)
})
// Touch long-press: 600 ms hold (Leaflet's tapHold feel) with a 10 px
// move tolerance so slow pans and pinches don't open the form.
let lpTimer: number | null = null
let lpStart: { x: number; y: number } | null = null
const cancelLongPress = () => {
if (lpTimer !== null) window.clearTimeout(lpTimer)
lpTimer = null
lpStart = null
}
const onTouchStart = (ev: TouchEvent) => {
// A fresh gesture clears a stale suppression flag: not every long-press
// is followed by a click (finger drag after the hold, Android's native
// contextmenu path), and the flag must never swallow a later real tap.
suppressNextClick = false
if (ev.touches.length !== 1) { cancelLongPress(); return }
if ((ev.target as HTMLElement).closest('.mapboxgl-marker, .maplibregl-marker')) return
const t = ev.touches[0]
lpStart = { x: t.clientX, y: t.clientY }
lpTimer = window.setTimeout(() => {
lpTimer = null
if (!lpStart) return
const rect = canvas.getBoundingClientRect()
const lngLat = map.unproject([lpStart.x - rect.left, lpStart.y - rect.top])
lpStart = null
// Only suppress the tap when OUR fire opened the form — if the native
// contextmenu beat us to it (dedupe), no click needs swallowing.
if (fireContext({ lat: lngLat.lat, lng: lngLat.lng }, ev)) suppressNextClick = true
}, 600)
}
const onTouchMove = (ev: TouchEvent) => {
const t = ev.touches[0]
if (lpStart && (!t || Math.hypot(t.clientX - lpStart.x, t.clientY - lpStart.y) > 10)) cancelLongPress()
}
canvas.addEventListener('touchstart', onTouchStart, { passive: true })
canvas.addEventListener('touchmove', onTouchMove, { passive: true })
canvas.addEventListener('touchend', cancelLongPress)
canvas.addEventListener('touchcancel', cancelLongPress)
// Drop follow mode if the user pans the map manually — matches the
// Apple Maps behaviour where the blue dot stays but the map no longer
@@ -624,11 +535,6 @@ export function MapViewGL({
return () => {
canvas.removeEventListener('mousedown', onAuxDown)
canvas.removeEventListener('auxclick', onAuxClick)
canvas.removeEventListener('touchstart', onTouchStart)
canvas.removeEventListener('touchmove', onTouchMove)
canvas.removeEventListener('touchend', cancelLongPress)
canvas.removeEventListener('touchcancel', cancelLongPress)
cancelLongPress()
markersRef.current.forEach(m => m.remove())
markersRef.current.clear()
if (popupRef.current) { popupRef.current.remove(); popupRef.current = null }
@@ -744,21 +650,16 @@ export function MapViewGL({
const el = createMarkerElement(place as Place & { category_color?: string; category_icon?: string }, photoUrl, orderNumbers, selected)
el.addEventListener('click', (ev) => {
ev.stopPropagation()
// Clear the card right away — the flyTo that follows moves the marker
// out from under the cursor and mouseleave never fires (#1404).
hoverIdRef.current = null
setHoverPlace(null)
setHoverPos(null)
onClickRefs.current.marker?.(place.id)
})
el.addEventListener('mouseenter', (ev) => {
if (hoverDisabledRef.current || camMovingRef.current) return
if (hoverDisabledRef.current) return
hoverIdRef.current = place.id
setHoverPlace(place as Place & { category_color?: string; category_icon?: string; category_name?: string })
setHoverPos({ x: (ev as MouseEvent).clientX, y: (ev as MouseEvent).clientY })
})
el.addEventListener('mousemove', (ev) => {
if (hoverDisabledRef.current || camMovingRef.current) return
if (hoverDisabledRef.current) return
setHoverPos({ x: (ev as MouseEvent).clientX, y: (ev as MouseEvent).clientY })
})
el.addEventListener('mouseleave', () => {
@@ -899,13 +800,10 @@ export function MapViewGL({
// DayPlanSidebar — nothing is rendered until the user enables a
// booking's route, matching the Leaflet MapView's behaviour.
const visibleReservations = useMemo(() => {
const set = new Set(visibleConnectionIds || [])
// Transit journeys ride the route toggle — they are part of the computed
// day route, so hiding the route hides them too (#1065).
return reservations.filter(r => (r.type === 'transit' && showTransitRoutes) || set.has(r.id))
}, [reservations, visibleConnectionIds, showTransitRoutes])
// Real road geometry for car/bus/taxi/bicycle bookings (straight line until it loads/if it fails).
const transportRoutes = useTransportRoutes(visibleReservations)
if (!visibleConnectionIds || visibleConnectionIds.length === 0) return []
const set = new Set(visibleConnectionIds)
return reservations.filter(r => set.has(r.id))
}, [reservations, visibleConnectionIds])
useEffect(() => {
const map = mapRef.current
@@ -923,8 +821,8 @@ export function MapViewGL({
showStats: showReservationStats,
showEndpointLabels,
onEndpointClick: (id) => onReservationClickRef.current?.(id),
}, transportRoutes)
}, [visibleReservations, transportRoutes, showReservationStats, showEndpointLabels, mapReady, glProvider])
})
}, [visibleReservations, showReservationStats, showEndpointLabels, mapReady, glProvider])
// Fit bounds on fitKey change — matches the Leaflet BoundsController
const paddingOpts = useMemo(() => {
@@ -1,11 +1,9 @@
import { Fragment, createElement, useEffect, useMemo, useRef, useState } from 'react'
import { createElement, useEffect, useMemo, useRef, useState } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet'
import L from 'leaflet'
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront } from 'lucide-react'
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
import { escapeHtml } from '@trek/shared'
import { getTransitMapSegments, type TransitMapSegment } from './transitGeometry'
import { geodesicArcs } from './flightGeodesy'
import { useSettingsStore } from '../../store/settingsStore'
import type { Reservation, ReservationEndpoint } from '../../types'
@@ -13,8 +11,8 @@ const ENDPOINT_PANE = 'reservation-endpoints'
const AIRPORT_BADGE_HALF_PX = 16
const BADGE_GAP_PX = 5
type TransportType = 'flight' | 'train' | 'cruise' | 'car' | 'bus' | 'taxi' | 'bicycle' | 'ferry' | 'transit' | 'transport_other'
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car', 'bus', 'taxi', 'bicycle', 'ferry', 'transit', 'transport_other']
type TransportType = 'flight' | 'train' | 'cruise' | 'car' | 'bus' | 'taxi' | 'bicycle' | 'ferry' | 'transport_other'
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car', 'bus', 'taxi', 'bicycle', 'ferry', 'transport_other']
const TRANSPORT_COLOR = '#3b82f6'
@@ -27,7 +25,6 @@ const TYPE_META: Record<TransportType, { color: string; icon: typeof Plane; geod
taxi: { color: TRANSPORT_COLOR, icon: CarTaxiFront, geodesic: false },
bicycle: { color: TRANSPORT_COLOR, icon: Bike, geodesic: false },
ferry: { color: TRANSPORT_COLOR, icon: Sailboat, geodesic: true },
transit: { color: TRANSPORT_COLOR, icon: TramFront, geodesic: false },
transport_other: { color: TRANSPORT_COLOR, icon: Route, geodesic: false },
}
@@ -65,6 +62,41 @@ function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
}
function toRad(d: number) { return d * Math.PI / 180 }
function toDeg(r: number) { return r * 180 / Math.PI }
function greatCircle(a: [number, number], b: [number, number], steps = 256): [number, number][] {
const [lat1, lng1] = [toRad(a[0]), toRad(a[1])]
const [lat2, lng2] = [toRad(b[0]), toRad(b[1])]
const d = 2 * Math.asin(Math.sqrt(Math.sin((lat2 - lat1) / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin((lng2 - lng1) / 2) ** 2))
if (d === 0) return [a, b]
const pts: [number, number][] = []
for (let i = 0; i <= steps; i++) {
const f = i / steps
const A = Math.sin((1 - f) * d) / Math.sin(d)
const B = Math.sin(f * d) / Math.sin(d)
const x = A * Math.cos(lat1) * Math.cos(lng1) + B * Math.cos(lat2) * Math.cos(lng2)
const y = A * Math.cos(lat1) * Math.sin(lng1) + B * Math.cos(lat2) * Math.sin(lng2)
const z = A * Math.sin(lat1) + B * Math.sin(lat2)
const lat = Math.atan2(z, Math.sqrt(x * x + y * y))
const lng = Math.atan2(y, x)
pts.push([toDeg(lat), toDeg(lng)])
}
return pts
}
function splitAntimeridian(points: [number, number][]): [number, number][][] {
const segments: [number, number][][] = []
let cur: [number, number][] = []
for (let i = 0; i < points.length; i++) {
if (i > 0 && Math.abs(points[i][1] - points[i - 1][1]) > 180) {
if (cur.length > 1) segments.push(cur)
cur = []
}
cur.push(points[i])
}
if (cur.length > 1) segments.push(cur)
return segments
}
function cleanName(name: string): string {
return name.replace(/\s*\([^)]*\)/g, '').trim()
@@ -129,7 +161,6 @@ interface TransportItem {
waypoints: ReservationEndpoint[]
type: TransportType
arcs: [number, number][][]
transitSegs: TransitMapSegment[]
primaryArc: [number, number][]
fallback: [number, number]
mainLabel: string | null
@@ -308,19 +339,16 @@ interface Props {
showConnections: boolean
showStats: boolean
onEndpointClick?: (reservationId: number) => void
// Real road-network geometry for car/bus/taxi/bicycle bookings, keyed by
// reservation id. When present it is drawn instead of the straight arc.
roadRoutes?: Map<number, [number, number][]>
}
export default function ReservationOverlay({ reservations, showConnections, showStats, onEndpointClick, roadRoutes }: Props) {
export default function ReservationOverlay({ reservations, showConnections, showStats, onEndpointClick }: Props) {
useEndpointPane()
const map = useMap()
const [zoom, setZoom] = useState(() => map.getZoom())
useMapEvents({
zoomend: () => setZoom(map.getZoom()),
})
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) === true
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) !== false
const items = useMemo<TransportItem[]>(() => {
const out: TransportItem[] = []
@@ -344,7 +372,7 @@ export default function ReservationOverlay({ reservations, showConnections, show
const a = waypoints[i]
const b = waypoints[i + 1]
const segArcs = isGeo
? geodesicArcs([a.lat, a.lng], [b.lat, b.lng], true)
? splitAntimeridian(greatCircle([a.lat, a.lng], [b.lat, b.lng]))
: [[[a.lat, a.lng], [b.lat, b.lng]] as [number, number][]]
arcs.push(...segArcs)
distanceKm += haversineKm([a.lat, a.lng], [b.lat, b.lng])
@@ -364,7 +392,7 @@ export default function ReservationOverlay({ reservations, showConnections, show
const subParts = [duration, distance].filter(Boolean) as string[]
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
out.push({ res: r, from, to, waypoints, type, arcs, transitSegs: type === 'transit' ? getTransitMapSegments(r) : [], primaryArc, fallback, mainLabel, subLabel })
out.push({ res: r, from, to, waypoints, type, arcs, primaryArc, fallback, mainLabel, subLabel })
}
return out
}, [reservations])
@@ -383,7 +411,7 @@ export default function ReservationOverlay({ reservations, showConnections, show
for (const item of visibleItems) {
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : item.type === 'transit' ? 900 : 400
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : 400
if (fromPx.distanceTo(toPx) >= minPx) set.add(item.res.id)
}
return set
@@ -393,41 +421,18 @@ export default function ReservationOverlay({ reservations, showConnections, show
return (
<>
{visibleItems.map(item => {
if (item.transitSegs.length > 0) {
return item.transitSegs.map((seg, segIdx) => (
<Fragment key={`transit-${item.res.id}-${segIdx}`}>
{!seg.walk && (
<Polyline
positions={seg.coords}
pathOptions={{ color: '#ffffff', weight: 6, opacity: 0.85, lineCap: 'round', lineJoin: 'round' }}
/>
)}
<Polyline
positions={seg.coords}
pathOptions={seg.walk
? { color: '#64748b', weight: 3, opacity: 0.8, dashArray: '1, 7', lineCap: 'round' }
: { color: seg.color || TYPE_META.transit.color, weight: 3.5, opacity: 0.95, lineCap: 'round', lineJoin: 'round' }}
/>
</Fragment>
))
}
// Prefer the real road route (car/bus/taxi/bicycle) over the straight arc.
const road = roadRoutes?.get(item.res.id)
const lines = road && road.length >= 2 ? [road] : item.arcs
return lines.map((seg, segIdx) => (
<Polyline
key={`line-${item.res.id}-${segIdx}`}
positions={seg}
pathOptions={{
color: TYPE_META[item.type].color,
weight: 2.5,
opacity: item.res.status === 'confirmed' ? 0.75 : 0.55,
dashArray: item.res.status === 'confirmed' ? undefined : '6, 6',
}}
/>
))
})}
{visibleItems.map(item => item.arcs.map((seg, segIdx) => (
<Polyline
key={`line-${item.res.id}-${segIdx}`}
positions={seg}
pathOptions={{
color: TYPE_META[item.type].color,
weight: 2.5,
opacity: item.res.status === 'confirmed' ? 0.75 : 0.55,
dashArray: item.res.status === 'confirmed' ? undefined : '6, 6',
}}
/>
)))}
{visibleItems.flatMap(item => item.waypoints.map((wp, wi) => (
<Marker
@@ -1,63 +0,0 @@
import { describe, it, expect } from 'vitest'
import { greatCircle, unwrapLngs, geodesicArcs } from './flightGeodesy'
const YVR: [number, number] = [49.19, -123.18]
const ICN: [number, number] = [37.46, 126.44]
const FRA: [number, number] = [50.03, 8.57]
const JFK: [number, number] = [40.64, -73.78]
const maxConsecutiveDeltaLng = (pts: [number, number][]) =>
pts.reduce((max, p, i) => (i === 0 ? 0 : Math.max(max, Math.abs(p[1] - pts[i - 1][1]))), 0)
describe('flightGeodesy (#1411)', () => {
it('a date-line crossing unwraps into one continuous arc plus a shifted copy for Leaflet', () => {
const arcs = geodesicArcs(YVR, ICN, true)
expect(arcs).toHaveLength(2)
const [base, shifted] = arcs
// continuous: no ±360 jump anywhere
expect(maxConsecutiveDeltaLng(base)).toBeLessThan(180)
// endpoints: starts at YVR, ends at ICN unwrapped westwards (126.44 - 360)
expect(base[0][0]).toBeCloseTo(YVR[0], 5)
expect(base[0][1]).toBeCloseTo(YVR[1], 5)
expect(base[base.length - 1][1]).toBeCloseTo(ICN[1] - 360, 5)
// the copy is the base shifted by exactly +360
expect(shifted).toHaveLength(base.length)
shifted.forEach(([lat, lng], i) => {
expect(lat).toBe(base[i][0])
expect(lng).toBeCloseTo(base[i][1] + 360, 10)
})
})
it('an eastbound crossing unwraps upwards and shifts by -360', () => {
const arcs = geodesicArcs(ICN, YVR, true)
expect(arcs).toHaveLength(2)
const [base, shifted] = arcs
expect(maxConsecutiveDeltaLng(base)).toBeLessThan(180)
expect(base[base.length - 1][1]).toBeCloseTo(YVR[1] + 360, 5)
expect(shifted[0][1]).toBeCloseTo(base[0][1] - 360, 10)
})
it('a non-crossing flight stays a single in-range arc', () => {
const arcs = geodesicArcs(FRA, JFK, true)
expect(arcs).toHaveLength(1)
for (const [, lng] of arcs[0]) {
expect(lng).toBeGreaterThanOrEqual(-180)
expect(lng).toBeLessThanOrEqual(180)
}
})
it('GL mode always returns exactly one continuous arc (world copies handle the wrap)', () => {
const arcs = geodesicArcs(YVR, ICN, false)
expect(arcs).toHaveLength(1)
expect(maxConsecutiveDeltaLng(arcs[0])).toBeLessThan(180)
})
it('a zero-distance leg passes the endpoints through', () => {
expect(greatCircle(YVR, YVR)).toEqual([YVR, YVR])
})
it('unwrapLngs keeps already-continuous input unchanged', () => {
const pts: [number, number][] = [[0, 170], [0, 175], [0, 179]]
expect(unwrapLngs(pts)).toEqual(pts)
})
})
@@ -1,61 +0,0 @@
// Great-circle geometry for transport routes (flights, cruises, ferries),
// shared by the Leaflet and Mapbox/MapLibre renderers (#1411).
const toRad = (d: number) => d * Math.PI / 180
const toDeg = (r: number) => r * 180 / Math.PI
export function greatCircle(a: [number, number], b: [number, number], steps = 256): [number, number][] {
const [lat1, lng1] = [toRad(a[0]), toRad(a[1])]
const [lat2, lng2] = [toRad(b[0]), toRad(b[1])]
const d = 2 * Math.asin(Math.sqrt(Math.sin((lat2 - lat1) / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin((lng2 - lng1) / 2) ** 2))
if (d === 0) return [a, b]
const pts: [number, number][] = []
for (let i = 0; i <= steps; i++) {
const f = i / steps
const A = Math.sin((1 - f) * d) / Math.sin(d)
const B = Math.sin(f * d) / Math.sin(d)
const x = A * Math.cos(lat1) * Math.cos(lng1) + B * Math.cos(lat2) * Math.cos(lng2)
const y = A * Math.cos(lat1) * Math.sin(lng1) + B * Math.cos(lat2) * Math.sin(lng2)
const z = A * Math.sin(lat1) + B * Math.sin(lat2)
const lat = Math.atan2(z, Math.sqrt(x * x + y * y))
const lng = Math.atan2(y, x)
pts.push([toDeg(lat), toDeg(lng)])
}
return pts
}
/**
* Make the longitudes of a sampled arc continuous: atan2 normalizes every
* sample to [-180, 180], so a date-line crossing shows up as a ±360 jump
* between neighbours which the renderers draw as a line across the whole
* map. Carrying a running offset keeps each Δlng under 180°, at the cost of
* longitudes leaving the [-180, 180] range (both map libraries project those
* linearly, which is exactly what makes the wrap seamless).
*/
export function unwrapLngs(points: [number, number][]): [number, number][] {
let offset = 0
return points.map(([lat, lng], i) => {
if (i === 0) return [lat, lng] as [number, number]
const prev = points[i - 1][1] + offset
let cur = lng + offset
if (cur - prev > 180) { offset -= 360; cur -= 360 }
else if (cur - prev < -180) { offset += 360; cur += 360 }
return [lat, cur] as [number, number]
})
}
/**
* The polylines to draw for one leg. The base arc is continuous (unwrapped),
* so panning across the antimeridian shows one unbroken line. With
* `wrapCopies` (Leaflet its vector layers don't repeat across world
* copies), a ±360-shifted duplicate keeps both halves visible in the
* standard [-180, 180] view; GL maps repeat features themselves
* (renderWorldCopies), so the duplicate would just double the line opacity.
*/
export function geodesicArcs(a: [number, number], b: [number, number], wrapCopies: boolean): [number, number][][] {
const arc = unwrapLngs(greatCircle(a, b))
const crosses = arc.some(([, lng]) => lng < -180 || lng > 180)
if (!crosses || !wrapCopies) return [arc]
const shift = arc.some(([, lng]) => lng < -180) ? 360 : -360
return [arc, arc.map(([lat, lng]) => [lat, lng + shift] as [number, number])]
}
@@ -1,78 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { ReservationMapboxOverlay } from './reservationsMapbox'
import type { Reservation } from '../../types'
// A minimal mapbox-gl stand-in: a persistent source that records the last
// setData, and project() spreading points far enough apart to pass the
// per-type pixel-distance visibility filter.
function fakeMap() {
const source = { setData: vi.fn() }
return {
_source: source,
getSource: () => source,
addSource: vi.fn(),
addLayer: vi.fn(),
getLayer: () => undefined,
removeLayer: vi.fn(),
removeSource: vi.fn(),
on: vi.fn(),
off: vi.fn(),
getZoom: () => 12,
project: ([lng, lat]: [number, number]) => ({ x: lng * 1000, y: lat * 1000 }),
}
}
const FakeMarker = vi.fn(function () {
const marker = {
setLngLat: () => marker,
addTo: () => marker,
remove: vi.fn(),
getElement: () => document.createElement('div'),
}
return marker
}) as unknown as new () => unknown
function carBooking(): Reservation {
return {
id: 1, type: 'car', status: 'confirmed',
endpoints: [
{ role: 'from', sequence: 0, name: 'A', code: null, lat: 48.0, lng: 2.0, timezone: null, local_time: null, local_date: null },
{ role: 'to', sequence: 1, name: 'B', code: null, lat: 48.2, lng: 2.3, timezone: null, local_time: null, local_date: null },
],
} as unknown as Reservation
}
const opts = { showConnections: true, showStats: false, showEndpointLabels: false }
function lastFeatureCoords(map: ReturnType<typeof fakeMap>) {
const calls = map._source.setData.mock.calls
const data = calls[calls.length - 1]?.[0] as { features: { geometry: { coordinates: [number, number][] } }[] }
return data.features[0].geometry.coordinates
}
describe('ReservationMapboxOverlay road routes (#1425)', () => {
it('draws the real road geometry when a road route is supplied', () => {
const map = fakeMap()
const overlay = new ReservationMapboxOverlay(map as never, opts, FakeMarker as never)
const road: [number, number][] = [[48.0, 2.0], [48.1, 2.15], [48.2, 2.3]]
overlay.update([carBooking()], opts, new Map([[1, road]]))
// GeoJSON is [lng, lat]; the routed 3-point line, not the straight 2-point arc.
expect(lastFeatureCoords(map)).toEqual([[2.0, 48.0], [2.15, 48.1], [2.3, 48.2]])
})
it('falls back to the straight arc when no road route is supplied', () => {
const map = fakeMap()
const overlay = new ReservationMapboxOverlay(map as never, opts, FakeMarker as never)
overlay.update([carBooking()], opts)
expect(lastFeatureCoords(map)).toEqual([[2.0, 48.0], [2.3, 48.2]])
})
it('sets no line features while connections are hidden', () => {
const map = fakeMap()
const overlay = new ReservationMapboxOverlay(map as never, opts, FakeMarker as never)
overlay.update([carBooking()], { ...opts, showConnections: false }, new Map([[1, [[48, 2], [48.2, 2.3]]]]))
const calls = map._source.setData.mock.calls
const data = calls[calls.length - 1]?.[0] as { features: unknown[] }
expect(data.features).toHaveLength(0)
})
})
+59 -67
View File
@@ -9,17 +9,15 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import type mapboxgl from 'mapbox-gl'
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront } from 'lucide-react'
import { getTransitMapSegments } from './transitGeometry'
import { geodesicArcs } from './flightGeodesy'
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
import { escapeHtml } from '@trek/shared'
import type { Reservation, ReservationEndpoint } from '../../types'
export const RESERVATION_SOURCE_ID = 'trek-reservations'
export const RESERVATION_LINE_LAYER_ID = 'trek-reservations-lines'
type TransportType = 'flight' | 'train' | 'cruise' | 'car' | 'bus' | 'taxi' | 'bicycle' | 'ferry' | 'transit' | 'transport_other'
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car', 'bus', 'taxi', 'bicycle', 'ferry', 'transit', 'transport_other']
type TransportType = 'flight' | 'train' | 'cruise' | 'car' | 'bus' | 'taxi' | 'bicycle' | 'ferry' | 'transport_other'
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car', 'bus', 'taxi', 'bicycle', 'ferry', 'transport_other']
const TRANSPORT_COLOR = '#3b82f6'
const TYPE_META: Record<TransportType, { icon: typeof Plane; geodesic: boolean }> = {
@@ -31,12 +29,46 @@ const TYPE_META: Record<TransportType, { icon: typeof Plane; geodesic: boolean }
taxi: { icon: CarTaxiFront, geodesic: false },
bicycle: { icon: Bike, geodesic: false },
ferry: { icon: Sailboat, geodesic: true },
transit: { icon: TramFront, geodesic: false },
transport_other: { icon: Route, geodesic: false },
}
// ── geometry helpers (shared with ReservationOverlay via flightGeodesy) ──
// ── geometry helpers (ported from ReservationOverlay.tsx) ────────────────
const toRad = (d: number) => d * Math.PI / 180
const toDeg = (r: number) => r * 180 / Math.PI
function greatCircle(a: [number, number], b: [number, number], steps = 256): [number, number][] {
const [lat1, lng1] = [toRad(a[0]), toRad(a[1])]
const [lat2, lng2] = [toRad(b[0]), toRad(b[1])]
const d = 2 * Math.asin(Math.sqrt(Math.sin((lat2 - lat1) / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin((lng2 - lng1) / 2) ** 2))
if (d === 0) return [a, b]
const pts: [number, number][] = []
for (let i = 0; i <= steps; i++) {
const f = i / steps
const A = Math.sin((1 - f) * d) / Math.sin(d)
const B = Math.sin(f * d) / Math.sin(d)
const x = A * Math.cos(lat1) * Math.cos(lng1) + B * Math.cos(lat2) * Math.cos(lng2)
const y = A * Math.cos(lat1) * Math.sin(lng1) + B * Math.cos(lat2) * Math.sin(lng2)
const z = A * Math.sin(lat1) + B * Math.sin(lat2)
const lat = Math.atan2(z, Math.sqrt(x * x + y * y))
const lng = Math.atan2(y, x)
pts.push([toDeg(lat), toDeg(lng)])
}
return pts
}
function splitAntimeridian(points: [number, number][]): [number, number][][] {
const segments: [number, number][][] = []
let cur: [number, number][] = []
for (let i = 0; i < points.length; i++) {
if (i > 0 && Math.abs(points[i][1] - points[i - 1][1]) > 180) {
if (cur.length > 1) segments.push(cur)
cur = []
}
cur.push(points[i])
}
if (cur.length > 1) segments.push(cur)
return segments
}
function haversineKm(a: [number, number], b: [number, number]): number {
const R = 6371
@@ -123,10 +155,7 @@ function buildItems(reservations: Reservation[]): TransportItem[] {
const a = waypoints[i]
const b = waypoints[i + 1]
const segArcs = isGeo
// GL maps repeat features across world copies themselves, so one
// continuous unwrapped arc is enough (a shifted duplicate would
// coincide with the wrapped copy and double the line opacity).
? geodesicArcs([a.lat, a.lng], [b.lat, b.lng], false)
? splitAntimeridian(greatCircle([a.lat, a.lng], [b.lat, b.lng]))
: [[[a.lat, a.lng], [b.lat, b.lng]] as [number, number][]]
arcs.push(...segArcs)
distanceKm += haversineKm([a.lat, a.lng], [b.lat, b.lng])
@@ -203,7 +232,6 @@ type MarkerConstructor = new (options?: { element?: HTMLElement; anchor?: string
export class ReservationMapboxOverlay {
private map: mapboxgl.Map
private items: TransportItem[] = []
private roadRoutes: Map<number, [number, number][]> = new Map()
private opts: ReservationOverlayOptions
private MarkerCtor: MarkerConstructor
private endpointMarkers: GlMarker[] = []
@@ -222,10 +250,9 @@ export class ReservationMapboxOverlay {
map.on('render', this.updateStatsRotation)
}
update(reservations: Reservation[], opts: ReservationOverlayOptions, roadRoutes?: Map<number, [number, number][]>) {
update(reservations: Reservation[], opts: ReservationOverlayOptions) {
this.opts = opts
this.items = buildItems(reservations)
this.roadRoutes = roadRoutes ?? new Map()
this.render()
}
@@ -248,25 +275,16 @@ export class ReservationMapboxOverlay {
const map = this.map
if (map.getSource(RESERVATION_SOURCE_ID)) return
map.addSource(RESERVATION_SOURCE_ID, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
// White casing under real transit paths so the colored lines read cleanly.
map.addLayer({
id: RESERVATION_LINE_LAYER_ID + '-transit-casing',
type: 'line',
source: RESERVATION_SOURCE_ID,
filter: ['all', ['==', ['get', 'transitPath'], true], ['!=', ['get', 'walk'], true]] as any,
paint: { 'line-color': '#ffffff', 'line-width': 6, 'line-opacity': 0.85 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
map.addLayer({
id: RESERVATION_LINE_LAYER_ID,
type: 'line',
source: RESERVATION_SOURCE_ID,
paint: {
'line-color': ['coalesce', ['get', 'color'], TRANSPORT_COLOR] as any,
'line-width': ['case', ['==', ['get', 'transitPath'], true], ['case', ['==', ['get', 'walk'], true], 3, 3.5], 2.5] as any,
// Confirmed = solid + 0.75; pending = dashed + 0.55; walks always dotted.
'line-opacity': ['case', ['==', ['get', 'transitPath'], true], 0.95, ['case', ['==', ['get', 'status'], 'confirmed'], 0.75, 0.55]] as any,
'line-dasharray': ['case', ['==', ['get', 'walk'], true], ['literal', [0.1, 2.5]], ['case', ['==', ['get', 'status'], 'confirmed'], ['literal', [1, 0]], ['literal', [3, 3]]]] as any,
'line-color': TRANSPORT_COLOR,
'line-width': 2.5,
// Confirmed = solid + 0.75; pending = dashed + 0.55.
'line-opacity': ['case', ['==', ['get', 'status'], 'confirmed'], 0.75, 0.55] as any,
'line-dasharray': ['case', ['==', ['get', 'status'], 'confirmed'], ['literal', [1, 0]], ['literal', [3, 3]]] as any,
},
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
@@ -302,51 +320,25 @@ export class ReservationMapboxOverlay {
const toPx = map.project([item.to.lng, item.to.lat])
const dx = fromPx.x - toPx.x, dy = fromPx.y - toPx.y
const dist = Math.sqrt(dx * dx + dy * dy)
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : item.type === 'transit' ? 900 : 400
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : 400
if (dist >= minPx) labelVisibleIds.add(item.res.id)
} catch { /* ignore */ }
}
}
// ── line features ───────────────────────────────────────────────
const features = visibleItems.flatMap(item => {
const transitSegs = item.type === 'transit' ? getTransitMapSegments(item.res) : []
if (transitSegs.length > 0) {
return transitSegs.map(seg => ({
type: 'Feature' as const,
properties: {
resId: item.res.id,
type: item.type,
status: item.res.status ?? 'pending',
transitPath: true,
walk: seg.walk,
color: seg.walk ? '#64748b' : (seg.color || '#7c3aed'),
},
geometry: {
type: 'LineString' as const,
coordinates: seg.coords.map(([lat, lng]) => [lng, lat]),
},
}))
}
// Prefer the real road route (car/bus/taxi/bicycle) over the straight arc.
const road = this.roadRoutes.get(item.res.id)
const lines = road && road.length >= 2 ? [road] : item.arcs
return lines.map(seg => ({
type: 'Feature' as const,
properties: {
resId: item.res.id,
type: item.type,
status: item.res.status ?? 'pending',
transitPath: false,
walk: false,
color: null as string | null,
},
geometry: {
type: 'LineString' as const,
coordinates: seg.map(([lat, lng]) => [lng, lat]),
},
}))
})
const features = visibleItems.flatMap(item => item.arcs.map(seg => ({
type: 'Feature' as const,
properties: {
resId: item.res.id,
type: item.type,
status: item.res.status ?? 'pending',
},
geometry: {
type: 'LineString' as const,
coordinates: seg.map(([lat, lng]) => [lng, lat]),
},
})))
const src = map.getSource(RESERVATION_SOURCE_ID) as mapboxgl.GeoJSONSource | undefined
src?.setData({ type: 'FeatureCollection', features })
@@ -1,63 +0,0 @@
import type { Reservation } from '../../types'
/**
* Real-path geometry for transit journeys on the map (#1065). MOTIS delivers
* each leg's shape as a Google-encoded polyline; we store it on
* metadata.transit.legs[].geometry and decode it here so the map can draw the
* actual rail/bus alignment instead of a straight line.
*/
export interface TransitMapSegment {
coords: [number, number][]
color: string | null
walk: boolean
}
/** Google polyline decoding with a configurable precision (MOTIS uses 6). */
export function decodePolyline(encoded: string, precision = 6): [number, number][] {
const factor = Math.pow(10, precision)
const coords: [number, number][] = []
let index = 0
let lat = 0
let lng = 0
while (index < encoded.length) {
for (const which of [0, 1] as const) {
let result = 0
let shift = 0
let byte = 0x20
while (byte >= 0x20) {
if (index >= encoded.length) return coords
byte = encoded.charCodeAt(index++) - 63
result |= (byte & 0x1f) << shift
shift += 5
}
const delta = result & 1 ? ~(result >> 1) : result >> 1
if (which === 0) lat += delta
else lng += delta
}
coords.push([lat / factor, lng / factor])
}
return coords
}
/**
* The decoded per-leg segments of a transit reservation, or [] when it has no
* stored geometry (pre-geometry entries fall back to the straight line).
*/
export function getTransitMapSegments(res: Reservation): TransitMapSegment[] {
if (res.type !== 'transit') return []
let meta: any = res.metadata
if (typeof meta === 'string') {
try { meta = JSON.parse(meta) } catch { return [] }
}
const legs = meta?.transit?.legs
if (!Array.isArray(legs)) return []
const out: TransitMapSegment[] = []
for (const leg of legs) {
if (!leg?.geometry || typeof leg.geometry !== 'string') continue
const coords = decodePolyline(leg.geometry, typeof leg.geometry_precision === 'number' ? leg.geometry_precision : 6)
if (coords.length < 2) continue
out.push({ coords, color: leg.line_color || null, walk: leg.mode === 'WALK' })
}
return out
}
+2 -16
View File
@@ -6,7 +6,7 @@ import { accommodationsApi, mapsApi } from '../../api/client'
import type { Trip, Day, Place, Category, AssignmentsMap, DayNote } from '../../types'
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
import { splitReservationDateTime } from '../../utils/formatters'
import { getFlightLegs, getTrainLegs } from '../../utils/flightLegs'
import { getFlightLegs } from '../../utils/flightLegs'
function renderLucideIcon(icon:LucideIcon, props = {}) {
if (!_renderToStaticMarkup) return ''
@@ -243,21 +243,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
subtitle = [meta.airline, meta.flight_number, route].filter(Boolean).join(' · ')
}
}
else if (r.type === 'train') {
const legs = getTrainLegs(r)
if (legs.length > 1) {
// Multi-leg: one line per leg so every train number + segment route shows.
subtitleLines = legs.map(l =>
[l.train_number, l.platform ? `Gl. ${l.platform}` : '',
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '']
.filter(Boolean).join(' · '))
.filter(Boolean)
} else {
const stops = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)).map(e => e.code || e.name)
const route = stops.length >= 2 ? stops.join(' → ') : ''
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : '', route].filter(Boolean).join(' · ')
}
}
else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ')
else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
@@ -71,36 +71,6 @@ describe('DayDetailPanel', () => {
expect(screen.getByText('Day in Paris')).toBeInTheDocument();
});
// ── Inline rename (#1065 — moved here from the sidebar pencil) ──────────────
it('FE-PLANNER-DAYDETAIL-064: pencil next to the title renames the day (Enter commits)', async () => {
const user = userEvent.setup();
const onUpdateDayTitle = vi.fn();
render(<DayDetailPanel {...defaultProps} onUpdateDayTitle={onUpdateDayTitle} />);
await user.click(screen.getByLabelText('Edit'));
const input = await screen.findByDisplayValue('Day in Paris');
await user.clear(input);
await user.type(input, 'New Title');
await user.keyboard('{Enter}');
expect(onUpdateDayTitle).toHaveBeenCalledWith(1, 'New Title');
});
it('FE-PLANNER-DAYDETAIL-065: Escape cancels the rename without saving', async () => {
const user = userEvent.setup();
const onUpdateDayTitle = vi.fn();
render(<DayDetailPanel {...defaultProps} onUpdateDayTitle={onUpdateDayTitle} />);
await user.click(screen.getByLabelText('Edit'));
await screen.findByDisplayValue('Day in Paris');
await user.keyboard('{Escape}');
expect(onUpdateDayTitle).not.toHaveBeenCalled();
expect(screen.getByText('Day in Paris')).toBeInTheDocument();
});
it('FE-PLANNER-DAYDETAIL-066: no rename pencil without the onUpdateDayTitle prop', () => {
render(<DayDetailPanel {...defaultProps} />);
expect(screen.queryByLabelText('Edit')).not.toBeInTheDocument();
});
it('FE-PLANNER-DAYDETAIL-004: shows day number when title is null', () => {
const untitled = buildDay({ id: 1, trip_id: 1, date: '2025-06-15', title: null });
render(<DayDetailPanel {...defaultProps} day={untitled} days={[untitled]} />);
@@ -1,9 +1,9 @@
import React, { useState, useEffect, useRef } from 'react'
import ReactDOM from 'react-dom'
import { X, Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind, Droplets, Sunrise, Sunset, Hotel, Calendar, MapPin, LogIn, LogOut, Hash, Pencil, Plane, Utensils, Train, Car, Ship, Ticket, FileText, Users, ChevronsDown, ChevronsUp, TramFront } from 'lucide-react'
import { X, Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind, Droplets, Sunrise, Sunset, Hotel, Calendar, MapPin, LogIn, LogOut, Hash, Pencil, Plane, Utensils, Train, Car, Ship, Ticket, FileText, Users, ChevronsDown, ChevronsUp } from 'lucide-react'
const RES_TYPE_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, transit: TramFront, event: Ticket, tour: Users, other: FileText }
const RES_TYPE_COLORS = { flight: '#3b82f6', hotel: '#8b5cf6', restaurant: '#ef4444', train: '#06b6d4', car: '#6b7280', cruise: '#0ea5e9', transit: '#7c3aed', event: '#f59e0b', tour: '#10b981', other: '#6b7280' }
const RES_TYPE_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
const RES_TYPE_COLORS = { flight: '#3b82f6', hotel: '#8b5cf6', restaurant: '#ef4444', train: '#06b6d4', car: '#6b7280', cruise: '#0ea5e9', event: '#f59e0b', tour: '#10b981', other: '#6b7280' }
import { weatherApi, accommodationsApi } from '../../api/client'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
@@ -60,11 +60,9 @@ interface DayDetailPanelProps {
collapsed?: boolean
onToggleCollapse?: () => void
mobile?: boolean
/** Rename the day from here — the sidebar pencil moved to the transit search (#1065). */
onUpdateDayTitle?: (dayId: number, title: string) => void
}
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse, mobile = false, onUpdateDayTitle }: DayDetailPanelProps) {
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse, mobile = false }: DayDetailPanelProps) {
const { t, language, locale } = useTranslation()
const can = useCanDo()
const tripObj = useTripStore((s) => s.trip)
@@ -80,22 +78,6 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const unit = isFahrenheit ? '°F' : '°C'
const collapsed = collapsedProp
const toggleCollapse = () => onToggleCollapse?.()
// Inline day rename (#1065) — took over from the sidebar's pencil, which the
// transit search button replaced.
const [editingTitle, setEditingTitle] = useState(false)
const [titleDraft, setTitleDraft] = useState('')
const titleInputRef = useRef<HTMLInputElement | null>(null)
useEffect(() => { if (editingTitle) titleInputRef.current?.focus() }, [editingTitle])
const startRename = (e: React.MouseEvent) => {
e.stopPropagation()
setTitleDraft(day?.title || '')
setEditingTitle(true)
}
const commitRename = () => {
setEditingTitle(false)
if (day && onUpdateDayTitle) onUpdateDayTitle(day.id, titleDraft.trim())
}
const {
weather, loading, accommodation, setAccommodation, dayAccommodations, setDayAccommodations,
accommodations, setAccommodations, showHotelPicker, setShowHotelPicker,
@@ -151,35 +133,10 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<Calendar size={collapsed ? 16 : 20} className="text-content" />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
{editingTitle ? (
<input
ref={titleInputRef}
value={titleDraft}
onChange={e => setTitleDraft(e.target.value)}
onClick={e => e.stopPropagation()}
onBlur={commitRename}
onKeyDown={e => { if (e.key === 'Enter') commitRename(); if (e.key === 'Escape') setEditingTitle(false) }}
placeholder={t('planner.dayN', { n: (days.indexOf(day) + 1) || '?' })}
className="text-content"
style={{ width: '100%', border: 'none', outline: 'none', background: 'transparent', padding: 0, fontFamily: 'inherit', fontSize: 15, fontWeight: 700, borderBottom: '1.5px solid var(--text-primary)' }}
/>
) : collapsed ? (
<div className="text-content" style={{ fontSize: 13, fontWeight: 700, transition: 'font-size 0.15s ease' }}>
{day.title || t('planner.dayN', { n: (days.indexOf(day) + 1) || '?' })}
{formattedDate && <span className="text-content-muted" style={{ fontWeight: 500, marginLeft: 8 }}>{formattedDate}</span>}
</div>
) : (
<div className="text-content" style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 15, fontWeight: 700, transition: 'font-size 0.15s ease', minWidth: 0 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{day.title || t('planner.dayN', { n: (days.indexOf(day) + 1) || '?' })}
</span>
{canEditDays && onUpdateDayTitle && (
<button onClick={startRename} aria-label={t('common.edit')} title={t('common.edit')} className="text-content-faint" style={{ border: 'none', background: 'none', padding: 3, cursor: 'pointer', display: 'flex', flexShrink: 0 }}>
<Pencil size={12} strokeWidth={1.8} />
</button>
)}
</div>
)}
<div className="text-content" style={{ fontSize: collapsed ? 13 : 15, fontWeight: 700, transition: 'font-size 0.15s ease' }}>
{day.title || t('planner.dayN', { n: (days.indexOf(day) + 1) || '?' })}
{collapsed && formattedDate && <span className="text-content-muted" style={{ fontWeight: 500, marginLeft: 8 }}>{formattedDate}</span>}
</div>
{!collapsed && formattedDate && <div className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 1 }}>{formattedDate}</div>}
</div>
<button onClick={(e) => { e.stopPropagation(); toggleCollapse() }} title={collapsed ? t('common.expand') : t('common.collapse')}
@@ -1,11 +1,11 @@
import {
FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship,
Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle,
ShoppingBag, Bookmark, Hotel, Utensils, Users, Sailboat, Bike, CarTaxiFront, Route, TramFront,
ShoppingBag, Bookmark, Hotel, Utensils, Users, Sailboat, Bike, CarTaxiFront, Route,
Wine, ParkingSquare, Fuel, Footprints, Mountain, Waves, Sun, Umbrella, Music, Landmark, Gift,
} from 'lucide-react'
export const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, bus: Bus, ferry: Sailboat, bicycle: Bike, taxi: CarTaxiFront, transit: TramFront, transport_other: Route, event: Ticket, tour: Users, other: FileText }
export const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, bus: Bus, ferry: Sailboat, bicycle: Bike, taxi: CarTaxiFront, transport_other: Route, event: Ticket, tour: Users, other: FileText }
export const NOTE_ICONS = [
{ id: 'FileText', Icon: FileText },
@@ -50,4 +50,4 @@ export const TYPE_ICONS = {
transport_other: '🧭', event: '🎫', other: '📋',
}
export const TRANSPORT_DETAIL_COLORS = { flight: '#3b82f6', train: '#06b6d4', bus: '#059669', ferry: '#0d9488', bicycle: '#84cc16', taxi: '#ca8a04', car: '#6b7280', cruise: '#0ea5e9', transit: '#7c3aed', transport_other: '#6b7280' }
export const TRANSPORT_DETAIL_COLORS = { flight: '#3b82f6', train: '#06b6d4', bus: '#059669', ferry: '#0d9488', bicycle: '#84cc16', taxi: '#ca8a04', car: '#6b7280', cruise: '#0ea5e9', transport_other: '#6b7280' }
@@ -129,7 +129,6 @@ beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
sessionStorage.clear()
localStorage.clear()
// Reset mutable day-notes state
mockDayNotesState.noteUi = {}
mockDayNotesState.dayNotes = {}
@@ -298,92 +297,41 @@ describe('DayPlanSidebar', () => {
expect(screen.getByText('Louvre Museum')).toBeInTheDocument()
})
// ── Transit search button (#1065 — replaced the rename pencil; renaming
// moved next to the day name in the day detail panel) ─────────────────
// ── Day title editing ───────────────────────────────────────────────────
it('FE-PLANNER-DAYPLAN-015: transit button opens the route search for the day', async () => {
it('FE-PLANNER-DAYPLAN-015: clicking edit button enters edit mode', async () => {
const user = userEvent.setup()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const onPlanTransit = vi.fn()
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onPlanTransit })} />)
await user.click(screen.getByLabelText('Public transit'))
expect(onPlanTransit).toHaveBeenCalledWith(10)
})
it('FE-PLANNER-DAYPLAN-016: transit button is absent without the onPlanTransit prop', () => {
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
expect(screen.queryByLabelText('Public transit')).not.toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-017: the day header no longer has a rename pencil (#1065)', () => {
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onPlanTransit: vi.fn() })} />)
expect(screen.queryByLabelText('Edit')).not.toBeInTheDocument()
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
await user.click(screen.getByLabelText('Edit'))
await waitFor(() => {
expect(screen.getByDisplayValue('Original Title')).toBeInTheDocument()
})
})
it('FE-PLANNER-DAYPLAN-104: a transit journey renders line chips and opens its itinerary view, not the edit form (#1065)', async () => {
it('FE-PLANNER-DAYPLAN-016: pressing Enter commits title', async () => {
const user = userEvent.setup()
const onEditTransport = vi.fn()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const res = {
...buildReservation({
id: 300, type: 'transit', title: 'Fernsehturm → Zoo',
reservation_time: '2025-06-01T08:30:00', day_id: 10,
}),
metadata: {
transit: {
provider: 'transitous', duration: 1800, transfers: 1, walk_seconds: 240,
legs: [
{ mode: 'WALK', duration: 240, from: { name: 'Start' }, to: { name: 'Alexanderplatz' } },
{ mode: 'SUBWAY', line: 'U2', line_color: '#FF3300', line_text_color: '#FFFFFF', headsign: 'Ruhleben', duration: 1440, stops: 6, from: { name: 'Alexanderplatz', time: '08:36' }, to: { name: 'Zoo', time: '09:00' } },
],
},
},
}
const onOpenTransit = vi.fn()
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [res as any], onEditTransport, onOpenTransit })} />)
// Line chip + transfer summary render inline in the timeline row; the
// title uses an arrow icon, so its parts are separate text nodes.
expect(screen.getByText('U2')).toBeInTheDocument()
// Transfer counts stay out of the compact row — the chips say it all.
expect(screen.queryByText(/1 transfers/)).not.toBeInTheDocument()
// Clicking the row opens the journey view — not the edit form.
await user.click(screen.getByText('Fernsehturm'))
expect(onEditTransport).not.toHaveBeenCalled()
expect(onOpenTransit).toHaveBeenCalledWith(expect.objectContaining({ id: 300 }))
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
const onUpdateDayTitle = vi.fn()
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />)
// Enter edit mode
await user.click(screen.getByLabelText('Edit'))
const input = await screen.findByDisplayValue('Original Title')
await user.clear(input)
await user.type(input, 'New Title')
await user.keyboard('{Enter}')
expect(onUpdateDayTitle).toHaveBeenCalledWith(10, 'New Title')
})
it('FE-PLANNER-DAYPLAN-105: the transit row folds its itinerary out inline (#1065)', async () => {
it('FE-PLANNER-DAYPLAN-017: pressing Escape cancels edit', async () => {
const user = userEvent.setup()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const res = {
...buildReservation({ id: 301, type: 'transit', title: 'A → B', reservation_time: '2025-06-01T08:30:00', day_id: 10 }),
metadata: {
transit: {
provider: 'transitous', duration: 1800, transfers: 1, walk_seconds: 240,
legs: [
{ mode: 'WALK', duration: 240, from: { name: 'Start' }, to: { name: 'Alexanderplatz' } },
{ mode: 'SUBWAY', line: 'U2', line_color: '#FF3300', headsign: 'Ruhleben', duration: 1440, stops: 6, from: { name: 'Alexanderplatz', time: '08:36', track: '2' }, to: { name: 'Zoo', time: '09:00' } },
],
},
},
endpoints: [
{ role: 'from', sequence: 0, name: 'A', code: null, lat: 1, lng: 2, timezone: null, local_date: null, local_time: null },
{ role: 'to', sequence: 1, name: 'B', code: null, lat: 3, lng: 4, timezone: null, local_date: null, local_time: null },
],
}
const onToggleConnection = vi.fn()
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [res as any], onOpenTransit: vi.fn(), onToggleConnection, visibleConnectionIds: [] })} />)
// No map-connections toggle on transit rows — the expander replaces it.
expect(screen.queryByTitle(/connections/i)).not.toBeInTheDocument()
// Collapsed: no stop names beyond the chips.
expect(screen.queryByText('Alexanderplatz')).not.toBeInTheDocument()
await user.click(screen.getByLabelText('Expand'))
expect(await screen.findByText('Alexanderplatz')).toBeInTheDocument()
expect(screen.getByText(/Platform 2/)).toBeInTheDocument()
await user.click(screen.getByLabelText('Collapse'))
expect(screen.queryByText('Alexanderplatz')).not.toBeInTheDocument()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
await user.click(screen.getByLabelText('Edit'))
const input = await screen.findByDisplayValue('Original Title')
await user.keyboard('{Escape}')
expect(screen.queryByDisplayValue('Original Title')).not.toBeInTheDocument()
expect(screen.getByText('Original Title')).toBeInTheDocument()
})
// ── Day info button ─────────────────────────────────────────────────────
@@ -694,7 +642,20 @@ describe('DayPlanSidebar', () => {
await waitFor(() => expect(onReorder).toHaveBeenCalledWith(10, expect.any(Array)))
})
// Day-title renaming moved to DayDetailPanel (#1065) — covered there.
// ── Title blur commits ───────────────────────────────────────────────────
it('FE-PLANNER-DAYPLAN-042: blurring title input commits the edit', async () => {
const user = userEvent.setup()
const onUpdateDayTitle = vi.fn()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Old Title' })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />)
await user.click(screen.getByLabelText('Edit'))
const input = await screen.findByDisplayValue('Old Title')
await user.clear(input)
await user.type(input, 'New Title')
fireEvent.blur(input)
await waitFor(() => expect(onUpdateDayTitle).toHaveBeenCalledWith(10, 'New Title'))
})
// ── ICS export button ────────────────────────────────────────────────────
+24 -103
View File
@@ -4,7 +4,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react'
import { avatarSrc } from '../../utils/avatarSrc'
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark, TramFront } from 'lucide-react'
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark } from 'lucide-react'
import { assignmentsApi, reservationsApi } from '../../api/client'
import { calculateRoute, calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl } from '../Map/RouteCalculator'
import PlaceAvatar from '../shared/PlaceAvatar'
@@ -37,7 +37,6 @@ import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar'
import { DayPlanSidebarNoteModal } from './DayPlanSidebarNoteModal'
import { DayPlanSidebarTimeConfirmModal } from './DayPlanSidebarTimeConfirmModal'
import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDetailModal'
import { TransitTitle, TransitLegChips, TransitItineraryInline } from './transitDisplay'
import { DayPlanSidebarFooter } from './DayPlanSidebarFooter'
import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
@@ -85,10 +84,6 @@ interface DayPlanSidebarProps {
onUndo?: () => void
onRouteRefresh?: () => void
onAddTransport?: (dayId: number) => void
/** Opens the public-transit route search for a day (#1065). */
onPlanTransit?: (dayId: number) => void
/** Opens the journey view for a saved transit entry (#1065). */
onOpenTransit?: (reservation: Reservation) => void
onEditTransport?: (reservation: Reservation) => void
onEditReservation?: (reservation: Reservation) => void
onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void
@@ -96,9 +91,6 @@ interface DayPlanSidebarProps {
onScrollTopChange?: (top: number) => void
/** Mobile: show the route tools footer (Route toggle / Optimize / travel profile) on expanded days, since selecting a day closes the sheet */
showRouteToolsWhenExpanded?: boolean
/** Mobile: drag & drop reorder is disabled (touch-scroll hijack, #1432); the
* grip handle is hidden and the arrow reorder buttons take over instead. */
isMobile?: boolean
}
/**
@@ -135,15 +127,12 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
onUndo,
onRouteRefresh,
onAddTransport,
onPlanTransit,
onOpenTransit,
onEditTransport,
onEditReservation,
onAddBookingToAssignment,
initialScrollTop,
onScrollTopChange,
showRouteToolsWhenExpanded = false,
isMobile = false,
} = props
const toast = useToast()
const { t, language, locale } = useTranslation()
@@ -157,7 +146,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const [expandedDays, setExpandedDays] = useState(() => {
try {
const saved = localStorage.getItem(`day-expanded-${tripId}`)
const saved = sessionStorage.getItem(`day-expanded-${tripId}`)
if (saved) return new Set<number>(JSON.parse(saved) as number[])
} catch {}
return new Set<number>(days.map(d => d.id))
@@ -191,8 +180,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const [pdfHover, setPdfHover] = useState(false)
const [icsHover, setIcsHover] = useState(false)
const [hoveredAssignmentId, setHoveredAssignmentId] = useState<number | null>(null)
// Transit rows fold their itinerary out inline (#1065).
const [expandedTransitIds, setExpandedTransitIds] = useState<Set<number>>(new Set())
const [dropTargetKey, _setDropTargetKey] = useState(null)
const dropTargetRef = useRef(null)
const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) }
@@ -264,7 +251,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
setExpandedDays(prev => {
const n = new Set(prev)
days.forEach(d => { if (!prev.has(d.id)) n.add(d.id) })
try { localStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {}
try { sessionStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {}
return n
})
}
@@ -297,7 +284,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
setExpandedDays(prev => {
const n = new Set(prev)
n.has(dayId) ? n.delete(dayId) : n.add(dayId)
try { localStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {}
try { sessionStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {}
return n
})
}
@@ -1013,17 +1000,12 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
onUndo,
onRouteRefresh,
onAddTransport,
onPlanTransit,
onOpenTransit,
onEditTransport,
expandedTransitIds,
setExpandedTransitIds,
onEditReservation,
onAddBookingToAssignment,
initialScrollTop,
onScrollTopChange,
showRouteToolsWhenExpanded,
isMobile,
toast,
t,
language,
@@ -1179,17 +1161,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
onUndo,
onRouteRefresh,
onAddTransport,
onPlanTransit,
onOpenTransit,
onEditTransport,
expandedTransitIds,
setExpandedTransitIds,
onEditReservation,
onAddBookingToAssignment,
initialScrollTop,
onScrollTopChange,
showRouteToolsWhenExpanded,
isMobile,
toast,
t,
language,
@@ -1495,13 +1472,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const div = '1px solid var(--border-faint)'
return (
<div className="dp-day-actions" style={{ alignSelf: 'flex-start', flexShrink: 0, display: 'grid', gridTemplateColumns: '1fr 1fr', border: div, borderRadius: 9, overflow: 'hidden' }}>
{/* Public transit search (#1065) replaced the rename pencil,
which moved next to the day name in the day detail view. */}
{onPlanTransit ? (
<button onClick={e => { e.stopPropagation(); onPlanTransit(day.id) }} title={t('transit.title')} aria-label={t('transit.title')} style={{ ...cell, border: 'none', borderRight: div, borderBottom: div }}>
<TramFront size={14} strokeWidth={1.8} />
</button>
) : <div style={{ borderRight: div, borderBottom: div }} />}
<button onClick={e => startEditTitle(day, e)} aria-label={t('common.edit')} style={{ ...cell, border: 'none', borderRight: div, borderBottom: div }}>
<Pencil size={14} strokeWidth={1.8} />
</button>
{onAddTransport ? (
<button onClick={e => { e.stopPropagation(); onAddTransport(day.id) }} title={t('transport.addTransport')} style={{ ...cell, border: 'none', borderBottom: div }}>
<Plus size={14} strokeWidth={1.8} />
@@ -1654,9 +1627,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
return (
<React.Fragment key={`place-${assignment.id}`}>
<div
draggable={canEditDays && !isMobile}
draggable={canEditDays}
onDragStart={e => {
if (!canEditDays || isMobile) { e.preventDefault(); return }
if (!canEditDays) { e.preventDefault(); return }
e.dataTransfer.setData('assignmentId', String(assignment.id))
e.dataTransfer.setData('fromDayId', String(day.id))
e.dataTransfer.effectAllowed = 'move'
@@ -1751,7 +1724,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
opacity: isDraggingThis ? 0.4 : 1,
}}
>
{canEditDays && !isMobile && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
{canEditDays && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
<GripVertical size={13} strokeWidth={1.8} />
</div>}
<div
@@ -1959,10 +1932,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
// Subtitle aus Metadaten zusammensetzen
let subtitle = ''
if (res.__leg) {
// One leg of a multi-leg flight/train — show this segment's own detail.
const parts = res.type === 'train'
? [res.__leg.train_number, res.__leg.platform ? `Gl. ${res.__leg.platform}` : '', res.__leg.seat ? `Sitz ${res.__leg.seat}` : ''].filter(Boolean)
: [res.__leg.airline, res.__leg.flight_number].filter(Boolean)
// One leg of a multi-leg flight — show this segment's own route.
const parts = [res.__leg.airline, res.__leg.flight_number].filter(Boolean)
if (res.__leg.from || res.__leg.to)
parts.push([res.__leg.from, res.__leg.to].filter(Boolean).join(' → '))
subtitle = parts.join(' · ')
@@ -1975,11 +1946,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Sitz ${meta.seat}` : ''].filter(Boolean).join(' · ')
}
// A transit journey (#1065) renders its itinerary inline —
// line badges in their colors instead of a plain subtitle,
// so the connection is recognisable at a glance.
const transitMeta = res.type === 'transit' && meta.transit && Array.isArray(meta.transit.legs) ? meta.transit : null
// Multi-day span phase (single-leg / non-flight only — a
// multi-leg flight is shown as one row per leg, see below).
const spanLabel = res.__leg ? null : getSpanLabel(res, spanPhase)
@@ -1990,16 +1956,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<React.Fragment key={`transport-${res.id}-${legKey}-${day.id}`}>
<div
onClick={() => {
const target = reservations.find(x => x.id === res.id) ?? res
// A transit journey opens its own journey view — the rich
// stop-by-stop breakdown with its booking fields, never the
// generic edit form (#1065).
if (transitMeta) {
if (onOpenTransit) onOpenTransit(target)
else setTransportDetail(target)
return
}
if (!canEditDays) return
const target = reservations.find(x => x.id === res.id) ?? res
if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(target)
else onEditReservation?.(target)
}}
@@ -2011,9 +1969,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const key = inBottom ? `transport-after-${res.id}${ls}-${day.id}` : `transport-${res.id}${ls}-${day.id}`
if (dropTargetRef.current !== key) setDropTargetKey(key)
}}
draggable={canEditDays && spanPhase !== 'middle' && !res.__leg && !isMobile}
draggable={canEditDays && spanPhase !== 'middle' && !res.__leg}
onDragStart={e => {
if (!canEditDays || spanPhase === 'middle' || res.__leg || isMobile) { e.preventDefault(); return }
if (!canEditDays || spanPhase === 'middle' || res.__leg) { e.preventDefault(); return }
// setData is required for the drag to start reliably (Firefox) and
// matches how place/note items initiate their drag.
e.dataTransfer.setData('reservationId', String(res.id))
@@ -2057,12 +2015,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
borderTop: showDropLine ? '2px solid var(--text-primary)' : undefined,
borderBottom: showDropLineAfter ? '2px solid var(--text-primary)' : undefined,
background: `${color}08`,
cursor: (transitMeta || (canEditDays && onEditTransport)) ? 'pointer' : 'default', userSelect: 'none',
cursor: canEditDays && onEditTransport ? 'pointer' : 'default', userSelect: 'none',
transition: 'background 0.1s',
opacity: draggingId === res.id ? 0.4 : spanPhase === 'middle' ? 0.65 : 1,
}}
>
{canEditDays && spanPhase !== 'middle' && !res.__leg && !isMobile && (
{canEditDays && spanPhase !== 'middle' && !res.__leg && (
<div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
<GripVertical size={13} strokeWidth={1.8} />
</div>
@@ -2083,8 +2041,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
{spanLabel}
</span>
)}
<span style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
{transitMeta ? <TransitTitle title={res.title} iconSize={11} /> : res.title}
<span style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{res.title}
</span>
{(() => {
const { time: dispTime } = splitReservationDateTime(displayTime)
@@ -2101,44 +2059,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
)
})()}
</div>
{transitMeta ? (
<div style={{ display: 'flex', alignItems: 'center', marginTop: 3 }}>
<TransitLegChips legs={transitMeta.legs} size="sm" t={t} />
</div>
) : subtitle && (
{subtitle && (
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{subtitle}
</div>
)}
</div>
{transitMeta && (() => {
const expanded = expandedTransitIds.has(res.id)
return (
<button
type="button"
onClick={e => {
e.stopPropagation()
setExpandedTransitIds(prev => {
const next = new Set(prev)
if (next.has(res.id)) next.delete(res.id); else next.add(res.id)
return next
})
}}
title={t(expanded ? 'common.collapse' : 'common.expand')}
aria-label={t(expanded ? 'common.collapse' : 'common.expand')}
style={{
flexShrink: 0, appearance: 'none', width: 26, height: 26, borderRadius: 6,
display: 'grid', placeItems: 'center', cursor: 'pointer', border: 'none',
background: 'transparent', color: 'var(--text-faint)',
}}
onMouseEnter={e => { e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { e.currentTarget.style.color = 'var(--text-faint)' }}
>
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
)
})()}
{!transitMeta && onToggleConnection && (!res.__leg || res.__leg.index === 0) && (res.endpoints || []).length >= 2 && (() => {
{onToggleConnection && (!res.__leg || res.__leg.index === 0) && (res.endpoints || []).length >= 2 && (() => {
const active = visibleConnectionIds.includes(res.id)
return (
<button
@@ -2162,11 +2089,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
)
})()}
</div>
{transitMeta && expandedTransitIds.has(res.id) && (
<div style={{ margin: '2px 8px 4px', padding: '9px 10px 9px 12px', borderRadius: 6, border: `1px solid ${color}26`, background: `${color}05` }}>
<TransitItineraryInline legs={transitMeta.legs} t={t} />
</div>
)}
{routeLegs[day.id]?.[res.id] && <RouteConnector seg={routeLegs[day.id]![res.id]} profile={routeProfile} />}
</React.Fragment>
)
@@ -2179,8 +2101,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
return (
<React.Fragment key={`note-${note.id}`}>
<div
draggable={canEditDays && !isMobile}
onDragStart={e => { if (!canEditDays || isMobile) { e.preventDefault(); return } e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
draggable={canEditDays}
onDragStart={e => { if (!canEditDays) { e.preventDefault(); return } e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
onDragEnd={() => { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null }}
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
onDrop={e => {
@@ -2248,7 +2170,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
}}
>
{canEditDays && !isMobile && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
{canEditDays && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
<GripVertical size={13} strokeWidth={1.8} />
</div>}
<div style={{ width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', background: 'var(--bg-hover)', overflow: 'hidden' }}>
@@ -2469,7 +2391,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
transportDetail={transportDetail}
setTransportDetail={setTransportDetail}
onNavigateToFiles={onNavigateToFiles}
onEdit={canEditDays && onEditTransport ? (res) => { setTransportDetail(null); onEditTransport(res) } : undefined}
t={t}
locale={locale}
timeFormat={timeFormat}
@@ -1,5 +1,5 @@
import ReactDOM from 'react-dom'
import { Ticket, FileText, ExternalLink, Footprints, ArrowRight, Pencil } from 'lucide-react'
import { Ticket, FileText, ExternalLink } from 'lucide-react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
@@ -13,15 +13,13 @@ interface DayPlanSidebarTransportDetailModalProps {
transportDetail: Reservation | null
setTransportDetail: (v: Reservation | null) => void
onNavigateToFiles?: () => void
/** Opens the edit form for this reservation (shown as a footer action). */
onEdit?: (res: Reservation) => void
t: (key: string, params?: Record<string, any>) => string
locale: string
timeFormat: string
}
export function DayPlanSidebarTransportDetailModal({
transportDetail, setTransportDetail, onNavigateToFiles, onEdit, t, locale, timeFormat,
transportDetail, setTransportDetail, onNavigateToFiles, t, locale, timeFormat,
}: DayPlanSidebarTransportDetailModalProps) {
if (!transportDetail) return null
return ReactDOM.createPortal(
@@ -119,69 +117,6 @@ export function DayPlanSidebarTransportDetailModal({
</div>
)}
{/* Public-transit itinerary (#1065) — legs from the transit search */}
{meta.transit?.legs && Array.isArray(meta.transit.legs) && meta.transit.legs.length > 0 && (
<>
{/* journey summary: duration · transfers · walking */}
<div className="bg-surface-tertiary text-content" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10, padding: '9px 12px', borderRadius: 8, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, flexWrap: 'wrap' }}>
{meta.transit.duration > 0 && (
<span>{Math.floor(meta.transit.duration / 3600) > 0 ? `${Math.floor(meta.transit.duration / 3600)} h ${Math.round((meta.transit.duration % 3600) / 60)} min` : t('transit.min', { count: Math.round(meta.transit.duration / 60) })}</span>
)}
<span className="text-content-faint">·</span>
<span>{meta.transit.transfers > 0 ? t('transit.transfers', { count: meta.transit.transfers }) : t('transit.direct')}</span>
{meta.transit.walk_seconds > 59 && (
<>
<span className="text-content-faint">·</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}><Footprints size={13} /> {t('transit.min', { count: Math.round(meta.transit.walk_seconds / 60) })}</span>
</>
)}
</div>
<div className="bg-surface-tertiary" style={{ padding: '10px 12px', borderRadius: 8 }}>
<div className="text-content-faint" style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 8 }}>
{t('transit.itinerary')}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{meta.transit.legs.map((leg: { mode?: string; line?: string | null; line_color?: string | null; line_text_color?: string | null; headsign?: string | null; duration?: number; stops?: number; from?: { name?: string; time?: string | null }; to?: { name?: string; time?: string | null } }, i: number) => {
const isWalk = leg.mode === 'WALK'
const mins = leg.duration ? Math.round(leg.duration / 60) : null
return (
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
{isWalk ? (
<span className="text-content-faint" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 0, paddingTop: 1 }}>
<Footprints size={12} />
</span>
) : (
<span style={{ display: 'inline-flex', alignItems: 'center', background: leg.line_color || 'var(--bg-hover)', color: leg.line_color ? (leg.line_text_color || '#fff') : 'var(--text-primary)', borderRadius: 5, padding: '1px 6px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, flexShrink: 0 }}>
{leg.line || leg.mode}
</span>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 500, display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
{isWalk
? <span className="text-content-muted">{t('transit.walkTo', { name: leg.to?.name || '' })}</span>
: <>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{leg.from?.name}</span>
<ArrowRight size={10} className="text-content-faint" style={{ flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{leg.to?.name}</span>
</>}
</div>
<div className="text-content-faint" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', marginTop: 1 }}>
{[
leg.from?.time && !isWalk ? `${leg.from.time}${leg.to?.time ? ` ${leg.to.time}` : ''}` : null,
mins ? t('transit.min', { count: mins }) : null,
!isWalk && leg.stops ? t('transit.stops', { count: leg.stops }) : null,
!isWalk && leg.headsign ? `${leg.headsign}` : null,
].filter(Boolean).join(' · ')}
</div>
</div>
</div>
)
})}
</div>
</div>
</>
)}
{/* Notizen */}
{res.notes && (
<div className="bg-surface-tertiary" style={{ padding: '8px 10px', borderRadius: 8 }}>
@@ -227,17 +162,8 @@ export function DayPlanSidebarTransportDetailModal({
)
})()}
{/* Aktionen */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
{onEdit && (
<button onClick={() => onEdit(res)} className="bg-surface-tertiary text-content" style={{
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
border: 'none', borderRadius: 8, padding: '6px 14px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 5,
}}>
<Pencil size={12} /> {t('common.edit')}
</button>
)}
{/* Schließen */}
<div style={{ textAlign: 'right' }}>
<button onClick={() => setTransportDetail(null)} className="bg-accent text-accent-text" style={{
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit',
@@ -259,8 +259,8 @@ describe('PlaceInspector', () => {
onRemoveAssignment={onRemoveAssignment}
/>
);
// Find the remove button — it carries the "Remove from Day" label
const removeBtn = screen.getByText('Remove from Day').closest('button')!;
// Find the remove button — it has "Remove" text (sm:hidden span)
const removeBtn = screen.getByText('Remove').closest('button')!;
await user.click(removeBtn);
// Component calls onRemoveAssignment(selectedDayId, assignmentInDay.id)
expect(onRemoveAssignment).toHaveBeenCalledWith(1, 99);
@@ -8,7 +8,7 @@ import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus
import PlaceAvatar from '../shared/PlaceAvatar'
import GuestBadge from '../shared/GuestBadge'
import StatusBadge from '../Collections/StatusBadge'
import { mapsApi, pluginsApi } from '../../api/client'
import { mapsApi } from '../../api/client'
import { collectionsApi } from '../../api/collections'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
@@ -16,8 +16,6 @@ import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'
import { getCategoryIcon } from '../shared/categoryIcons'
import { useToast } from '../shared/Toast'
import { useTranslation, translateApiError } from '../../i18n'
import { usePluginStore } from '../../store/pluginStore'
import PluginFrame from '../Plugins/PluginFrame'
import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
import type { CollectionStatus } from '@trek/shared'
import { splitReservationDateTime, formatTime } from '../../utils/formatters'
@@ -144,21 +142,6 @@ export default function PlaceInspector({
leftWidth = 0, rightWidth = 0,
collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList,
}: PlaceInspectorProps) {
// Plugins that declared a place-detail slot mount at the bottom of this panel,
// scoped to the open place (trip mode only). Inline-filter like the other sites.
const placeDetailPlugins = usePluginStore((s) => s.plugins).filter((p) => p.type === 'widget' && p.slot === 'place-detail')
// Extra native rows contributed by placeDetailProvider plugins (#1429). Fail-safe:
// any provider error/timeout is dropped server-side, so this only ever adds rows.
const [providerDetails, setProviderDetails] = useState<Array<{ pluginId: string; items: Array<{ label: string; value?: string; url?: string }> }>>([])
const placeIdForDetails = mode === 'trip' ? place?.id : undefined
useEffect(() => {
if (placeIdForDetails == null) { setProviderDetails([]); return }
let cancelled = false
pluginsApi.placeDetails(placeIdForDetails)
.then((d) => { if (!cancelled) setProviderDetails((d.providers || []).filter((p) => Array.isArray(p.items) && p.items.length > 0)) })
.catch(() => { if (!cancelled) setProviderDetails([]) })
return () => { cancelled = true }
}, [placeIdForDetails])
const { t, locale, language } = useTranslation()
const toast = useToast()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
@@ -373,34 +356,6 @@ export default function PlaceInspector({
fileInputRef={fileInputRef} handleFileUpload={handleFileUpload} isUploading={isUploading}
distanceUnit={distanceUnit} />
{/* Extra native rows from placeDetailProvider plugins (#1429). */}
{mode === 'trip' && providerDetails.length > 0 && (
<div className="bg-surface-hover" style={{ borderRadius: 10, padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
{providerDetails.flatMap((p) => p.items.map((it, i) => (
<div key={`${p.pluginId}-${i}`} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8, fontSize: 'calc(12.5px * var(--fs-scale-body, 1))' }}>
<span className="text-content-secondary" style={{ fontWeight: 500, flexShrink: 0 }}>{it.label}</span>
{it.url
? <a href={it.url} target="_blank" rel="noreferrer noopener" className="text-accent" style={{ textDecoration: 'none', textAlign: 'right', overflow: 'hidden', textOverflow: 'ellipsis' }}>{it.value ?? '↗'}</a>
: <span className="text-content-muted" style={{ textAlign: 'right' }}>{it.value}</span>}
</div>
)))}
</div>
)}
{/* Place-detail plugin slots (#1429): sandboxed, scoped to this place. */}
{mode === 'trip' && placeDetailPlugins.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{placeDetailPlugins.map((p) => {
const tid = (place as { trip_id?: number | string }).trip_id
return (
<div key={p.id} className="bg-surface-hover" style={{ borderRadius: 10, overflow: 'hidden' }}>
<PluginFrame pluginId={p.id} tripId={tid != null ? String(tid) : null} placeId={String(place.id)} title={p.name} />
</div>
)
})}
</div>
)}
</div>
{/* Footer actions */}
@@ -417,7 +372,7 @@ export default function PlaceInspector({
{mode === 'trip' && selectedDayId && (
assignmentInDay ? (
<ActionButton onClick={() => onRemoveAssignment?.(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />}
label={<span className="hidden sm:inline">{t('inspector.removeFromDay')}</span>} />
label={<><span className="hidden sm:inline">{t('inspector.removeFromDay')}</span><span className="sm:hidden">{t('inspector.remove')}</span></>} />
) : (
<ActionButton onClick={() => onAssignToDay?.(place.id)} variant="primary" icon={<Plus size={13} />} label={t('inspector.addToDay')} />
)
@@ -23,13 +23,13 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
} = S
return (
<div
onDragEnter={isMobile ? undefined : handleSidebarDragEnter}
onDragOver={isMobile ? undefined : handleSidebarDragOver}
onDragLeave={isMobile ? undefined : handleSidebarDragLeave}
onDrop={isMobile ? undefined : handleSidebarDrop}
onDragEnter={handleSidebarDragEnter}
onDragOver={handleSidebarDragOver}
onDragLeave={handleSidebarDragLeave}
onDrop={handleSidebarDrop}
style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: "var(--font-system)", position: 'relative' }}
>
{!isMobile && sidebarDragOver && <PlacesDropOverlay {...S} />}
{sidebarDragOver && <PlacesDropOverlay {...S} />}
{/* Kopfbereich */}
<PlacesHeader {...S} />
@@ -36,9 +36,8 @@ export const MemoPlaceRow = React.memo(function MemoPlaceRow({
ref={element => registerPlaceRow(place.id, element)}
aria-selected={isSelected}
data-place-id={place.id}
draggable={!selectMode && !isMobile}
draggable={!selectMode}
onDragStart={e => {
if (isMobile) { e.preventDefault(); return }
e.dataTransfer.setData('placeId', String(place.id))
e.dataTransfer.effectAllowed = 'copy'
window.__dragData = { placeId: String(place.id) }
@@ -56,7 +55,7 @@ export const MemoPlaceRow = React.memo(function MemoPlaceRow({
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '9px 14px 9px 16px',
cursor: selectMode || isMobile ? 'pointer' : 'grab',
cursor: selectMode ? 'pointer' : 'grab',
background: isChecked ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : isSelected ? 'var(--border-faint)' : 'transparent',
borderBottom: '1px solid var(--border-faint)',
transition: 'background 0.1s',
@@ -409,12 +409,6 @@ describe('ReservationModal', () => {
expect(screen.getByRole('button', { name: /Attach file/i })).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-029b: file input accepts pkpass (#1448)', () => {
render(<ReservationModal {...defaultProps} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(fileInput.accept).toContain('.pkpass');
});
it('FE-PLANNER-RESMODAL-030: hotel type — saving calls onSave with correct hotel shape', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<ReservationModal {...defaultProps} onSave={onSave} />);
@@ -682,7 +682,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</button>
</div>
))}
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,.pkpass,.pkpasses,image/*,application/vnd.apple.pkpass,application/vnd.apple.pkpasses" style={{ display: 'none' }} onChange={handleFileChange} />
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{onFileUpload && <button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} className="text-content-faint" style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
@@ -9,10 +9,8 @@ import {
Plane, Hotel, Utensils, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Ticket, FileText, MapPin,
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users,
ExternalLink, BookMarked, Lightbulb, Link2, Clock, ArrowRight, AlertCircle, Download,
TramFront, Footprints, StickyNote,
} from 'lucide-react'
import { openFile } from '../../utils/fileDownload'
import { TransitTitle, TransitLegChips, TransitMetaBadges, fmtTransitDuration } from './transitDisplay'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
@@ -39,7 +37,6 @@ const TYPE_OPTIONS = [
{ value: 'bicycle', labelKey: 'reservations.type.bicycle', Icon: Bike, color: '#84cc16' },
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship, color: '#0ea5e9' },
{ value: 'ferry', labelKey: 'reservations.type.ferry', Icon: Sailboat, color: '#0d9488' },
{ value: 'transit', labelKey: 'reservations.type.transit', Icon: TramFront, color: '#7c3aed' },
{ value: 'transport_other', labelKey: 'reservations.type.transport_other', Icon: Route, color: '#6b7280' },
{ value: 'event', labelKey: 'reservations.type.event', Icon: Ticket, color: '#f59e0b' },
{ value: 'tour', labelKey: 'reservations.type.tour', Icon: Users, color: '#10b981' },
@@ -112,7 +109,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
const hasCode = !!r.confirmation_number
const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length
const TRANSPORT_TYPES_SET = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'])
const TRANSPORT_TYPES_SET = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'])
const isTransportType = TRANSPORT_TYPES_SET.has(r.type)
const isHotel = r.type === 'hotel'
// For a hotel linked to an accommodation, the accommodation's own start/end days are
@@ -494,95 +491,6 @@ function Section({ title, count, children, defaultOpen = true, accent, storageKe
)
}
/**
* A transit journey's own card (#1065) leg chips + journey stats instead of
* the generic booking layout. Clicking anywhere opens the journey view.
*/
function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit }: {
r: Reservation
days: Day[]
onOpen: (r: Reservation) => void
onDelete: (id: number) => void
canEdit: boolean
}) {
const { t, locale } = useTranslation()
const timeFormat = useSettingsStore(st => st.settings.time_format) || '24h'
const [confirmOpen, setConfirmOpen] = useState(false)
const meta = typeof r.metadata === 'string' ? (() => { try { return JSON.parse(r.metadata || '{}') } catch { return {} } })() : (r.metadata || {})
const transit = meta.transit && Array.isArray(meta.transit.legs) ? meta.transit : null
const { date, time } = splitReservationDateTime(r.reservation_time)
const { time: endTime } = splitReservationDateTime(r.reservation_end_time)
const day = r.day_id ? days.find(d => d.id === r.day_id) : undefined
const dateStr = date ? new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) : null
const mins = transit?.duration ? Math.round(transit.duration / 60) : null
return (
<div
className="bg-surface-card"
onClick={() => onOpen(r)}
style={{ borderRadius: 12, border: '1px solid rgba(124,58,237,0.22)', padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 9, cursor: 'pointer', transition: 'box-shadow 0.15s ease' }}
onMouseEnter={e => e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.06)'}
onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 34, height: 34, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 10, background: 'rgba(124,58,237,0.1)' }}>
<TramFront size={16} strokeWidth={1.8} color="#7c3aed" />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<TransitTitle title={r.title} iconSize={12} />
</div>
<div style={{ marginTop: 2 }}>
<TransitMetaBadges size="sm" items={[
{ text: day ? (day.title || t('dayplan.dayN', { n: day.day_number })) : '' },
{ icon: Calendar, text: dateStr || '' },
{ icon: Clock, text: time ? `${formatTime(time, locale, timeFormat)}${endTime ? ` ${formatTime(endTime, locale, timeFormat)}` : ''}` : '' },
{ text: transit?.duration ? fmtTransitDuration(transit.duration, t) : '' },
]} />
</div>
</div>
{canEdit && (
<button
onClick={e => { e.stopPropagation(); setConfirmOpen(true) }}
title={t('common.delete')}
className="bg-transparent text-content-faint"
style={{ appearance: 'none', border: 'none', width: 26, height: 26, borderRadius: 6, display: 'grid', placeItems: 'center', cursor: 'pointer', flexShrink: 0 }}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(239,68,68,0.08)'; e.currentTarget.style.color = '#ef4444' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}
>
<Trash2 size={13} />
</button>
)}
</div>
{transit && (
<div style={{ paddingLeft: 44 }}>
<TransitLegChips legs={transit.legs} size="md" t={t} />
</div>
)}
{r.notes && (
<div className="text-content-faint" style={{ paddingLeft: 44, display: 'flex', alignItems: 'center', gap: 5, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', minWidth: 0 }}>
<StickyNote size={11} style={{ flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Markdown remarkPlugins={[remarkGfm]} allowedElements={['strong', 'em', 'del', 'code', 'a']} unwrapDisallowed>{r.notes.split('\n')[0]}</Markdown>
</span>
</div>
)}
{confirmOpen && ReactDOM.createPortal(
<div className="bg-[rgba(0,0,0,0.35)]" style={{ position: 'fixed', inset: 0, zIndex: 3000, display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={e => { e.stopPropagation(); setConfirmOpen(false) }}>
<div className="bg-surface-card" style={{ borderRadius: 14, padding: 20, width: 340, boxShadow: '0 16px 48px rgba(0,0,0,0.22)' }} onClick={e => e.stopPropagation()}>
<div className="text-content" style={{ fontWeight: 600, fontSize: 'calc(14px * var(--fs-scale-body, 1))', marginBottom: 6 }}>{t('reservations.confirm.deleteTitle')}</div>
<div className="text-content-muted" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', marginBottom: 14 }}>{t('reservations.confirm.deleteBody', { name: r.title })}</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={e => { e.stopPropagation(); setConfirmOpen(false) }} className="text-content-muted" style={{ padding: '7px 14px', borderRadius: 9, border: '1px solid var(--border-primary)', background: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button onClick={e => { e.stopPropagation(); setConfirmOpen(false); onDelete(r.id) }} style={{ padding: '7px 14px', borderRadius: 9, border: 'none', background: '#ef4444', color: '#fff', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.delete')}</button>
</div>
</div>
</div>,
document.body
)}
</div>
)
}
interface ReservationsPanelProps {
tripId: number
reservations: Reservation[]
@@ -631,12 +539,8 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
typeFilters.size === 0 ? reservations : reservations.filter(r => typeFilters.has(r.type)),
[reservations, typeFilters])
// Automated public transit (#1065) gets its own section — journeys planned via
// the transit search live alongside manual transports without mixing in.
const transitEntries = filtered.filter(r => r.type === 'transit')
const nonTransit = filtered.filter(r => r.type !== 'transit')
const allPending = nonTransit.filter(r => r.status !== 'confirmed')
const allConfirmed = nonTransit.filter(r => r.status === 'confirmed')
const allPending = filtered.filter(r => r.status !== 'confirmed')
const allConfirmed = filtered.filter(r => r.status === 'confirmed')
const total = filtered.length
const usedTypes = useMemo(() => new Set(reservations.map(r => r.type)), [reservations])
@@ -774,11 +678,6 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
</div>
) : (
<>
{transitEntries.length > 0 && (
<Section title={t('transit.sectionTitle')} count={transitEntries.length} accent="gray" storageKey={`trek:bookings-transit-open:${tripId}`}>
{transitEntries.map(r => <TransitJourneyCard key={r.id} r={r} days={days} onOpen={onEdit} onDelete={onDelete} canEdit={canEdit} />)}
</Section>
)}
{allPending.length > 0 && (
<Section title={t('reservations.pending')} count={allPending.length} accent="gray" storageKey={`trek:bookings-pending-open:${tripId}`}>
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} days={days} />)}
@@ -1,135 +0,0 @@
// FE-PLANNER-TRANSITJOURNEY-001 to 005 — the journey view for a saved transit entry.
import { render, screen, waitFor } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
import { buildUser, buildReservation } from '../../../tests/helpers/factories'
import TransitJourneyModal from './TransitJourneyModal'
function makeReservation() {
return {
...buildReservation({ id: 7, type: 'transit', title: 'Fernsehturm → Zoo', reservation_time: '2025-06-01T08:30:00', status: 'confirmed' }),
metadata: {
transit: {
provider: 'transitous', duration: 1800, transfers: 1, walk_seconds: 240,
legs: [
{ mode: 'WALK', duration: 240, from: { name: 'Start' }, to: { name: 'Alexanderplatz' } },
{ mode: 'SUBWAY', line: 'U2', line_color: '#FF3300', line_text_color: '#FFFFFF', headsign: 'Ruhleben', agency: 'BVG', duration: 1440, stops: 6, from: { name: 'Alexanderplatz', time: '08:36', track: '2' }, to: { name: 'Zoo', time: '09:00' } },
],
},
},
endpoints: [
{ role: 'from', sequence: 0, name: 'Fernsehturm', code: null, lat: 52.52, lng: 13.4, timezone: 'Europe/Berlin', local_date: null, local_time: null },
{ role: 'to', sequence: 1, name: 'Zoo', code: null, lat: 52.5, lng: 13.33, timezone: 'Europe/Berlin', local_date: null, local_time: null },
],
} as any
}
function makeProps(overrides = {}) {
return {
reservation: makeReservation(),
onClose: vi.fn(),
onSave: vi.fn().mockResolvedValue({}),
onDelete: vi.fn().mockResolvedValue({}),
onChangeRoute: vi.fn(),
canEdit: true,
...overrides,
}
}
beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true })
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any)
})
describe('TransitJourneyModal', () => {
it('FE-PLANNER-TRANSITJOURNEY-001: shows summary, line badge, platform and legs', () => {
render(<TransitJourneyModal {...makeProps()} />)
expect(screen.getByText('U2')).toBeInTheDocument()
// stat tiles: value + caption
expect(screen.getByText('Transfers')).toBeInTheDocument()
expect(screen.getByText('Walking')).toBeInTheDocument()
expect(screen.getByText(/Platform 2/)).toBeInTheDocument()
expect(screen.getByText(/Ruhleben/)).toBeInTheDocument()
expect(screen.getByText(/BVG/)).toBeInTheDocument()
})
it('FE-PLANNER-TRANSITJOURNEY-002: inline title rename + notes save as a minimal field payload', async () => {
const user = userEvent.setup()
const onSave = vi.fn().mockResolvedValue({})
render(<TransitJourneyModal {...makeProps({ onSave })} />)
// The title renames inline in the header via its pencil.
await user.click(screen.getByLabelText('Edit'))
const titleInput = screen.getByDisplayValue('Fernsehturm → Zoo')
await user.clear(titleInput)
await user.type(titleInput, 'Zum Zoo')
await user.keyboard('{Enter}')
await user.type(screen.getByPlaceholderText(/notes/i), 'Take **coffee**')
await user.click(screen.getByRole('button', { name: /^Save$/ }))
await waitFor(() => expect(onSave).toHaveBeenCalled())
expect(onSave).toHaveBeenCalledWith({ title: 'Zum Zoo', notes: 'Take **coffee**' })
})
it('FE-PLANNER-TRANSITJOURNEY-006: no status or booking-code fields; notes support a markdown preview', async () => {
const user = userEvent.setup()
render(<TransitJourneyModal {...makeProps()} />)
expect(screen.queryByText('Status')).not.toBeInTheDocument()
expect(screen.queryByText(/Booking code|Confirmation/i)).not.toBeInTheDocument()
await user.type(screen.getByPlaceholderText(/notes/i), '**bold** note')
await user.click(screen.getByRole('button', { name: 'Preview' }))
const bold = document.querySelector('.collab-note-md strong')
expect(bold?.textContent).toBe('bold')
})
it('FE-PLANNER-TRANSITJOURNEY-008: existing notes open rendered as markdown, not raw text', () => {
const res = { ...makeReservation(), notes: 'bring **wefwe** along' }
render(<TransitJourneyModal {...makeProps({ reservation: res })} />)
// Preview tab is active on open: bold is rendered, no raw asterisks visible.
const bold = document.querySelector('.collab-note-md strong')
expect(bold?.textContent).toBe('wefwe')
expect(screen.queryByDisplayValue(/\*\*wefwe\*\*/)).not.toBeInTheDocument()
})
it('FE-PLANNER-TRANSITJOURNEY-007: the markdown toolbar wraps the note text', async () => {
const user = userEvent.setup()
render(<TransitJourneyModal {...makeProps()} />)
const area = screen.getByPlaceholderText(/notes/i) as HTMLTextAreaElement
await user.type(area, 'coffee')
area.setSelectionRange(0, 6)
await user.click(screen.getByRole('button', { name: 'Bold' }))
expect(area.value).toBe('**coffee**')
await user.click(screen.getByRole('button', { name: 'Checklist' }))
expect((screen.getByPlaceholderText(/notes/i) as HTMLTextAreaElement).value).toMatch(/^- \[ \] /)
})
it('FE-PLANNER-TRANSITJOURNEY-003: change route triggers onChangeRoute', async () => {
const user = userEvent.setup()
const onChangeRoute = vi.fn()
render(<TransitJourneyModal {...makeProps({ onChangeRoute })} />)
await user.click(screen.getByRole('button', { name: /Change route/ }))
expect(onChangeRoute).toHaveBeenCalled()
})
it('FE-PLANNER-TRANSITJOURNEY-004: delete asks for confirmation, then calls onDelete', async () => {
const user = userEvent.setup()
const onDelete = vi.fn().mockResolvedValue({})
render(<TransitJourneyModal {...makeProps({ onDelete })} />)
await user.click(screen.getByRole('button', { name: /^Delete$/ }))
expect(onDelete).not.toHaveBeenCalled()
// Confirm dialog appears — confirm it.
const confirmBtns = await screen.findAllByRole('button', { name: /Delete/ })
await user.click(confirmBtns[confirmBtns.length - 1])
await waitFor(() => expect(onDelete).toHaveBeenCalled())
})
it('FE-PLANNER-TRANSITJOURNEY-005: read-only without edit rights — no delete/save/change-route', () => {
render(<TransitJourneyModal {...makeProps({ canEdit: false })} />)
expect(screen.queryByRole('button', { name: /^Delete$/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Change route/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /^Save$/ })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /Close/ })).toBeInTheDocument()
})
})
@@ -1,403 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { ArrowRight, ArrowRightLeft, Bold, Clock, Code, Footprints, Heading2, Italic, Link2, List, ListChecks, MoveRight, Pencil, RefreshCw, Strikethrough, TramFront, Trash2 } from 'lucide-react'
import Modal from '../shared/Modal'
import ConfirmDialog from '../shared/ConfirmDialog'
import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { splitReservationDateTime, formatTime } from '../../utils/formatters'
import { TransitTitle, TransitMetaBadges, TransitWalkDivider, fmtTransitDuration } from './transitDisplay'
import type { Reservation } from '../../types'
/**
* The journey view for an automated public-transit entry (#1065): a roomy modal
* around the stop-by-stop itinerary. The title renames inline right in the
* header, notes get the full width with markdown support, and "Change route"
* re-enters the transit search pre-seeded with this journey's route.
*/
interface TransitLegMeta {
mode?: string
line?: string | null
line_color?: string | null
line_text_color?: string | null
headsign?: string | null
agency?: string | null
duration?: number
stops?: number
from?: { name?: string; time?: string | null; track?: string | null }
to?: { name?: string; time?: string | null; track?: string | null }
}
interface TransitJourneyModalProps {
reservation: Reservation
onClose: () => void
/** Partial field update — endpoints + itinerary stay untouched. */
onSave: (fields: { title: string; notes: string | null }) => Promise<unknown>
onDelete: () => Promise<unknown>
onChangeRoute: () => void
canEdit: boolean
}
export default function TransitJourneyModal({ reservation, onClose, onSave, onDelete, onChangeRoute, canEdit }: TransitJourneyModalProps) {
const { t, locale } = useTranslation()
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
const timeFormat = useSettingsStore(st => st.settings.time_format) || '24h'
const res = reservation
const meta = typeof res.metadata === 'string' ? (() => { try { return JSON.parse(res.metadata || '{}') } catch { return {} } })() : (res.metadata || {})
const transit = meta.transit && Array.isArray(meta.transit.legs) ? meta.transit : null
const [title, setTitle] = useState(res.title || '')
const [editingTitle, setEditingTitle] = useState(false)
const [notes, setNotes] = useState(res.notes || '')
// Existing notes open rendered; the write tab is for editing.
const [notesTab, setNotesTab] = useState<'write' | 'preview'>(() => (res.notes ? 'preview' : 'write'))
const [saving, setSaving] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
const titleInputRef = useRef<HTMLInputElement | null>(null)
const notesRef = useRef<HTMLTextAreaElement | null>(null)
// Markdown toolbar: wrap the selection / prefix the current lines, then
// restore focus and a sensible cursor.
const applyMd = (action: { wrap?: [string, string]; linePrefix?: string }) => {
const el = notesRef.current
if (!el) return
const start = el.selectionStart ?? 0
const end = el.selectionEnd ?? 0
const value = notes
let next = value
let selStart = start
let selEnd = end
if (action.wrap) {
const [pre, post] = action.wrap
const selected = value.slice(start, end)
next = value.slice(0, start) + pre + selected + post + value.slice(end)
selStart = start + pre.length
selEnd = selStart + selected.length
} else if (action.linePrefix) {
const prefix = action.linePrefix
const lineStart = value.lastIndexOf('\n', start - 1) + 1
const block = value.slice(lineStart, end)
const prefixed = block.split('\n').map(l => prefix + l).join('\n')
next = value.slice(0, lineStart) + prefixed + value.slice(end)
selStart = start + prefix.length
selEnd = end + (prefixed.length - block.length)
}
setNotes(next)
requestAnimationFrame(() => {
el.focus()
el.setSelectionRange(selStart, selEnd)
})
}
const MD_TOOLS: { Icon: typeof Bold; label: string; action: { wrap?: [string, string]; linePrefix?: string } }[] = [
{ Icon: Bold, label: 'Bold', action: { wrap: ['**', '**'] } },
{ Icon: Italic, label: 'Italic', action: { wrap: ['*', '*'] } },
{ Icon: Strikethrough, label: 'Strikethrough', action: { wrap: ['~~', '~~'] } },
{ Icon: Heading2, label: 'Heading', action: { linePrefix: '## ' } },
{ Icon: List, label: 'List', action: { linePrefix: '- ' } },
{ Icon: ListChecks, label: 'Checklist', action: { linePrefix: '- [ ] ' } },
{ Icon: Link2, label: 'Link', action: { wrap: ['[', '](https://)'] } },
{ Icon: Code, label: 'Code', action: { wrap: ['`', '`'] } },
]
useEffect(() => {
setTitle(res.title || '')
setNotes(res.notes || '')
setEditingTitle(false)
setNotesTab(res.notes ? 'preview' : 'write')
}, [res.id])
useEffect(() => { if (editingTitle) titleInputRef.current?.focus() }, [editingTitle])
const dirty = title !== (res.title || '') || notes !== (res.notes || '')
const save = async () => {
if (!title.trim()) return
setSaving(true)
try {
await onSave({ title: title.trim(), notes: notes.trim() || null })
onClose()
} finally { setSaving(false) }
}
const { date, time } = splitReservationDateTime(res.reservation_time)
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
const dateStr = date ? new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' }) : ''
const statTiles = transit ? [
{ Icon: Clock, value: transit.duration > 0 ? fmtTransitDuration(transit.duration, t) : '—', label: t('transit.durationLabel') },
{ Icon: ArrowRightLeft, value: String(transit.transfers ?? 0), label: t('transit.transfersLabel') },
{ Icon: Footprints, value: transit.walk_seconds > 59 ? t('transit.min', { count: Math.round(transit.walk_seconds / 60) }) : '—', label: t('transit.walkLabel') },
] : []
return (
<Modal
isOpen
onClose={onClose}
title={t('transit.journey')}
size="2xl"
footer={
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{canEdit && (
<button onClick={() => setConfirmDelete(true)} aria-label={t('common.delete')} title={t('common.delete')} style={{
display: 'inline-flex', alignItems: 'center', gap: 5, padding: isMobile ? '9px 11px' : '8px 14px', borderRadius: 10,
border: '1px solid rgba(239,68,68,0.3)', background: 'rgba(239,68,68,0.06)', color: '#ef4444',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
}}>
<Trash2 size={13} /> {!isMobile && t('common.delete')}
</button>
)}
<div style={{ flex: 1 }} />
{canEdit && (
<button onClick={onChangeRoute} className="text-content-muted" style={{
display: 'inline-flex', alignItems: 'center', gap: 6, padding: '8px 16px', borderRadius: 10,
border: '1px solid var(--border-primary)', background: 'none',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit',
}}>
<RefreshCw size={13} /> {t('transit.changeRoute')}
</button>
)}
{canEdit ? (
<button onClick={save} disabled={saving || !title.trim() || !dirty} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{
padding: '8px 20px', borderRadius: 10, border: 'none',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
opacity: saving || !title.trim() || !dirty ? 0.5 : 1,
}}>
{saving ? t('common.saving') : t('common.save')}
</button>
) : (
<button onClick={onClose} className="bg-accent text-accent-text" style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
{t('common.close')}
</button>
)}
</div>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, fontFamily: 'var(--font-system)' }}>
{/* header: icon + inline-renamable title + date/time */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ width: isMobile ? 40 : 48, height: isMobile ? 40 : 48, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 13, background: '#7c3aed18' }}>
<TramFront size={isMobile ? 19 : 23} strokeWidth={1.8} color="#7c3aed" />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
{editingTitle ? (
<input
ref={titleInputRef}
value={title}
onChange={e => setTitle(e.target.value)}
onBlur={() => setEditingTitle(false)}
onKeyDown={e => { if (e.key === 'Enter') setEditingTitle(false); if (e.key === 'Escape') { setTitle(res.title || ''); setEditingTitle(false) } }}
className="text-content"
aria-label={t('reservations.titleLabel')}
style={{ width: '100%', border: 'none', outline: 'none', background: 'transparent', padding: 0, fontFamily: 'inherit', fontSize: 'calc(17px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.015em', borderBottom: '1.5px solid var(--text-primary)' }}
/>
) : (
<div className="text-content" style={{ display: 'flex', alignItems: 'center', gap: 7, fontSize: 'calc(17px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.015em', minWidth: 0 }}>
<span style={{ minWidth: 0, overflow: 'hidden' }}><TransitTitle title={title} iconSize={15} /></span>
{canEdit && (
<button onClick={() => setEditingTitle(true)} aria-label={t('common.edit')} title={t('common.edit')} className="text-content-faint" style={{ border: 'none', background: 'none', padding: 3, cursor: 'pointer', display: 'flex', flexShrink: 0 }}>
<Pencil size={13} strokeWidth={1.8} />
</button>
)}
</div>
)}
<div className="text-content-muted" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', marginTop: 3 }}>
{[dateStr, time ? `${formatTime(time, locale, timeFormat)}${endTime ? ` ${formatTime(endTime, locale, timeFormat)}` : ''}` : ''].filter(Boolean).join(' · ')}
</div>
</div>
</div>
{transit && (
<>
{/* journey stats — three full-width tiles; iconless and flat on mobile */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: isMobile ? 6 : 10 }}>
{statTiles.map(({ Icon, value, label }, i) => (
isMobile ? (
<div key={i} className="bg-surface-tertiary" style={{ padding: '7px 6px 6px', borderRadius: 11, textAlign: 'center', minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 700, letterSpacing: '-0.01em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{value}</div>
<div className="text-content-faint" style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', marginTop: 1 }}>{label}</div>
</div>
) : (
<div key={i} className="bg-surface-tertiary" style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '13px 16px', borderRadius: 12 }}>
<div className="bg-surface-card" style={{ width: 34, height: 34, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 10 }}>
<Icon size={16} strokeWidth={1.9} className="text-content-muted" />
</div>
<div style={{ minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.01em', whiteSpace: 'nowrap' }}>{value}</div>
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>{label}</div>
</div>
</div>
)
))}
</div>
{/* stop-by-stop itinerary */}
<div className="bg-surface-tertiary" style={{ padding: isMobile ? '11px 10px' : '14px 16px', borderRadius: 12 }}>
<div className="text-content-faint" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: 12 }}>
{t('transit.itinerary')}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{(transit.legs as TransitLegMeta[]).map((leg, i) => {
if (leg.mode === 'WALK') return <TransitWalkDivider key={i} leg={leg} t={t} size={isMobile ? 'sm' : 'md'} />
const mins = leg.duration ? Math.round(leg.duration / 60) : null
if (isMobile) {
// The wide from → to line plus the chip row doesn't fit a
// phone: each leg becomes a depart / ride / arrive rail in
// the line's color, meta as one quiet text line.
const color = leg.line_color || 'var(--text-muted)'
const metaLine = [
mins ? t('transit.min', { count: mins }) : null,
leg.stops ? t('transit.stops', { count: leg.stops }) : null,
leg.agency || null,
].filter(Boolean).join(' · ')
// Names wrap instead of clipping; the platform gets its
// own quiet line below so it never pushes the name out.
const stopName = (stop: TransitLegMeta['from']) => (
<div style={{ minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, lineHeight: 1.35, overflowWrap: 'anywhere' }}>
{stop?.name}
</div>
{stop?.track && (
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}>
{t('transit.platform', { track: stop.track })}
</div>
)}
</div>
)
const timeCell = (time?: string | null) => (
<div className="text-content-muted" style={{ fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, textAlign: 'right', paddingTop: 1 }}>
{time || ''}
</div>
)
return (
<div key={i} style={{ display: 'grid', gridTemplateColumns: '34px 14px 1fr', columnGap: 8 }}>
{timeCell(leg.from?.time)}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<span style={{ width: 9, height: 9, borderRadius: '50%', border: `2.5px solid ${color}`, background: 'var(--bg-tertiary)', flexShrink: 0, marginTop: 3 }} />
<span style={{ flex: 1, width: 3, borderRadius: 2, background: color, marginTop: 2 }} />
</div>
{stopName(leg.from)}
<div />
<div style={{ display: 'flex', justifyContent: 'center' }}>
<span style={{ width: 3, borderRadius: 2, background: color }} />
</div>
<div style={{ padding: '6px 0 8px', minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
<span style={{ display: 'inline-flex', alignItems: 'center', background: leg.line_color || 'var(--bg-hover)', color: leg.line_color ? (leg.line_text_color || '#fff') : 'var(--text-primary)', borderRadius: 6, padding: '1px 7px', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, flexShrink: 0 }}>
{leg.line || leg.mode}
</span>
{leg.headsign && (
<span className="text-content-faint" style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', minWidth: 0 }}>
<MoveRight size={10} style={{ flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{leg.headsign}</span>
</span>
)}
</div>
{metaLine && (
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', marginTop: 3 }}>{metaLine}</div>
)}
</div>
{timeCell(leg.to?.time)}
<div style={{ display: 'flex', justifyContent: 'center' }}>
<span style={{ width: 9, height: 9, borderRadius: '50%', border: `2.5px solid ${color}`, background: 'var(--bg-tertiary)', marginTop: 3 }} />
</div>
{stopName(leg.to)}
</div>
)
}
return (
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div className="text-content-muted" style={{ width: 44, flexShrink: 0, textAlign: 'right', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, paddingTop: 1 }}>
{leg.from?.time || ''}
</div>
<span style={{ display: 'inline-flex', alignItems: 'center', background: leg.line_color || 'var(--bg-hover)', color: leg.line_color ? (leg.line_text_color || '#fff') : 'var(--text-primary)', borderRadius: 6, padding: '2px 8px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 700, flexShrink: 0 }}>
{leg.line || leg.mode}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 600, display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{leg.from?.name}</span>
{leg.from?.track && <span className="text-content-faint" style={{ fontWeight: 500 }}>({t('transit.platform', { track: leg.from.track })})</span>}
<ArrowRight size={12} className="text-content-faint" style={{ flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{leg.to?.name}</span>
</div>
<div style={{ marginTop: 3 }}>
<TransitMetaBadges items={[
{ icon: Clock, text: leg.from?.time ? `${leg.from.time}${leg.to?.time ? ` ${leg.to.time}` : ''}` : '' },
{ text: mins ? t('transit.min', { count: mins }) : '' },
{ text: leg.stops ? t('transit.stops', { count: leg.stops }) : '' },
{ icon: MoveRight, text: leg.headsign || '' },
{ text: leg.agency || '', dim: true },
]} />
</div>
</div>
</div>
)
})}
</div>
</div>
</>
)}
{/* notes — full width, markdown */}
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6, gap: 8, flexWrap: 'wrap' }}>
<label className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.notes')}</label>
{canEdit && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{notesTab === 'write' && (
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 8, padding: 2, gap: 1 }}>
{MD_TOOLS.map(({ Icon, label, action }) => (
<button key={label} type="button" onClick={() => applyMd(action)} title={label} aria-label={label}
className="text-content-muted"
style={{ width: 26, height: 24, display: 'grid', placeItems: 'center', borderRadius: 6, border: 0, background: 'transparent', cursor: 'pointer' }}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-card)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}>
<Icon size={13} strokeWidth={2} />
</button>
))}
</div>
)}
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 8, padding: 2, gap: 2 }}>
{([['write', t('common.edit')], ['preview', t('common.preview')]] as const).map(([tab, label]) => (
<button key={tab} type="button" onClick={() => setNotesTab(tab)}
className={notesTab === tab ? 'bg-surface-card text-content' : 'text-content-muted'}
style={{ padding: '4px 12px', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, borderRadius: 6, border: 0, cursor: 'pointer', fontFamily: 'inherit', background: notesTab === tab ? undefined : 'transparent' }}>
{label}
</button>
))}
</div>
</div>
)}
</div>
{canEdit && notesTab === 'write' ? (
<textarea
ref={notesRef}
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder={t('reservations.notesPlaceholder')}
className="w-full border border-edge rounded-[10px] px-[12px] py-[10px] text-[13px] font-[inherit] outline-none box-border text-content bg-surface-input"
style={{ minHeight: 130, resize: 'vertical', lineHeight: 1.55 }}
/>
) : (
<div className="bg-surface-tertiary" style={{ borderRadius: 10, padding: '12px 14px', minHeight: canEdit ? 130 : undefined }}>
{notes.trim()
? <div className="collab-note-md text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{notes}</Markdown></div>
: <span className="text-content-faint" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))' }}>{t('reservations.notesPlaceholder')}</span>}
</div>
)}
</div>
</div>
<ConfirmDialog
isOpen={confirmDelete}
onClose={() => setConfirmDelete(false)}
onConfirm={async () => { setConfirmDelete(false); await onDelete(); onClose() }}
title={t('reservations.confirm.deleteTitle')}
message={t('reservations.confirm.deleteBody', { name: res.title })}
/>
</Modal>
)
}
@@ -1,149 +0,0 @@
// FE-PLANNER-TRANSIT-001 to FE-PLANNER-TRANSIT-006 — the transit search panel
// (embedded as the TransportModal's Automated mode).
import { render, screen, waitFor } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
import { buildUser, buildDay, buildPlace } from '../../../tests/helpers/factories'
import TransitSearchPanel from './TransitSearchPanel'
const { transitApiMock } = vi.hoisted(() => ({
transitApiMock: { geocode: vi.fn(), plan: vi.fn() },
}))
vi.mock('../../api/client', async (importOriginal) => {
const actual = await importOriginal() as object
return { ...actual, transitApi: transitApiMock }
})
vi.mock('../shared/Toast', () => ({ useToast: () => ({ error: vi.fn(), success: vi.fn() }) }))
// Berlin, summer time (UTC+2): 06:30Z departs 08:30 local, 07:00Z arrives 09:00.
const ITINERARY = {
startTime: '2025-06-01T06:30:00Z',
endTime: '2025-06-01T07:00:00Z',
duration: 1800,
transfers: 1,
walkSeconds: 240,
legs: [
{ mode: 'WALK', from: { name: 'Start', lat: 52.52, lng: 13.4, time: '2025-06-01T06:30:00Z', scheduledTime: null, track: null }, to: { name: 'Alexanderplatz', lat: 52.521, lng: 13.41, time: '2025-06-01T06:34:00Z', scheduledTime: null, track: null }, duration: 240, distance: 300, headsign: null, line: null, lineColor: null, lineTextColor: null, agency: null, intermediateStops: 0 },
{ mode: 'SUBWAY', from: { name: 'Alexanderplatz', lat: 52.521, lng: 13.41, time: '2025-06-01T06:36:00Z', scheduledTime: null, track: '2' }, to: { name: 'Zoologischer Garten', lat: 52.507, lng: 13.332, time: '2025-06-01T07:00:00Z', scheduledTime: null, track: null }, duration: 1440, distance: null, headsign: 'Ruhleben', line: 'U2', lineColor: '#FF3300', lineTextColor: '#FFFFFF', agency: 'BVG', intermediateStops: 6 },
],
}
const day = buildDay({ id: 10, trip_id: 1, date: '2025-06-01', title: 'Berlin Day' })
function makeProps(overrides = {}) {
return {
day,
days: [day],
places: [buildPlace({ id: 1, name: 'Fernsehturm', lat: 52.5208, lng: 13.4094 })],
accommodations: [],
onAdd: vi.fn().mockResolvedValue({}),
...overrides,
}
}
async function pickFromAndTo(user: ReturnType<typeof userEvent.setup>) {
// Quick picks (the day's places) appear on focus with an empty query.
const [fromInput, toInput] = screen.getAllByPlaceholderText('Search stop or station…')
await user.click(fromInput)
await user.click(await screen.findByText('Fernsehturm'))
transitApiMock.geocode.mockResolvedValueOnce({ results: [{ name: 'Zoologischer Garten', lat: 52.507, lng: 13.332, type: 'STOP', area: 'Berlin' }] })
await user.click(toInput)
await user.type(toInput, 'Zoo')
await user.click(await screen.findByText(/Zoologischer Garten/))
}
beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true })
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any)
})
describe('TransitSearchPanel', () => {
it('FE-PLANNER-TRANSIT-001: renders from/to pickers, modes and preferences', () => {
render(<TransitSearchPanel {...makeProps()} />)
expect(screen.getAllByPlaceholderText('Search stop or station…')).toHaveLength(2)
expect(screen.getByText('Subway')).toBeInTheDocument()
expect(screen.getByText('Fewer transfers')).toBeInTheDocument()
})
it('FE-PLANNER-TRANSIT-002: searching lists itineraries with times, transfers and line badges', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY] })
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
// Local Berlin times, U2 badge, 1 transfer.
expect(await screen.findByText(/08:30 09:00/)).toBeInTheDocument()
expect(screen.getByText('U2')).toBeInTheDocument()
expect(screen.getByText('1 transfers')).toBeInTheDocument()
})
it('FE-PLANNER-TRANSIT-003: adding a route builds a transport payload with local times + endpoints', async () => {
const user = userEvent.setup()
const onAdd = vi.fn().mockResolvedValue({})
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY] })
render(<TransitSearchPanel {...makeProps({ onAdd })} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
await user.click(await screen.findByText(/08:30 09:00/))
await user.click(await screen.findByRole('button', { name: 'Add to day' }))
await waitFor(() => expect(onAdd).toHaveBeenCalled())
const payload = onAdd.mock.calls[0][0]
expect(payload.type).toBe('transit') // first-class transit type (#1065)
expect(payload.title).toBe('Fernsehturm → Zoologischer Garten')
expect(payload.day_id).toBe(10)
expect(payload.reservation_time).toBe('2025-06-01T08:30')
expect(payload.reservation_end_time).toBe('2025-06-01T09:00')
expect(payload.status).toBe('confirmed')
// from + to endpoints (single transit leg → no transfer stops)
expect(payload.endpoints).toHaveLength(2)
expect(payload.endpoints[0]).toMatchObject({ role: 'from', name: 'Fernsehturm', timezone: 'Europe/Berlin' })
expect(payload.endpoints[1]).toMatchObject({ role: 'to', name: 'Zoologischer Garten' })
// compact itinerary stored for the detail modal
expect(payload.metadata.transit.provider).toBe('transitous')
expect(payload.metadata.transit.legs).toHaveLength(2)
expect(payload.metadata.transit.legs[1]).toMatchObject({ mode: 'SUBWAY', line: 'U2', line_color: '#FF3300', headsign: 'Ruhleben' })
})
it('FE-PLANNER-TRANSIT-004: search failure shows the empty state, not a crash', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockRejectedValueOnce(new Error('boom'))
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
expect(await screen.findByText(/No connections found/)).toBeInTheDocument()
})
it('FE-PLANNER-TRANSIT-005: preference "fewer transfers" re-ranks the list', async () => {
const user = userEvent.setup()
const direct = { ...ITINERARY, startTime: '2025-06-01T06:40:00Z', endTime: '2025-06-01T07:20:00Z', duration: 2400, transfers: 0 }
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY, direct] })
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
await screen.findByText(/08:30 09:00/)
await user.click(screen.getByText('Fewer transfers'))
const cards = screen.getAllByText(//).filter(el => el.textContent?.match(/\d{2}:\d{2} \d{2}:\d{2}/))
// The direct (0-transfer) itinerary now ranks first.
expect(cards[0].textContent).toContain('08:40')
})
it('FE-PLANNER-TRANSIT-006: swap exchanges from and to', async () => {
const user = userEvent.setup()
render(<TransitSearchPanel {...makeProps()} />)
const [fromInput] = screen.getAllByPlaceholderText('Search stop or station…')
await user.click(fromInput)
await user.click(await screen.findByText('Fernsehturm'))
await user.click(screen.getByLabelText('Swap'))
const inputs = screen.getAllByPlaceholderText('Search stop or station…')
expect((inputs[0] as HTMLInputElement).value).toBe('')
expect((inputs[1] as HTMLInputElement).value).toBe('Fernsehturm')
})
})
@@ -1,602 +0,0 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import tzlookup from 'tz-lookup'
import { ArrowLeftRight, ArrowRight, Bus, CableCar, ChevronDown, ChevronUp, Clock, Footprints, MapPin, Sailboat, Search, Train, TramFront, TrainFront } from 'lucide-react'
import CustomTimePicker from '../shared/CustomTimePicker'
import { TransitMetaBadges } from './transitDisplay'
import { transitApi } from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import type { Day, Place, Accommodation } from '../../types'
/**
* Public transit route search (#1065), backed by Transitous (MOTIS) through the
* server proxy no paid providers. Google-Maps-like flow in TREK's clean style:
* pick from/to (stop search + the day's own places as quick picks), filter by
* mode and preference, compare the returned itineraries, then add the chosen
* one to the day. The result is saved as a regular transport reservation
* (endpoints + metadata.transit), so timeline slotting, editing, deleting and
* drag/drop all reuse the existing machinery.
*/
// ── transit data shapes (mirrors the server's compact mapping) ──────────────
interface TransitLegStop { name: string; lat: number; lng: number; time: string | null; scheduledTime: string | null; track: string | null }
interface TransitLeg {
mode: string; from: TransitLegStop; to: TransitLegStop; duration: number; distance: number | null
headsign: string | null; line: string | null; lineColor: string | null; lineTextColor: string | null
agency: string | null; intermediateStops: number
geometry?: string | null; geometryPrecision?: number
}
export interface TransitItinerary {
startTime: string; endTime: string; duration: number; transfers: number; walkSeconds: number; legs: TransitLeg[]
}
interface TransitPlaceResult { name: string; lat: number; lng: number; type: string; area: string | null }
export interface PickedPlace { name: string; lat: number; lng: number }
// ── helpers ──────────────────────────────────────────────────────────────────
const MODE_GROUPS: { key: string; labelKey: string; Icon: React.ComponentType<{ size?: number; strokeWidth?: number }>; modes: string }[] = [
{ key: 'rail', labelKey: 'transit.mode.rail', Icon: Train, modes: 'HIGHSPEED_RAIL,LONG_DISTANCE,NIGHT_RAIL,REGIONAL_RAIL,SUBURBAN' },
{ key: 'subway', labelKey: 'transit.mode.subway', Icon: TrainFront, modes: 'SUBWAY' },
{ key: 'tram', labelKey: 'transit.mode.tram', Icon: TramFront, modes: 'TRAM' },
{ key: 'bus', labelKey: 'transit.mode.bus', Icon: Bus, modes: 'BUS,COACH' },
{ key: 'ferry', labelKey: 'transit.mode.ferry', Icon: Sailboat, modes: 'FERRY' },
{ key: 'cable', labelKey: 'transit.mode.cable', Icon: CableCar, modes: 'FUNICULAR,AERIAL_LIFT' },
]
function legIcon(mode: string) {
if (mode === 'WALK') return Footprints
if (mode === 'BUS' || mode === 'COACH') return Bus
if (mode === 'TRAM') return TramFront
if (mode === 'SUBWAY') return TrainFront
if (mode === 'FERRY') return Sailboat
if (mode === 'FUNICULAR' || mode === 'AERIAL_LIFT') return CableCar
return Train
}
function tzAt(lat: number, lng: number): string {
try { return tzlookup(lat, lng) } catch { return 'UTC' }
}
/** 'YYYY-MM-DD' + 'HH:mm' in an IANA zone → UTC ISO string. */
function localToUtcIso(dateStr: string, timeStr: string, tz: string): string {
const naive = Date.parse(`${dateStr}T${timeStr}:00Z`)
const inTz = new Date(new Date(naive).toLocaleString('en-US', { timeZone: tz })).getTime()
const inUtc = new Date(new Date(naive).toLocaleString('en-US', { timeZone: 'UTC' })).getTime()
return new Date(naive - (inTz - inUtc)).toISOString()
}
function fmtTimeInTz(iso: string | null, tz: string, is12h: boolean): string {
if (!iso) return ''
try { return new Intl.DateTimeFormat(is12h ? 'en-US' : 'en-GB', { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: is12h }).format(new Date(iso)) } catch { return '' }
}
function timeHHmmInTz(iso: string, tz: string): string {
return new Intl.DateTimeFormat('en-GB', { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(iso))
}
function dateYMDInTz(iso: string, tz: string): string {
return new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(iso))
}
function fmtDuration(seconds: number, t: (k: string, p?: Record<string, string | number>) => string): string {
const mins = Math.round(seconds / 60)
if (mins < 60) return t('transit.min', { count: mins })
const h = Math.floor(mins / 60)
const m = mins % 60
return m > 0 ? `${h} h ${m} min` : `${h} h`
}
// ── from/to stop picker ──────────────────────────────────────────────────────
function StopPicker({ label, value, onPick, quickPicks, near, placeholder }: {
label: string
value: PickedPlace | null
onPick: (p: PickedPlace | null) => void
quickPicks: PickedPlace[]
near: string | null
placeholder: string
}) {
const { language } = useTranslation()
const [text, setText] = useState('')
const [results, setResults] = useState<TransitPlaceResult[]>([])
const [open, setOpen] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const rootRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const close = (e: MouseEvent) => { if (!rootRef.current?.contains(e.target as Node)) setOpen(false) }
document.addEventListener('mousedown', close)
return () => document.removeEventListener('mousedown', close)
}, [])
useEffect(() => () => { if (debounceRef.current) clearTimeout(debounceRef.current) }, [])
const search = (q: string) => {
setText(q)
onPick(null)
if (debounceRef.current) clearTimeout(debounceRef.current)
if (q.trim().length < 2) { setResults([]); return }
debounceRef.current = setTimeout(() => {
transitApi.geocode(q, { lang: language, near: near || undefined })
.then((d: { results: TransitPlaceResult[] }) => setResults(d.results || []))
.catch(() => setResults([]))
}, 300)
}
const display = value ? value.name : text
return (
<div ref={rootRef} style={{ position: 'relative', flex: 1, minWidth: 0 }}>
<label className="block text-[11px] font-semibold text-content-faint mb-[5px] uppercase tracking-[0.03em]">{label}</label>
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 7, borderRadius: 10, padding: '0 10px', height: 38 }}>
<MapPin size={14} className="text-content-faint" style={{ flexShrink: 0 }} />
<input
value={display}
onChange={e => search(e.target.value)}
onFocus={() => setOpen(true)}
placeholder={placeholder}
className="text-content"
style={{ border: 0, background: 'none', outline: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', width: '100%', fontFamily: 'inherit' }}
/>
</div>
{open && (results.length > 0 || (!value && text.trim().length < 2 && quickPicks.length > 0)) && (
<div className="bg-surface-card border border-edge" style={{ position: 'absolute', top: '100%', left: 0, right: 0, marginTop: 4, borderRadius: 10, boxShadow: '0 8px 32px rgba(0,0,0,0.14)', zIndex: 30, overflow: 'hidden', maxHeight: 240, overflowY: 'auto' }}>
{results.length > 0
? results.map((r, i) => (
<button key={i} onClick={() => { onPick({ name: r.name, lat: r.lat, lng: r.lng }); setText(''); setResults([]); setOpen(false) }}
className="text-content"
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', textAlign: 'left', padding: '8px 10px', border: 'none', background: 'none', cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
<MapPin size={13} className="text-content-faint" style={{ flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{r.name}
{r.area && <span className="text-content-faint"> · {r.area}</span>}
</span>
</button>
))
: quickPicks.map((p, i) => (
<button key={i} onClick={() => { onPick(p); setText(''); setOpen(false) }}
className="text-content"
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', textAlign: 'left', padding: '8px 10px', border: 'none', background: 'none', cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
<MapPin size={13} className="text-content-faint" style={{ flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
</button>
))}
</div>
)}
</div>
)
}
// ── itinerary card ───────────────────────────────────────────────────────────
function LineBadge({ leg }: { leg: TransitLeg }) {
const bg = leg.lineColor || 'var(--bg-tertiary)'
const fg = leg.lineColor ? (leg.lineTextColor || '#fff') : 'var(--text-primary)'
const Icon = legIcon(leg.mode)
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, background: bg, color: fg, borderRadius: 6, padding: '2px 7px', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, whiteSpace: 'nowrap' }}>
<Icon size={11} strokeWidth={2.2} />
{leg.line || leg.mode}
</span>
)
}
function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, adding, t }: {
it: TransitItinerary
tzFrom: string
tzTo: string
is12h: boolean
expanded: boolean
onToggle: () => void
onAdd: () => void
adding: boolean
t: (k: string, p?: Record<string, string | number>) => string
}) {
const transitLegs = it.legs.filter(l => l.mode !== 'WALK')
const walkMins = Math.round(it.walkSeconds / 60)
return (
<div className="bg-surface-card border border-edge" style={{ borderRadius: 14, overflow: 'hidden' }}>
<button onClick={onToggle} style={{ display: 'block', width: '100%', textAlign: 'left', padding: '12px 14px', border: 'none', background: 'none', cursor: 'pointer', fontFamily: 'inherit' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
<span className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.01em' }}>
{fmtTimeInTz(it.startTime, tzFrom, is12h)} {fmtTimeInTz(it.endTime, tzTo, is12h)}
</span>
<span className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{fmtDuration(it.duration, t)}</span>
<span className="text-content-faint" style={{ marginLeft: 'auto', fontSize: 'calc(12px * var(--fs-scale-body, 1))', display: 'inline-flex', alignItems: 'center', gap: 10 }}>
<span>{it.transfers === 0 ? t('transit.direct') : t('transit.transfers', { count: it.transfers })}</span>
{walkMins > 0 && <span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}><Footprints size={12} />{t('transit.min', { count: walkMins })}</span>}
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</span>
</div>
{/* signature: Walk U2 Bus 100 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 5, marginTop: 8, flexWrap: 'wrap' }}>
{it.legs.map((leg, i) => (
<React.Fragment key={i}>
{i > 0 && <span className="text-content-faint" style={{ fontSize: 10 }}></span>}
{leg.mode === 'WALK'
? <Footprints size={13} className="text-content-faint" />
: <LineBadge leg={leg} />}
</React.Fragment>
))}
</div>
</button>
{expanded && (
<div style={{ borderTop: '1px solid var(--border-faint)', padding: '10px 14px 12px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{it.legs.map((leg, i) => {
const color = leg.mode === 'WALK' ? 'var(--border-primary)' : (leg.lineColor || 'var(--text-muted)')
return (
<div key={i} style={{ display: 'grid', gridTemplateColumns: '44px 18px 1fr', gap: 8, alignItems: 'stretch' }}>
<div className="text-content-muted" style={{ fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, paddingTop: 2, textAlign: 'right' }}>
{leg.mode === 'WALK' ? '' : fmtTimeInTz(leg.from.time, tzAt(leg.from.lat, leg.from.lng), is12h)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', border: `2.5px solid ${color}`, background: 'var(--bg-card)', flexShrink: 0, marginTop: 4 }} />
<span style={{ flex: 1, width: 3, borderRadius: 2, background: color, opacity: leg.mode === 'WALK' ? 0.45 : 1, margin: '2px 0', ...(leg.mode === 'WALK' ? { backgroundImage: 'repeating-linear-gradient(to bottom, var(--border-primary) 0 4px, transparent 4px 8px)', background: 'none' } : {}) }} />
</div>
<div style={{ paddingBottom: 14, minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{leg.mode === 'WALK' ? t('transit.walkTo', { name: leg.to.name }) : leg.from.name}
{leg.from.track && leg.mode !== 'WALK' && <span className="text-content-faint" style={{ fontWeight: 500 }}> · {t('transit.platform', { track: leg.from.track })}</span>}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
{leg.mode === 'WALK' ? (
<TransitMetaBadges size="sm" items={[
{ icon: Footprints, text: fmtDuration(leg.duration, t) },
{ text: leg.distance ? (leg.distance >= 1000 ? `${(leg.distance / 1000).toFixed(1)} km` : `${leg.distance} m`) : '' },
]} />
) : (
<>
<LineBadge leg={leg} />
<TransitMetaBadges size="sm" items={[
{ icon: ArrowRight, text: leg.headsign || '' },
{ text: fmtDuration(leg.duration, t) },
{ text: leg.intermediateStops > 0 ? t('transit.stops', { count: leg.intermediateStops }) : '' },
]} />
</>
)}
</div>
</div>
</div>
)
})}
{/* arrival row */}
<div style={{ display: 'grid', gridTemplateColumns: '44px 18px 1fr', gap: 8, alignItems: 'center' }}>
<div className="text-content-muted" style={{ fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, textAlign: 'right' }}>
{fmtTimeInTz(it.endTime, tzTo, is12h)}
</div>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', background: 'var(--text-primary)', flexShrink: 0 }} />
</div>
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 700, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{it.legs[it.legs.length - 1]?.to.name}
</div>
</div>
</div>
{transitLegs[0]?.agency && (
<div className="text-content-faint" style={{ marginTop: 8, fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>
{[...new Set(transitLegs.map(l => l.agency).filter(Boolean))].join(' · ')}
</div>
)}
<button
onClick={onAdd}
disabled={adding}
className="bg-accent text-accent-text"
style={{ marginTop: 12, width: '100%', border: 'none', borderRadius: 10, padding: '9px 0', fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: adding ? 'default' : 'pointer', fontFamily: 'inherit', opacity: adding ? 0.6 : 1 }}
>
{adding ? t('transit.adding') : t('transit.addToDay')}
</button>
</div>
)}
</div>
)
}
// ── the search panel ─────────────────────────────────────────────────────────
// Modal-less on purpose: it renders as the "Automated transport" mode inside
// the TransportModal (and could embed anywhere else). The host owns the modal
// chrome and closes itself once onAdd resolves.
interface TransitSearchPanelProps {
day: Day
days: Day[]
places: Place[]
accommodations?: Accommodation[]
/** Persist the built reservation payload; resolves when saved. */
onAdd: (payload: Record<string, unknown>) => Promise<unknown>
/** Pre-seed from/to — used by "change route" on an existing journey. */
initialFrom?: PickedPlace | null
initialTo?: PickedPlace | null
}
export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null }: TransitSearchPanelProps) {
const { t } = useTranslation()
const toast = useToast()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
const [from, setFrom] = useState<PickedPlace | null>(initialFrom)
const [to, setTo] = useState<PickedPlace | null>(initialTo)
const [time, setTime] = useState('09:00')
const [arriveBy, setArriveBy] = useState(false)
const [activeModes, setActiveModes] = useState<Set<string>>(() => new Set(MODE_GROUPS.map(m => m.key)))
const [pref, setPref] = useState<'best' | 'transfers' | 'walking'>('best')
const [itineraries, setItineraries] = useState<TransitItinerary[] | null>(null)
const [loading, setLoading] = useState(false)
const [expandedIdx, setExpandedIdx] = useState<number | null>(null)
const [addingIdx, setAddingIdx] = useState<number | null>(null)
// Quick picks: the day's located places, plus the trip's located accommodations.
const quickPicks = useMemo<PickedPlace[]>(() => {
const picks: PickedPlace[] = []
for (const p of places) {
if (p.lat != null && p.lng != null) picks.push({ name: p.name, lat: p.lat, lng: p.lng })
}
for (const a of accommodations) {
const lat = (a as { place_lat?: number | null }).place_lat
const lng = (a as { place_lng?: number | null }).place_lng
const name = (a as { place_name?: string | null }).place_name
if (lat != null && lng != null && name) picks.push({ name, lat, lng })
}
const seen = new Set<string>()
return picks.filter(p => { const k = `${p.name}:${p.lat}`; if (seen.has(k)) return false; seen.add(k); return true }).slice(0, 8)
}, [places, accommodations])
const near = quickPicks.length > 0 ? `${quickPicks[0].lat},${quickPicks[0].lng}` : null
const toggleMode = (key: string) => {
setActiveModes(prev => {
const next = new Set(prev)
if (next.has(key)) { if (next.size > 1) next.delete(key) } else { next.add(key) }
return next
})
}
const search = async () => {
if (!from || !to || !day.date) return
setLoading(true)
setItineraries(null)
setExpandedIdx(null)
try {
const tzFrom = tzAt(from.lat, from.lng)
const timeIso = localToUtcIso(day.date, time, tzFrom)
const allModes = activeModes.size === MODE_GROUPS.length
const modes = allModes ? undefined : MODE_GROUPS.filter(m => activeModes.has(m.key)).map(m => m.modes).join(',')
const d = await transitApi.plan({ from: `${from.lat},${from.lng}`, to: `${to.lat},${to.lng}`, time: timeIso, arriveBy, modes })
// MOTIS names the request coordinates START/END — swap in the places the
// user actually picked so walks read "Walk to Zoologischer Garten".
const cleanStop = (n: string) => (n === 'START' ? from.name : n === 'END' ? to.name : n)
const cleaned = (d.itineraries || []).map((it: TransitItinerary) => ({
...it,
legs: it.legs.map(l => ({
...l,
from: { ...l.from, name: cleanStop(l.from.name) },
to: { ...l.to, name: cleanStop(l.to.name) },
})),
}))
setItineraries(cleaned)
} catch {
toast.error(t('transit.searchError'))
setItineraries([])
} finally {
setLoading(false)
}
}
// Preference is a client-side ranking over one result set — no extra API calls.
const ranked = useMemo(() => {
if (!itineraries) return null
const list = itineraries.slice()
if (pref === 'transfers') list.sort((a, b) => a.transfers - b.transfers || a.duration - b.duration)
if (pref === 'walking') list.sort((a, b) => a.walkSeconds - b.walkSeconds || a.duration - b.duration)
return list
}, [itineraries, pref])
const addItinerary = async (it: TransitItinerary, idx: number) => {
if (!from || !to || !day.date) return
setAddingIdx(idx)
try {
const tzFrom = tzAt(from.lat, from.lng)
const tzTo = tzAt(to.lat, to.lng)
const depDate = dateYMDInTz(it.startTime, tzFrom)
const depTime = timeHHmmInTz(it.startTime, tzFrom)
const arrDate = dateYMDInTz(it.endTime, tzTo)
const arrTime = timeHHmmInTz(it.endTime, tzTo)
// An after-midnight arrival lands on the next trip day when it exists.
const endDay = arrDate !== depDate ? days.find(d2 => d2.date === arrDate) : null
// Endpoints: origin, each transfer stop, destination — the same shape
// flights persist, so the map + connectors work unchanged.
const transitLegs = it.legs.filter(l => l.mode !== 'WALK')
const endpoints: Record<string, unknown>[] = []
endpoints.push({ role: 'from', sequence: 0, name: from.name, code: null, lat: from.lat, lng: from.lng, timezone: tzFrom, local_date: depDate, local_time: depTime })
transitLegs.slice(0, -1).forEach((leg, i) => {
const s = leg.to
endpoints.push({ role: 'stop', sequence: i + 1, name: s.name, code: null, lat: s.lat, lng: s.lng, timezone: tzAt(s.lat, s.lng), local_date: s.time ? dateYMDInTz(s.time, tzAt(s.lat, s.lng)) : null, local_time: s.time ? timeHHmmInTz(s.time, tzAt(s.lat, s.lng)) : null })
})
endpoints.push({ role: 'to', sequence: endpoints.length, name: to.name, code: null, lat: to.lat, lng: to.lng, timezone: tzTo, local_date: arrDate, local_time: arrTime })
const payload = {
title: `${from.name}${to.name}`,
// Its own first-class type: transit journeys get their own icon, their
// rich timeline row and the itinerary detail view instead of the
// generic transport rendering.
type: 'transit',
status: 'confirmed',
day_id: day.id,
end_day_id: endDay ? endDay.id : day.id,
reservation_time: `${depDate}T${depTime}`,
reservation_end_time: `${arrDate}T${arrTime}`,
location: null,
confirmation_number: null,
notes: null,
metadata: {
transit: {
provider: 'transitous',
duration: it.duration,
transfers: it.transfers,
walk_seconds: it.walkSeconds,
legs: it.legs.map(l => ({
mode: l.mode,
line: l.line,
line_color: l.lineColor,
line_text_color: l.lineTextColor,
headsign: l.headsign,
agency: l.agency,
duration: l.duration,
stops: l.intermediateStops,
from: { name: l.from.name, time: l.from.time ? timeHHmmInTz(l.from.time, tzAt(l.from.lat, l.from.lng)) : null, track: l.from.track },
to: { name: l.to.name, time: l.to.time ? timeHHmmInTz(l.to.time, tzAt(l.to.lat, l.to.lng)) : null, track: l.to.track },
geometry: l.geometry || null,
geometry_precision: l.geometryPrecision ?? 6,
})),
},
},
endpoints,
needs_review: false,
}
await onAdd(payload)
} catch {
toast.error(t('common.unknownError'))
} finally {
setAddingIdx(null)
}
}
const tzFrom = from ? tzAt(from.lat, from.lng) : 'UTC'
const tzTo = to ? tzAt(to.lat, to.lng) : tzFrom
const segBtn = (active: boolean): React.CSSProperties => ({
padding: isMobile ? '6px 5px' : '6px 11px', fontSize: isMobile ? 'calc(11px * var(--fs-scale-body, 1))' : 'calc(12px * var(--fs-scale-body, 1))', borderRadius: 7, fontWeight: 500,
border: 0, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap',
background: active ? 'var(--bg-card)' : 'transparent', color: active ? 'var(--text-primary)' : 'var(--text-muted)',
boxShadow: active ? '0 1px 4px rgba(0,0,0,0.08)' : 'none',
})
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, fontFamily: 'var(--font-system)' }}>
{/* from / to — stacked tight on mobile, swap button on desktop only */}
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 8, alignItems: isMobile ? 'stretch' : 'flex-end' }}>
<StopPicker label={t('transit.from')} value={from} onPick={setFrom} quickPicks={quickPicks} near={near} placeholder={t('transit.searchStop')} />
{!isMobile && (
<button
onClick={() => { const f = from; setFrom(to); setTo(f) }}
aria-label={t('transit.swap')}
title={t('transit.swap')}
className="bg-surface-secondary text-content-muted"
style={{ border: 'none', borderRadius: 10, width: 38, height: 38, display: 'grid', placeItems: 'center', cursor: 'pointer', flexShrink: 0 }}
>
<ArrowLeftRight size={15} />
</button>
)}
<StopPicker label={t('transit.to')} value={to} onPick={setTo} quickPicks={quickPicks} near={near} placeholder={t('transit.searchStop')} />
</div>
{/* search options — one calm card: when + how on top, modes + go below */}
<div className="bg-surface-tertiary" style={{ borderRadius: 14, padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 9, padding: 3 }}>
<button onClick={() => setArriveBy(false)} style={segBtn(!arriveBy)}>{t('transit.depart')}</button>
<button onClick={() => setArriveBy(true)} style={segBtn(arriveBy)}>{t('transit.arrive')}</button>
</div>
<div style={{ width: 110 }}>
<CustomTimePicker value={time} onChange={setTime} />
</div>
{day.date && (
<span className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<Clock size={12} />
{new Date(day.date + 'T00:00:00Z').toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}
</span>
)}
</div>
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 9, padding: 3, width: isMobile ? '100%' : undefined }}>
<button onClick={() => setPref('best')} style={{ ...segBtn(pref === 'best'), flex: isMobile ? 1 : undefined }}>{t('transit.pref.best')}</button>
<button onClick={() => setPref('transfers')} style={{ ...segBtn(pref === 'transfers'), flex: isMobile ? 1 : undefined }}>{t('transit.pref.transfers')}</button>
<button onClick={() => setPref('walking')} style={{ ...segBtn(pref === 'walking'), flex: isMobile ? 1 : undefined }}>{t('transit.pref.walking')}</button>
</div>
</div>
<div style={{ height: 1, background: 'var(--border-faint)' }} />
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: 5, flexWrap: 'wrap' }}>
{MODE_GROUPS.map(m => {
const active = activeModes.has(m.key)
return (
<button key={m.key} onClick={() => toggleMode(m.key)}
className={active ? 'bg-surface-card text-content' : 'text-content-faint'}
style={{
display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit', transition: 'all 0.12s',
background: active ? undefined : 'transparent',
borderColor: active ? 'var(--border-primary)' : 'transparent',
boxShadow: active ? '0 1px 3px rgba(0,0,0,0.06)' : 'none',
opacity: active ? 1 : 0.75,
}}>
<m.Icon size={12} strokeWidth={2} />
{t(m.labelKey)}
</button>
)
})}
</div>
<button
onClick={search}
disabled={!from || !to || loading}
className="bg-accent text-accent-text"
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, border: 'none', borderRadius: 10, padding: '9px 18px', fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: (!from || !to || loading) ? 'default' : 'pointer', fontFamily: 'inherit', opacity: (!from || !to || loading) ? 0.55 : 1, flexShrink: 0, width: isMobile ? '100%' : undefined }}
>
<Search size={14} strokeWidth={2.2} />
{loading ? t('transit.searching') : t('transit.search')}
</button>
</div>
</div>
{/* results */}
{loading && (
<div className="text-content-faint" style={{ textAlign: 'center', padding: '28px 0' }}>
<div style={{ width: 20, height: 20, border: '2px solid var(--border-primary)', borderTopColor: 'var(--text-primary)', borderRadius: '50%', animation: 'spin 0.8s linear infinite', margin: '0 auto' }} />
</div>
)}
{!loading && ranked && ranked.length === 0 && (
<div className="text-content-faint" style={{ textAlign: 'center', padding: '24px 0', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
{t('transit.noResults')}
</div>
)}
{!loading && ranked && ranked.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{ranked.map((it, idx) => (
<ItineraryCard
key={`${it.startTime}-${it.endTime}-${idx}`}
it={it}
tzFrom={tzFrom}
tzTo={tzTo}
is12h={is12h}
expanded={expandedIdx === idx}
onToggle={() => setExpandedIdx(expandedIdx === idx ? null : idx)}
onAdd={() => addItinerary(it, idx)}
adding={addingIdx === idx}
t={t}
/>
))}
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', textAlign: 'center', marginTop: 2 }}>
{t('transit.attribution')}{' '}
<a href="https://transitous.org/sources/" target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline' }}>Transitous</a>
</div>
</div>
)}
</div>
)
}
@@ -71,12 +71,6 @@ describe('TransportModal', () => {
expect(screen.getByText(/Add transport/i)).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-002b: file input accepts pkpass (#1448)', () => {
render(<TransportModal {...defaultProps} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(fileInput.accept).toContain('.pkpass');
});
it('FE-PLANNER-TRANSMODAL-003: shows "Edit transport" title when editing', () => {
const res = buildReservation({ title: 'Paris Flight', type: 'flight' });
render(<TransportModal {...defaultProps} reservation={res} />);
@@ -330,134 +324,4 @@ describe('TransportModal', () => {
expect(fd.get('reservation_id')).toBe('99');
expect(fd.get('file')).toBeTruthy();
});
// ── Transit itinerary preservation (#1065) ─────────────────────────────────
it('FE-PLANNER-TRANSMODAL-020: re-saving a transit reservation keeps metadata.transit + stop endpoints', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const res = buildReservation({ title: 'Fernsehturm → Zoo', type: 'bus' }) as any;
res.metadata = { transit: { provider: 'transitous', transfers: 1, legs: [{ mode: 'BUS', line: '100' }] } };
res.endpoints = [
{ role: 'from', sequence: 0, name: 'Fernsehturm', code: null, lat: 52.5208, lng: 13.4094, timezone: 'Europe/Berlin', local_date: '2025-06-01', local_time: '08:30' },
{ role: 'stop', sequence: 1, name: 'Alexanderplatz', code: null, lat: 52.521, lng: 13.41, timezone: 'Europe/Berlin', local_date: '2025-06-01', local_time: '08:40' },
{ role: 'to', sequence: 2, name: 'Zoologischer Garten', code: null, lat: 52.507, lng: 13.332, timezone: 'Europe/Berlin', local_date: '2025-06-01', local_time: '09:00' },
];
render(<TransportModal {...defaultProps} reservation={res} onSave={onSave} />);
// Save without touching the route — the itinerary must survive.
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const payload = onSave.mock.calls[0][0];
expect(payload.metadata?.transit?.provider).toBe('transitous');
expect(payload.metadata?.transit?.legs).toHaveLength(1);
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'stop', 'to']);
expect(payload.endpoints[1]).toMatchObject({ name: 'Alexanderplatz', lat: 52.521 });
});
it('FE-PLANNER-TRANSMODAL-021: changing the destination drops the stale transit itinerary', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const res = buildReservation({ title: 'Fernsehturm → Zoo', type: 'bus' }) as any;
res.metadata = { transit: { provider: 'transitous', legs: [{ mode: 'BUS' }] } };
res.endpoints = [
{ role: 'from', sequence: 0, name: 'Fernsehturm', code: null, lat: 52.5208, lng: 13.4094, timezone: 'Europe/Berlin', local_date: null, local_time: null },
{ role: 'stop', sequence: 1, name: 'Alexanderplatz', code: null, lat: 52.521, lng: 13.41, timezone: 'Europe/Berlin', local_date: null, local_time: null },
{ role: 'to', sequence: 2, name: 'Zoologischer Garten', code: null, lat: 52.507, lng: 13.332, timezone: 'Europe/Berlin', local_date: null, local_time: null },
];
render(<TransportModal {...defaultProps} reservation={res} onSave={onSave} />);
// Pick a different destination (mocked LocationSelect emits lat/lng 0,0).
const locationInputs = screen.getAllByTestId('location-select');
fireEvent.change(locationInputs[1], { target: { value: 'Somewhere Else' } });
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const payload = onSave.mock.calls[0][0];
expect(payload.metadata?.transit).toBeUndefined();
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']);
});
// ── Manual / Automated creation switch (#1065) ─────────────────────────────
it('FE-PLANNER-TRANSMODAL-022: creating shows the Manual/Automated switch; Automated opens the transit search', async () => {
render(<TransportModal {...defaultProps} places={[]} accommodations={[]} />);
expect(screen.getByRole('button', { name: 'Manual transport' })).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Automated transport' }));
// No day selected in defaultProps (days: []) — the pick-day hint shows.
expect(screen.getByText(/Pick a day/)).toBeInTheDocument();
// The manual form is gone in automated mode.
expect(screen.queryByPlaceholderText(/e\.g\. Lufthansa/i)).not.toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-023: initialAutomated opens straight in the transit search with the day preset', () => {
const days = [{ id: 10, trip_id: 1, day_number: 1, date: '2025-06-01', title: 'Day 1' }] as any;
render(<TransportModal {...defaultProps} days={days} selectedDayId={10} initialAutomated places={[]} accommodations={[]} />);
expect(screen.getAllByPlaceholderText('Search stop or station…')).toHaveLength(2);
});
it('FE-PLANNER-TRANSMODAL-024: editing shows no Manual/Automated switch', () => {
const res = buildReservation({ title: 'My Train', type: 'train' });
render(<TransportModal {...defaultProps} reservation={res} />);
expect(screen.queryByRole('button', { name: 'Automated transport' })).not.toBeInTheDocument();
});
// ── Multi-leg trains (#1150) ───────────────────────────────────────────────
it('FE-PLANNER-TRANSMODAL-025: a train with an added stop saves from/stop/to endpoints + metadata.legs', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /^Train$/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Berlin → München');
// Insert an intermediate station (2 → 3 stations = 2 legs).
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
const stations = screen.getAllByTestId('location-select');
expect(stations).toHaveLength(3);
fireEvent.change(stations[0], { target: { value: 'Berlin Hbf' } });
fireEvent.change(stations[1], { target: { value: 'Frankfurt Hbf' } });
fireEvent.change(stations[2], { target: { value: 'München Hbf' } });
// Per-leg train number on the first station (placeholder ICE 123).
const trainNumbers = screen.getAllByPlaceholderText('ICE 123');
fireEvent.change(trainNumbers[0], { target: { value: 'ICE 100' } });
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const payload = onSave.mock.calls[0][0];
expect(payload.type).toBe('train');
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'stop', 'to']);
expect(payload.endpoints.map((e: { name: string }) => e.name)).toEqual(['Berlin Hbf', 'Frankfurt Hbf', 'München Hbf']);
expect(payload.metadata.legs).toHaveLength(2);
expect(payload.metadata.legs[0]).toMatchObject({ from: 'Berlin Hbf', to: 'Frankfurt Hbf', train_number: 'ICE 100' });
expect(payload.metadata.train_number).toBe('ICE 100'); // flat mirror of leg 0
});
it('FE-PLANNER-TRANSMODAL-027: a train with a day + train number but no geocoded station still saves them (#1150 regression)', async () => {
const days = [{ id: 10, trip_id: 1, day_number: 1, date: '2026-08-01', title: 'Day 1' }] as any;
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} days={days} selectedDayId={10} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /^Train$/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE 599');
// Fill the train number + a departure time, but never pick a geocoded station.
fireEvent.change(screen.getAllByPlaceholderText('ICE 123')[0], { target: { value: 'ICE 599' } });
fireEvent.change(screen.getAllByTestId('time-picker')[0], { target: { value: '08:00' } });
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const payload = onSave.mock.calls[0][0];
// The day, time and train number survive even without any map-picked station.
expect(payload.day_id).toBe(10);
expect(payload.reservation_time).toBe('2026-08-01T08:00');
expect(payload.metadata.train_number).toBe('ICE 599');
expect(payload.endpoints).toEqual([]); // no geocoded station → no map endpoints, like before
});
it('FE-PLANNER-TRANSMODAL-026: a two-station train saves flat (no metadata.legs)', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /^Train$/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Köln → Aachen');
const stations = screen.getAllByTestId('location-select');
fireEvent.change(stations[0], { target: { value: 'Köln Hbf' } });
fireEvent.change(stations[1], { target: { value: 'Aachen Hbf' } });
fireEvent.change(screen.getAllByPlaceholderText('ICE 123')[0], { target: { value: 'RE 9' } });
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const payload = onSave.mock.calls[0][0];
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']);
expect(payload.metadata.legs).toBeUndefined();
expect(payload.metadata.train_number).toBe('RE 9');
});
});
+36 -294
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react'
import { useParams } from 'react-router-dom'
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2 } from 'lucide-react'
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2 } from 'lucide-react'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import CustomTimePicker from '../shared/CustomTimePicker'
@@ -13,15 +13,14 @@ import { useAddonStore } from '../../store/addonStore'
import { formatDate, splitReservationDateTime, resolveDayId } from '../../utils/formatters'
import { openFile } from '../../utils/fileDownload'
import apiClient from '../../api/client'
import type { Day, Place, Accommodation, Reservation, ReservationEndpoint, TripFile, BudgetItem } from '../../types'
import type { Day, Reservation, ReservationEndpoint, TripFile, BudgetItem } from '../../types'
import { parseReservationMetadata, orderedEndpoints } from '../../utils/flightLegs'
import { BookingCostsSection } from './BookingCostsSection'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
import type { BookingReviewDraft } from './parsedItemToDraft'
import TransitSearchPanel, { type PickedPlace } from './TransitSearchPanel'
import { typeToCostCategory } from '@trek/shared'
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'] as const
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'] as const
type TransportType = typeof TRANSPORT_TYPES[number]
interface EndpointPick {
@@ -41,7 +40,7 @@ function endpointFromAirport(a: Airport, role: 'from' | 'to' | 'stop', sequence:
}
}
function endpointFromLocation(l: LocationPoint, role: 'from' | 'to' | 'stop', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
function endpointFromLocation(l: LocationPoint, role: 'from' | 'to', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
return {
role, sequence,
name: l.name,
@@ -88,24 +87,6 @@ function emptyWaypoint(dayId: string | number = ''): WaypointForm {
return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '', seat: '' }
}
// ── Multi-leg train stations ───────────────────────────────────────────────
// A train mirrors the flight route model, but its waypoints are STATIONS
// (location search, no timezone) and each leg carries a train number + platform
// instead of an airline + flight number. N stations = N-1 legs.
interface StationWaypointForm {
location: LocationPoint | null
arrDayId: string | number
arrTime: string
depDayId: string | number
depTime: string
train_number: string
platform: string
seat: string
}
function emptyStationWaypoint(dayId: string | number = ''): StationWaypointForm {
return { location: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', train_number: '', platform: '', seat: '' }
}
const TYPE_OPTIONS = [
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
@@ -149,16 +130,9 @@ interface TransportModalProps {
// Pre-fill a brand-new transport booking from a parsed import item (review-
// before-save); like `reservation` for the form but stays in create mode.
prefill?: BookingReviewDraft | null
/** Data for the Automated (public transit) mode's quick picks. */
places?: Place[]
accommodations?: Accommodation[]
/** Open directly in the Automated public-transit mode (day-header tram button, "change route"). */
initialAutomated?: boolean
/** Pre-seed the transit search — used by "change route" on an existing journey. */
transitPrefill?: { from?: PickedPlace | null; to?: PickedPlace | null } | null
}
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense, prefill = null, places = [], accommodations = [], initialAutomated = false, transitPrefill = null }: TransportModalProps) {
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense, prefill = null }: TransportModalProps) {
const { t, locale } = useTranslation()
const toast = useToast()
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
@@ -170,15 +144,11 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
// the post-save handler knows to open the Costs editor for the saved booking.
const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null)
const [form, setForm] = useState({ ...defaultForm })
// Manual vs Automated (public transit search) creation mode (#1065).
const [automated, setAutomated] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [fromPick, setFromPick] = useState<EndpointPick>({})
const [toPick, setToPick] = useState<EndpointPick>({})
// Flight route as an ordered list of airports (origin .. stops .. destination).
const [waypoints, setWaypoints] = useState<WaypointForm[]>([emptyWaypoint(), emptyWaypoint()])
// Train route as an ordered list of stations (origin .. stops .. destination).
const [trainWaypoints, setTrainWaypoints] = useState<StationWaypointForm[]>([emptyStationWaypoint(), emptyStationWaypoint()])
const [uploadingFile, setUploadingFile] = useState(false)
const [pendingFiles, setPendingFiles] = useState<File[]>([])
const [showFilePicker, setShowFilePicker] = useState(false)
@@ -191,7 +161,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
// Either way the init reads the same fields — `reservation` still decides
// edit-vs-create at submit time.
const src = (reservation ?? prefill) as Reservation | null
if (src) setAutomated(initialAutomated)
// On a review-import, seed the booking's Files with the parsed source document.
setPendingFiles(!reservation && prefill?._sourceFiles ? prefill._sourceFiles : [])
if (src) {
@@ -257,57 +226,15 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
wps = [dep, arr]
}
setWaypoints(wps)
} else if (type === 'train') {
// Mirror the flight seeding with stations + per-leg train fields. A
// current single-leg train (2 endpoints, no metadata.legs) round-trips
// through the >=2 branch: the flat train_number/platform/seat land on
// the first station, dep/arr day+time from src.day_id/end_day_id.
const orderedEps = orderedEndpoints(src)
const metaLegs: any[] = Array.isArray(meta.legs) ? meta.legs : []
let wps: StationWaypointForm[]
if (orderedEps.length >= 2) {
wps = orderedEps.map((ep, i) => {
const legInto = metaLegs[i - 1]
const legOut = metaLegs[i]
const isFirst = i === 0
const isLast = i === orderedEps.length - 1
return {
location: locationFromEndpoint(ep),
arrDayId: legInto?.arr_day_id ?? (isLast ? (src.end_day_id ?? '') : ''),
arrTime: legInto?.arr_time ?? (!isFirst ? (ep.local_time ?? '') : ''),
depDayId: legOut?.dep_day_id ?? (isFirst ? (src.day_id ?? '') : ''),
depTime: legOut?.dep_time ?? (!isLast ? (ep.local_time ?? '') : ''),
train_number: legOut?.train_number ?? (isFirst ? (meta.train_number ?? '') : ''),
platform: legOut?.platform ?? (isFirst ? (meta.platform ?? '') : ''),
seat: legOut?.seat ?? (isFirst ? (meta.seat ?? '') : ''),
}
})
} else {
const dep = emptyStationWaypoint(src.day_id ?? '')
dep.location = locationFromEndpoint(from)
dep.depTime = splitReservationDateTime(src.reservation_time).time ?? ''
dep.train_number = meta.train_number ?? ''
dep.platform = meta.platform ?? ''
dep.seat = meta.seat ?? ''
const arr = emptyStationWaypoint(src.end_day_id ?? src.day_id ?? '')
arr.location = locationFromEndpoint(to)
arr.arrTime = splitReservationDateTime(src.reservation_end_time).time ?? ''
wps = [dep, arr]
}
setTrainWaypoints(wps)
setFromPick({})
setToPick({})
} else {
setFromPick({ location: locationFromEndpoint(from) || undefined })
setToPick({ location: locationFromEndpoint(to) || undefined })
}
} else {
setForm({ ...defaultForm, start_day_id: selectedDayId ?? '', end_day_id: selectedDayId ?? '' })
setAutomated(initialAutomated)
setFromPick({})
setToPick({})
setWaypoints([emptyWaypoint(selectedDayId ?? ''), emptyWaypoint(selectedDayId ?? '')])
setTrainWaypoints([emptyStationWaypoint(selectedDayId ?? ''), emptyStationWaypoint(selectedDayId ?? '')])
}
}, [isOpen, reservation, prefill, selectedDayId, budgetItems])
@@ -331,15 +258,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
const flightWps = form.type === 'flight' ? waypoints.filter(w => w.airport) : []
const firstWp = flightWps[0]
const lastWp = flightWps[flightWps.length - 1]
// Train route: the first/last waypoint drive the span + flat metadata
// (day/time/train number are entered independently of geocoding, exactly
// like the old form fields, so a train with no map-picked station still
// saves its day/time/train number). Only geocoded stations become map
// endpoints + legs, mirroring the old "push only if the location is set".
const trainWps = form.type === 'train' ? trainWaypoints : []
const firstTrainWp = trainWps[0]
const lastTrainWp = trainWps[trainWps.length - 1]
const trainStations = form.type === 'train' ? trainWaypoints.filter(w => w.location) : []
// Per-leg day-plan positions are owned by the day planner, not this form — keep
// them when re-saving so editing a flight doesn't reset where its legs sit.
const origLegs: any[] = reservation ? (parseReservationMetadata(reservation).legs || []) : []
@@ -378,48 +296,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
}
if (firstWp?.seat) metadata.seat = firstWp.seat
} else if (form.type === 'train') {
// Flat keys mirror the first leg so legacy readers keep working; a
// 2-station train emits exactly {train_number?,platform?,seat?} — the
// same shape it saved before this feature.
if (firstTrainWp?.train_number) metadata.train_number = firstTrainWp.train_number
if (firstTrainWp?.platform) metadata.platform = firstTrainWp.platform
if (firstTrainWp?.seat) metadata.seat = firstTrainWp.seat
// Per-leg detail only for a true multi-leg train (>2 geocoded stations);
// a simple train keeps the same flat metadata it saved before.
if (trainStations.length > 2) {
metadata.legs = trainStations.slice(0, -1).map((w, i) => {
const next = trainStations[i + 1]
return {
from: w.location!.name,
to: next.location!.name,
...(w.train_number ? { train_number: w.train_number } : {}),
...(w.platform ? { platform: w.platform } : {}),
...(w.seat ? { seat: w.seat } : {}),
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
dep_time: w.depTime || null,
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
arr_time: next.arrTime || null,
...(origLegs[i]?.day_positions ? { day_positions: origLegs[i].day_positions } : {}),
}
})
}
if (form.meta_train_number) metadata.train_number = form.meta_train_number
if (form.meta_platform) metadata.platform = form.meta_platform
if (form.meta_seat) metadata.seat = form.meta_seat
}
// A transit itinerary (#1065) lives in metadata.transit + 'stop' endpoints,
// neither of which this form shows or edits — so re-saving must not wipe
// them. They're kept only while from/to are unchanged: picking a different
// origin or destination invalidates the stored connection.
const prevMeta = reservation ? parseReservationMetadata(reservation) : {}
const prevEndpointsAll = reservation?.endpoints || []
const prevFrom = prevEndpointsAll.find(ep => ep.role === 'from')
const prevTo = prevEndpointsAll.find(ep => ep.role === 'to')
const near = (a?: number | null, b?: number | null) => a != null && b != null && Math.abs(a - b) < 1e-6
const keepTransit = !!(prevMeta.transit && form.type !== 'flight' &&
prevFrom && prevTo && fromPick.location && toPick.location &&
near(prevFrom.lat, fromPick.location.lat) && near(prevFrom.lng, fromPick.location.lng) &&
near(prevTo.lat, toPick.location.lat) && near(prevTo.lng, toPick.location.lng))
if (keepTransit) metadata.transit = prevMeta.transit
const startDate = startDay?.date ?? null
const endDate = (endDay ?? startDay)?.date ?? null
const endpoints: ReturnType<typeof endpointFromAirport>[] = []
@@ -432,56 +312,27 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
const time = isLast ? w.arrTime : w.depTime
endpoints.push(endpointFromAirport(w.airport!, role, i, dayDate(dId), time || null))
})
} else if (form.type === 'train') {
trainStations.forEach((w, i) => {
const isFirst = i === 0
const isLast = i === trainStations.length - 1
const role: 'from' | 'to' | 'stop' = isFirst ? 'from' : isLast ? 'to' : 'stop'
const dId = isLast ? w.arrDayId : w.depDayId
const time = isLast ? w.arrTime : w.depTime
// The destination date falls back to the departure day (as the old flat
// path did via `endDay ?? startDay`) when the arrival day is left blank.
const date = dayDate(dId) ?? (isLast ? dayDate(firstTrainWp?.depDayId ?? '') : null)
endpoints.push(endpointFromLocation(w.location!, role, i, date, time || null))
})
} else {
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null))
// Keep the itinerary's transfer stops while the route is unchanged (#1065).
const stops = keepTransit
? prevEndpointsAll.filter(ep => ep.role === 'stop').slice().sort((a, b) => (a.sequence || 0) - (b.sequence || 0))
: []
stops.forEach((s, i) => endpoints.push({
role: 'stop', sequence: i + 1, name: s.name, code: s.code ?? null,
lat: s.lat, lng: s.lng, timezone: s.timezone ?? null,
local_date: s.local_date ?? null, local_time: s.local_time ?? null,
}))
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', stops.length + 1, endDate, form.arrival_time || null))
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', 1, endDate, form.arrival_time || null))
}
// Flights and trains derive their span from the first/last waypoint; other
// transports keep using the single departure/arrival form fields unchanged.
// Flights derive their span from the first/last waypoint; other transports
// keep using the single departure/arrival form fields unchanged.
const flightDepDay = firstWp && firstWp.depDayId ? Number(firstWp.depDayId) : null
const flightArrDay = lastWp && lastWp.arrDayId ? Number(lastWp.arrDayId) : null
const trainDepDay = firstTrainWp && firstTrainWp.depDayId ? Number(firstTrainWp.depDayId) : null
const trainArrDay = lastTrainWp && lastTrainWp.arrDayId ? Number(lastTrainWp.arrDayId) : null
const payload = {
title: form.title,
type: form.type,
status: form.status,
day_id: form.type === 'flight' ? flightDepDay : form.type === 'train' ? trainDepDay : (form.start_day_id ? Number(form.start_day_id) : null),
end_day_id: form.type === 'flight' ? flightArrDay : form.type === 'train' ? trainArrDay : (form.end_day_id ? Number(form.end_day_id) : null),
day_id: form.type === 'flight' ? flightDepDay : (form.start_day_id ? Number(form.start_day_id) : null),
end_day_id: form.type === 'flight' ? flightArrDay : (form.end_day_id ? Number(form.end_day_id) : null),
reservation_time: form.type === 'flight'
? buildTime(days.find(d => d.id === flightDepDay), firstWp?.depTime || '')
: form.type === 'train'
? buildTime(days.find(d => d.id === trainDepDay), firstTrainWp?.depTime || '')
: buildTime(startDay, form.departure_time),
: buildTime(startDay, form.departure_time),
reservation_end_time: form.type === 'flight'
? buildTime(days.find(d => d.id === flightArrDay), lastWp?.arrTime || '')
: form.type === 'train'
// Fall back to the departure day so a same-day train (arrival day left
// blank) still gets its date, matching the non-flight `endDay ?? startDay`.
? buildTime(days.find(d => d.id === trainArrDay) ?? days.find(d => d.id === trainDepDay), lastTrainWp?.arrTime || '')
: buildTime(endDay ?? startDay, form.arrival_time),
: buildTime(endDay ?? startDay, form.arrival_time),
location: null,
confirmation_number: form.confirmation_number || null,
notes: form.notes || null,
@@ -588,71 +439,19 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
<Modal
isOpen={isOpen}
onClose={onClose}
title={automated ? t('transit.title') : reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')}
title={reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')}
size="2xl"
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button type="button" onClick={onClose} className="text-content-muted" style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>
{t('common.cancel')}
</button>
{!automated && (
<button type="button" onClick={handleSubmit} disabled={isSaving || !form.title.trim()} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
</button>
)}
</div>
}
>
{/* Manual vs Automated creation switch (#1065) creating only; editing a
journey re-enters via "change route" with the switch hidden. */}
{!reservation && (
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 11, padding: 3, gap: 2, marginBottom: 14 }}>
{([['manual', t('transport.modeManual')], ['automated', t('transport.modeAutomated')]] as const).map(([m, label]) => {
const active = (m === 'automated') === automated
return (
<button key={m} type="button" onClick={() => setAutomated(m === 'automated')}
className={active ? 'bg-surface-card text-content' : 'text-content-muted'}
style={{ flex: 1, padding: '8px 6px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, borderRadius: 8, border: 0, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap', background: active ? undefined : 'transparent', boxShadow: active ? '0 1px 4px rgba(0,0,0,0.08)' : 'none' }}>
{label}
</button>
)
})}
</div>
)}
{automated ? (
/* ── Automated: public transit search (#1065) ── */
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
{/* search header: what this is + the day it plans for */}
<div className="bg-surface-tertiary" style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '14px 16px', borderRadius: 14, flexWrap: 'wrap' }}>
<div style={{ width: 42, height: 42, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 12, background: '#7c3aed18' }}>
<TramFront size={20} strokeWidth={1.8} color="#7c3aed" />
</div>
<div style={{ flex: 1, minWidth: 180 }}>
<div className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, letterSpacing: '-0.01em' }}>{t('transit.title')}</div>
<div className="text-content-faint" style={{ fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', marginTop: 1 }}>{t('transit.searchHint')}</div>
</div>
<div style={{ width: typeof window !== 'undefined' && window.innerWidth < 768 ? '100%' : 210, flexShrink: 0 }}>
<CustomSelect value={form.start_day_id} onChange={v => set('start_day_id', v)} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
</div>
</div>
{(() => {
const transitDay = days.find(d => d.id === Number(form.start_day_id))
if (!transitDay) return <div className="text-content-faint" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', padding: '4px 2px 12px' }}>{t('transit.pickDay')}</div>
return (
<TransitSearchPanel
day={transitDay}
days={days}
places={places}
accommodations={accommodations}
onAdd={(payload) => onSave(payload as Record<string, any> & { title: string })}
initialFrom={transitPrefill?.from ?? null}
initialTo={transitPrefill?.to ?? null}
/>
)
})()}
</div>
) : (
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Type selector */}
@@ -765,80 +564,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
)
})}
</div>
) : form.type === 'train' ? (
/* ── Train route: ordered stations (origin · stops · destination) ── */
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<label className={labelClass}>{t('reservations.layover.route')}</label>
{trainWaypoints.map((wp, i) => {
const isFirst = i === 0
const isLast = i === trainWaypoints.length - 1
const updateWp = (patch: Partial<StationWaypointForm>) => setTrainWaypoints(prev => prev.map((w, j) => (j === i ? { ...w, ...patch } : w)))
const roleLabel = isFirst ? t('reservations.meta.from') : isLast ? t('reservations.meta.to') : t('reservations.layover.stop')
return (
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div className="bg-surface-card" style={{ border: '1px solid var(--border-primary)', borderRadius: 10, padding: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="text-content-faint" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.03em', flexShrink: 0 }}>{roleLabel}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<LocationSelect value={wp.location} onChange={l => updateWp({ location: l || null })} />
</div>
{!isFirst && !isLast && (
<button type="button" onClick={() => setTrainWaypoints(prev => prev.filter((_, j) => j !== i))} aria-label={t('common.delete')} className="text-content-faint" style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', padding: 4, flexShrink: 0 }}>
<Trash2 size={14} />
</button>
)}
</div>
{!isFirst && (
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{t('reservations.arrivalDate')}</label>
<CustomSelect value={wp.arrDayId} onChange={v => updateWp({ arrDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{t('reservations.arrivalTime')}</label>
<CustomTimePicker value={wp.arrTime} onChange={v => updateWp({ arrTime: v })} />
</div>
</div>
)}
{!isLast && (
<>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{t('reservations.departureDate')}</label>
<CustomSelect value={wp.depDayId} onChange={v => updateWp({ depDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{t('reservations.departureTime')}</label>
<CustomTimePicker value={wp.depTime} onChange={v => updateWp({ depTime: v })} />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label className={labelClass}>{t('reservations.meta.trainNumber')}</label>
<input type="text" value={wp.train_number} onChange={e => updateWp({ train_number: e.target.value })} placeholder="ICE 123" className={inputClass} />
</div>
<div>
<label className={labelClass}>{t('reservations.meta.platform')}</label>
<input type="text" value={wp.platform} onChange={e => updateWp({ platform: e.target.value })} placeholder="12" className={inputClass} />
</div>
<div>
<label className={labelClass}>{t('reservations.meta.seat')}</label>
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="42A" className={inputClass} />
</div>
</div>
</>
)}
</div>
{!isLast && (
<button type="button" onClick={() => setTrainWaypoints(prev => [...prev.slice(0, i + 1), emptyStationWaypoint(prev[i]?.depDayId || ''), ...prev.slice(i + 1)])}
className="text-content-faint hover:text-content-secondary" style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, padding: '6px 10px', border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>
<Plus size={12} /> {t('reservations.layover.addStop')}
</button>
)}
</div>
)
})}
</div>
) : (
<>
{/* From / To endpoints (non-flight) */}
@@ -880,7 +605,25 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
)}
{/* Train-specific fields */}
{/* Train number / platform / seat are per-leg now (in the route above). */}
{form.type === 'train' && (
<div className="grid grid-cols-3 gap-3">
<div>
<label className={labelClass}>{t('reservations.meta.trainNumber')}</label>
<input type="text" value={form.meta_train_number} onChange={e => set('meta_train_number', e.target.value)}
placeholder="ICE 123" className={inputClass} />
</div>
<div>
<label className={labelClass}>{t('reservations.meta.platform')}</label>
<input type="text" value={form.meta_platform} onChange={e => set('meta_platform', e.target.value)}
placeholder="12" className={inputClass} />
</div>
<div>
<label className={labelClass}>{t('reservations.meta.seat')}</label>
<input type="text" value={form.meta_seat} onChange={e => set('meta_seat', e.target.value)}
placeholder="42A" className={inputClass} />
</div>
</div>
)}
{/* Booking Code + Status */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
@@ -946,7 +689,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
</button>
</div>
))}
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,.pkpass,.pkpasses,image/*,application/vnd.apple.pkpass,application/vnd.apple.pkpasses" style={{ display: 'none' }} onChange={handleFileChange} />
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{onFileUpload && <button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} className="text-content-faint" style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
@@ -1012,7 +755,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
)}
</form>
)}
</Modal>
)
}
@@ -1,46 +0,0 @@
import { useEffect, useState } from 'react'
import { AlertTriangle, Info, AlertCircle } from 'lucide-react'
import { pluginsApi } from '../../api/client'
/**
* Shows validation/warning contributions from `warningProvider` plugins (#1429).
* Self-contained + fail-safe: the server skips any slow/failing provider, so this
* only ever adds rows; it renders nothing (and takes no space) when there are none.
*/
type Warning = { pluginId: string; level: 'info' | 'warning' | 'error'; message: string }
const STYLE = {
info: { Icon: Info, color: 'var(--info)', bg: 'var(--info-soft)' },
warning: { Icon: AlertTriangle, color: 'var(--warning)', bg: 'var(--warning-soft)' },
error: { Icon: AlertCircle, color: 'var(--danger)', bg: 'var(--danger-soft)' },
} as const
export default function TripWarningsBanner({ tripId }: { tripId: number }) {
const [warnings, setWarnings] = useState<Warning[]>([])
useEffect(() => {
if (!Number.isFinite(tripId)) { setWarnings([]); return }
let cancelled = false
pluginsApi.tripWarnings(tripId)
.then((d) => { if (!cancelled) setWarnings(d.warnings || []) })
.catch(() => { if (!cancelled) setWarnings([]) })
return () => { cancelled = true }
}, [tripId])
if (warnings.length === 0) return null
// A non-blocking overlay pinned to the top of the (fixed) planner content region:
// the wrapper ignores pointer events so it never covers the map/panels, and only
// the warning pills themselves are interactive.
return (
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 6, pointerEvents: 'none', display: 'flex', flexDirection: 'column', gap: 6, padding: '8px 16px' }}>
{warnings.map((w, i) => {
const s = STYLE[w.level] ?? STYLE.warning
return (
<div key={`${w.pluginId}-${i}`} style={{ pointerEvents: 'auto', display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', borderRadius: 10, background: s.bg, color: s.color, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, boxShadow: 'var(--shadow-card)' }}>
<s.Icon size={15} style={{ flexShrink: 0 }} />
<span>{w.message}</span>
</div>
)
})}
</div>
)
}
@@ -43,7 +43,7 @@ export function parsedItemToDraft(item: BookingImportPreviewItem): BookingReview
}
/** Transport types route to the TransportModal; everything else to the ReservationModal. */
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'])
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'])
export function isTransportItem(item: BookingImportPreviewItem): boolean {
return TRANSPORT_TYPES.has(item.type)
}
@@ -1,209 +0,0 @@
import React from 'react'
import { Footprints, MoveRight, type LucideIcon } from 'lucide-react'
/**
* Shared display bits for public-transit entries (#1065) the timeline row,
* the Transports-tab card and the journey modal all render the same language:
* "A → B" titles with a real arrow icon, and the leg sequence as chips where
* walks carry their minutes (🚶 3 U2 🚶 3) instead of a detached summary.
*/
export interface TransitLegDisplay {
mode?: string
line?: string | null
line_color?: string | null
line_text_color?: string | null
duration?: number
headsign?: string | null
stops?: number
from?: { name?: string; time?: string | null; track?: string | null }
to?: { name?: string; time?: string | null; track?: string | null }
}
/**
* A walk leg as a one-line centred divider dashed rules left and right,
* the walk itself in the middle (🚶 Walk to X · 4 min). Used by the journey
* modal and the day-plan inline itinerary.
*/
export function TransitWalkDivider({ leg, t, size = 'md' }: {
leg: TransitLegDisplay
t: (k: string, p?: Record<string, string | number>) => string
size?: 'sm' | 'md'
}) {
const mins = leg.duration ? Math.round(leg.duration / 60) : null
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
// Hairlines that fade towards the outer edges — strongest next to the text.
const rule = (dir: 'left' | 'right'): React.CSSProperties => ({
flex: 1, height: 1, minWidth: 12, borderRadius: 1,
background: `linear-gradient(to ${dir}, var(--text-faint), transparent)`,
opacity: 0.55,
})
// On phones the minutes lead so a long stop name only ever clips the name.
const text = isMobile
? `${mins ? `${t('transit.min', { count: mins })} · ` : ''}${t('transit.walkTo', { name: leg.to?.name || '' })}`
: `${t('transit.walkTo', { name: leg.to?.name || '' })}${mins ? ` · ${t('transit.min', { count: mins })}` : ''}`
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: size === 'sm' ? '1px 0' : '2px 0' }}>
<span style={rule('left')} />
<span className="text-content-faint" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: size === 'sm' ? 'calc(10px * var(--fs-scale-caption, 1))' : 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: isMobile ? '85%' : '75%' }}>
<Footprints size={size === 'sm' ? 11 : 12} style={{ flexShrink: 0 }} />
{text}
</span>
<span style={rule('right')} />
</div>
)
}
/**
* The itinerary folded out right inside the day-plan row (#1065): one compact
* line per leg time, badge or foot icon, stations sized for the sidebar.
*/
export function TransitItineraryInline({ legs, t }: {
legs: TransitLegDisplay[]
t: (k: string, p?: Record<string, string | number>) => string
}) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
{legs.map((leg, i) => {
if (leg.mode === 'WALK') return <TransitWalkDivider key={i} leg={leg} t={t} size="sm" />
const mins = leg.duration ? Math.round(leg.duration / 60) : null
return (
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 7 }}>
<span className="text-content-muted" style={{ width: 34, flexShrink: 0, textAlign: 'right', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, paddingTop: 1 }}>
{leg.from?.time || ''}
</span>
<span style={{
display: 'inline-flex', alignItems: 'center', borderRadius: 4, padding: '0 5px',
fontSize: 'calc(9.5px * var(--fs-scale-caption, 1))', fontWeight: 700, lineHeight: '15px', flexShrink: 0,
background: leg.line_color || 'var(--bg-tertiary)',
color: leg.line_color ? (leg.line_text_color || '#fff') : 'var(--text-primary)',
}}>
{leg.line || leg.mode}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, display: 'flex', alignItems: 'center', gap: 4, minWidth: 0 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{leg.from?.name}</span>
<MoveRight size={10} className="text-content-faint" style={{ flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{leg.to?.name}</span>
</div>
<div className="text-content-faint" style={{ fontSize: 'calc(9.5px * var(--fs-scale-caption, 1))', marginTop: 1 }}>
{[
mins ? t('transit.min', { count: mins }) : null,
leg.stops ? t('transit.stops', { count: leg.stops }) : null,
leg.from?.track ? t('transit.platform', { track: leg.from.track }) : null,
].filter(Boolean).join(' · ')}
</div>
</div>
</div>
)
})}
</div>
)
}
/** "1 h 9 min" / "42 min" from seconds — shared by all transit surfaces. */
export function fmtTransitDuration(seconds: number, t: (k: string, p?: Record<string, string | number>) => string): string {
const mins = Math.round(seconds / 60)
if (mins < 60) return t('transit.min', { count: mins })
const h = Math.floor(mins / 60)
const m = mins % 60
return m > 0 ? `${h} h ${m} min` : `${h} h`
}
export interface TransitMetaItem {
icon?: LucideIcon
text: string
/** De-emphasised (operator names and the like). */
dim?: boolean
}
/**
* A quiet badge row replacing dot-joined meta text ("21:06 - 22:15 - 69 min -
* 14 stops ..."): each fact becomes a small chip, optional icon in front.
*/
export function TransitMetaBadges({ items, size = 'md' }: { items: TransitMetaItem[]; size?: 'sm' | 'md' }) {
const font = size === 'sm' ? 'calc(10px * var(--fs-scale-caption, 1))' : 'calc(10.5px * var(--fs-scale-caption, 1))'
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' }}>
{items.filter(i => i.text).map(({ icon: Icon, text, dim }, i) => (
<span
key={i}
className={dim ? 'bg-surface-card text-content-faint' : 'bg-surface-card text-content-muted'}
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: size === 'sm' ? '1px 6px' : '2px 8px', borderRadius: 6,
fontSize: font, fontWeight: 500, whiteSpace: 'nowrap',
border: '1px solid var(--border-faint)',
}}
>
{Icon && <Icon size={size === 'sm' ? 9 : 10} strokeWidth={2} />}
{text}
</span>
))}
</span>
)
}
/** Renders "From → To" titles with an arrow icon instead of the text glyph. */
export function TransitTitle({ title, iconSize = 12 }: { title: string; iconSize?: number }) {
const parts = title.split(' → ')
if (parts.length < 2) return <>{title}</>
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5, minWidth: 0, maxWidth: '100%' }}>
{parts.map((p, i) => (
<React.Fragment key={i}>
{i > 0 && <MoveRight size={iconSize} style={{ flexShrink: 0, opacity: 0.55 }} />}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p}</span>
</React.Fragment>
))}
</span>
)
}
/**
* The leg sequence as chips. Walk legs show their minutes right at the foot
* icon (sub-minute walks are dropped); transit legs are line badges in their
* colors. Optionally appends "· N transfers" never a redundant "direct".
*/
export function TransitLegChips({ legs, transfers, size = 'sm', t }: {
legs: TransitLegDisplay[]
transfers?: number
size?: 'sm' | 'md'
t: (k: string, p?: Record<string, string | number>) => string
}) {
const badgeFont = size === 'sm' ? 'calc(9.5px * var(--fs-scale-caption, 1))' : 'calc(10.5px * var(--fs-scale-caption, 1))'
const walkIcon = size === 'sm' ? 10 : 12
const shown = legs.filter(l => l.mode !== 'WALK' || (l.duration || 0) >= 60)
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: size === 'sm' ? 4 : 5, flexWrap: 'wrap' }}>
{shown.map((leg, i) => (
<React.Fragment key={i}>
{i > 0 && <span className="text-content-faint" style={{ fontSize: size === 'sm' ? 9 : 10 }}></span>}
{leg.mode === 'WALK' ? (
<span className="text-content-faint" style={{ display: 'inline-flex', alignItems: 'center', gap: 2, fontSize: badgeFont, fontWeight: 600 }}>
<Footprints size={walkIcon} />
{Math.round((leg.duration || 0) / 60)}
</span>
) : (
<span style={{
display: 'inline-flex', alignItems: 'center', borderRadius: size === 'sm' ? 4 : 5,
padding: size === 'sm' ? '0 5px' : '1px 7px', lineHeight: size === 'sm' ? '15px' : undefined,
fontSize: badgeFont, fontWeight: 700,
background: leg.line_color || 'var(--bg-tertiary)',
color: leg.line_color ? (leg.line_text_color || '#fff') : 'var(--text-primary)',
}}>
{leg.line || leg.mode}
</span>
)}
</React.Fragment>
))}
{typeof transfers === 'number' && transfers > 0 && (
<span className="text-content-faint" style={{ fontSize: badgeFont, marginLeft: 2 }}>
· {t('transit.transfers', { count: transfers })}
</span>
)}
</span>
)
}
@@ -1,104 +0,0 @@
// FE-PLUGINS-FRAME-001 to 004
import { render, cleanup, waitFor } from '@testing-library/react';
import PluginFrame from './PluginFrame';
const navigate = vi.fn();
const toast = { info: vi.fn(), success: vi.fn(), warning: vi.fn(), error: vi.fn() };
const invoke = vi.fn((..._args: unknown[]) => Promise.resolve({ ok: true }));
vi.mock('react-router-dom', () => ({ useNavigate: () => navigate }));
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
vi.mock('../../i18n', () => ({ useTranslation: () => ({ locale: 'en' }) }));
vi.mock('../../store/authStore', () => ({ useAuthStore: (sel: (s: unknown) => unknown) => sel({ user: { id: 7, username: 'ada', avatar_url: null, role: 'admin' } }) }));
vi.mock('../../store/settingsStore', () => ({ useSettingsStore: (sel: (s: unknown) => unknown) => sel({ settings: { default_currency: 'EUR', time_format: '24h', distance_unit: 'metric', temperature_unit: 'celsius' } }) }));
vi.mock('../../api/client', () => ({ pluginsApi: { invoke: (id: string, sub: string, init?: unknown) => invoke(id, sub, init) } }));
function fromFrame(frame: HTMLIFrameElement, data: unknown) {
window.dispatchEvent(new MessageEvent('message', { source: frame.contentWindow, data } as MessageEventInit));
}
afterEach(() => {
cleanup();
navigate.mockClear();
Object.values(toast).forEach((f) => f.mockClear());
invoke.mockClear();
});
describe('PluginFrame', () => {
it('FE-PLUGINS-FRAME-001: renders an opaque sandboxed iframe (no allow-same-origin)', () => {
const { container } = render(<PluginFrame pluginId="demo" />);
const iframe = container.querySelector('iframe')!;
expect(iframe.getAttribute('src')).toBe('/plugin-frame/demo/index.html');
const sandbox = iframe.getAttribute('sandbox') || '';
expect(sandbox).toContain('allow-scripts');
expect(sandbox).not.toContain('allow-same-origin');
});
it('FE-PLUGINS-FRAME-002: authenticates messages by sender window — a foreign source is ignored', () => {
const { container } = render(<PluginFrame pluginId="demo" />);
const iframe = container.querySelector('iframe')!;
// message NOT from our iframe -> ignored
window.dispatchEvent(new MessageEvent('message', { source: window, data: { type: 'trek:navigate', to: '/admin' } }));
expect(navigate).not.toHaveBeenCalled();
// message from our iframe -> handled
fromFrame(iframe, { type: 'trek:navigate', to: '/dashboard' });
expect(navigate).toHaveBeenCalledWith('/dashboard');
});
it('FE-PLUGINS-FRAME-003: blocks unsafe navigation targets and renders notifications as text', () => {
const { container } = render(<PluginFrame pluginId="demo" />);
const iframe = container.querySelector('iframe')!;
fromFrame(iframe, { type: 'trek:navigate', to: '//evil.example' }); // protocol-relative
expect(navigate).not.toHaveBeenCalled();
fromFrame(iframe, { type: 'trek:notify', level: 'success', message: 'saved' });
expect(toast.success).toHaveBeenCalledWith('saved');
});
it('FE-PLUGINS-FRAME-004: trek:invoke calls the host proxy and replies to the frame', async () => {
const { container } = render(<PluginFrame pluginId="demo" />);
const iframe = container.querySelector('iframe')!;
const posted: unknown[] = [];
// capture host->frame messages
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m);
fromFrame(iframe, { type: 'trek:invoke', requestId: 'r1', sub: '/status', method: 'GET' });
await waitFor(() => expect(invoke).toHaveBeenCalledWith('demo', '/status', { method: 'GET', body: undefined }));
await waitFor(() => expect(posted.some((m) => (m as { type?: string }).type === 'trek:response')).toBe(true));
});
it('FE-PLUGINS-FRAME-005: context carries theme tokens, formats and non-secret display identity', () => {
const { container } = render(<PluginFrame pluginId="demo" />);
const iframe = container.querySelector('iframe')!;
const posted: Array<Record<string, unknown>> = [];
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record<string, unknown>);
fromFrame(iframe, { type: 'trek:context:request' });
const ctx = posted.find((m) => m.type === 'trek:context') as Record<string, unknown> | undefined;
expect(ctx).toBeTruthy();
expect(ctx!.tokens).toBeTruthy(); // resolved design tokens (empty {} in jsdom, but present)
expect(ctx!.formats).toMatchObject({ currency: 'EUR', timeFormat: '24h', distanceUnit: 'metric' });
// Display identity is present but carries NO secret (no email, role only as a boolean).
expect(ctx!.user).toMatchObject({ name: 'ada', isAdmin: true });
expect(JSON.stringify(ctx)).not.toContain('@'); // no email leaked
});
it('FE-PLUGINS-FRAME-006: context mirrors the host appearance state (scheme/density/flags)', () => {
const { container } = render(<PluginFrame pluginId="demo" />);
const iframe = container.querySelector('iframe')!;
const posted: Array<Record<string, unknown>> = [];
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record<string, unknown>);
fromFrame(iframe, { type: 'trek:context:request' });
const ctx = posted.find((m) => m.type === 'trek:context') as Record<string, unknown> | undefined;
expect(ctx).toBeTruthy();
// A plugin can honour the same accent/density/accessibility choices as the host.
expect(ctx!.appearance).toMatchObject({
scheme: 'default',
density: 'comfortable',
noTransparency: false,
reducedMotion: false,
});
});
});
@@ -1,251 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from '../../i18n'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
import { pluginsApi } from '../../api/client'
// The design-token contract handed to plugins (#4 richer context): non-secret CSS
// values, resolved for the CURRENT theme, so a plugin can match TREK exactly (and
// re-match on a theme toggle / accent change) instead of hard-coding a mirror of
// the palette that drifts. Names mirror index.css so a plugin can apply them
// verbatim as CSS variables. This is the whole GLOBAL (:root/.dark) palette — the
// part that a user can recolour via appearance settings (accent scheme, custom
// accent, high-contrast) flows through here live. The glassy `.trek-dash` layer
// (--glass-*/--r-*/--sh-*) is intentionally NOT read here: it is scoped to the
// dashboard subtree, so it resolves EMPTY at documentElement — the SDK design kit
// bakes those values instead (they don't vary with the accent, only light/dark).
const TOKEN_VARS = [
// surfaces
'--bg-primary', '--bg-secondary', '--bg-tertiary', '--bg-elevated',
'--bg-card', '--bg-input', '--bg-hover', '--bg-selected', '--bg-inverse',
// text
'--text-primary', '--text-secondary', '--text-muted', '--text-faint', '--text-inverse',
// borders
'--border-primary', '--border-secondary', '--border-faint',
// accent (recoloured by the chosen scheme / custom accent)
'--accent', '--accent-text', '--accent-on', '--accent-hover', '--accent-subtle',
// semantic + soft fills
'--success', '--success-soft', '--danger', '--danger-soft',
'--warning', '--warning-soft', '--info', '--info-soft',
// shadows
'--shadow-card', '--shadow-elevated', '--shadow-sm', '--shadow-md', '--shadow-lg',
// radii, type, misc
'--radius-sm', '--radius-md', '--radius-lg', '--radius-xl',
'--font-system', '--font-subtext', '--overlay', '--ease-out-quint',
]
function readThemeTokens(): Record<string, string> {
const cs = getComputedStyle(document.documentElement)
const out: Record<string, string> = {}
for (const v of TOKEN_VARS) {
const val = cs.getPropertyValue(v).trim()
if (val) out[v] = val
}
return out
}
/**
* The host's current appearance state, mirrored from the attributes applyAppearance
* writes on <html>, so a plugin can honour the same accessibility/appearance choices
* inside its own sandboxed document (it can't read the parent DOM). All booleans/enums
* nothing secret.
*/
function readAppearance() {
const el = document.documentElement
return {
scheme: el.dataset.scheme || 'default',
density: el.dataset.density === 'compact' ? 'compact' : 'comfortable',
noTransparency: el.hasAttribute('data-no-transparency'),
reducedMotion:
el.hasAttribute('data-reduce-motion') ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
}
}
/**
* Renders a plugin's sandboxed page/widget iframe and hosts the trekBridge
* (#plugins, M3).
*
* The frame is served same-origin from /plugin-frame/:id but sandboxed WITHOUT
* allow-same-origin, so it runs at an OPAQUE origin: no access to the trek_session
* cookie, no parent DOM, no credentialed fetch. Its only channel is postMessage,
* and we authenticate every inbound message by the SENDER WINDOW IDENTITY
* (event.source === our iframe), never by a claimed id or by origin (which is
* "null" for opaque frames). Data reads go through the host (app-origin, session
* cookie) so the plugin never handles credentials.
*/
interface PluginFrameProps {
pluginId: string
tripId?: string | null
/** The place in view — set for a place-detail slot so the plugin can scope to it. */
placeId?: string | null
className?: string
title?: string
}
type Inbound =
| { type: 'trek:ready' }
| { type: 'trek:context:request' }
| { type: 'trek:navigate'; to: string }
| { type: 'trek:notify'; level?: 'info' | 'success' | 'warning' | 'error'; message?: string }
| { type: 'trek:resize'; height?: number }
| { type: 'trek:invoke'; requestId: string; sub: string; method?: string; body?: unknown }
export default function PluginFrame({ pluginId, tripId = null, placeId = null, className, title }: PluginFrameProps) {
const frameRef = useRef<HTMLIFrameElement | null>(null)
// A sandboxed frame may navigate ITSELF (connect-src can't stop that), and its
// window identity keeps matching our iframe afterwards. Track loads and refuse
// the bridge once a second document loads. NOTE: this is best-effort — the load
// event fires at end-of-document, so a navigated attacker doc that posts during
// its own load (or holds it open) can still reach the bridge for one exchange.
// The exposure is bounded (only this plugin's own routes + the trek:context
// ids the plugin already had; never the httpOnly cookie); fully closing it
// would require not running plugin client JS at all.
const loadsRef = useRef(0)
const { locale } = useTranslation()
const navigate = useNavigate()
const toast = useToast()
const userId = useAuthStore((s) => s.user?.id)
const userName = useAuthStore((s) => s.user?.username ?? null)
const userAvatar = useAuthStore((s) => s.user?.avatar_url ?? null)
const isAdmin = useAuthStore((s) => s.user?.role === 'admin')
const settings = useSettingsStore((s) => s.settings)
const [height, setHeight] = useState<number | null>(null)
// opaque frame -> targetOrigin must be '*'. Hoisted so the iframe's onLoad can
// deliver the context too: the trek:ready handshake alone is racy — if the frame
// boots before the effect's listener attaches, the plugin never learns the theme
// and falls back to the OS scheme (dark mode looking "off" until a toggle).
const postFrame = useCallback((msg: unknown) => frameRef.current?.contentWindow?.postMessage(msg, '*'), [])
const buildContext = useCallback(() => ({
type: 'trek:context',
tripId,
placeId,
userId: userId != null ? String(userId) : null,
theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light',
locale,
hostOrigin: window.location.origin,
// #4 richer context — non-secret display data so plugins render natively:
// who the user is (name/avatar/isAdmin — never email/role beyond a boolean),
// how TREK formats things, the resolved theme tokens, and the appearance state
// (accent scheme, density, reduced-motion / no-transparency) so a plugin can
// mirror the same look and accessibility choices as the host.
user: userName != null ? { name: userName, avatar: userAvatar, isAdmin } : null,
appearance: readAppearance(),
formats: {
locale,
currency: settings.default_currency,
timeFormat: settings.time_format,
distanceUnit: settings.distance_unit,
temperatureUnit: settings.temperature_unit,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
tokens: readThemeTokens(),
}), [tripId, placeId, userId, locale, userName, userAvatar, isAdmin, settings])
useEffect(() => {
const frame = frameRef.current
if (!frame) return
const post = postFrame
const context = buildContext
const onMessage = async (ev: MessageEvent) => {
// The ONLY trusted identity: the message came from OUR iframe's window.
if (ev.source !== frame.contentWindow) return
// …AND that window still holds the original plugin document (loaded once).
// A 2nd load means the frame navigated elsewhere — stop bridging to it.
if (loadsRef.current > 1) return
const msg = ev.data as Inbound
if (!msg || typeof msg !== 'object') return
switch (msg.type) {
case 'trek:ready':
case 'trek:context:request':
post(context())
break
case 'trek:navigate': {
const to = typeof msg.to === 'string' ? msg.to : ''
// In-app paths only; block protocol-relative and admin unless allowed by the app itself.
if (/^\/[a-zA-Z0-9/_?=&%.-]*$/.test(to) && !to.startsWith('//')) navigate(to)
break
}
case 'trek:notify': {
const text = String(msg.message ?? '').slice(0, 200)
const level = msg.level ?? 'info'
if (text) (toast[level] ?? toast.info)(text)
break
}
case 'trek:resize':
if (typeof msg.height === 'number' && msg.height > 0) setHeight(Math.min(msg.height, 2000))
break
case 'trek:invoke': {
// The plugin's own route, called host-side with the user's session.
try {
const data = await pluginsApi.invoke(pluginId, msg.sub, { method: msg.method, body: msg.body })
post({ type: 'trek:response', requestId: msg.requestId, data })
} catch (e) {
const err = e as { response?: { status?: number }; message?: string }
post({ type: 'trek:error', requestId: msg.requestId, code: err.response?.status ?? 'error', message: err.message ?? 'invoke failed' })
}
break
}
}
}
window.addEventListener('message', onMessage)
// The frame is opaque-origin and can't read our DOM, and we otherwise send the
// context (incl. theme + tokens) only once on trek:ready — so a plugin can't
// follow an in-app appearance change. Watch the <html> element for anything
// applyAppearance touches (the `dark` class, the data-* appearance attributes,
// and inline style for the custom-accent vars) and re-post the context when the
// resulting look actually changes, so plugins restyle live. A compact signature
// dedupes: unrelated mutations don't trigger a repost. (Plugins re-apply on
// trek:context.)
const htmlEl = document.documentElement
const appearanceSig = () => {
const cs = getComputedStyle(htmlEl)
return [
htmlEl.classList.contains('dark'),
htmlEl.dataset.scheme || '',
htmlEl.dataset.density || '',
htmlEl.hasAttribute('data-no-transparency'),
htmlEl.hasAttribute('data-reduce-motion'),
cs.getPropertyValue('--accent').trim(),
].join('|')
}
let prevSig = appearanceSig()
const themeObserver = new MutationObserver(() => {
const sig = appearanceSig()
if (sig === prevSig) return
prevSig = sig
if (loadsRef.current <= 1) post(context())
})
themeObserver.observe(htmlEl, {
attributes: true,
attributeFilter: ['class', 'style', 'data-scheme', 'data-density', 'data-no-transparency', 'data-reduce-motion'],
})
return () => { window.removeEventListener('message', onMessage); themeObserver.disconnect() }
}, [pluginId, navigate, toast, postFrame, buildContext])
return (
<iframe
ref={frameRef}
src={`/plugin-frame/${pluginId}/index.html`}
// Deliver the context as soon as the document is parsed (the plugin sets up its
// message listener during parse), closing the trek:ready race so the theme is
// right on first paint. A 2nd load is a self-navigation — don't bridge to it.
onLoad={() => { loadsRef.current += 1; if (loadsRef.current === 1) postFrame(buildContext()) }}
sandbox="allow-scripts allow-forms"
referrerPolicy="no-referrer"
loading="lazy"
title={title || pluginId}
className={className}
style={{ width: '100%', height: height ?? '100%', border: 0 }}
/>
)
}
@@ -1,49 +0,0 @@
import { Blocks } from 'lucide-react'
import PluginFrame from './PluginFrame'
import type { ActivePlugin } from '../../store/pluginStore'
/**
* Renders active `widget` plugins as dashboard cards (#plugins, M8). Each is a
* sandboxed PluginFrame; the widget talks to TREK only over the bridge.
*
* The card mirrors the native dashboard tools (glassy `.tool` surface + uppercase
* title) so plugins sit alongside them seamlessly, and the body auto-sizes to the
* height the widget reports over trek:resize no fixed height that would clip a
* taller widget's controls.
*/
export default function PluginWidgets({ plugins, tripId = null }: { plugins: ActivePlugin[]; tripId?: string | null }) {
if (plugins.length === 0) return null
return (
<>
{plugins.map((p) => (
<div
key={p.id}
style={{
background: 'var(--glass-bg)',
border: '1px solid var(--glass-border)',
borderRadius: 'var(--r-xl, 20px)',
boxShadow: 'var(--glass-shadow), var(--glass-highlight)',
backdropFilter: 'var(--glass-blur)',
WebkitBackdropFilter: 'var(--glass-blur)',
overflow: 'hidden',
}}
>
<div
style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '16px 20px 8px',
fontSize: 13, fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.14em',
color: 'var(--ink-3)',
}}
>
<Blocks size={14} style={{ flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
</div>
{/* min-height is just a pre-resize floor; trek:resize drives the real height. */}
<div style={{ minHeight: 60 }}>
<PluginFrame pluginId={p.id} tripId={tripId} title={p.name} />
</div>
</div>
))}
</>
)
}
@@ -457,8 +457,7 @@ function SliderRow({ label, value, onChange }: { label: string; value: number; o
step={0.05}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="trek-range"
style={{ '--fill': `${((value - APPEARANCE_SCALE_MIN) / (APPEARANCE_SCALE_MAX - APPEARANCE_SCALE_MIN)) * 100}%` } as React.CSSProperties}
style={{ width: '100%', accentColor: 'var(--accent)', cursor: 'pointer' }}
/>
</div>
)
@@ -483,8 +482,7 @@ function SizeRow({ sampleClass, name, example, sample, value, onChange }: { samp
step={0.05}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="trek-range"
style={{ '--fill': `${((value - APPEARANCE_SCALE_MIN) / (APPEARANCE_SCALE_MAX - APPEARANCE_SCALE_MIN)) * 100}%` } as React.CSSProperties}
style={{ width: '100%', accentColor: 'var(--accent)', cursor: 'pointer' }}
/>
</div>
)
@@ -247,8 +247,8 @@ export default function DisplaySettingsTab(): React.ReactElement {
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
border: (settings.map_booking_labels === true) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: (settings.map_booking_labels === true) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
border: (settings.map_booking_labels !== false) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: (settings.map_booking_labels !== false) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
+1 -1
View File
@@ -51,7 +51,7 @@ export default function Modal({
return ReactDOM.createPortal(
<div
className="fixed inset-0 z-[10000] flex items-start sm:items-center justify-center px-4 trek-modal-backdrop trek-backdrop-enter bg-[rgba(15,23,42,0.5)]"
className="fixed inset-0 z-[200] flex items-start sm:items-center justify-center px-4 trek-modal-backdrop trek-backdrop-enter bg-[rgba(15,23,42,0.5)]"
style={{ paddingTop: 70, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflow: 'hidden' }}
onMouseDown={e => { mouseDownTarget.current = e.target }}
onClick={e => {
+1 -1
View File
@@ -7,7 +7,7 @@ import { getDayBookendHotels } from '../utils/dayOrder'
import type { TripStoreState } from '../store/tripStore'
import type { RouteSegment, RouteResult, Accommodation } from '../types'
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other']
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other']
const NO_ACCOMMODATIONS: Accommodation[] = []
-103
View File
@@ -1,103 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import type { Reservation, ReservationEndpoint } from '../types'
import { calculateRouteWithLegs } from '../components/Map/RouteCalculator'
/**
* Real road-network geometry for road-based transport bookings (car, bus, taxi,
* bicycle), so their map lines follow actual streets instead of a straight
* as-the-crow-flies line the same idea as the real transit paths (#1065),
* but routed on demand rather than stored on the reservation.
*
* Trains and "other transport" keep their straight line (rail/unknown modes
* aren't road-routable); flights/cruises/ferries use the great-circle arc.
* Any routing failure falls back to the straight line the overlay already draws.
*/
const ROAD_PROFILE: Record<string, 'driving' | 'cycling'> = {
car: 'driving',
bus: 'driving',
taxi: 'driving',
bicycle: 'cycling',
}
// Beyond this straight-line distance a car/taxi/bike (or even coach) booking is
// almost always a data quirk or an inter-continental hop the road router can't
// resolve — keep the straight line and don't hammer the public OSRM demo.
const MAX_ROUTE_KM = 2000
function haversineKm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
const R = 6371
const dLat = (b.lat - a.lat) * Math.PI / 180
const dLng = (b.lng - a.lng) * Math.PI / 180
const la1 = a.lat * Math.PI / 180
const la2 = b.lat * Math.PI / 180
const h = Math.sin(dLat / 2) ** 2 + Math.cos(la1) * Math.cos(la2) * Math.sin(dLng / 2) ** 2
return 2 * R * Math.asin(Math.sqrt(h))
}
function orderedWaypoints(r: Reservation): ReservationEndpoint[] {
return (r.endpoints || [])
.filter(e => e.role === 'from' || e.role === 'to' || e.role === 'stop')
.slice()
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
}
/**
* Returns a map of reservation id routed [lat, lng] polyline for the
* road-based bookings among `reservations`. Missing entries mean "not routed
* (yet / not routable)" the caller should draw its straight line for those.
* Routing runs once per reservation waypoint-set and is cached across the app
* by RouteCalculator, so day switches and re-renders don't re-fetch.
*/
export function useTransportRoutes(reservations: Reservation[]): Map<number, [number, number][]> {
const [routes, setRoutes] = useState<Map<number, [number, number][]>>(new Map())
// id → waypoint signature already fetched/attempted, so an unchanged booking
// is never re-requested even as the reservations array identity churns.
const attemptedRef = useRef<Map<number, string>>(new Map())
// Aborted only on unmount — a reservations change must not cancel an
// in-flight route we still want.
const abortRef = useRef<AbortController | null>(null)
if (!abortRef.current) abortRef.current = new AbortController()
useEffect(() => () => abortRef.current?.abort(), [])
useEffect(() => {
const jobs: { id: number; profile: 'driving' | 'cycling'; points: { lat: number; lng: number }[] }[] = []
for (const r of reservations) {
const profile = ROAD_PROFILE[r.type]
if (!profile) continue
const wps = orderedWaypoints(r)
if (wps.length < 2) continue
let dist = 0
for (let i = 0; i < wps.length - 1; i++) dist += haversineKm(wps[i], wps[i + 1])
if (dist > MAX_ROUTE_KM) continue
const key = `${profile}:${wps.map(w => `${w.lat},${w.lng}`).join('|')}`
if (attemptedRef.current.get(r.id) === key) continue
attemptedRef.current.set(r.id, key)
jobs.push({ id: r.id, profile, points: wps.map(w => ({ lat: w.lat, lng: w.lng })) })
}
if (!jobs.length) return
let cancelled = false
void (async () => {
// Sequential to stay gentle on the shared public router.
for (const job of jobs) {
try {
const result = await calculateRouteWithLegs(job.points, { signal: abortRef.current?.signal, profile: job.profile })
if (cancelled) return
if (result.coordinates.length >= 2) {
setRoutes(prev => {
const next = new Map(prev)
next.set(job.id, result.coordinates)
return next
})
}
} catch {
// Leave it unrouted — the overlay keeps the straight line.
}
}
})()
return () => { cancelled = true }
}, [reservations])
return routes
}
-33
View File
@@ -1093,36 +1093,3 @@ img[alt="TREK"] {
.dp-day-header:hover .dp-day-actions,
.dp-day-header[data-selected="true"] .dp-day-actions { opacity: 1; }
}
/* ── TREK range slider — thin filled track + soft round thumb ─────────────── */
.trek-range {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 5px;
border-radius: 999px;
background: linear-gradient(to right, var(--accent) var(--fill, 50%), var(--border-faint) var(--fill, 50%));
outline: none;
cursor: pointer;
}
.trek-range::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--bg-card, #fff);
border: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
transition: transform 0.12s ease;
}
.trek-range::-webkit-slider-thumb:hover { transform: scale(1.1); }
.trek-range::-webkit-slider-thumb:active { transform: scale(1.18); }
.trek-range::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--bg-card, #fff);
border: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
}
.trek-range::-moz-range-track { height: 5px; border-radius: 999px; background: transparent; }
+1 -31
View File
@@ -703,8 +703,7 @@ describe('AdminPage', () => {
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
// Maps is the first 'Enter key...' input (Unsplash is the second).
const keyInput = (await screen.findAllByPlaceholderText('Enter key...'))[0];
const keyInput = await screen.findByPlaceholderText('Enter key...');
// Type a value — covers the onChange handler
fireEvent.change(keyInput, { target: { value: 'test-api-key-abc123' } });
@@ -979,35 +978,6 @@ describe('AdminPage', () => {
expect(capturedBody).toMatchObject({ maps_api_key: 'test-maps-key-123' });
});
});
it('typing in the Unsplash API key and clicking Save sends unsplash_api_key', async () => {
let capturedBody: Record<string, unknown> | undefined;
server.use(
http.put('/api/auth/me/api-keys', async ({ request }) => {
capturedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ success: true });
}),
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
render(<AdminPage />);
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
const apiKeysHeading = await screen.findByRole('heading', { name: /^api keys$/i });
const apiKeysCard = apiKeysHeading.closest<HTMLElement>('.bg-white');
// The Unsplash key is the second 'Enter key...' input (after Maps).
const keyInputs = within(apiKeysCard!).getAllByPlaceholderText('Enter key...');
fireEvent.change(keyInputs[1], { target: { value: 'test-unsplash-key' } });
fireEvent.click(within(apiKeysCard!).getByRole('button', { name: /^save$/i }));
await waitFor(() => {
expect(capturedBody?.unsplash_api_key).toBe('test-unsplash-key');
});
});
});
describe('FE-PAGE-ADMIN-044: Validate API key in Settings tab', () => {
+1 -5
View File
@@ -11,8 +11,7 @@ import AddonManager from '../components/Admin/AddonManager'
import PackingTemplateManager from '../components/Admin/PackingTemplateManager'
import AuditLogPanel from '../components/Admin/AuditLogPanel'
import AdminMcpTokensPanel from '../components/Admin/AdminMcpTokensPanel'
import AdminPluginsPanel from '../components/Admin/AdminPluginsPanel'
import { Users, Map, Briefcase, Shield, FileText, SlidersHorizontal, UserCog, Puzzle, Blocks, Settings as SettingsIcon, Bell, Database, ScrollText, KeyRound, GitBranch, Bug } from 'lucide-react'
import { Users, Map, Briefcase, Shield, FileText, SlidersHorizontal, UserCog, Puzzle, Settings as SettingsIcon, Bell, Database, ScrollText, KeyRound, GitBranch, Bug } from 'lucide-react'
import PageSidebar, { type PageSidebarTab } from '../components/Layout/PageSidebar'
import { useAdmin } from './admin/useAdmin'
import AdminUpdateBanner from './admin/AdminUpdateBanner'
@@ -46,7 +45,6 @@ export default function AdminPage(): React.ReactElement {
{ id: 'config', label: t('admin.tabs.config'), icon: SlidersHorizontal, group: gConfig },
{ id: 'settings', label: t('admin.tabs.settings'), icon: SettingsIcon, group: gConfig },
{ id: 'addons', label: t('admin.tabs.addons'), icon: Puzzle, group: gConfig },
{ id: 'plugins', label: t('admin.tabs.plugins'), icon: Blocks, group: gConfig },
{ id: 'notifications', label: t('admin.tabs.notifications'), icon: Bell, group: gIntegration },
...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens'), icon: KeyRound, group: gIntegration }] : []),
{ id: 'github', label: t('admin.tabs.github'), icon: GitBranch, group: gIntegration },
@@ -159,8 +157,6 @@ export default function AdminPage(): React.ReactElement {
{activeTab === 'mcp-tokens' && <AdminMcpTokensPanel />}
{activeTab === 'plugins' && <AdminPluginsPanel />}
{activeTab === 'github' && <GitHubPanel isPrerelease={updateInfo?.is_prerelease ?? false} />}
{activeTab === 'defaults' && <DefaultUserSettingsTab />}
+1 -45
View File
@@ -1,5 +1,5 @@
import React from 'react'
import { List as ListIcon, Map as MapIcon, Search, Bookmark, CheckCheck, X, Trash2, Copy, CopyPlus, FolderInput, Plus, Tags } from 'lucide-react'
import { List as ListIcon, Map as MapIcon, Search, Bookmark, CheckCheck, X, Trash2, Copy, CopyPlus, FolderInput, Plus } from 'lucide-react'
import Navbar from '../components/Layout/Navbar'
import Modal from '../components/shared/Modal'
import ListsRail from '../components/Collections/ListsRail'
@@ -13,8 +13,6 @@ import MoveToListModal from '../components/Collections/MoveToListModal'
import ShareCollectionModal from '../components/Collections/ShareCollectionModal'
import AddPlaceToCollectionModal from '../components/Collections/AddPlaceToCollectionModal'
import CollectionPlaceDetail from '../components/Collections/CollectionPlaceDetail'
import LabelManager from '../components/Collections/LabelManager'
import BulkAssignLabelModal from '../components/Collections/BulkAssignLabelModal'
import { useCollections } from './collections/useCollections'
import '../styles/dashboard.css'
import '../styles/collections.css'
@@ -43,9 +41,6 @@ export default function CollectionsPage(): React.ReactElement {
const hasPlaces = c.places.length > 0
const noLists = !c.loading && c.collections.length === 0
const showSelect = c.isAllSaved || c.activeCollection != null
// Labels are per-collection, so only on a real (non "All saved") list.
const isRealList = !c.isAllSaved && typeof c.activeId === 'number'
const canManageLabels = isRealList && c.canEdit
// Selecting a place toggles it, so clicking it again — or the map background —
// clears it. Below the desktop breakpoint the list and map are separate views;
@@ -71,7 +66,6 @@ export default function CollectionsPage(): React.ReactElement {
const listEl = (
<CollectionList
places={c.visiblePlaces}
labels={c.labels}
selectedPlaceId={c.selectedPlaceId}
selectMode={c.selectMode}
selectedIds={c.selectedIds}
@@ -109,12 +103,6 @@ export default function CollectionsPage(): React.ReactElement {
categoryOptions={c.categoryOptions}
onStatusFilter={c.setStatusFilter}
onCategoryFilter={c.setCategoryFilter}
showLabels={isRealList}
labelOptions={c.labelOptions}
labelFilter={c.labelFilter}
onLabelFilter={c.setLabelFilter}
canManageLabels={canManageLabels}
onManageLabels={() => c.setShowLabelManager(true)}
showSelect={showSelect}
selectMode={c.selectMode}
onToggleSelect={() => c.setSelectMode(!c.selectMode)}
@@ -243,11 +231,6 @@ export default function CollectionsPage(): React.ReactElement {
</button>
<span className="lbl">{t('collections.selectedCount', { count: c.selectedIds.length })}</span>
<div className="col-toolbar-spacer" />
{c.canEdit && isRealList && (
<button type="button" onClick={() => c.setLabelPickerOpen(true)} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
<Tags size={14} /> {t('collections.labels.assign')}
</button>
)}
{c.canEdit && (
<button type="button" onClick={() => c.setListPickerMode('move')} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
<FolderInput size={14} /> {t('collections.moveToList')}
@@ -299,7 +282,6 @@ export default function CollectionsPage(): React.ReactElement {
canEdit={c.canEdit}
canDelete={c.canDelete}
categories={c.categories}
labels={c.labels}
anchorRect={desktopSplit ? c.listColRect : null}
onClose={c.handleCloseDetail}
onSetStatus={c.handleDetailStatus}
@@ -362,32 +344,6 @@ export default function CollectionsPage(): React.ReactElement {
{/* Create / edit a list — name, colour, cover, description, links */}
<ListEditorModal target={c.editorTarget} onClose={() => c.setEditorTarget(null)} onCreated={c.handleEditorCreated} onRequestDelete={c.setConfirmDeleteList} t={t} />
{/* Manage the list's custom labels (editor+) */}
{canManageLabels && (
<LabelManager
isOpen={c.showLabelManager}
labels={c.labels}
onCreate={c.handleCreateLabel}
onUpdate={c.handleUpdateLabel}
onDelete={c.handleDeleteLabel}
onClose={() => c.setShowLabelManager(false)}
t={t}
/>
)}
{/* Bulk-assign labels to the current selection */}
{canManageLabels && (
<BulkAssignLabelModal
isOpen={c.labelPickerOpen}
labels={c.labels}
count={c.selectedIds.length}
onAssign={c.handleBulkAssignLabels}
onManage={() => { c.setLabelPickerOpen(false); c.setShowLabelManager(true) }}
onClose={() => c.setLabelPickerOpen(false)}
t={t}
/>
)}
{/* Delete-list confirm */}
<Modal
isOpen={c.confirmDeleteList != null}
-33
View File
@@ -901,38 +901,5 @@ describe('DashboardPage', () => {
expect(s.dashboard_fx_to).toBe('CHF');
expect(s.dashboard_timezones).toEqual(['America/New_York']);
});
it('keeps the legacy localStorage keys when the server write fails, so nothing is lost (#1311)', async () => {
// The write to persist the migrated values fails (server briefly unavailable during a
// docker upgrade). The legacy localStorage source must survive so the next load retries —
// the old code deleted it unconditionally, permanently losing the values.
server.use(
http.put('/api/settings', () => new HttpResponse(null, { status: 500 })),
http.post('/api/settings/bulk', () => new HttpResponse(null, { status: 500 })),
);
localStorage.setItem('trek_fx_from', 'CAD');
localStorage.setItem('trek_fx_to', 'CHF');
localStorage.setItem('trek_dashboard_tz', JSON.stringify(['America/New_York']));
seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true });
render(<DashboardPage />);
// The optimistic store update proves the migration effect ran; the failed write must NOT
// have removed the localStorage source.
await waitFor(() => {
expect(useSettingsStore.getState().settings.dashboard_fx_from).toBe('CAD');
});
expect(localStorage.getItem('trek_fx_from')).toBe('CAD');
expect(localStorage.getItem('trek_fx_to')).toBe('CHF');
expect(localStorage.getItem('trek_dashboard_tz')).toBe(JSON.stringify(['America/New_York']));
});
it('drops a malformed legacy timezone value instead of retrying forever (#1311)', async () => {
localStorage.setItem('trek_dashboard_tz', 'not-json');
seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true });
render(<DashboardPage />);
await waitFor(() => {
expect(localStorage.getItem('trek_dashboard_tz')).toBeNull();
});
expect(useSettingsStore.getState().settings.dashboard_timezones).toBeUndefined();
});
});
});
+11 -35
View File
@@ -20,9 +20,6 @@ import {
} from 'lucide-react'
import { IcsSubscribeModal } from '../components/Planner/IcsSubscribeModal'
import CollectionsWidget from '../components/Dashboard/CollectionsWidget'
import PluginWidgets from '../components/Plugins/PluginWidgets'
import PluginFrame from '../components/Plugins/PluginFrame'
import { usePluginStore } from '../store/pluginStore'
import { formatTime, splitReservationDateTime } from '../utils/formatters'
import { convertDistance, getDistanceUnitLabel } from '../utils/units'
import { useSettingsStore } from '../store/settingsStore'
@@ -128,8 +125,7 @@ export default function DashboardPage(): React.ReactElement {
const isAddonEnabled = useAddonStore(s => s.isEnabled)
const showCollections = isAddonEnabled('collections') && sideWidgets.collections
// Desktop has a master toggle for the whole right column; off → centered layout.
const widgetPlugins = usePluginStore(s => s.plugins).filter(p => p.type === 'widget' && p.slot !== 'hero')
const sidebarVisible = (isMobile || dashCfg.desktop.sidebar) && (showCurrency || showCollections || showTimezones || showUpcoming || widgetPlugins.length > 0)
const sidebarVisible = (isMobile || dashCfg.desktop.sidebar) && (showCurrency || showCollections || showTimezones || showUpcoming)
return (
<>
@@ -238,7 +234,6 @@ export default function DashboardPage(): React.ReactElement {
{showCollections && <CollectionsWidget onOpen={() => navigate('/collections')} />}
{showTimezones && <TimezoneTool locale={locale} />}
{showUpcoming && <UpcomingTool items={upcoming} locale={locale} onOpen={(tripId) => navigate(`/trips/${tripId}`)} />}
<PluginWidgets plugins={widgetPlugins} tripId={spotlight ? String(spotlight.id) : null} />
</aside>
)}
</main>
@@ -294,7 +289,6 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch
}): React.ReactElement {
const { t } = useTranslation()
const mobile = useIsMobile()
const heroPlugins = usePluginStore(s => s.plugins).filter(p => p.type === 'widget' && p.slot === 'hero')
const stop = (e: React.MouseEvent, fn: () => void) => { e.stopPropagation(); fn() }
const status = getTripStatus(trip)
const start = splitDate(trip.start_date, locale)
@@ -410,16 +404,7 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch
</div>
{!mobile && (
<div className="hero-pass-wrap">
{heroPlugins.length > 0 && (
<div className="hero-pass-overlay" aria-hidden="true">
{heroPlugins.map(p => (
<PluginFrame key={p.id} pluginId={p.id} tripId={String(trip.id)} title={p.name} className="hero-overlay-frame" />
))}
</div>
)}
<div className="hero-pass" onClick={(e) => { e.stopPropagation(); onOpen() }}>{passCells}</div>
</div>
<div className="hero-pass" onClick={(e) => { e.stopPropagation(); onOpen() }}>{passCells}</div>
)}
</div>
</section>
@@ -618,15 +603,10 @@ function CurrencyTool(): React.ReactElement {
const lf = localStorage.getItem('trek_fx_from')
const lt = localStorage.getItem('trek_fx_to')
if (!lf && !lt) return
const writes: Promise<void>[] = []
if (lf) writes.push(updateSetting('dashboard_fx_from', lf))
if (lt) writes.push(updateSetting('dashboard_fx_to', lt))
// Only drop the localStorage source once the server has durably stored the values, so a
// failed write during a (docker) upgrade can't destroy the only copy (#1311). Retry next load.
Promise.all(writes).then(() => {
localStorage.removeItem('trek_fx_from')
localStorage.removeItem('trek_fx_to')
}).catch(() => { /* keep localStorage; retry on next load */ })
if (lf) updateSetting('dashboard_fx_from', lf).catch(() => {})
if (lt) updateSetting('dashboard_fx_to', lt).catch(() => {})
localStorage.removeItem('trek_fx_from')
localStorage.removeItem('trek_fx_to')
}, [isLoaded, updateSetting])
const currencies = rates ? Object.keys(rates).sort() : FX_FALLBACK
@@ -702,15 +682,11 @@ function TimezoneTool({ locale }: { locale: string }): React.ReactElement {
if (!isLoaded) return
const raw = localStorage.getItem('trek_dashboard_tz')
if (!raw) return
let parsed: unknown
// A malformed/non-array value can never be written, so drop it now to avoid retrying forever.
try { parsed = JSON.parse(raw) } catch { localStorage.removeItem('trek_dashboard_tz'); return }
if (!Array.isArray(parsed)) { localStorage.removeItem('trek_dashboard_tz'); return }
// Only drop the localStorage source once the server has durably stored the value, so a failed
// write during a (docker) upgrade can't destroy the only copy (#1311). Retry next load.
updateSetting('dashboard_timezones', parsed)
.then(() => { localStorage.removeItem('trek_dashboard_tz') })
.catch(() => { /* keep localStorage; retry on next load */ })
try {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) updateSetting('dashboard_timezones', parsed).catch(() => {})
} catch { /* ignore malformed storage */ }
localStorage.removeItem('trek_dashboard_tz')
}, [isLoaded, updateSetting])
const allZones = React.useMemo<string[]>(() => {
-34
View File
@@ -1,34 +0,0 @@
import React from 'react'
import { useParams } from 'react-router-dom'
import PageShell from '../components/Layout/PageShell'
import PluginFrame from '../components/Plugins/PluginFrame'
import { usePluginStore } from '../store/pluginStore'
import { useTranslation } from '../i18n'
/**
* Full-page host for a `page` plugin (#plugins, M3). Thin by design (no state /
* effects the page-pattern rule): it resolves the plugin from the store and
* renders its sandboxed iframe; PluginFrame owns the bridge.
*/
export default function PluginPage(): React.ReactElement {
const { pluginId = '' } = useParams()
const { t } = useTranslation()
const plugin = usePluginStore((s) => s.getById(pluginId))
const loaded = usePluginStore((s) => s.loaded)
if (loaded && (!plugin || plugin.type !== 'page')) {
return (
<PageShell background="var(--bg-secondary)">
<div className="w-full px-6 py-16 text-center text-sm text-content-faint">{t('plugins.notFound')}</div>
</PageShell>
)
}
return (
<PageShell background="var(--bg-secondary)" navOffset="var(--nav-h, 56px)">
<div style={{ height: 'calc(100vh - var(--nav-h, 56px))' }}>
<PluginFrame pluginId={pluginId} title={plugin?.name} className="w-full h-full" />
</div>
</PageShell>
)
}
+4 -14
View File
@@ -12,7 +12,7 @@ import { renderToStaticMarkup } from 'react-dom/server'
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
import { isDayInAccommodationRange } from '../utils/dayOrder'
import { getTransportForDay, getMergedItems } from '../utils/dayMerge'
import { getFlightLegs, getTrainLegs } from '../utils/flightLegs'
import { getFlightLegs } from '../utils/flightLegs'
import { splitReservationDateTime } from '../utils/formatters'
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
@@ -226,14 +226,7 @@ export default function SharedTripPage() {
sub = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport}${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
}
}
else if (r.type === 'train') {
if (r.__leg) {
// One leg of a multi-leg train — show this segment's own train/route.
sub = [r.__leg.train_number, r.__leg.platform ? `Gl. ${r.__leg.platform}` : '', (r.__leg.from || r.__leg.to) ? [r.__leg.from, r.__leg.to].filter(Boolean).join(' → ') : ''].filter(Boolean).join(' · ')
} else {
sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ')
}
}
else if (r.type === 'train') sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ')
return (
<div key={r.__leg ? `t-${r.id}-leg${r.__leg.index}` : `t-${r.id}`} className="bg-[rgba(59,130,246,0.06)]" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', borderRadius: 6, border: '1px solid rgba(59,130,246,0.15)' }}>
<div className="bg-[rgba(59,130,246,0.12)]" style={{ width: 24, height: 24, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
@@ -305,11 +298,8 @@ export default function SharedTripPage() {
? getFlightLegs(r).map((leg, i) => (
<span key={i}>{[leg.airline, leg.flight_number, (leg.from || leg.to) ? [leg.from, leg.to].filter(Boolean).join(' → ') : ''].filter(Boolean).join(' ')}</span>
))
: r.type === 'train'
? getTrainLegs(r).map((leg, i) => (
<span key={i}>{[leg.train_number, leg.platform ? `${t('reservations.meta.platform')} ${leg.platform}` : '', (leg.from || leg.to) ? [leg.from, leg.to].filter(Boolean).join(' → ') : ''].filter(Boolean).join(' ')}</span>
))
: meta.airline && <span>{meta.airline} {meta.flight_number || ''}</span>}
: meta.airline && <span>{meta.airline} {meta.flight_number || ''}</span>}
{meta.train_number && <span>{meta.train_number}</span>}
</div>
</div>
<span className={r.status === 'confirmed' ? 'bg-[rgba(22,163,74,0.1)] text-[#16a34a]' : 'bg-[rgba(217,119,6,0.1)] text-[#d97706]'} style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', padding: '2px 8px', borderRadius: 20, fontWeight: 600 }}>
+5 -50
View File
@@ -17,7 +17,6 @@ import SlidingTabs from '../components/shared/SlidingTabs'
import TripMembersModal from '../components/Trips/TripMembersModal'
import { ReservationModal } from '../components/Planner/ReservationModal'
import { TransportModal } from '../components/Planner/TransportModal'
import TransitJourneyModal from '../components/Planner/TransitJourneyModal'
import BookingImportModal from '../components/Planner/BookingImportModal'
import AirTrailImportModal from '../components/Planner/AirTrailImportModal'
// MemoriesPanel moved to Journey addon
@@ -30,8 +29,6 @@ import CostsPanel, { ExpenseModal, type ExpensePrefill } from '../components/Bud
import type { BookingExpenseRequest } from '../components/Planner/BookingCostsSection.types'
import type { BudgetItem } from '../types'
import CollabPanel from '../components/Collab/CollabPanel'
import PluginFrame from '../components/Plugins/PluginFrame'
import TripWarningsBanner from '../components/Planner/TripWarningsBanner'
import Navbar from '../components/Layout/Navbar'
import { useToast } from '../components/shared/Toast'
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
@@ -197,7 +194,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
bookingForAssignmentId, setBookingForAssignmentId,
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
transportModalDayId, setTransportModalDayId,
transportModalAutomated, setTransportModalAutomated, transitPrefill, setTransitPrefill, transitJourney, setTransitJourney,
reservationPrefill, transportPrefill, importReviewActive, advanceImportReview,
routeShown, setRouteShown, routeProfile, setRouteProfile, fitKey, setFitKey,
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
@@ -300,16 +296,12 @@ export default function TripPlannerPage(): React.ReactElement | null {
{/* Offset by navbar + tab bar (44px) */}
<div style={{ position: 'fixed', top: 'calc(var(--nav-h) + 44px)', left: 0, right: 0, bottom: 0, overflow: 'hidden', overscrollBehavior: 'contain' }}>
{/* Plugin validation/warning contributions (#1429) — non-blocking overlay. */}
<TripWarningsBanner tripId={tripId} />
{activeTab === 'plan' && (
<div style={{ position: 'absolute', inset: 0 }}>
<MapView
places={mapPlaces}
dayPlaces={dayPlaces}
route={route}
showTransitRoutes={routeShown}
routeSegments={routeSegments}
selectedPlaceId={selectedPlaceId}
onMarkerClick={handleMarkerClick}
@@ -381,7 +373,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
opacity: leftCollapsed ? 0 : 1,
}}>
<DayPlanSidebar
isMobile={isMobile}
tripId={tripId}
trip={trip}
days={days}
@@ -405,9 +396,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
externalTransportDetail={mapTransportDetail}
onExternalTransportDetailHandled={() => setMapTransportDetail(null)}
onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true) }}
onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true) } : undefined}
onOpenTransit={(r) => setTransitJourney(r)}
onPlanTransit={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true) } : undefined}
onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true) } : undefined}
onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true) } : undefined}
onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true) } : undefined}
onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }}
@@ -537,7 +526,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
collapsed={dayDetailCollapsed}
onToggleCollapse={() => setDayDetailCollapsed(c => !c)}
mobile={isMobile}
onUpdateDayTitle={handleUpdateDayTitle}
/>
)
})()}
@@ -630,7 +618,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
{mobileSidebarOpen === 'left'
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile />
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
}
</div>
@@ -649,12 +637,12 @@ export default function TripPlannerPage(): React.ReactElement | null {
days={days}
assignments={assignments}
files={files}
onAdd={() => { setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true) }}
onAdd={() => { setEditingTransport(null); setShowTransportModal(true) }}
onImport={() => setShowBookingImport(true)}
bookingImportAvailable={bookingImportAvailable}
onAirTrailImport={() => setShowAirTrailImport(true)}
airTrailAvailable={airTrailAvailable}
onEdit={(r) => { if (r.type === 'transit') { setTransitJourney(r) } else { setEditingTransport(r); setTransportModalAutomated(false); setShowTransportModal(true) } }}
onEdit={(r) => { setEditingTransport(r); setShowTransportModal(true) }}
onDelete={handleDeleteReservation}
onNavigateToFiles={() => handleTabChange('dateien')}
titleKey="transport.title"
@@ -715,12 +703,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
<CollabPanel tripId={tripId} tripMembers={tripMembers} collabFeatures={collabFeatures} />
</div>
)}
{activeTab.startsWith('plugin:') && (
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 'var(--bottom-nav-h)', overflow: 'hidden' }}>
<PluginFrame pluginId={activeTab.slice('plugin:'.length)} tripId={String(tripId)} className="w-full h-full" />
</div>
)}
</div>
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} isMobile={isMobile} />
@@ -733,34 +715,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
/>
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
<ReservationModal isOpen={showReservationModal} onClose={() => { if (importReviewActive) { advanceImportReview() } else { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) } }} onSave={async (data) => { const r = await handleSaveReservation(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingReservation} prefill={reservationPrefill} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} onOpenExpense={openBookingExpense} />
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { if (importReviewActive) { advanceImportReview() } else { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null); setTransportModalAutomated(false); setTransitPrefill(null) } }} onSave={async (data) => { const r = await handleSaveTransport(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingTransport} prefill={transportPrefill} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} onOpenExpense={openBookingExpense} places={places} accommodations={tripAccommodations} initialAutomated={transportModalAutomated} transitPrefill={transitPrefill} />}
{/* Journey view for a saved public-transit entry (#1065) */}
{transitJourney && (
<TransitJourneyModal
reservation={reservations.find(r => r.id === transitJourney.id) ?? transitJourney}
canEdit={can('day_edit', trip)}
onClose={() => setTransitJourney(null)}
onSave={async (fields) => { await tripActions.updateReservation(tripId, transitJourney.id, fields); setTransitJourney(null) }}
onDelete={async () => { await handleDeleteReservation(transitJourney.id); setTransitJourney(null) }}
onChangeRoute={() => {
// Re-enter the transit search seeded with this journey's route; the
// existing reservation is REPLACED on save (editingTransport drives
// handleSaveTransport's update path).
const eps = transitJourney.endpoints || []
const from = eps.find(e => e.role === 'from')
const to = eps.find(e => e.role === 'to')
setTransitPrefill({
from: from ? { name: from.name, lat: from.lat, lng: from.lng } : null,
to: to ? { name: to.name, lat: to.lat, lng: to.lng } : null,
})
setEditingTransport(transitJourney)
setTransportModalDayId(transitJourney.day_id ?? null)
setTransportModalAutomated(true)
setTransitJourney(null)
setShowTransportModal(true)
}}
/>
)}
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { if (importReviewActive) { advanceImportReview() } else { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) } }} onSave={async (data) => { const r = await handleSaveTransport(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingTransport} prefill={transportPrefill} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} onOpenExpense={openBookingExpense} />}
{bookingExpense && (
<ExpenseModal
tripId={tripId}
+1 -25
View File
@@ -26,7 +26,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
passkeyLogin, setPasskeyLogin, passkeyConfigured,
webauthnRpId, setWebauthnRpId, webauthnOrigins, setWebauthnOrigins, savingWebauthn, handleSaveWebauthn,
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
mapsKey, setMapsKey, unsplashKey, setUnsplashKey, showKeys, savingKeys, validating, validation,
mapsKey, setMapsKey, showKeys, savingKeys, validating, validation,
setShowRotateJwtModal,
handleToggleAuthSetting, handleToggleRequireMfa,
toggleKey, handleSaveApiKeys, handleValidateKey,
@@ -304,30 +304,6 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
)}
</div>
{/* Unsplash Key */}
<div>
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 mb-1.5">
{t('admin.unsplashKey')}
</label>
<div className="relative">
<input
type={showKeys.unsplash ? 'text' : 'password'}
value={unsplashKey}
onChange={e => setUnsplashKey(e.target.value)}
placeholder={t('settings.keyPlaceholder')}
className="w-full pr-10 px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<button
type="button"
onClick={() => toggleKey('unsplash')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{showKeys.unsplash ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-slate-400 mt-1">{t('admin.unsplashKeyHint')}</p>
</div>
{/* Place Photos Toggle */}
<div className="flex items-center justify-between gap-4 py-3 border-t border-slate-100">
<div>
+3 -6
View File
@@ -97,7 +97,6 @@ export function useAdmin() {
// API Keys
const [mapsKey, setMapsKey] = useState<string>('')
const [weatherKey, setWeatherKey] = useState<string>('')
const [unsplashKey, setUnsplashKey] = useState<string>('')
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({})
const [savingKeys, setSavingKeys] = useState<boolean>(false)
const [validating, setValidating] = useState<Record<string, boolean>>({})
@@ -167,7 +166,6 @@ export function useAdmin() {
const data = await authApi.getSettings()
setMapsKey(data.settings?.maps_api_key || '')
setWeatherKey(data.settings?.openweather_api_key || '')
setUnsplashKey(data.settings?.unsplash_api_key || '')
} catch (err: unknown) {
// ignore
}
@@ -222,7 +220,6 @@ export function useAdmin() {
await updateApiKeys({
maps_api_key: mapsKey,
openweather_api_key: weatherKey,
unsplash_api_key: unsplashKey,
})
toast.success(t('admin.keySaved'))
} catch (err: unknown) {
@@ -236,7 +233,7 @@ export function useAdmin() {
setValidating({ maps: true, weather: true })
try {
// Save first so validation uses the current values
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey, unsplash_api_key: unsplashKey })
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey })
const result = await authApi.validateKeys()
setValidation(result)
} catch (err: unknown) {
@@ -250,7 +247,7 @@ export function useAdmin() {
setValidating(prev => ({ ...prev, [keyType]: true }))
try {
// Save first so validation uses the current values
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey, unsplash_api_key: unsplashKey })
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey })
const result = await authApi.validateKeys()
setValidation(prev => ({ ...prev, [keyType]: result[keyType] }))
} catch (err: unknown) {
@@ -381,7 +378,7 @@ export function useAdmin() {
invites, setInvites, inviteTrips, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm,
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
smtpValues, setSmtpValues, smtpLoaded,
mapsKey, setMapsKey, weatherKey, setWeatherKey, unsplashKey, setUnsplashKey,
mapsKey, setMapsKey, weatherKey, setWeatherKey,
showKeys, setShowKeys, savingKeys, validating, validation,
updateInfo, setUpdateInfo, showUpdateModal, setShowUpdateModal,
showRotateJwtModal, setShowRotateJwtModal, rotatingJwt, setRotatingJwt,
@@ -7,11 +7,9 @@ import {
sortPlaces,
statusCounts,
presentCategories,
presentLabels,
mappablePlaces,
normalizeLinkUrl,
} from './collectionsModel';
import type { CollectionLabel } from '@trek/shared';
// ── Inline CollectionPlace-ish builder ────────────────────────────────────────
// Only the fields the helpers actually read are meaningful; the rest satisfy the
@@ -34,7 +32,6 @@ interface PlaceLike {
notes?: string | null;
sort_order?: number;
created_at?: string;
label_ids?: number[];
}
function cp(overrides: PlaceLike): CollectionPlace {
return {
@@ -322,46 +319,4 @@ describe('collectionsModel', () => {
expect(normalizeLinkUrl('//booking.com')).toBe('https://booking.com');
});
});
describe('labels', () => {
const places = [
cp({ id: 1, name: 'Berlin gate', status: 'idea', label_ids: [10] }),
cp({ id: 2, name: 'Hamburg port', status: 'idea', label_ids: [11] }),
cp({ id: 3, name: 'Both', status: 'idea', label_ids: [10, 11] }),
cp({ id: 4, name: 'None', status: 'idea', label_ids: [] }),
];
const labels: CollectionLabel[] = [
{ id: 10, collection_id: 1, name: 'Berlin', color: '#f00' },
{ id: 11, collection_id: 1, name: 'Hamburg', color: '#00f' },
{ id: 12, collection_id: 1, name: 'Unused', color: null },
];
it('FE-COLLECTIONS-MODEL-031: an empty label filter keeps every place', () => {
expect(filterPlaces(places, 'all', '', 'all', [])).toHaveLength(4);
});
it('FE-COLLECTIONS-MODEL-032: a single label keeps places carrying it (incl. multi-label)', () => {
const out = filterPlaces(places, 'all', '', 'all', [10]).map(p => p.id);
expect(out).toEqual([1, 3]);
});
it('FE-COLLECTIONS-MODEL-033: multiple labels are OR — any match passes', () => {
const out = filterPlaces(places, 'all', '', 'all', [10, 11]).map(p => p.id);
expect(out).toEqual([1, 2, 3]);
});
it('FE-COLLECTIONS-MODEL-034: the label filter composes with status + search', () => {
const mixed = [
cp({ id: 5, name: 'Museum', status: 'visited', label_ids: [10] }),
cp({ id: 6, name: 'Museum', status: 'idea', label_ids: [10] }),
];
const out = filterPlaces(mixed, 'visited', 'mus', 'all', [10]).map(p => p.id);
expect(out).toEqual([5]);
});
it('FE-COLLECTIONS-MODEL-035: presentLabels keeps definition order with per-label counts, incl. zero', () => {
const opts = presentLabels(labels, places);
expect(opts.map(o => [o.id, o.count])).toEqual([[10, 2], [11, 2], [12, 0]]);
});
});
});
@@ -1,6 +1,6 @@
import { Circle, Bookmark, CheckCircle2 } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type { CollectionPlace, CollectionStatus, CollectionLabel } from '@trek/shared'
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
import { COLLECTION_STATUSES } from '@trek/shared'
import type { StatusFilter } from '../../store/collectionStore'
@@ -44,23 +44,18 @@ export function sortPlaces(places: CollectionPlace[]): CollectionPlace[] {
})
}
/** Apply the active status filter + free-text search (name/address/notes) +
* category + per-collection label filter. The label filter is OR semantics: a
* place matches if it carries ANY of the selected labels. This one function
* drives BOTH the list and the map, so every filter stays in lockstep. */
/** Apply the active status filter + free-text search (name/address/notes). */
export function filterPlaces(
places: CollectionPlace[],
statusFilter: StatusFilter,
search: string,
categoryFilter: number | 'all' = 'all',
labelFilter: number[] = [],
): CollectionPlace[] {
const q = search.trim().toLowerCase()
return places.filter(p => {
if (!p) return false
if (statusFilter !== 'all' && p.status !== statusFilter) return false
if (categoryFilter !== 'all' && (p.category_id ?? null) !== categoryFilter) return false
if (labelFilter.length && !labelFilter.some(id => (p.label_ids ?? []).includes(id))) return false
if (!q) return true
return (
p.name.toLowerCase().includes(q) ||
@@ -91,20 +86,6 @@ export function presentCategories(places: CollectionPlace[]): CategoryOption[] {
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name))
}
export interface LabelOption { id: number; name: string; color: string | null; count: number }
/** The collection's labels in definition order, each with how many of the given
* places carry it. Zero-count labels are kept so the manager/filter still lists
* a freshly-created label. */
export function presentLabels(labels: CollectionLabel[], places: CollectionPlace[]): LabelOption[] {
const counts = new Map<number, number>()
for (const p of places) {
if (!p) continue
for (const id of p.label_ids ?? []) counts.set(id, (counts.get(id) ?? 0) + 1)
}
return labels.map(l => ({ id: l.id, name: l.name, color: l.color ?? null, count: counts.get(l.id) ?? 0 }))
}
/** Only the places that can render on a map. */
export function mappablePlaces(places: CollectionPlace[]): CollectionPlace[] {
return places.filter(p => p && typeof p.lat === 'number' && typeof p.lng === 'number')
+7 -53
View File
@@ -13,8 +13,7 @@ import type { ActiveCollectionId } from '../../store/collectionStore'
import { categoriesApi } from '../../api/client'
import type { Collection, CollectionStatus } from '@trek/shared'
import type { Category, Place } from '../../types'
import { filterPlaces, sortPlaces, statusCounts, mappablePlaces, presentCategories, presentLabels } from './collectionsModel'
import type { CollectionLabelUpdateRequest } from '@trek/shared'
import { filterPlaces, sortPlaces, statusCounts, mappablePlaces, presentCategories } from './collectionsModel'
/**
* Collections page logic owns the page-local UI state (new/edit-list forms,
@@ -49,16 +48,15 @@ export function useCollections() {
const store = useCollectionStore()
const {
collections, activeId, places, members, labels, incomingInvites,
view, statusFilter, categoryFilter, labelFilter, search, selectedPlaceId, selectMode, selectedIds,
collections, activeId, places, members, incomingInvites,
view, statusFilter, categoryFilter, search, selectedPlaceId, selectMode, selectedIds,
loading, placesLoading,
loadAll, setActive, refreshActive, loadCollection,
deleteCollection,
setStatus, updatePlace, deletePlace, deleteMany, copyToTrip, clearSelection,
moveToList, duplicateToList, setSelectedIds,
createLabel, updateLabel, deleteLabel, assignLabels,
acceptInvite, declineInvite,
setView, setStatusFilter, setCategoryFilter, setLabelFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect,
setView, setStatusFilter, setCategoryFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect,
} = store
// ── Page-local UI state ─────────────────────────────────────────────
@@ -165,15 +163,12 @@ export function useCollections() {
const ownedLists = useMemo(() => collections.filter(c => c.is_owner !== false), [collections])
const sharedLists = useMemo(() => collections.filter(c => c.is_owner === false), [collections])
// Labels are per-collection, so never apply them on the "All saved" union.
const visiblePlaces = useMemo(
() => sortPlaces(filterPlaces(places, statusFilter, search, categoryFilter, isAllSaved ? [] : labelFilter)),
[places, statusFilter, search, categoryFilter, isAllSaved, labelFilter],
() => sortPlaces(filterPlaces(places, statusFilter, search, categoryFilter)),
[places, statusFilter, search, categoryFilter],
)
// Categories actually present in this list, for the category filter dropdown.
const categoryOptions = useMemo(() => presentCategories(places), [places])
// The active list's labels (with per-label counts) for the filter + manager.
const labelOptions = useMemo(() => presentLabels(labels, places), [labels, places])
// Stable reference so the map doesn't tear down + rebuild every marker on each
// unrelated re-render (which would swallow marker clicks mid-rebuild).
const mappable = useMemo(() => mappablePlaces(visiblePlaces), [visiblePlaces])
@@ -259,43 +254,6 @@ export function useCollections() {
}
}, [selectedIds, duplicateToList, toast, t])
// ── Labels ──────────────────────────────────────────────────────────
const [showLabelManager, setShowLabelManager] = useState(false)
const [labelPickerOpen, setLabelPickerOpen] = useState(false)
const handleCreateLabel = useCallback(async (name: string, color?: string) => {
try { await createLabel(name, color) }
catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) }
}, [createLabel, toast, t])
const handleUpdateLabel = useCallback(async (labelId: number, body: CollectionLabelUpdateRequest) => {
try { await updateLabel(labelId, body) }
catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) }
}, [updateLabel, toast, t])
const handleDeleteLabel = useCallback(async (labelId: number) => {
try { await deleteLabel(labelId) }
catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) }
}, [deleteLabel, toast, t])
// Bulk-assign one or more labels to the current selection.
const handleBulkAssignLabels = useCallback(async (labelIds: number[]) => {
if (selectedIds.length === 0 || labelIds.length === 0) return
try {
await assignLabels(labelIds, selectedIds)
toast.success(t('collections.labels.assignedCount', { count: selectedIds.length }))
setLabelPickerOpen(false)
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
}
}, [assignLabels, selectedIds, toast, t])
// Replace a single place's labels (from the detail sheet chips).
const handleAssignPlaceLabels = useCallback(async (placeId: number, labelIds: number[]) => {
try { await updatePlace(placeId, { label_ids: labelIds }) }
catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) }
}, [updatePlace, toast, t])
const handleAcceptInvite = useCallback(async (collectionId: number) => {
try {
await acceptInvite(collectionId)
@@ -379,14 +337,10 @@ export function useCollections() {
canShare, shareMemberCount,
activeId, places, visiblePlaces, mappable, members, incomingInvites, counts,
view, statusFilter, categoryFilter, categoryOptions, search, selectedPlaceId, selectMode, selectedIds,
labels, labelFilter, labelOptions,
loading, placesLoading,
// store setters
setView, setStatusFilter, setCategoryFilter, setLabelFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect,
setView, setStatusFilter, setCategoryFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect,
updatePlace,
// labels
showLabelManager, setShowLabelManager, labelPickerOpen, setLabelPickerOpen,
handleCreateLabel, handleUpdateLabel, handleDeleteLabel, handleBulkAssignLabels, handleAssignPlaceLabels,
// local UI state
editorTarget, setEditorTarget, handleEditorCreated,
showAddPlace, setShowAddPlace, handlePlaceAdded,
+3 -19
View File
@@ -5,7 +5,7 @@ import { useCanDo } from '../../store/permissionsStore'
import { useSettingsStore } from '../../store/settingsStore'
import { getCached, fetchPhoto } from '../../services/photoService'
import { useToast } from '../../components/shared/Toast'
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train, Blocks } from 'lucide-react'
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
import { useTranslation, translateApiError } from '../../i18n'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi, mapsApi, placesApi } from '../../api/client'
import { parsedItemToDraft, isTransportItem, type BookingReviewDraft } from '../../components/Planner/parsedItemToDraft'
@@ -21,7 +21,6 @@ import { useRouteCalculation } from '../../hooks/useRouteCalculation'
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
import { usePluginStore } from '../../store/pluginStore'
import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types'
import { resolvePoolAssignmentId } from './tripPlannerModel'
@@ -43,9 +42,6 @@ export function useTripPlanner() {
const toast = useToast()
const { t, language } = useTranslation()
const { settings } = useSettingsStore()
// trip-page plugins mount as tabs inside this trip planner (tripId-scoped).
const allPlugins = usePluginStore(s => s.plugins)
const pluginsLoaded = usePluginStore(s => s.loaded)
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
const trip = useTripStore(s => s.trip)
const days = useTripStore(s => s.days)
@@ -96,10 +92,7 @@ export function useTripPlanner() {
}).catch(() => {})
}, [])
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'])
const tripPagePlugins = allPlugins.filter(p => p.type === 'trip-page')
const tripPluginIds = tripPagePlugins.map(p => p.id).join(',')
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'])
const TRIP_TABS = [
{ id: 'plan', label: t('trip.tabs.plan'), icon: Map },
@@ -109,7 +102,6 @@ export function useTripPlanner() {
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget'), icon: Wallet }] : []),
...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files'), icon: FolderOpen }] : []),
...(enabledAddons.collab ? [{ id: 'collab', label: t('admin.addons.catalog.collab.name'), icon: Users }] : []),
...tripPagePlugins.map(p => ({ id: `plugin:${p.id}`, label: p.name, icon: Blocks })),
]
const [activeTab, setActiveTab] = useState<string>(() => {
@@ -118,14 +110,12 @@ export function useTripPlanner() {
})
useEffect(() => {
// Don't evict a saved plugin tab before the plugin feed has loaded.
if (activeTab.startsWith('plugin:') && !pluginsLoaded) return
const validTabIds = TRIP_TABS.map(t => t.id)
if (!validTabIds.includes(activeTab)) {
setActiveTab('plan')
sessionStorage.setItem(`trip-tab-${tripId}`, 'plan')
}
}, [enabledAddons, tripPluginIds, pluginsLoaded])
}, [enabledAddons])
const handleTabChange = (tabId: string): void => {
setActiveTab(tabId)
@@ -172,11 +162,6 @@ export function useTripPlanner() {
const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
const [transportModalDayId, setTransportModalDayId] = useState<number | null>(null)
// Public transit (#1065): open the TransportModal in its Automated mode, seed
// the search (change-route), and show the journey view for a saved entry.
const [transportModalAutomated, setTransportModalAutomated] = useState<boolean>(false)
const [transitPrefill, setTransitPrefill] = useState<{ from?: { name: string; lat: number; lng: number } | null; to?: { name: string; lat: number; lng: number } | null } | null>(null)
const [transitJourney, setTransitJourney] = useState<Reservation | null>(null)
// The bottom-nav "+" is context-aware per tab: on the Bookings / Transports tabs
// it opens the booking / transport modal via ?create=reservation|transport
@@ -874,7 +859,6 @@ export function useTripPlanner() {
bookingForAssignmentId, setBookingForAssignmentId,
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
transportModalDayId, setTransportModalDayId,
transportModalAutomated, setTransportModalAutomated, transitPrefill, setTransitPrefill, transitJourney, setTransitJourney,
reservationPrefill, transportPrefill, importReviewActive, startImportReview, advanceImportReview,
routeShown, setRouteShown, routeProfile, setRouteProfile, fitKey, setFitKey,
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
+4 -64
View File
@@ -10,8 +10,6 @@ import type {
CollectionCreateRequest,
CollectionUpdateRequest,
CollectionPlaceUpdateRequest,
CollectionLabel,
CollectionLabelUpdateRequest,
} from '@trek/shared'
/** A pending invitation the current user has received (derived server-side). */
@@ -29,12 +27,10 @@ interface CollectionState {
activeId: ActiveCollectionId
places: CollectionPlace[]
members: CollectionMember[]
labels: CollectionLabel[]
incomingInvites: IncomingCollectionInvite[]
view: CollectionView
statusFilter: StatusFilter
categoryFilter: number | 'all'
labelFilter: number[]
search: string
selectedPlaceId: number | null
selectMode: boolean
@@ -61,11 +57,6 @@ interface CollectionState {
moveToList: (placeIds: number[], targetId: number) => Promise<void>
duplicateToList: (placeIds: number[], targetId: number) => Promise<void>
createLabel: (name: string, color?: string) => Promise<void>
updateLabel: (labelId: number, body: CollectionLabelUpdateRequest) => Promise<void>
deleteLabel: (labelId: number) => Promise<void>
assignLabels: (labelIds: number[], placeIds: number[], remove?: boolean) => Promise<void>
invite: (collectionId: number, userId: number, role?: CollectionRole) => Promise<void>
setMemberRole: (collectionId: number, userId: number, role: CollectionRole) => Promise<void>
acceptInvite: (collectionId: number) => Promise<void>
@@ -77,7 +68,6 @@ interface CollectionState {
setView: (view: CollectionView) => void
setStatusFilter: (filter: StatusFilter) => void
setCategoryFilter: (filter: number | 'all') => void
setLabelFilter: (labelIds: number[]) => void
setSearch: (search: string) => void
setSelectedPlaceId: (id: number | null) => void
setSelectMode: (on: boolean) => void
@@ -91,12 +81,10 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
activeId: null,
places: [],
members: [],
labels: [],
incomingInvites: [],
view: 'list',
statusFilter: 'all',
categoryFilter: 'all',
labelFilter: [],
search: '',
selectedPlaceId: null,
selectMode: false,
@@ -122,28 +110,26 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
activeId: id,
places: data.places,
members: data.collection.members ?? [],
labels: data.collection.labels ?? [],
})
} catch {
// The list may have been left / removed / deleted out from under us (a WS
// event or the URL sync can re-request an id we just lost access to). Clear
// it instead of leaving an uncaught 403/404 rejection; the route sync then
// bounces to /collections.
if (get().activeId === id) set({ activeId: null, places: [], members: [], labels: [] })
if (get().activeId === id) set({ activeId: null, places: [], members: [] })
} finally {
set({ placesLoading: false })
}
},
setActive: async (id: ActiveCollectionId) => {
// Labels are per-collection, so their filter can't carry across lists.
set({ selectMode: false, selectedIds: [], selectedPlaceId: null, labelFilter: [] })
set({ selectMode: false, selectedIds: [], selectedPlaceId: null })
if (id === null) {
set({ activeId: null, places: [], members: [], labels: [] })
set({ activeId: null, places: [], members: [] })
return
}
if (id === ALL_SAVED) {
set({ activeId: ALL_SAVED, members: [], labels: [], placesLoading: true })
set({ activeId: ALL_SAVED, members: [], placesLoading: true })
try {
// Client-side union of every list the user owns or co-owns (no server change).
// On first load the lists may not be fetched yet (loadAll still in flight),
@@ -291,51 +277,6 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
await get().loadAll()
},
createLabel: async (name: string, color?: string) => {
const active = get().activeId
if (typeof active !== 'number') return
await collectionsApi.createLabel(active, name, color)
await get().loadCollection(active)
},
updateLabel: async (labelId: number, body: CollectionLabelUpdateRequest) => {
// optimistic recolor/rename
set({ labels: get().labels.map(l => (l.id === labelId ? { ...l, ...body } : l)) })
await collectionsApi.updateLabel(labelId, body)
const active = get().activeId
if (typeof active === 'number') await get().loadCollection(active)
},
deleteLabel: async (labelId: number) => {
// optimistic: drop the label + its assignments + any active filter on it
set({
labels: get().labels.filter(l => l.id !== labelId),
labelFilter: get().labelFilter.filter(id => id !== labelId),
places: get().places.map(p => ({ ...p, label_ids: (p.label_ids ?? []).filter(id => id !== labelId) })),
})
await collectionsApi.deleteLabel(labelId)
const active = get().activeId
if (typeof active === 'number') await get().loadCollection(active)
},
assignLabels: async (labelIds: number[], placeIds: number[], remove = false) => {
const idSet = new Set(placeIds)
// optimistic per-place label_ids update
set({
places: get().places.map(p => {
if (!idSet.has(p.id)) return p
const current = new Set(p.label_ids ?? [])
if (remove) labelIds.forEach(id => current.delete(id))
else labelIds.forEach(id => current.add(id))
return { ...p, label_ids: [...current] }
}),
})
if (remove) await collectionsApi.unassignLabels(labelIds, placeIds)
else await collectionsApi.assignLabels(labelIds, placeIds)
const active = get().activeId
if (typeof active === 'number') await get().loadCollection(active)
},
invite: async (collectionId: number, userId: number, role?: CollectionRole) => {
await collectionsApi.invite(collectionId, userId, role)
if (get().activeId === collectionId) await get().loadCollection(collectionId)
@@ -375,7 +316,6 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
setView: (view: CollectionView) => set({ view }),
setStatusFilter: (filter: StatusFilter) => set({ statusFilter: filter }),
setCategoryFilter: (filter: number | 'all') => set({ categoryFilter: filter }),
setLabelFilter: (labelIds: number[]) => set({ labelFilter: labelIds }),
setSearch: (search: string) => set({ search }),
setSelectedPlaceId: (id: number | null) => set({ selectedPlaceId: id }),
setSelectMode: (on: boolean) => set({ selectMode: on, selectedIds: on ? get().selectedIds : [] }),
-50
View File
@@ -1,50 +0,0 @@
// FE-STORE-PLUGIN-001 to 004
import { http, HttpResponse } from 'msw';
import { server } from '../../tests/helpers/msw/server';
import { usePluginStore } from './pluginStore';
const initial = usePluginStore.getState();
beforeEach(() => {
usePluginStore.setState(initial, true);
});
describe('pluginStore', () => {
it('FE-STORE-PLUGIN-001: loads active plugins and splits pages/widgets', async () => {
server.use(
http.get('/api/plugins', () =>
HttpResponse.json({
plugins: [
{ id: 'flights', name: 'Flights', type: 'widget', icon: 'Plane' },
{ id: 'report', name: 'Report', type: 'page', icon: 'FileText' },
{ id: 'diary', name: 'Diary', type: 'trip-page', icon: 'Book' },
],
}),
),
);
await usePluginStore.getState().loadPlugins();
const s = usePluginStore.getState();
expect(s.loaded).toBe(true);
expect(s.plugins).toHaveLength(3);
expect(s.pages().map((p) => p.id)).toEqual(['report']);
expect(s.widgets().map((p) => p.id)).toEqual(['flights']);
expect(s.tripPages().map((p) => p.id)).toEqual(['diary']);
expect(s.getById('flights')?.name).toBe('Flights');
expect(s.getById('nope')).toBeUndefined();
});
it('FE-STORE-PLUGIN-002: a failed fetch still marks the store loaded (no crash)', async () => {
server.use(http.get('/api/plugins', () => HttpResponse.error()));
await usePluginStore.getState().loadPlugins();
expect(usePluginStore.getState().loaded).toBe(true);
expect(usePluginStore.getState().plugins).toEqual([]);
});
it('FE-STORE-PLUGIN-003: tolerates a missing plugins array', async () => {
server.use(http.get('/api/plugins', () => HttpResponse.json({})));
await usePluginStore.getState().loadPlugins();
expect(usePluginStore.getState().plugins).toEqual([]);
});
});
-49
View File
@@ -1,49 +0,0 @@
import { create } from 'zustand'
import { pluginsApi } from '../api/client'
/**
* Active plugins the client renders (#plugins, M3). Page plugins become nav
* entries + a full-page iframe route; widget plugins mount on the dashboard.
* Cloned from addonStore plugins have their own feed and lifecycle, so they
* don't overload the addon store.
*/
export interface ActivePlugin {
id: string
name: string
type: 'integration' | 'page' | 'widget' | 'trip-page'
icon: string | null
slot?: 'sidebar' | 'hero' | 'place-detail'
}
interface PluginState {
plugins: ActivePlugin[]
loaded: boolean
loadPlugins: () => Promise<void>
getById: (id: string) => ActivePlugin | undefined
pages: () => ActivePlugin[]
widgets: () => ActivePlugin[]
heroWidgets: () => ActivePlugin[]
tripPages: () => ActivePlugin[]
placeDetailWidgets: () => ActivePlugin[]
}
export const usePluginStore = create<PluginState>((set, get) => ({
plugins: [],
loaded: false,
loadPlugins: async () => {
try {
const data = await pluginsApi.active()
set({ plugins: (data.plugins as ActivePlugin[]) || [], loaded: true })
} catch {
set({ loaded: true })
}
},
getById: (id) => get().plugins.find((p) => p.id === id),
pages: () => get().plugins.filter((p) => p.type === 'page'),
widgets: () => get().plugins.filter((p) => p.type === 'widget' && p.slot !== 'hero' && p.slot !== 'place-detail'),
heroWidgets: () => get().plugins.filter((p) => p.type === 'widget' && p.slot === 'hero'),
tripPages: () => get().plugins.filter((p) => p.type === 'trip-page'),
placeDetailWidgets: () => get().plugins.filter((p) => p.type === 'widget' && p.slot === 'place-detail'),
}))
-14
View File
@@ -270,20 +270,6 @@
.trek-dash .col-lrow-cat svg { flex-shrink: 0; }
.trek-dash .col-lrow-div { width: 1px; height: 18px; flex-shrink: 0; background: var(--line-2); }
/* Per-collection label chips — list rows, filter bar and detail read view */
.trek-dash .col-lrow-label { display: inline-flex; align-items: center; gap: 5px; max-width: 120px; padding: 3px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; overflow: hidden; color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 14%, transparent); }
.trek-dash .col-lrow-label.more { color: var(--ink-3); background: var(--surface-2); }
.trek-dash .col-labelchip-dot { width: 8px; height: 8px; border-radius: 999px; flex-shrink: 0; background: var(--label, var(--accent)); }
.trek-dash .col-labelfilter { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.trek-dash .col-labelchip { display: inline-flex; align-items: center; gap: 6px; max-width: 180px; padding: 6px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; white-space: nowrap; color: var(--ink-2); background: var(--surface); border: 1px solid var(--line-2); transition: background .12s, border-color .12s, color .12s; }
.trek-dash .col-labelchip:hover { background: var(--surface-2); color: var(--ink); }
.trek-dash .col-labelchip .col-filter-lbl { overflow: hidden; text-overflow: ellipsis; }
.trek-dash .col-labelchip.on { color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 16%, transparent); border-color: color-mix(in oklch, var(--label, var(--accent)) 42%, transparent); }
.trek-dash .col-labelchip.on .col-labelchip-dot { background: var(--label, var(--accent)); }
.trek-dash .col-labelchip.static { cursor: default; color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 14%, transparent); border-color: transparent; }
.trek-dash .col-labelchip-manage { color: var(--ink-3); border-style: dashed; }
.trek-dash .col-detail-labels { display: flex; flex-wrap: wrap; gap: 7px; margin: 2px 0 10px; }
/* ----------------- filter bar (status + category dropdowns) ----------------- */
.trek-dash .col-filterbar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
.trek-dash .col-filter { position: relative; }
-13
View File
@@ -262,19 +262,6 @@
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor; mask-composite: exclude; pointer-events: none;
}
/* Hero-slot plugin overlay: a transparent strip whose bottom edge sits on the
pass's top edge, so a plugin (e.g. the mascot) appears to stand ON the bar.
pointer-events none it must never block the pass or hero interactions. */
.trek-dash .hero-pass-wrap { position: relative; }
.trek-dash .hero-pass-overlay {
position: absolute; left: 16px; right: 16px; bottom: 100%; height: 110px;
pointer-events: none; z-index: 3; overflow: hidden;
}
.trek-dash .hero-overlay-frame {
position: absolute; inset: 0; width: 100%; height: 100% !important;
border: 0; background: transparent; color-scheme: light;
}
.trek-dash .pass-cell {
padding: 4px 18px; position: relative; display: flex; flex-direction: column;
justify-content: center; align-items: center; text-align: center; gap: 6px; flex: 1; min-width: 0;
-4
View File
@@ -1,4 +0,0 @@
declare module 'tz-lookup' {
/** Returns the IANA time zone for a coordinate, throwing on invalid input. */
export default function tzlookup(lat: number, lng: number): string;
}
-26
View File
@@ -128,32 +128,6 @@ describe('getTransportForDay', () => {
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [42], days })).toHaveLength(0)
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [99], days })).toHaveLength(1)
})
it('expands a multi-leg TRAIN into one row per leg with train detail on __leg (#1150)', () => {
const reservations = [{
id: 40, type: 'train', day_id: 1, end_day_id: 2,
metadata: JSON.stringify({
train_number: 'ICE 100',
legs: [
{ from: 'Berlin', to: 'Frankfurt', train_number: 'ICE 100', platform: '5', dep_day_id: 1, dep_time: '08:00', arr_day_id: 1, arr_time: '12:00' },
{ from: 'Frankfurt', to: 'München', train_number: 'ICE 500', platform: '9', dep_day_id: 2, dep_time: '09:00', arr_day_id: 2, arr_time: '12:00' },
],
}),
}]
const day1 = getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })
expect(day1).toHaveLength(1)
expect(day1[0].__leg).toMatchObject({ index: 0, total: 2, from: 'Berlin', to: 'Frankfurt', train_number: 'ICE 100', platform: '5' })
const day2 = getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })
expect(day2).toHaveLength(1)
expect(day2[0].__leg).toMatchObject({ index: 1, from: 'Frankfurt', to: 'München', train_number: 'ICE 500', platform: '9' })
})
it('leaves a single-leg train untouched (no __leg)', () => {
const reservations = [{ id: 41, type: 'train', day_id: 1, end_day_id: 1, metadata: JSON.stringify({ train_number: 'RE 1' }) }]
const rows = getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })
expect(rows).toHaveLength(1)
expect(rows[0].__leg).toBeUndefined()
})
})
describe('getMergedItems', () => {
+11 -18
View File
@@ -1,4 +1,4 @@
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'])
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'])
export interface MergedItem {
type: 'place' | 'note' | 'transport'
@@ -66,23 +66,23 @@ export function getDisplayTimeForDay(
return r.reservation_time || null
}
/** Per-leg detail of a multi-leg flight or train, or null for single-leg / other. */
function parseMultiLegs(r: any): any[] | null {
if (r?.type !== 'flight' && r?.type !== 'train') return null
/** Per-leg detail of a multi-leg flight, or null for single-leg / non-flight. */
function parseFlightLegs(r: any): any[] | null {
if (r?.type !== 'flight') return null
let meta = r.metadata
if (typeof meta === 'string') { try { meta = JSON.parse(meta || '{}') } catch { meta = {} } }
// Defensive: recover metadata that was accidentally double-encoded by an earlier
// bug (a JSON string of a JSON string) so already-saved bookings heal on read.
// bug (a JSON string of a JSON string) so already-saved flights heal on read.
if (typeof meta === 'string') { try { meta = JSON.parse(meta || '{}') } catch { meta = {} } }
if (meta && Array.isArray(meta.legs) && meta.legs.length > 1) return meta.legs
return null
}
/**
* Expand a multi-leg flight/train into one synthetic reservation per leg that
* touches `dayId`, each with its own day span + departure/arrival time so it
* slots into the timeline independently. A single-leg booking (or any other
* reservation) is returned untouched, so existing behaviour is unchanged.
* Expand a multi-leg flight into one synthetic reservation per leg that touches
* `dayId`, each with its own day span + departure/arrival time so it slots into
* the timeline independently. A single-leg flight (or any other reservation) is
* returned untouched, so existing behaviour is unchanged.
*/
export function expandFlightLegsForDay(
r: any,
@@ -90,7 +90,7 @@ export function expandFlightLegsForDay(
getDayOrder: (id: number) => number,
days: Array<{ id: number; date?: string | null }>
): any[] {
const legs = parseMultiLegs(r)
const legs = parseFlightLegs(r)
if (!legs) return [r]
const dateOf = (id: number | null): string | null => (id == null ? null : (days.find(d => d.id === id)?.date ?? null))
const thisOrder = getDayOrder(dayId)
@@ -114,14 +114,7 @@ export function expandFlightLegsForDay(
// dropped between legs and persist; absent → falls back to time ordering.
day_positions: leg.day_positions || undefined,
day_plan_position: undefined,
__leg: {
index: i, total: legs.length,
from: leg.from ?? null, to: leg.to ?? null,
airline: leg.airline ?? null, flight_number: leg.flight_number ?? null,
// Train legs carry their own per-leg detail; added only for trains so a
// flight's __leg object stays byte-identical to before.
...(r.type === 'train' ? { train_number: leg.train_number ?? null, platform: leg.platform ?? null, seat: leg.seat ?? null } : {}),
},
__leg: { index: i, total: legs.length, from: leg.from ?? null, to: leg.to ?? null, airline: leg.airline ?? null, flight_number: leg.flight_number ?? null },
})
})
return out
-52
View File
@@ -1,52 +0,0 @@
import { describe, it, expect } from 'vitest'
import { getFlightLegs, getTrainLegs, isMultiLegTrain } from './flightLegs'
import type { Reservation } from '../types'
function res(partial: Partial<Reservation>): Reservation {
return { id: 1, type: 'train', status: 'confirmed', ...partial } as unknown as Reservation
}
const ep = (role: 'from' | 'to' | 'stop', seq: number, name: string, extra: Record<string, unknown> = {}) =>
({ role, sequence: seq, name, code: null, lat: 0, lng: 0, timezone: null, local_time: null, local_date: null, ...extra })
describe('getTrainLegs (#1150)', () => {
it('reads ordered legs from metadata.legs', () => {
const r = res({
metadata: JSON.stringify({
train_number: 'ICE 100', platform: '5',
legs: [
{ from: 'Berlin Hbf', to: 'Frankfurt Hbf', train_number: 'ICE 100', platform: '5', dep_time: '08:00', arr_time: '12:00' },
{ from: 'Frankfurt Hbf', to: 'München Hbf', train_number: 'ICE 500', platform: '9', dep_time: '12:30', arr_time: '15:30' },
],
}),
})
const legs = getTrainLegs(r)
expect(legs).toHaveLength(2)
expect(legs[0]).toMatchObject({ from: 'Berlin Hbf', to: 'Frankfurt Hbf', train_number: 'ICE 100', platform: '5' })
expect(legs[1]).toMatchObject({ from: 'Frankfurt Hbf', to: 'München Hbf', train_number: 'ICE 500', platform: '9' })
expect(isMultiLegTrain(r)).toBe(true)
})
it('derives a single leg from endpoints + flat metadata (legacy train)', () => {
const r = res({
day_id: 3, end_day_id: 3,
metadata: JSON.stringify({ train_number: 'RE 42', platform: '2' }),
endpoints: [ep('from', 0, 'Köln Hbf', { local_time: '09:00' }), ep('to', 1, 'Aachen Hbf', { local_time: '10:00' })],
})
const legs = getTrainLegs(r)
expect(legs).toHaveLength(1)
expect(legs[0]).toMatchObject({ from: 'Köln Hbf', to: 'Aachen Hbf', train_number: 'RE 42', platform: '2', dep_time: '09:00', arr_time: '10:00' })
expect(isMultiLegTrain(r)).toBe(false)
})
it('returns [] for a train with no stations and no train number', () => {
expect(getTrainLegs(res({ metadata: '{}' }))).toEqual([])
})
it('does not disturb getFlightLegs for flights', () => {
const flight = res({ type: 'flight', metadata: JSON.stringify({ departure_airport: 'FRA', arrival_airport: 'JFK', airline: 'LH', flight_number: 'LH 400' }) })
const legs = getFlightLegs(flight)
expect(legs).toHaveLength(1)
expect(legs[0]).toMatchObject({ from: 'FRA', to: 'JFK', airline: 'LH', flight_number: 'LH 400' })
})
})
-60
View File
@@ -84,71 +84,11 @@ export function getFlightLegs(r: Reservation): FlightLeg[] {
}]
}
/**
* A train booking mirrors the flight leg model (#1150), but its stops are
* STATIONS (labels, not IATA codes) and each leg carries a train number +
* platform instead of an airline + flight number.
*/
export interface TrainLeg {
from: string | null // station label (or null)
to: string | null
train_number?: string
platform?: string
seat?: string
dep_day_id?: number | null
dep_time?: string | null
arr_day_id?: number | null
arr_time?: string | null
}
/**
* Ordered legs of a train booking. Prefers `metadata.legs`; otherwise derives a
* single leg from the endpoints + flat metadata, so single-leg trains and
* trains created before this feature still work.
*/
export function getTrainLegs(r: Reservation): TrainLeg[] {
const meta = parseReservationMetadata(r)
if (Array.isArray(meta.legs) && meta.legs.length > 0) {
return meta.legs.map((l: any): TrainLeg => ({
from: l.from ?? null,
to: l.to ?? null,
train_number: l.train_number || undefined,
platform: l.platform || undefined,
seat: l.seat || undefined,
dep_day_id: l.dep_day_id ?? null,
dep_time: l.dep_time ?? null,
arr_day_id: l.arr_day_id ?? null,
arr_time: l.arr_time ?? null,
}))
}
const eps = orderedEndpoints(r)
const first = eps[0]
const last = eps[eps.length - 1]
const fromLabel = first ? (first.code || first.name) : null
const toLabel = last ? (last.code || last.name) : null
if (!fromLabel && !toLabel && !meta.train_number) return []
return [{
from: fromLabel,
to: toLabel,
train_number: meta.train_number || undefined,
platform: meta.platform || undefined,
seat: meta.seat || undefined,
dep_day_id: r.day_id ?? null,
dep_time: first?.local_time ?? null,
arr_day_id: r.end_day_id ?? r.day_id ?? null,
arr_time: last?.local_time ?? null,
}]
}
/** Number of flight segments. 1 for a simple from -> to booking. */
export function legCount(r: Reservation): number {
return getFlightLegs(r).length
}
export function isMultiLegTrain(r: Reservation): boolean {
return r.type === 'train' && getTrainLegs(r).length > 1
}
export function isMultiLegFlight(r: Reservation): boolean {
return r.type === 'flight' && legCount(r) > 1
}
+1 -15
View File
@@ -1,6 +1,5 @@
import { describe, it, expect } from 'vitest'
import { splitReservationDateTime, resolveDayId, formatMoney } from './formatters'
import { CURRENCIES, SYMBOLS } from '../components/Budget/BudgetPanel.constants'
import { splitReservationDateTime, resolveDayId } from './formatters'
import type { Day } from '../types'
const days = [
@@ -26,19 +25,6 @@ describe('resolveDayId', () => {
})
})
describe('KGS currency (#1400)', () => {
it('is selectable everywhere the shared currency list feeds', () => {
expect(CURRENCIES).toContain('KGS')
expect(SYMBOLS.KGS).toBeTruthy()
})
it('formats without throwing', () => {
// Lenient: older ICU builds may lack ru-KG/KGS display data and fall back.
const out = formatMoney(1234.56, 'KGS', 'en')
expect(out).toMatch(/сом|KGS/)
expect(out).toContain('234')
})
})
describe('splitReservationDateTime', () => {
it('parses full ISO datetime', () => {
expect(splitReservationDateTime('2026-06-25T10:00')).toEqual({ date: '2026-06-25', time: '10:00' })
+3 -3
View File
@@ -52,9 +52,9 @@ const CURRENCY_LOCALE: Record<string, string> = {
PHP: 'en-PH', SGD: 'en-SG', KRW: 'ko-KR', CNY: 'zh-CN', HKD: 'en-HK',
TWD: 'zh-TW', ZAR: 'en-ZA', AED: 'en-AE', SAR: 'en-SA', ILS: 'he-IL',
EGP: 'en-EG', MAD: 'fr-MA', HUF: 'hu-HU', RON: 'ro-RO', BGN: 'bg-BG',
HRK: 'hr-HR', ISK: 'is-IS', RUB: 'ru-RU', UAH: 'uk-UA', KGS: 'ru-KG',
BDT: 'en-BD', LKR: 'en-LK', VND: 'vi-VN', CLP: 'es-CL', COP: 'es-CO',
PEN: 'es-PE', ARS: 'es-AR',
HRK: 'hr-HR', ISK: 'is-IS', RUB: 'ru-RU', UAH: 'uk-UA', BDT: 'en-BD',
LKR: 'en-LK', VND: 'vi-VN', CLP: 'es-CL', COP: 'es-CO', PEN: 'es-PE',
ARS: 'es-AR',
}
export function currencyLocale(currency: string): string {
@@ -1,83 +0,0 @@
import { renderHook, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { Reservation } from '../../../src/types'
const { calculateRouteWithLegs } = vi.hoisted(() => ({ calculateRouteWithLegs: vi.fn() }))
vi.mock('../../../src/components/Map/RouteCalculator', () => ({ calculateRouteWithLegs }))
import { useTransportRoutes } from '../../../src/hooks/useTransportRoutes'
function booking(id: number, type: string, from: [number, number], to: [number, number]): Reservation {
return {
id,
type,
status: 'confirmed',
endpoints: [
{ role: 'from', sequence: 0, name: 'A', code: null, lat: from[0], lng: from[1], timezone: null, local_time: null, local_date: null },
{ role: 'to', sequence: 1, name: 'B', code: null, lat: to[0], lng: to[1], timezone: null, local_time: null, local_date: null },
],
} as unknown as Reservation
}
const PARIS: [number, number] = [48.8566, 2.3522]
const VERSAILLES: [number, number] = [48.8049, 2.1204]
beforeEach(() => {
calculateRouteWithLegs.mockReset()
calculateRouteWithLegs.mockResolvedValue({ coordinates: [PARIS, [48.83, 2.24], VERSAILLES], distance: 20000, duration: 1500, legs: [] })
})
describe('useTransportRoutes (#1425 real road routes)', () => {
it('routes a car booking with the driving profile and returns its geometry', async () => {
const res = [booking(1, 'car', PARIS, VERSAILLES)]
const { result } = renderHook(() => useTransportRoutes(res))
await waitFor(() => expect(result.current.get(1)).toBeTruthy())
expect(result.current.get(1)).toHaveLength(3)
expect(calculateRouteWithLegs).toHaveBeenCalledWith(expect.any(Array), expect.objectContaining({ profile: 'driving' }))
})
it('routes a bicycle booking with the cycling profile', async () => {
const res = [booking(2, 'bicycle', PARIS, VERSAILLES)]
renderHook(() => useTransportRoutes(res))
await waitFor(() => expect(calculateRouteWithLegs).toHaveBeenCalled())
expect(calculateRouteWithLegs).toHaveBeenCalledWith(expect.any(Array), expect.objectContaining({ profile: 'cycling' }))
})
it('does not route non-road types (flight, train, transit)', async () => {
const res = [
booking(3, 'flight', PARIS, VERSAILLES),
booking(4, 'train', PARIS, VERSAILLES),
booking(5, 'transit', PARIS, VERSAILLES),
]
renderHook(() => useTransportRoutes(res))
await new Promise(r => setTimeout(r, 20))
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
})
it('skips routing beyond the sanity distance cap (keeps the straight line)', async () => {
// Paris → Tokyo as a "car" booking: ~9700 km, well past the 2000 km cap.
const res = [booking(6, 'car', PARIS, [35.68, 139.69])]
renderHook(() => useTransportRoutes(res))
await new Promise(r => setTimeout(r, 20))
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
})
it('falls back silently (no entry) when routing throws', async () => {
calculateRouteWithLegs.mockRejectedValueOnce(new Error('OSRM down'))
const res = [booking(7, 'taxi', PARIS, VERSAILLES)]
const { result } = renderHook(() => useTransportRoutes(res))
await waitFor(() => expect(calculateRouteWithLegs).toHaveBeenCalled())
await new Promise(r => setTimeout(r, 10))
expect(result.current.get(7)).toBeUndefined()
})
it('does not re-request an unchanged booking when the array identity changes', async () => {
const res1 = [booking(8, 'car', PARIS, VERSAILLES)]
const { rerender } = renderHook(({ r }) => useTransportRoutes(r), { initialProps: { r: res1 } })
await waitFor(() => expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1))
// New array, same booking coordinates → no second fetch.
rerender({ r: [booking(8, 'car', PARIS, VERSAILLES)] })
await new Promise(r => setTimeout(r, 20))
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
})
})

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