Compare commits

...

17 Commits

Author SHA1 Message Date
Maurice 52ed63715c Tours: tests follow the provenance read and the shared day assignment, the tours addon ends MCP sessions, a line without a point is not copied
The repository stubs of the tour service tests answer findProvenance, which getTour and createTour read since P2C-02.
Switching the Tours addon off now ends live MCP sessions like every other addon that gates a tool (MCP-GATE-PARITY-001).
copyTour refuses a line without a point instead of placing the copy at 0/0, and new tests cover the reachable paths
of the copy, the source check and the provenance of a snapshot without its import record, including a trip copy
keeping that record. On the client the shared day assignment hands the plan exactly the tour and the day, the new
visibility store is reset between tests, and the tests of the sidebar, the detail, the phone sheet and the origin
section match the views this PR built.
2026-10-15 22:34:33 +00:00
Maurice 1d7758b819 Tours: the trip copy keeps its copied tours in one map, so trips.service stays at its size baseline; unused TourMapVisibility type removed 2026-10-15 21:40:19 +00:00
Maurice b33727ec75 Tours: no non-null assertion, useless escape or awaited loop in the tour and trip copy paths 2026-10-15 23:25:16 +02:00
Maurice 97b5f36ef0 Tours: the list filter names a tour planned here TREK, as its badge does 2026-10-15 23:11:39 +02:00
Maurice 845979902c Tours: client tests for the new hooks and views, row chips fade at their end, admin user table aligned; strict and lint ratchets green 2026-10-15 23:08:46 +02:00
Maurice a0884685ef Map: tracks drawn from their own modules on both maps; mobile filter chips on the type scale 2026-10-15 22:25:34 +02:00
Maurice 485e360c64 Tours: save a tour as a new, independent tour (P2C-07) 2026-10-15 22:18:41 +02:00
Maurice 356df90095 Tours: read the saved tours of a trip over MCP (P2D-MCP-01) 2026-10-15 22:08:39 +02:00
Maurice cd1b1410bd Tours: filter a tour list by source, routing limit and type where it varies (PR1-13) 2026-10-15 22:02:41 +02:00
Maurice 3d9095ccde Tours: provenance contract, source check, one assignment path and the map overview
- P2C-02: tours record their origin (planned, GPX with file name, wanderer)
  instead of reading it off their shape; the detail answers a provenance
  contract and a trip copy keeps a tour's import record
- MF-03: a wanderer snapshot's source can be checked on purpose, read only,
  with a confirmed reimport as a new snapshot
- PR1-18: every way of putting a tour on a day shares one set of rules
- PR1-07/08: each person can hide tours from the trip map; the list shows
  each tour's line colour and the picked tour stands out on both maps
2026-10-15 21:51:22 +02:00
Maurice 018d2d1425 Tours/wanderer: cover the new service, repository and restore paths
- unit suites for WandererService (every refusal and fallback), the
  wanderer connection and tour source repositories, and restoring a
  recorded track (GPX and wanderer imports, empty or missing track, 404s)
- client cases for a body that breaks off and the trail count fallbacks
- an import whose realtime notification fails still stands (e2e)
- the search controller passes the validated body on; the schema already
  defaults q and page, so the fallbacks there could never run
2026-10-15 20:15:17 +02:00
Maurice cab742e641 Tours: bring the pinned counts and test doubles up to the new tables and switch
- tours locale key count, entity count and NOT NULL field count follow
  the wanderer tables and strings
- the shared MSW admin handlers answer /api/admin/tours-wanderer
- the mobile tour detail test checks the dialog, not the dropped heading
- the refused-origin e2e sends one request per case instead of four at once
2026-10-15 19:25:22 +02:00
Maurice 11f23997e8 Tours: copy a planned tour's import link in the repository, and teach the tests the new columns 2026-10-15 18:38:44 +02:00
Maurice f1c437b8b8 Keep api/client.ts and trips.service.ts within their size baselines
The admin switch for wanderer moves to api/wanderer.ts and the recorded
track restore to api/recordedTrack.ts; trips.service.ts keeps dev's
formatting with only the derived-from remap on copy.
2026-10-15 18:16:25 +02:00
Maurice f690065de8 Tours: resolve the distance unit once where tour facts are built 2026-10-15 17:51:19 +02:00
Maurice 782e613506 Tours: day GPX import, wanderer trails and planning over an import
- GPX files drop onto the map or a day; the import lands on that day
- wanderer connection per user in Integrations; the workspace offers the
  user's own trails only, behind an admin switch under the Tours addon
- wanderer difficulty is stored on the SAC scale (T1 to T3), backfilled
- a GPX or wanderer import can be planned as an editable tour, either in
  place (the recorded track is kept and can be restored) or as a new tour
  that remembers the import it came from
- rails, rows and detail dialog follow the plan sidebar's cards and badges
- admin addon shelves with more than one row fold, collapsed by default
- reverse-geocoded waypoint names are cached on the device
2026-10-15 17:44:15 +02:00
Maurice 31ffcf1228 fix(ferratas): sort route tile keys with localeCompare 2026-10-15 12:09:48 +02:00
302 changed files with 18636 additions and 1242 deletions
+6 -6
View File
@@ -1,25 +1,25 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 404,
"@typescript-eslint/no-misused-promises": 876,
"@typescript-eslint/no-misused-promises": 875,
"no-console": 88,
"complexity": 230,
"@typescript-eslint/no-explicit-any": 148,
"@typescript-eslint/no-explicit-any": 147,
"trek/no-swallowed-catch": 133,
"max-params": 35,
"trek/store-without-selector": 69,
"react-hooks/refs": 561,
"react-hooks/refs": 560,
"react-hooks/set-state-in-effect": 197,
"@typescript-eslint/no-floating-promises": 167,
"react-hooks/exhaustive-deps": 121,
"@typescript-eslint/no-unused-vars": 155,
"@typescript-eslint/no-unused-vars": 130,
"react-hooks/immutability": 28,
"max-lines-per-function": 133,
"react-hooks/static-components": 78,
"react-hooks/preserve-manual-memoization": 15,
"no-useless-assignment": 7,
"no-empty": 19,
"trek/no-window-globals": 42,
"trek/no-window-globals": 41,
"react-refresh/only-export-components": 46,
"unused-disable-directive": 6,
"max-depth": 21,
@@ -35,7 +35,7 @@
},
"tests": {
"@typescript-eslint/no-explicit-any": 491,
"@typescript-eslint/no-non-null-assertion": 1949,
"@typescript-eslint/no-non-null-assertion": 1946,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33,
+6 -1
View File
@@ -717,12 +717,17 @@
"hooks": ["src/components/Tours/tourPresentation.ts"],
"desktop": [
"src/components/Tours/TourDetailDialog.tsx",
"src/components/Tours/ToursSidebar.tsx",
"src/components/Tours/planner/TourPlannerPanels.tsx",
"src/components/Tours/tourParts.tsx"
],
"mobile": ["src/mobile/screens/trip/places/MToursSelectionList.tsx"]
},
{
"feature": "Tours list facets",
"hooks": ["src/components/Tours/useTourListFilters.ts"],
"desktop": ["src/components/Tours/ToursSidebar.tsx"],
"mobile": ["src/mobile/screens/trip/places/MPlacesBrowser.tsx"]
},
{
"feature": "Transport booking detail view: reservationFiles",
"hooks": ["src/utils/reservationFiles.ts"],
-1
View File
@@ -1028,7 +1028,6 @@
"src/components/Todo/useTodoList.ts",
"src/components/Tours/PlacesToursModeSwitch.tsx",
"src/components/Tours/TourDetailDialog.test.tsx",
"src/components/Tours/TourDetailDialog.tsx",
"src/components/Tours/TourListRow.tsx",
"src/components/Tours/ToursSidebar.test.tsx",
"src/components/Tours/ToursSidebar.tsx",
-1
View File
@@ -130,7 +130,6 @@
"src/mobile/screens/trip/tabs/MCollabNotes.tsx",
"src/pages/JourneyDetailPage.tsx",
"src/pages/OAuthAuthorizePage.tsx",
"src/pages/TripPlannerPage.tsx",
"src/pages/admin/AdminSettingsTab.tsx",
"src/pages/admin/useAdmin.ts",
"src/pages/admin/useNotificationDefaults.ts",
+3 -3
View File
@@ -5,9 +5,9 @@
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
"src/components/Budget/CostsPanel.test.tsx": 2764,
"src/components/Budget/CostsPanel.tsx": 1816,
"src/components/Map/MapView.tsx": 1450,
"src/components/Map/MapView.tsx": 1409,
"src/components/Map/MapViewGL.test.tsx": 2920,
"src/components/Map/MapViewGL.tsx": 2414,
"src/components/Map/MapViewGL.tsx": 2371,
"src/components/Map/RouteCalculator.ts": 1195,
"src/components/PDF/TripPDF.tsx": 1082,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
@@ -24,7 +24,7 @@
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
"src/pages/JourneyDetailPage.test.tsx": 4030,
"src/pages/LoginPage.tsx": 1348,
"src/pages/TripPlannerPage.tsx": 1153,
"src/pages/TripPlannerPage.tsx": 1147,
"src/pages/atlas/useAtlas.ts": 1287,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3309,
-1
View File
@@ -89,7 +89,6 @@
"src/components/Todo/todoListModel.ts": 1,
"src/components/Tours/planner/TourPlannerPanels.tsx": 2,
"src/components/Tours/planner/useTourPlanner.ts": 2,
"src/components/Tours/tourParts.tsx": 8,
"src/components/Trips/TripFormModal.tsx": 3,
"src/components/Trips/TripMembersModal.tsx": 1,
"src/components/Vacay/VacayCalendar.tsx": 1,
+1 -1
View File
@@ -62,7 +62,7 @@
"components/Map/LocationButton.tsx": 1,
"components/Map/MapPluginMarkers.tsx": 2,
"components/Map/MapTogglePill.tsx": 1,
"components/Map/MapView.tsx": 10,
"components/Map/MapView.tsx": 8,
"components/Map/MapViewGL.tsx": 11,
"components/Map/NightPauseTooltip.tsx": 1,
"components/Map/PlaceHoverCard.tsx": 1,
+8
View File
@@ -0,0 +1,8 @@
import type { TourDetailResponse } from '@trek/shared';
import apiClient from './client';
/** An import that was planned over in place keeps its recorded track; this brings it back. */
export const recordedTrackApi = {
restore: (tripId: number | string, placeId: number | string): Promise<TourDetailResponse> =>
apiClient.post(`/trips/${tripId}/tours/${placeId}/restore-recorded`).then((r) => r.data),
};
+8
View File
@@ -0,0 +1,8 @@
import type { TourCopyRequest, TourCreateResponse } from '@trek/shared';
import apiClient from './client';
/** P2C-07: a tour of the trip saved again as a new, independent tour. */
export const tourCopiesApi = {
copy: (tripId: number | string, placeId: number | string, body: TourCopyRequest): Promise<TourCreateResponse> =>
apiClient.post(`/trips/${tripId}/tours/${placeId}/copy`, body).then((r) => r.data),
};
+53
View File
@@ -0,0 +1,53 @@
import type {
AdminFeatureToggleResponse,
TourSourceCheckResponse,
WandererConnection,
WandererImportPreview,
WandererImportRequest,
WandererImportResponse,
WandererSaveResponse,
WandererSearchRequest,
WandererSearchResponse,
WandererSettingsRequest,
WandererTestRequest,
WandererTestResponse,
} from '@trek/shared';
import apiClient from './client';
/**
* How long the browser waits for a call that makes the server ask wanderer.
* A preview reads the trail's index entry, its record and its GPX one after
* another, each allowed 15 s on the server, so the shared 8 s would give up
* while the server carries on.
*/
const WANDERER_UPSTREAM_TIMEOUT_MS = 60_000;
const upstream = { timeout: WANDERER_UPSTREAM_TIMEOUT_MS };
export const wandererApi = {
getSettings: (): Promise<WandererConnection> => apiClient.get('/integrations/wanderer/settings').then((r) => r.data),
saveSettings: (data: WandererSettingsRequest): Promise<WandererSaveResponse> =>
apiClient.put('/integrations/wanderer/settings', data).then((r) => r.data),
disconnect: (): Promise<{ success: true }> => apiClient.delete('/integrations/wanderer/settings').then((r) => r.data),
test: (data: WandererTestRequest): Promise<WandererTestResponse> =>
apiClient.post('/integrations/wanderer/test', data, upstream).then((r) => r.data),
search: (tripId: number, body: WandererSearchRequest, signal?: AbortSignal): Promise<WandererSearchResponse> =>
apiClient.post(`/trips/${tripId}/tours/wanderer/search`, body, { ...upstream, signal }).then((r) => r.data),
preview: (tripId: number, trailId: string, signal?: AbortSignal): Promise<WandererImportPreview> =>
apiClient
.get(`/trips/${tripId}/tours/wanderer/trails/${encodeURIComponent(trailId)}`, { ...upstream, signal })
.then((r) => r.data),
importTrail: (tripId: number, body: WandererImportRequest): Promise<WandererImportResponse> =>
apiClient.post(`/trips/${tripId}/tours/wanderer/import`, body, upstream).then((r) => r.data),
/** MF-03: whether the trail a snapshot came from changed since; the server reads, it writes nothing. */
checkSource: (tripId: number, placeId: number): Promise<TourSourceCheckResponse> =>
apiClient.post(`/trips/${tripId}/tours/wanderer/sources/${placeId}/check`, {}, upstream).then((r) => r.data),
};
/** The admin's switch for wanderer under the Tours addon. */
export const wandererAdminApi = {
get: (): Promise<AdminFeatureToggleResponse> => apiClient.get('/admin/tours-wanderer').then((r) => r.data),
update: (enabled: boolean): Promise<AdminFeatureToggleResponse> =>
apiClient.put('/admin/tours-wanderer', { enabled }).then((r) => r.data),
};
@@ -1,4 +1,3 @@
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-039
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { delay, http, HttpResponse } from 'msw';
@@ -56,6 +55,12 @@ function isOn(toggle: HTMLElement): boolean {
return toggle.getAttribute('aria-pressed') === 'true';
}
/** A shelf of more than one row starts folded; open every one so its rows can be read. */
async function unfoldShelves(): Promise<void> {
const bars = await screen.findAllByRole('button', { name: /\d+ options?/, expanded: false });
for (const bar of bars) await userEvent.click(bar);
}
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
@@ -72,7 +77,8 @@ beforeEach(() => {
seedStore(useSettingsStore, { settings: { dark_mode: false } });
vi.spyOn(useAddonStore.getState(), 'loadAddons').mockResolvedValue(undefined);
server.use(
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] }))
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] })),
http.get('/api/admin/tours-wanderer', () => HttpResponse.json({ enabled: false }))
);
});
@@ -228,6 +234,7 @@ describe('AddonManager', () => {
)
);
render(<AddonManager />);
await unfoldShelves();
// Provider sub-rows are visible under Journey addon
await screen.findByText('Unsplash');
@@ -293,6 +300,7 @@ describe('AddonManager', () => {
buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: false }),
]));
render(<AddonManager />);
await unfoldShelves();
await screen.findByText('Immich');
// immich and synologyphotos ship a vendor glyph, unsplash does not
@@ -427,6 +435,8 @@ describe('AddonManager', () => {
]));
render(<AddonManager />);
await unfoldShelves();
await screen.findByText('Paperless-ngx');
const paperlessRow = screen.getByText('Paperless-ngx').closest('li') as HTMLElement;
expect(paperlessRow.querySelector('svg')).toBeInTheDocument();
@@ -524,6 +534,8 @@ describe('AddonManager', () => {
/>,
);
await unfoldShelves();
await screen.findByText('Chat');
expect(screen.getByText('Notes')).toBeInTheDocument();
expect(screen.getByText('Polls')).toBeInTheDocument();
@@ -551,8 +563,9 @@ describe('AddonManager', () => {
<AddonManager
collabFeatures={{ chat: true, notes: true, links: true, polls: true, whatsnext: true }}
onToggleCollabFeature={vi.fn()}
/>,
/>
);
await unfoldShelves();
const links = await screen.findByText('Links');
expect(links).toHaveAttribute('title');
@@ -839,4 +852,89 @@ describe('AddonManager', () => {
await waitFor(() => expect(screen.getByDisplayValue('https://proxy.local/v1')).toBeInTheDocument());
expect(urls).toHaveLength(0);
});
it('FE-ADMIN-ADDON-040: a shelf of several options starts folded and says how many there are and how many are on', async () => {
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
render(
<AddonManager
collabFeatures={{ chat: true, notes: false, links: true, polls: false, whatsnext: false }}
onToggleCollabFeature={vi.fn()}
/>
);
const bar = await screen.findByRole('button', { name: /5 options/, expanded: false });
expect(bar).toHaveTextContent('2 on');
expect(screen.queryByText('Chat')).not.toBeInTheDocument();
await userEvent.click(bar);
expect(bar).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByText('Chat')).toBeInTheDocument();
await userEvent.click(bar);
expect(screen.queryByText('Chat')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-041: a shelf of one option shows it at once, without a fold', async () => {
server.use(addonsRoute([buildAddon({ id: 'packing', name: 'Lists', icon: 'ListChecks', enabled: true })]));
render(<AddonManager bagTrackingEnabled onToggleBagTracking={vi.fn()} />);
expect(await screen.findByRole('button', { name: 'Bag Tracking' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /\d+ options?/ })).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-042: wanderer hangs off Tours and saves its switch', async () => {
const puts: unknown[] = [];
server.use(
addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]),
http.put('/api/admin/tours-wanderer', async ({ request }) => {
puts.push(await request.json());
return HttpResponse.json({ enabled: true });
})
);
render(
<>
<AddonManager />
<ToastContainer />
</>
);
const toggle = await screen.findByRole('button', { name: 'wanderer' });
expect(isOn(toggle)).toBe(false);
await userEvent.click(toggle);
await waitFor(() => expect(puts).toEqual([{ enabled: true }]));
expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(true);
});
it('FE-ADMIN-ADDON-043: a failed wanderer save puts the switch back', async () => {
server.use(
addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]),
http.put('/api/admin/tours-wanderer', () => HttpResponse.json({ error: 'nope' }, { status: 500 }))
);
render(
<>
<AddonManager />
<ToastContainer />
</>
);
await userEvent.click(await screen.findByRole('button', { name: 'wanderer' }));
await waitFor(() => expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(false));
});
it('FE-ADMIN-ADDON-044: Tours and Road trip show their own icons, not the fallback puzzle', async () => {
server.use(
addonsRoute([
buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: false }),
buildAddon({ id: 'roadtrip', name: 'Road trip', icon: 'Route', enabled: false }),
])
);
render(<AddonManager />);
const tours = (await screen.findByText('Tours')).closest('article') as HTMLElement;
const roadtrip = screen.getByText('Road trip').closest('article') as HTMLElement;
expect(tours.querySelector('svg.lucide-mountain')).toBeInTheDocument();
expect(roadtrip.querySelector('svg.lucide-car-front')).toBeInTheDocument();
expect(tours.querySelector('svg.lucide-puzzle')).not.toBeInTheDocument();
});
});
+18 -1
View File
@@ -1,7 +1,7 @@
import { useId, type ComponentType } from 'react'
import { useTranslation } from '../../i18n'
import { useIsDark } from '../../hooks/useIsDark'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw } from 'lucide-react'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw, Mountain, CarFront } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared'
import EmptyState from '../shared/EmptyState'
@@ -18,12 +18,16 @@ import { type Addon, type CollabFeatures, ANTHROPIC_HINT, COLLAB_SUB_FEATURES, g
import { useAddonManager } from './addons/useAddonManager'
import { useLlmParsingConfig } from './addons/useLlmParsingConfig'
import { ImmichIcon, SynologyIcon } from './addons/PhotoProviderIcons'
import WandererMark from '../Tours/wanderer/WandererMark'
// Keys are the `icon` column from the addons table (see server seeds.ts); anything
// unknown falls back to Puzzle. Users/Sparkles cover collab and llm_parsing, which
// used to land on the fallback.
const ICON_MAP = {
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark, Users, Sparkles,
Mountain,
// Road trip was seeded as 'Route'; the car says what it is better than a line does.
Route: CarFront,
// Dawarich ships a brand mark rather than a lucide name, the same way the photo
// providers below do. Keyed by the string in the addons table.
Dawarich: DawarichIcon,
@@ -71,6 +75,8 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
const dark = useIsDark()
const {
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions,
toursWanderer,
toggleToursWanderer,
} = useAddonManager()
if (loading) {
@@ -147,6 +153,17 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
)
})
}
if (addon.id === 'tours' && addon.enabled) {
return (
<AddonSubRow
icon={<WandererMark size={14} />}
title={t('admin.toursWanderer.title')}
description={t('admin.toursWanderer.subtitle')}
enabled={toursWanderer}
onToggle={() => void toggleToursWanderer()}
/>
)
}
// The AI-parsing settings hang off their tile like every other sub-feature,
// just as a form instead of toggle rows.
if (addon.id === 'llm_parsing' && addon.enabled) {
+50 -3
View File
@@ -1,6 +1,13 @@
import type { ReactNode } from 'react'
import { Children, type ReactNode, isValidElement, useId, useState } from 'react'
import ToggleSwitch from '../Settings/ToggleSwitch'
import { fs } from '../shared/DialogShell'
import { ChevronDown } from 'lucide-react'
import { useTranslation } from '../../i18n'
/** How many of the shelf's rows are switched on, read from the rows' own `enabled`. */
function enabledRows(rows: ReactNode[]): number {
return rows.filter((row) => isValidElement<{ enabled?: boolean }>(row) && row.props.enabled === true).length
}
/**
* One addon in its type's card: a row like the bag sidebar's (#2541), with the
@@ -14,6 +21,10 @@ import { fs } from '../shared/DialogShell'
*
* The row is not a click target, only the switches are. No lift, no pointer
* cursor, no `role="button"`.
*
* A shelf of more than one row starts folded behind a bar that says how many
* options there are and how many are on, so a page of addons stays short and
* the options are still plain to see.
*/
export default function AddonTile({
icon,
@@ -31,6 +42,13 @@ export default function AddonTile({
/** The sub-shelf: `<AddonSubRow>` children. */
children?: ReactNode
}) {
const { t } = useTranslation()
const shelfId = useId()
const [open, setOpen] = useState(false)
const rows = Children.toArray(children)
const foldable = rows.length > 1
const active = enabledRows(rows)
return (
<article className="px-3.5 py-3 transition-colors duration-150 focus-within:bg-surface-secondary">
<div className="flex items-start gap-3">
@@ -62,11 +80,40 @@ export default function AddonTile({
</div>
</div>
{children && (
{rows.length > 0 && (
<div className="mt-3 overflow-hidden rounded-[12px] border border-edge-faint bg-surface-secondary">
{foldable && (
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-controls={shelfId}
className={`flex min-h-[44px] w-full items-center gap-2.5 px-3 py-1.5 text-start transition-colors hover:bg-surface-tertiary focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent ${open ? 'border-b border-edge-faint' : ''}`}
>
{/* The same slot and type as a row below, so the bar reads as one more row. */}
<span className="grid h-6 w-6 flex-none place-items-center text-content-muted">
<ChevronDown
size={15}
strokeWidth={2.2}
className={`transition-transform duration-150 ${open ? 'rotate-180' : ''}`}
/>
</span>
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>
{t('admin.addons.subOptions', { count: rows.length })}
</span>
<span
className={`flex-none rounded-full px-2 py-0.5 font-semibold ${active > 0 ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-muted'}`}
style={fs(10.5, 'body')}
>
{t('admin.addons.subOptionsOn', { count: active })}
</span>
</button>
)}
{/* Sitting inside the parent on its own fill is enough to say these belong
to it: no rail, no indent guides. */}
<ul className="m-0 list-none divide-y divide-edge-faint p-0">{children}</ul>
{(!foldable || open) && (
<ul id={shelfId} className="m-0 list-none divide-y divide-edge-faint p-0">{children}</ul>
)}
</div>
)}
</article>
@@ -1,6 +1,7 @@
import { useEffect, useEffectEvent, useState } from 'react';
import { adminApi } from '../../../api/client';
import { wandererAdminApi } from '../../../api/wanderer';
import { useTranslation } from '../../../i18n';
import { useAddonStore } from '../../../store/addonStore';
import { useToast } from '../../shared/Toast';
@@ -18,6 +19,7 @@ export function useAddonManager() {
const refreshGlobalAddons = useAddonStore((s) => s.loadAddons);
const [addons, setAddons] = useState<Addon[]>([]);
const [loading, setLoading] = useState(true);
const [toursWanderer, setToursWanderer] = useState(false);
const loadAddons = async () => {
try {
@@ -30,6 +32,10 @@ export function useAddonManager() {
const loadOnMount = useEffectEvent(() => {
void loadAddons().finally(() => setLoading(false));
wandererAdminApi
.get()
.then((flag) => setToursWanderer(flag.enabled))
.catch(() => toast.error(t('admin.addons.toast.error')));
});
useEffect(() => {
loadOnMount();
@@ -57,6 +63,21 @@ export function useAddonManager() {
toast.success(t('admin.addons.toast.updated'));
};
/** wanderer under Tours: flipped at once, put back when the save fails. */
const toggleToursWanderer = async () => {
const next = !toursWanderer;
setToursWanderer(next);
try {
await wandererAdminApi.update(next);
} catch {
setToursWanderer(!next);
toast.error(t('admin.addons.toast.error'));
return;
}
void refreshGlobalAddons();
toast.success(t('admin.addons.toast.updated'));
};
const asOption = (provider: Addon): ProviderOption => ({
key: provider.id,
label: provider.name,
@@ -83,5 +104,7 @@ export function useAddonManager() {
integrationAddons,
providerOptions,
documentProviderOptions,
toursWanderer,
toggleToursWanderer,
};
}
@@ -112,7 +112,13 @@ export function useTripFerratas(
);
// The tiles a tour's line runs through load with the layer off too, for the warning
// that a route climbs a ferrata; only the layer's own state decides what it shows.
const routeKey = routeTiles && routeTiles.length > 0 ? routeTiles.map(ferrataTileKey).sort().join('|') : null;
const routeKey =
routeTiles && routeTiles.length > 0
? routeTiles
.map(ferrataTileKey)
.sort((a, b) => a.localeCompare(b))
.join('|')
: null;
const layerOn = enabled && !!store;
const active = layerOn || (!!routeKey && !!store);
const fits = !!view && ferrataBboxFits(view);
+4 -46
View File
@@ -61,6 +61,7 @@ import type { DayBoundaryControls } from './dayBoundaryDrag'
import type { Poi } from './poiCategories'
import { poiDetailRowKey, poiDetailRows, poiPinCacheKey, poiPinParts } from './poiMarker'
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import { SelectedTrackLine, TrackLines } from './TrackLines'
import DawarichTrailLayer from './DawarichTrailLayer'
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, OPENTOPOMAP_TILE_URL, OPENTOPOMAP_TILE_ATTRIBUTION, OPENTOPOMAP_TILE_MAXZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_MAXZOOM, AMAP_SATELLITE, attributionForTile } from '../../constants/mapDefaults'
import { crsForBasemap } from './gcj02Crs'
@@ -1034,55 +1035,11 @@ export const MapView = memo(function MapView({
return [{ place, coords, cased: hasManualTrackColor(place), color: resolveTrackColor(place) }]
}), [places])
// Keeps the click handler out of the polyline memo below: `handleMarkerClick`
// Keeps the click handler out of the track lines' memo (TrackLines): `handleMarkerClick`
// changes on every selection, and depending on it would redraw all tracks.
const markerClickRef = useRef(handleMarkerClick)
markerClickRef.current = handleMarkerClick
const gpxPolylines = useMemo(() => (
<>
{/* Casings live in their own pane below the lines. Leaflet stacks paths by
insertion order, so drawing them inline would put the casing of a track
added later — or of one just given a colour — on top of an earlier
track's line. Always rendered, hidden via opacity when there is no
colour, so toggling one never remounts the path. */}
{gpxTracks.map(({ place, coords, cased }) => (
<Polyline
key={`gpx-${place.id}-casing`}
positions={coords}
pane={hasCasingPane ? TRACK_CASING_PANE : undefined}
pathOptions={{ color: '#ffffff', weight: 6.5, opacity: cased ? 0.7 : 0, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>
))}
{/* pathOptions, not bare color/weight props: react-leaflet only calls
setStyle when the pathOptions reference changes, so bare props would
stick at their mount-time colour and a repaint would never arrive. */}
{gpxTracks.map(({ place, coords, cased, color }) => (
<Polyline
key={`gpx-${place.id}`}
positions={coords}
pathOptions={{ color, weight: 3.5, opacity: cased ? 0.9 : 0.75 }}
interactive={false}
/>
))}
{/* Invisible fat line on top so the track can actually be hit — 3.5px is
not a target, least of all on touch, and the start markers cluster below
zoom 11. bubblingMouseEvents is essential: paths bubble to the map by
default (markers do not), and the map's own click handler clears the
selection this one just made. */}
{gpxTracks.map(({ place, coords }) => (
<Polyline
key={`gpx-${place.id}-hit`}
positions={coords}
pathOptions={{ color: '#000', weight: 14, opacity: 0, lineCap: 'round', lineJoin: 'round' }}
bubblingMouseEvents={false}
eventHandlers={{ click: () => markerClickRef.current(place.id) }}
/>
))}
</>
), [gpxTracks, hasCasingPane])
const TooltipOverlay = !hoverDisabled && hoveredPlace && tooltipPos && !isTouchDevice
&& (!hoveredPlace.routeVia || routeVias.includes(hoveredPlace.routeVia))
@@ -1312,7 +1269,8 @@ export const MapView = memo(function MapView({
{/* GPX imported route geometries */}
<TrackCasingPane onReady={setHasCasingPane} />
{gpxPolylines}
<TrackLines tracks={gpxTracks} casingPane={hasCasingPane ? TRACK_CASING_PANE : undefined} selectRef={markerClickRef} />
<SelectedTrackLine tracks={gpxTracks} selectedPlaceId={selectedPlaceId} />
{routeProfileFocus && (
<CircleMarker
+10 -53
View File
@@ -41,8 +41,7 @@ import PlaceHoverCard from './PlaceHoverCard'
import { ratingBadgeHtml } from './ratingBadge'
import type { Poi } from './poiCategories'
import { poiPinParts } from './poiMarker'
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import { parseRenderableRouteGeometry } from '../../utils/routeGeometry'
import { addGpxTrackLayers, GPX_HIT_LAYER_ID, hasGpxTrackLayers, selectGpxTrack, setGpxTracks } from './gpxTrackLayers'
import { buildPoiPopupHtml } from './placePopup'
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults'
@@ -66,7 +65,6 @@ const PLACE_CLUSTER_SOURCE_ID = 'trip-place-clusters'
const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle'
const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count'
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit'
const GPX_HIT_LAYER_ID = 'trip-gpx-hit'
/**
* The satellite base layer, the GL twin of the Leaflet one.
*
@@ -1345,40 +1343,8 @@ export function MapViewGL({
})
}
// gpx geometries source (place.route_geometry)
if (!map.getSource('trip-gpx')) {
map.addSource('trip-gpx', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
// Casing under the tracks that carry a picked colour (#776) — keeps them
// legible on satellite and dark styles. Untouched tracks are filtered
// out, so they look exactly as they did before.
map.addLayer({
id: 'trip-gpx-casing',
type: 'line',
source: 'trip-gpx',
filter: ['==', ['get', 'cased'], true],
paint: { 'line-color': '#ffffff', 'line-width': 6.5, 'line-opacity': 0.7 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
map.addLayer({
id: 'trip-gpx-line',
type: 'line',
source: 'trip-gpx',
paint: {
'line-color': ['coalesce', ['get', 'color'], '#3b82f6'],
'line-width': 3.5,
'line-opacity': ['case', ['==', ['get', 'cased'], true], 0.9, 0.75],
},
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
// Invisible fat line that catches the click — 3.5px is not a target,
// and the start markers cluster below zoom 11, so without this a track
// is unreachable at the very zoom where you compare walks side by side.
map.addLayer({
id: GPX_HIT_LAYER_ID,
type: 'line',
source: 'trip-gpx',
paint: { 'line-color': '#000', 'line-width': 14, 'line-opacity': 0 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
if (!hasGpxTrackLayers(map)) {
addGpxTrackLayers(map)
const selectTrack = (e: any) => { // eslint-disable-line @typescript-eslint/no-explicit-any
// A click on a cluster bubble sitting over a track belongs to the
// cluster (zoom-to-expand), not to the line underneath it.
@@ -2075,24 +2041,15 @@ export function MapViewGL({
useEffect(() => {
const map = mapRef.current
if (!map) return
const src = map.getSource('trip-gpx') as mapboxgl.GeoJSONSource | undefined
if (!src) return
const features = places.flatMap(place => {
const coords = parseRenderableRouteGeometry(place.route_geometry)
if (!coords) return []
return [{
type: 'Feature' as const,
properties: {
color: resolveTrackColor(place),
cased: hasManualTrackColor(place),
place_id: place.id,
},
geometry: { type: 'LineString' as const, coordinates: coords.map(([lat, lng]) => [lng, lat]) },
}]
})
src.setData({ type: 'FeatureCollection', features })
setGpxTracks(map, places)
}, [places, mapReady])
// The picked tour's track stands out (PR1-08).
useEffect(() => {
const map = mapRef.current
if (map && mapReady) selectGpxTrack(map, selectedPlaceId)
}, [selectedPlaceId, mapReady])
// Reservation overlay — mirrors the Leaflet ReservationOverlay: great-
// circle arcs for flights/cruises, straight lines for trains/cars,
// clickable endpoint badges, rotating mid-arc stats label for flights.
+109
View File
@@ -0,0 +1,109 @@
import { memo, type RefObject } from 'react';
import { Polyline } from 'react-leaflet';
import type { Place } from '../../types';
import { TRACK_CASING_COLOR } from './trackColors';
/** A place's track as the Leaflet map draws it: its points, its colour, and whether it carries a rim. */
export interface TrackLine {
place: Place;
coords: [number, number][];
cased: boolean;
color: string;
}
interface TrackLinesProps {
tracks: readonly TrackLine[];
/** The pane the rims go into, below the lines; none while the map has not made it yet. */
casingPane?: string;
/** Read on click, so a new selection handler never redraws every track. */
selectRef: RefObject<(placeId: number) => void>;
}
/**
* Every track of the map: the rims, the lines and an invisible fat line on top
* that takes the click. Memoised on the tracks alone; a selection changes
* nothing here, the picked track is drawn by {@link SelectedTrackLine}.
*/
export const TrackLines = memo(function TrackLines({ tracks, casingPane, selectRef }: TrackLinesProps) {
return (
<>
{/* Casings live in their own pane below the lines. Leaflet stacks paths by
insertion order, so drawing them inline would put the casing of a track
added later — or of one just given a colour — on top of an earlier
track's line. Always rendered, hidden via opacity when there is no
colour, so toggling one never remounts the path. */}
{tracks.map(({ place, coords, cased }) => (
<Polyline
key={`gpx-${place.id}-casing`}
positions={coords}
pane={casingPane}
pathOptions={{
color: TRACK_CASING_COLOR,
weight: 6.5,
opacity: cased ? 0.7 : 0,
lineCap: 'round',
lineJoin: 'round',
}}
interactive={false}
/>
))}
{/* pathOptions, not bare color/weight props: react-leaflet only calls
setStyle when the pathOptions reference changes, so bare props would
stick at their mount-time colour and a repaint would never arrive. */}
{tracks.map(({ place, coords, cased, color }) => (
<Polyline
key={`gpx-${place.id}`}
positions={coords}
pathOptions={{ color, weight: 3.5, opacity: cased ? 0.9 : 0.75 }}
interactive={false}
/>
))}
{/* Invisible fat line on top so the track can actually be hit — 3.5px is
not a target, least of all on touch, and the start markers cluster below
zoom 11. bubblingMouseEvents is essential: paths bubble to the map by
default (markers do not), and the map's own click handler clears the
selection this one just made. */}
{tracks.map(({ place, coords }) => (
<Polyline
key={`gpx-${place.id}-hit`}
positions={coords}
pathOptions={{ color: TRACK_CASING_COLOR, weight: 14, opacity: 0, lineCap: 'round', lineJoin: 'round' }}
bubblingMouseEvents={false}
eventHandlers={{ click: () => selectRef.current?.(place.id) }}
/>
))}
</>
);
});
/**
* PR1-08: the track of the picked tour, wider and with a light rim on top of
* the others, so the line and the list's row read as one. It takes no click:
* the hit line below still does.
*/
export function SelectedTrackLine({
tracks,
selectedPlaceId,
}: {
tracks: readonly TrackLine[];
selectedPlaceId: number | null;
}) {
const track = selectedPlaceId == null ? null : tracks.find((item) => item.place.id === selectedPlaceId);
if (!track) return null;
return (
<>
<Polyline
key={`gpx-selected-${track.place.id}-casing`}
positions={track.coords}
pathOptions={{ color: TRACK_CASING_COLOR, weight: 9, opacity: 0.95, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>
<Polyline
key={`gpx-selected-${track.place.id}`}
positions={track.coords}
pathOptions={{ color: track.color, weight: 5.5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>
</>
);
}
@@ -0,0 +1,66 @@
import type mapboxgl from 'mapbox-gl';
import { describe, expect, it, vi } from 'vitest';
import type { Place } from '../../types';
import { addGpxTrackLayers, GPX_HIT_LAYER_ID, hasGpxTrackLayers, selectGpxTrack, setGpxTracks } from './gpxTrackLayers';
function fakeMap() {
const layers = new Map<string, { filter?: unknown }>();
const sources = new Map<string, { setData: ReturnType<typeof vi.fn> }>();
const map = {
addSource: vi.fn((id: string) => sources.set(id, { setData: vi.fn() })),
getSource: (id: string) => sources.get(id),
addLayer: vi.fn((layer: { id: string; filter?: unknown }) => layers.set(layer.id, { filter: layer.filter })),
getLayer: (id: string) => layers.get(id),
setFilter: vi.fn((id: string, filter: unknown) => layers.set(id, { filter })),
};
return { map: map as unknown as mapboxgl.Map, layers, sources, raw: map };
}
describe('gpxTrackLayers (PR1-08)', () => {
it('adds the rim, the line, the picked track and the hit line once, picking nothing yet', () => {
const { map, layers } = fakeMap();
expect(hasGpxTrackLayers(map)).toBe(false);
addGpxTrackLayers(map);
expect(hasGpxTrackLayers(map)).toBe(true);
expect([...layers.keys()]).toEqual([
'trip-gpx-casing',
'trip-gpx-line',
'trip-gpx-selected-casing',
'trip-gpx-selected',
GPX_HIT_LAYER_ID,
]);
expect(layers.get('trip-gpx-selected')?.filter).toEqual(['==', ['get', 'place_id'], -1]);
});
it('puts only places with a drawable track into the source, with their colour and rim', () => {
const { map, sources } = fakeMap();
addGpxTrackLayers(map);
const track = { id: 5, route_geometry: '[[47,11],[47.1,11.1]]', route_color: '#ff0000' } as Place;
const plain = { id: 6, route_geometry: null } as Place;
setGpxTracks(map, [track, plain]);
const data = sources.get('trip-gpx')?.setData.mock.calls[0]?.[0];
expect(data.features).toHaveLength(1);
expect(data.features[0].properties).toEqual({ color: '#ff0000', cased: true, place_id: 5 });
expect(data.features[0].geometry.coordinates).toEqual([
[11, 47],
[11.1, 47.1],
]);
});
it('points the picked layers at the selected place, and at none again when nothing is picked', () => {
const { map, layers } = fakeMap();
addGpxTrackLayers(map);
selectGpxTrack(map, 5);
expect(layers.get('trip-gpx-selected')?.filter).toEqual(['==', ['get', 'place_id'], 5]);
expect(layers.get('trip-gpx-selected-casing')?.filter).toEqual(['==', ['get', 'place_id'], 5]);
selectGpxTrack(map, null);
expect(layers.get('trip-gpx-selected')?.filter).toEqual(['==', ['get', 'place_id'], -1]);
});
it('does nothing before the style has its layers', () => {
const { map, raw } = fakeMap();
selectGpxTrack(map, 5);
setGpxTracks(map, []);
expect(raw.setFilter).not.toHaveBeenCalled();
});
});
+103
View File
@@ -0,0 +1,103 @@
import { TRACK_COLOR_FALLBACK } from '@trek/shared';
import type mapboxgl from 'mapbox-gl';
import type { Place } from '../../types';
import { parseRenderableRouteGeometry } from '../../utils/routeGeometry';
import { hasManualTrackColor, resolveTrackColor, TRACK_CASING_COLOR } from './trackColors';
const SOURCE_ID = 'trip-gpx';
/** The invisible fat line that takes a click on a track. */
export const GPX_HIT_LAYER_ID = 'trip-gpx-hit';
const SELECTED_CASING_LAYER_ID = 'trip-gpx-selected-casing';
const SELECTED_LAYER_ID = 'trip-gpx-selected';
const ROUND = { 'line-cap': 'round', 'line-join': 'round' } as const;
const NO_TRACK = ['==', ['get', 'place_id'], -1] as mapboxgl.FilterSpecification;
/**
* The tracks of the vector map, as the Leaflet map draws them (TrackLines): a
* rim under the tracks that carry a picked colour, the lines, the picked
* track on top wider with a light rim (PR1-08), and an invisible fat line that
* takes the click. Added once with the style; whoever adds it wires the click.
*/
export function addGpxTrackLayers(map: mapboxgl.Map): void {
map.addSource(SOURCE_ID, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
// Casing under the tracks that carry a picked colour (#776): legible on satellite and dark styles.
// Untouched tracks are filtered out, so they look exactly as they did before.
map.addLayer({
id: 'trip-gpx-casing',
type: 'line',
source: SOURCE_ID,
filter: ['==', ['get', 'cased'], true],
paint: { 'line-color': TRACK_CASING_COLOR, 'line-width': 6.5, 'line-opacity': 0.7 },
layout: ROUND,
});
map.addLayer({
id: 'trip-gpx-line',
type: 'line',
source: SOURCE_ID,
paint: {
'line-color': ['coalesce', ['get', 'color'], TRACK_COLOR_FALLBACK],
'line-width': 3.5,
'line-opacity': ['case', ['==', ['get', 'cased'], true], 0.9, 0.75],
},
layout: ROUND,
});
// The filter follows the selection (selectGpxTrack); until something is picked it matches no track.
map.addLayer({
id: SELECTED_CASING_LAYER_ID,
type: 'line',
source: SOURCE_ID,
filter: NO_TRACK,
paint: { 'line-color': TRACK_CASING_COLOR, 'line-width': 9, 'line-opacity': 0.95 },
layout: ROUND,
});
map.addLayer({
id: SELECTED_LAYER_ID,
type: 'line',
source: SOURCE_ID,
filter: NO_TRACK,
paint: { 'line-color': ['coalesce', ['get', 'color'], TRACK_COLOR_FALLBACK], 'line-width': 5.5 },
layout: ROUND,
});
// 3.5px is not a target, and the start markers cluster below zoom 11, so without this a track
// is unreachable at the very zoom where you compare walks side by side.
map.addLayer({
id: GPX_HIT_LAYER_ID,
type: 'line',
source: SOURCE_ID,
paint: { 'line-color': TRACK_CASING_COLOR, 'line-width': 14, 'line-opacity': 0 },
layout: ROUND,
});
}
/** Whether the track layers are there yet (they come with the style). */
export function hasGpxTrackLayers(map: mapboxgl.Map): boolean {
return !!map.getSource(SOURCE_ID);
}
/** The places' tracks into the source, each with its colour and whether it carries a rim. */
export function setGpxTracks(map: mapboxgl.Map, places: readonly Place[]): void {
const src = map.getSource(SOURCE_ID) as mapboxgl.GeoJSONSource | undefined;
if (!src) return;
const features = places.flatMap((place) => {
const coords = parseRenderableRouteGeometry(place.route_geometry);
if (!coords) return [];
return [
{
type: 'Feature' as const,
properties: { color: resolveTrackColor(place), cased: hasManualTrackColor(place), place_id: place.id },
geometry: { type: 'LineString' as const, coordinates: coords.map(([lat, lng]) => [lng, lat]) },
},
];
});
src.setData({ type: 'FeatureCollection', features });
}
/** PR1-08: the picked place's track stands out; none picked, none does. */
export function selectGpxTrack(map: mapboxgl.Map, placeId: number | null): void {
if (typeof map.getLayer !== 'function') return;
const filter = ['==', ['get', 'place_id'], placeId ?? -1] as mapboxgl.FilterSpecification;
for (const id of [SELECTED_CASING_LAYER_ID, SELECTED_LAYER_ID]) {
if (map.getLayer(id)) map.setFilter(id, filter);
}
}
+18 -4
View File
@@ -18,18 +18,32 @@
* both end up as a real DOM node, which is all HTML5 drag needs.
*/
/**
* Start dragging `placeId` towards the day plan, from any row or marker that holds a
* place (a tour is one too). Every source goes through here, so the day plan cannot
* tell them apart.
*/
export function startPlaceDrag(transfer: DataTransfer | null, placeId: number): void {
transfer?.setData('placeId', String(placeId))
if (transfer) transfer.effectAllowed = 'copy'
window.__dragData = { placeId: String(placeId) }
}
/** The drag `startPlaceDrag` began has ended, dropped or not. */
export function endPlaceDrag(): void {
window.__dragData = null
}
/** Wire a marker element up as a drag source for `placeId`. */
export function makeMarkerDraggable(el: HTMLElement, placeId: number): () => void {
el.setAttribute('draggable', 'true')
const onDragStart = (e: DragEvent) => {
e.dataTransfer?.setData('placeId', String(placeId))
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy'
window.__dragData = { placeId: String(placeId) }
startPlaceDrag(e.dataTransfer, placeId)
el.classList.add('marker-dragging')
}
const onDragEnd = () => {
window.__dragData = null
endPlaceDrag()
el.classList.remove('marker-dragging')
}
// The map pans on mousedown anywhere on its surface, and a marker sits on that
+3
View File
@@ -1,6 +1,9 @@
import { TRACK_COLOR_FALLBACK } from '@trek/shared'
import type { Place } from '../../types'
/** The light rim under a track that carries a picked colour, and under the track picked on the map. */
export const TRACK_CASING_COLOR = '#ffffff'
/**
* The colour a track's polyline is drawn in (#776): a manually picked colour
* wins, then the category colour, then the blue every imported track used to
@@ -2,10 +2,11 @@ import type { TourListItem } from '@trek/shared';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useTranslation } from '../../i18n';
import '../../styles/fadeEnd.css';
import type { Category, Place } from '../../types';
import { formatTime } from '../../utils/formatters';
import { fs } from '../shared/DialogShell';
import TourDayRowFacts from '../Tours/TourDayRowFacts';
import { TourDayRowPills } from '../Tours/TourDayRowFacts';
import { formatPlannedTourDuration, type TourPlannedTimes } from '../Tours/tourPresentation';
import { TimePill } from './planParts';
@@ -65,7 +66,7 @@ function PlannedTourPills({ tourTimes }: { tourTimes: DayRowTourTimes }) {
);
}
// Description line; tour stops get two clamped lines instead of one.
// Description line, one line like every stop's.
function SublineDescription({ text, isTourPlace }: { text: string; isTourPlace: boolean }) {
return (
<div
@@ -75,10 +76,9 @@ function SublineDescription({ text, isTourPlace }: { text: string; isTourPlace:
...fs(10.5),
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: isTourPlace ? 'normal' : 'nowrap',
whiteSpace: 'nowrap',
lineHeight: 1.3,
maxHeight: isTourPlace ? '2.6em' : '1.3em',
...(isTourPlace ? { display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' as const } : {}),
maxHeight: '1.3em',
}}
>
<Markdown remarkPlugins={[remarkGfm]}>{text}</Markdown>
@@ -94,14 +94,16 @@ export function DayPlanPlaceSubline({ place, cat, tourTimes, isTourPlace, timeFo
const description = place.description || place.address || cat?.name;
if (!(dayRowTour || place.place_time || hasPlannedTimes(tourTimes) || description)) return null;
return (
<div className="mt-0.5 flex min-w-0 flex-wrap items-center gap-1.5">
// One line like the row's name: when the hover controls come out, the description
// gives way and the pills keep their place instead of wrapping onto a second line.
<div className="trek-fade-end mt-0.5 flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden">
{place.place_time && (
<TimePill quiet>
{formatTime(place.place_time, locale, timeFormat)}
{place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''}
</TimePill>
)}
{dayRowTour ? <TourDayRowFacts tour={dayRowTour} /> : tourTimes && <PlannedTourPills tourTimes={tourTimes} />}
{dayRowTour ? <TourDayRowPills tour={dayRowTour} /> : tourTimes && <PlannedTourPills tourTimes={tourTimes} />}
{description && <SublineDescription text={description} isTourPlace={isTourPlace} />}
</div>
);
@@ -155,7 +155,7 @@ beforeEach(() => {
});
describe('DayPlanSidebar', () => {
it('RS-01: previews Tour descriptions in two lines only while Tours is enabled', () => {
it('RS-01: previews a Tour description on one line like every stop, marked only while Tours is enabled', () => {
const description = 'A long description of the ridge route, the lake overlook, and the return path.';
const place = buildPlace({ id: 45, name: 'Ridge walk', description, tour_place_id: 45 });
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' });
@@ -172,9 +172,9 @@ describe('DayPlanSidebar', () => {
const view = render(<DayPlanSidebar {...props} />);
const tourPreview = view.container.querySelector('[data-testid="trip-plan-tour-description"]') as HTMLElement;
expect(tourPreview).toHaveTextContent(description);
expect(tourPreview.style.display).toBe('-webkit-box');
expect(tourPreview.style.webkitLineClamp).toBe('2');
expect(tourPreview.style.maxHeight).toBe('2.6em');
// One line like the row's name, so hover controls make the description give way instead of wrapping.
expect(tourPreview).toHaveStyle({ whiteSpace: 'nowrap', maxHeight: '1.3em' });
expect(tourPreview.parentElement).toHaveClass('flex-nowrap', 'overflow-hidden');
expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveTextContent('Walk: 1 h');
expect(screen.getByTestId('trip-plan-tour-breaks')).toHaveTextContent('Breaks: 35 min');
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('Planned: 1 h 35 min');
@@ -231,14 +231,77 @@ describe('DayPlanSidebar', () => {
});
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: true }] });
const view = render(<DayPlanSidebar {...props} />);
expect(screen.getByTestId('plan-tour-icon').querySelector('.lucide-mountain')).toBeTruthy();
// The tile carries the grade, as in the tour lists; the facts sit in the plan's white badges.
expect(screen.getByTestId('plan-tour-icon')).toHaveTextContent('T2');
expect(screen.getByTestId('plan-tour-distance')).toHaveTextContent('4');
expect(screen.getByTestId('plan-tour-ascent')).toHaveTextContent('100');
expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveAccessibleName('Walking time: 53 min');
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('1 h 3 min');
const time = screen.getByTestId('trip-plan-tour-time');
expect(time).toHaveTextContent('1 h 3 min');
expect(time).toHaveAccessibleName(
'Walking time: 53 min, Breaks / additional time: 10 min, Planned total duration: 1 h 3 min'
);
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: false }] });
view.rerender(<DayPlanSidebar {...props} />);
expect(screen.queryByTestId('plan-tour-icon')).not.toBeInTheDocument();
expect(screen.queryByTestId('plan-tour-facts')).not.toBeInTheDocument();
});
it('RC-01: an imported tour without a grade shows its type on the tile', () => {
const place = buildPlace({ id: 46, name: 'Recorded loop', tour_place_id: 46 });
const day = buildDay({ id: 10, title: 'Day 1' });
const assignment = buildAssignment({ id: 102, day_id: 10, order_index: 0, place });
const tour = {
place_id: 46,
name: place.name,
tour_type: 'hike' as const,
distance: 6,
elevation_gain: 300,
elevation_loss: 300,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: null,
planned: true,
caution: false,
has_waypoints: false,
};
const props = makeDefaultProps({
days: [day],
places: [place],
tourPlaceIds: new Set([46]),
assignments: { '10': [assignment] },
plannedTourDurations: new Map([
[46, { tour, walkingMinutes: null, breakMinutes: null, plannedTotalMinutes: null, manuallyOverridden: false }],
]),
});
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: true }] });
render(<DayPlanSidebar {...props} />);
expect(screen.getByTestId('plan-tour-icon').querySelector('.lucide-mountain')).toBeTruthy();
expect(screen.queryByTestId('trip-plan-tour-time')).not.toBeInTheDocument();
});
it('RC-01: a tour row offers no booking, a tour is not booked', () => {
const place = buildPlace({ id: 45, name: 'Ridge walk', tour_place_id: 45 });
const cafe = buildPlace({ id: 47, name: 'Lakeside cafe' });
const day = buildDay({ id: 10, title: 'Day 1' });
const assignments = {
'10': [
buildAssignment({ id: 101, day_id: 10, order_index: 0, place }),
buildAssignment({ id: 103, day_id: 10, order_index: 1, place: cafe }),
],
};
const props = makeDefaultProps({
days: [day],
places: [place, cafe],
tourPlaceIds: new Set([45]),
assignments,
onAddBookingToAssignment: vi.fn(),
});
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: true }] });
render(<DayPlanSidebar {...props} />);
// The cafe can be booked; the tour beside it cannot.
expect(screen.getAllByRole('button', { name: 'Add booking' })).toHaveLength(1);
});
});
@@ -83,7 +83,7 @@ import type { DayDeleteQuestion } from '../../utils/dayImpactLines'
import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types'
import type { TourPlannedTimes } from '../Tours/tourPresentation'
import type { TourListItem } from '@trek/shared'
import { TourDayRowIcon } from '../Tours/TourDayRowFacts'
import { TourDayRowAvatar } from '../Tours/TourDayRowFacts'
import { DayPlanPlaceSubline } from './DayPlanPlaceSubline'
export interface DayPlanSidebarProps {
@@ -2000,7 +2000,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
onMouseLeave={() => setLockHoverId(null)}
className="relative flex flex-none rounded-full"
>
{dayRowTour ? <TourDayRowIcon type={dayRowTour.tour_type} size={32} /> : <PlaceAvatar place={place} category={cat} size={32} />}
{dayRowTour ? <TourDayRowAvatar tour={dayRowTour} size={32} /> : <PlaceAvatar place={place} category={cat} size={32} />}
{/* Hover/locked overlay */}
{(lockHoverId === assignment.id || isLocked) && (
<span className="absolute inset-0 grid place-items-center rounded-full transition-colors"
@@ -2141,7 +2141,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<ChevronDown size={12} strokeWidth={2} />
</button>
</div>}
{canEditDays && onAddBookingToAssignment && (
{canEditDays && onAddBookingToAssignment && !isTourPlace && (
<Tooltip label={t('reservations.addBooking')} placement="top">
<button type="button" aria-label={t('reservations.addBooking')}
onClick={e => {
@@ -9,6 +9,7 @@ import { getCategoryIcon } from '../shared/categoryIcons'
import { resolveTrackColor } from '../Map/trackColors'
import { MoreButton, SoftPill, type MenuEntry } from './planParts'
import type { Place, Category } from '../../types'
import { startPlaceDrag } from '../Map/markerDrag'
interface MemoPlaceRowProps {
place: Place
@@ -73,9 +74,7 @@ export const MemoPlaceRow = React.memo(function MemoPlaceRow({
draggable={!selectMode && !dragDisabled}
onDragStart={e => {
if (dragDisabled) { e.preventDefault(); return }
e.dataTransfer.setData('placeId', String(place.id))
e.dataTransfer.effectAllowed = 'copy'
window.__dragData = { placeId: String(place.id) }
startPlaceDrag(e.dataTransfer, place.id)
}}
onClick={activate}
onKeyDown={e => {
@@ -16,6 +16,7 @@ import { EditorField, INPUT, Segmented, TEXTAREA } from '../shared/dialogParts'
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, SettingRows, SettingsHint, StatusPill } from './settingsKit'
import { OAUTH_PRESETS, useMcpIntegration, type OAuthClient } from './useMcpIntegration'
import { useIntegrationGates } from './useIntegrationGates'
import WandererConnectionSection from './WandererConnectionSection'
export default function IntegrationsTab(): React.ReactElement {
const S = useIntegrations()
@@ -28,6 +29,7 @@ export default function IntegrationsTab(): React.ReactElement {
{!managed && <PhotoProvidersSection />}
{S.airtrailEnabled && !managed && <AirTrailConnectionSection />}
{S.dawarichEnabled && !managed && <DawarichConnectionSection />}
{S.wandererEnabled && !managed && <WandererConnectionSection />}
{/* Which model reads a booking, and what that costs, comes with the instance on
a managed install. The per-user fallback exists for people who supply their
own key, and there nobody does. */}
@@ -45,11 +47,14 @@ export default function IntegrationsTab(): React.ReactElement {
function useIntegrations() {
const { t, locale } = useTranslation()
const toast = useToast()
const { mcpEnabled, airtrailEnabled, llmEnabled, dawarichEnabled, managed } = useIntegrationGates({ reloadAddons: true })
const { mcpEnabled, airtrailEnabled, llmEnabled, dawarichEnabled, wandererEnabled, managed } = useIntegrationGates({ reloadAddons: true })
const mcp = useMcpIntegration({ enabled: mcpEnabled, reloadClientsAfterWrite: false })
return { t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, dawarichEnabled, managed, ...mcp }
return { t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, dawarichEnabled,
wandererEnabled,
managed, ...mcp,
}
}
type IntegrationsState = ReturnType<typeof useIntegrations>
@@ -0,0 +1,131 @@
import userEvent from '@testing-library/user-event';
import type { WandererConnection } from '@trek/shared';
import { http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import WandererConnectionSection from './WandererConnectionSection';
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
const disconnected: WandererConnection = {
url: '',
tokenMasked: '',
allowInsecureTls: false,
connected: false,
lastCheckedAt: null,
lastCheckOk: null,
lastCheckError: null,
};
const connected: WandererConnection = {
...disconnected,
url: 'https://wanderer.test',
tokenMasked: '••••••••',
connected: true,
};
function settingsRoute(state: { current: typeof disconnected }) {
return http.get('/api/integrations/wanderer/settings', () => HttpResponse.json(state.current));
}
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
});
describe('WandererConnectionSection', () => {
it('RS-03: saves an address and a token, and never fills the token back in', async () => {
const state = { current: disconnected };
const saved: unknown[] = [];
server.use(
settingsRoute(state),
http.put('/api/integrations/wanderer/settings', async ({ request }) => {
saved.push(await request.json());
state.current = connected;
return HttpResponse.json({ success: true });
})
);
render(<WandererConnectionSection />);
expect(await screen.findByText('Not connected')).toBeInTheDocument();
await userEvent.type(screen.getByLabelText('Instance URL'), 'https://wanderer.test');
await userEvent.type(screen.getByLabelText('API token'), 'wanderer_key_abc');
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() =>
expect(saved).toEqual([{ url: 'https://wanderer.test', token: 'wanderer_key_abc', allowInsecureTls: false }])
);
expect(await screen.findByText('Connected')).toBeInTheDocument();
expect(screen.getByLabelText('API token')).toHaveValue('');
expect(toast.success).toHaveBeenCalled();
});
it('RS-03: says a token that is not a wanderer API token before sending it', async () => {
server.use(settingsRoute({ current: disconnected }));
render(<WandererConnectionSection />);
await userEvent.type(await screen.findByLabelText('Instance URL'), 'https://wanderer.test');
await userEvent.type(screen.getByLabelText('API token'), 'my-password');
expect(screen.getByLabelText('API token')).toHaveAttribute('aria-invalid', 'true');
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
});
it('RS-03: a test names how many own trails there are, or why it failed', async () => {
const answers = [
{ ok: true, trailCount: 5 },
{ ok: false, code: 'unauthorized' },
];
server.use(
settingsRoute({ current: connected }),
http.post('/api/integrations/wanderer/test', () => HttpResponse.json(answers.shift()))
);
render(<WandererConnectionSection />);
await screen.findByText('Connected');
await userEvent.click(screen.getByRole('button', { name: 'Test connection' }));
expect(await screen.findByText('Connected. You can import 5 of your trails.')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Test connection' }));
expect(await screen.findByRole('status')).not.toHaveTextContent('Connected. You can');
});
it('RS-03: shows what the last check said when it failed', async () => {
server.use(
settingsRoute({
current: { ...connected, lastCheckedAt: '2026-10-15 10:00:00', lastCheckOk: false, lastCheckError: 'disabled' },
})
);
render(<WandererConnectionSection />);
expect(await screen.findByText('Your admin has not enabled wanderer for Tours.')).toBeInTheDocument();
});
it('RS-03: disconnecting forgets the connection', async () => {
const state = { current: connected };
server.use(
settingsRoute(state),
http.delete('/api/integrations/wanderer/settings', () => {
state.current = disconnected;
return HttpResponse.json({ success: true });
})
);
render(<WandererConnectionSection />);
await userEvent.click(await screen.findByRole('button', { name: 'Disconnect' }));
expect(await screen.findByText('Not connected')).toBeInTheDocument();
});
it('RS-03: a refused save is said as its reason', async () => {
server.use(
settingsRoute({ current: disconnected }),
http.put('/api/integrations/wanderer/settings', () =>
HttpResponse.json({ error: 'x', code: 'blocked_address' }, { status: 400 })
)
);
render(<WandererConnectionSection />);
await userEvent.type(await screen.findByLabelText('Instance URL'), 'http://10.0.0.5');
await userEvent.type(screen.getByLabelText('API token'), 'wanderer_key_abc');
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(toast.error).toHaveBeenCalled());
});
});
@@ -0,0 +1,141 @@
import { Loader2, Plug, Save, Unplug } from 'lucide-react';
import React from 'react';
import { useTranslation } from '../../i18n';
import { EditorField, GRID_2, INPUT } from '../shared/dialogParts';
import { fs } from '../shared/DialogShell';
import WandererMark from '../Tours/wanderer/WandererMark';
import Section from './Section';
import {
SETTINGS_BUTTON,
SETTINGS_BUTTON_DANGER,
SETTINGS_BUTTON_PRIMARY,
SettingRow,
SettingRows,
SettingsHint,
StatusPill,
} from './settingsKit';
import ToggleSwitch from './ToggleSwitch';
import { useWandererConnection } from './useWandererConnection';
/**
* Settings → Integrations → wanderer: the reader's own trail database, from
* which the Tours workspace imports trails. Laid out like the Dawarich and
* AirTrail cards beside it; the behaviour is in `useWandererConnection`.
*/
export default function WandererConnectionSection(): React.ReactElement {
const { t } = useTranslation();
const S = useWandererConnection();
return (
<Section
title={t('wanderer.title')}
icon={WandererMark}
badge={
<StatusPill
tone={S.connected ? 'success' : 'neutral'}
icon={<span className="h-1.5 w-1.5 rounded-full bg-current" />}
>
{S.connected ? t('wanderer.connected') : t('wanderer.notConnected')}
</StatusPill>
}
>
<SettingsHint>{t('wanderer.intro')}</SettingsHint>
<div className={GRID_2}>
<EditorField label={t('wanderer.url')} htmlFor="wanderer-url">
<input
id="wanderer-url"
type="url"
value={S.url}
onChange={(event) => S.setUrl(event.target.value)}
placeholder="https://wanderer.example.com"
className={INPUT}
/>
</EditorField>
<EditorField
label={t('wanderer.token')}
htmlFor="wanderer-token"
hint={S.tokenInvalid ? undefined : t('wanderer.tokenHint')}
>
<input
id="wanderer-token"
type="password"
value={S.token}
onChange={(event) => S.setToken(event.target.value)}
autoComplete="off"
aria-invalid={S.tokenInvalid || undefined}
placeholder={S.connected && !S.token ? '••••••••' : 'wanderer_key_…'}
className={INPUT}
/>
{S.tokenInvalid && (
<p className="m-0 mt-1 text-danger" style={fs(11.5, 'body')}>
{t('wanderer.tokenInvalid')}
</p>
)}
</EditorField>
</div>
<SettingRows>
<SettingRow
label={t('wanderer.allowInsecureTls')}
hint={t('wanderer.allowInsecureTlsHint')}
control={
<ToggleSwitch
on={S.allowInsecureTls}
onToggle={S.toggleInsecureTls}
label={t('wanderer.allowInsecureTls')}
/>
}
/>
</SettingRows>
<div className="flex flex-wrap items-center gap-2" style={fs(13, 'body')}>
<button
type="button"
onClick={() => void S.save()}
disabled={S.saving || S.loading || !S.canSave}
className={SETTINGS_BUTTON_PRIMARY}
>
<Save size={14} strokeWidth={2.2} /> {t('common.save')}
</button>
<button
type="button"
onClick={() => void S.test()}
disabled={S.testing || S.loading || !S.url.trim() || S.tokenInvalid}
className={SETTINGS_BUTTON}
>
{S.testing ? (
<Loader2 size={14} strokeWidth={2.2} className="animate-spin" />
) : (
<Plug size={14} strokeWidth={2.2} />
)}
{t('wanderer.test.button')}
</button>
{S.connected && (
<button
type="button"
onClick={() => void S.disconnect()}
disabled={S.saving}
className={`${SETTINGS_BUTTON_DANGER} ms-auto`}
>
<Unplug size={14} strokeWidth={2.2} /> {t('wanderer.disconnect')}
</button>
)}
</div>
{(S.probe || S.lastCheck) && (
<div
role="status"
className="flex flex-col gap-1 rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3"
style={fs(12, 'body')}
>
{S.probe ? (
<p className={`m-0 font-medium ${S.probe.ok ? 'text-success' : 'text-danger'}`}>{S.probe.message}</p>
) : (
<p className="m-0 text-danger">{S.lastCheck}</p>
)}
</div>
)}
</Section>
);
}
@@ -52,4 +52,11 @@ describe('useIntegrationGates', () => {
renderHook(() => useIntegrationGates({ reloadAddons: true }));
expect(loud).toHaveBeenCalledTimes(1);
});
it('RS-03: offers the wanderer connection only while the admin allows wanderer under Tours', () => {
seedAddons(['tours']);
useAddonStore.setState({ toursWanderer: false });
expect(renderHook(() => useIntegrationGates({ reloadAddons: false })).result.current.wandererEnabled).toBe(false);
useAddonStore.setState({ toursWanderer: true });
expect(renderHook(() => useIntegrationGates({ reloadAddons: false })).result.current.wandererEnabled).toBe(true);
});
});
@@ -19,6 +19,8 @@ export function useIntegrationGates({ reloadAddons }: { reloadAddons: boolean })
const airtrailEnabled = addonEnabled('airtrail');
const llmEnabled = addonEnabled('llm_parsing');
const dawarichEnabled = addonEnabled('dawarich');
// The wanderer connection feeds the Tours workspace, while the admin allows it there.
const wandererEnabled = useAddonStore((s) => s.toursWanderer);
const hasIntegrations = memoriesEnabled || mcpEnabled || airtrailEnabled || llmEnabled || dawarichEnabled;
useEffect(() => {
@@ -31,6 +33,7 @@ export function useIntegrationGates({ reloadAddons }: { reloadAddons: boolean })
airtrailEnabled,
llmEnabled,
dawarichEnabled,
wandererEnabled,
hasIntegrations,
managed,
loadAddons,
@@ -0,0 +1,152 @@
import {
WANDERER_TOKEN_MASK,
WANDERER_TOKEN_PREFIX,
type WandererConnection,
type WandererTestResponse,
} from '@trek/shared';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from '../../i18n';
import { wandererErrorCode, wandererRepo } from '../../repo/wandererRepo';
import { useToast } from '../shared/Toast';
import { wandererErrorText } from '../Tours/wanderer/wandererErrors';
/** What the last test found, said in the card; null before a test. */
export interface WandererProbe {
ok: boolean;
message: string;
}
type Translate = (key: string, params?: Record<string, unknown>) => string;
/** What a test answered, as the line the card shows under its buttons. */
function probeOf(result: WandererTestResponse, t: Translate): WandererProbe {
if (!result.ok) return { ok: false, message: wandererErrorText(t, result.code ?? 'unknown') };
const message =
result.trailCount != null ? t('wanderer.test.okCount', { count: result.trailCount }) : t('wanderer.test.ok');
return { ok: true, message };
}
/**
* The wanderer connection card's logic: the form, saving, testing and
* disconnecting, with every failure turned into a sentence the reader can act
* on. The token field is never filled from the server; blank keeps the stored one.
*/
export function useWandererConnection() {
const { t } = useTranslation();
const toast = useToast();
const [url, setUrl] = useState('');
const [token, setToken] = useState('');
const [allowInsecureTls, setAllowInsecureTls] = useState(false);
const [saved, setSaved] = useState<WandererConnection | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [probe, setProbe] = useState<WandererProbe | null>(null);
const errorText = useCallback((code: string) => wandererErrorText(t, code), [t]);
const apply = useCallback((connection: WandererConnection) => {
setSaved(connection);
setUrl(connection.url);
setAllowInsecureTls(connection.allowInsecureTls);
setToken('');
}, []);
useEffect(() => {
let cancelled = false;
wandererRepo
.getSettings()
.then((connection) => {
if (!cancelled) apply(connection);
})
.catch(() => {
if (!cancelled) toast.error(t('wanderer.loadFailed'));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [apply, t, toast]);
const typedToken = token.trim();
/** A typed token that wanderer would read as no token at all, said before it is sent. */
const tokenInvalid =
typedToken !== '' && typedToken !== WANDERER_TOKEN_MASK && !typedToken.startsWith(WANDERER_TOKEN_PREFIX);
const connected = !!saved?.connected;
const canSave = !!url.trim() && !tokenInvalid && (connected || typedToken !== '');
const save = useCallback(async () => {
if (!canSave) return;
setSaving(true);
try {
const result = await wandererRepo.saveSettings({
url: url.trim(),
token: typedToken || undefined,
allowInsecureTls,
});
apply(await wandererRepo.getSettings());
setProbe(null);
toast.success(t('wanderer.saved'));
if (result.warning === 'private_ip') toast.info(t('wanderer.privateIp'));
} catch (error: unknown) {
toast.error(errorText(wandererErrorCode(error)));
} finally {
setSaving(false);
}
}, [allowInsecureTls, apply, canSave, errorText, t, toast, typedToken, url]);
const test = useCallback(async () => {
if (!url.trim() || tokenInvalid) return;
setTesting(true);
try {
const result = await wandererRepo.test({ url: url.trim(), token: typedToken || undefined, allowInsecureTls });
setProbe(probeOf(result, t));
if (!typedToken) setSaved(await wandererRepo.getSettings());
} catch (error: unknown) {
setProbe({ ok: false, message: errorText(wandererErrorCode(error)) });
} finally {
setTesting(false);
}
}, [allowInsecureTls, errorText, t, tokenInvalid, typedToken, url]);
const disconnect = useCallback(async () => {
setSaving(true);
try {
await wandererRepo.disconnect();
apply(await wandererRepo.getSettings());
setProbe(null);
toast.success(t('wanderer.disconnected'));
} catch (error: unknown) {
toast.error(errorText(wandererErrorCode(error)));
} finally {
setSaving(false);
}
}, [apply, errorText, t, toast]);
const lastCheck =
saved?.lastCheckedAt && saved.lastCheckOk === false && saved.lastCheckError
? errorText(saved.lastCheckError)
: null;
return {
url,
setUrl,
token,
setToken,
allowInsecureTls,
toggleInsecureTls: () => setAllowInsecureTls((value) => !value),
connected,
loading,
saving,
testing,
probe,
tokenInvalid,
canSave,
lastCheck,
save,
test,
disconnect,
};
}
@@ -4,6 +4,8 @@ import { createElement, useId, useState } from 'react';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { formatDistance, formatElevation } from '../../utils/units';
import { WhiteBadge } from '../Planner/planParts';
import { fs } from '../shared/DialogShell';
import { Tooltip } from '../shared/Tooltip';
import { tourDayRowIcon } from './tourDayRowIcon';
import { formatPlannedTourDuration, tourPlannedTimes } from './tourPresentation';
@@ -73,6 +75,121 @@ function Fact({
);
}
/**
* A tour's tile in the day plan and in its detail: the T-scale grade in a round
* tile, the same tile the tour lists use, so a tour reads alike wherever it
* shows. A GPX import has no grade and shows its type's icon instead.
*/
export function TourDayRowAvatar({
tour,
size = 32,
}: {
tour: Pick<TourListItem, 'tour_type' | 'max_hiking_difficulty'>;
size?: number;
}) {
const { t } = useTranslation();
const level = tour.max_hiking_difficulty;
const known = ['hike', 'bike', 'walk', 'kayak'].includes(tour.tour_type);
const label =
level != null
? t(`tours.planner.difficulty.t${level}`)
: known
? t(`tourTypes.${tour.tour_type}`)
: t('tours.mode.tours');
return (
<span
data-testid="plan-tour-icon"
role="img"
aria-label={label}
className="grid flex-none place-items-center rounded-full bg-edge font-geist font-semibold tabular-nums text-content"
style={{ width: size, height: size, ...fs(size >= 44 ? 17 : 12, 'body') }}
>
{level != null
? `T${level}`
: createElement(tourDayRowIcon(tour.tour_type), {
size: Math.round(size * 0.46),
strokeWidth: 1.9,
className: 'text-content-secondary',
'aria-hidden': 'true',
})}
</span>
);
}
/** One fact of a tour as the white badge a place's time sits in, named by its tooltip. */
function TourDayRowPill({
icon: Icon,
value,
label,
testId,
}: {
icon: LucideIcon;
value: string;
label: string;
testId: string;
}) {
return (
<Tooltip label={label}>
<span data-testid={testId} aria-label={label} className="inline-flex">
<WhiteBadge icon={<Icon size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
{value}
</WhiteBadge>
</span>
</Tooltip>
);
}
/**
* A tour's facts on its day-plan row, in the badges every other stop uses: the
* time it takes (the planned total where there is one, otherwise the walking
* time, the parts in the tooltip), how far and how much climbing.
*/
export function TourDayRowPills({ tour }: { tour: TourListItem }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) || 'metric';
const times = tourPlannedTimes(tour);
const shownMinutes = times.plannedTotalMinutes ?? times.walkingMinutes;
const timeParts = [
times.walkingMinutes != null
? `${t('tours.planner.inspector.duration')}: ${formatPlannedTourDuration(times.walkingMinutes)}`
: null,
times.breakMinutes
? `${t('tours.planner.breaksAdditional')}: ${formatPlannedTourDuration(times.breakMinutes)}`
: null,
times.plannedTotalMinutes != null
? `${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(times.plannedTotalMinutes)}${times.manuallyOverridden ? ` (${t('tours.planner.plannedTotalManual')})` : ''}`
: null,
].filter((part): part is string => part !== null);
return (
<>
{shownMinutes != null && (
<TourDayRowPill
icon={Clock}
value={formatPlannedTourDuration(shownMinutes)}
label={timeParts.join(', ')}
testId="trip-plan-tour-time"
/>
)}
{tour.distance != null && (
<TourDayRowPill
icon={Ruler}
value={formatDistance(tour.distance, unit)}
label={`${t('tours.detail.distance')}: ${formatDistance(tour.distance, unit)}`}
testId="plan-tour-distance"
/>
)}
{tour.elevation_gain != null && (
<TourDayRowPill
icon={ArrowUp}
value={formatElevation(tour.elevation_gain, unit)}
label={`${t('tours.detail.ascent')}: ${formatElevation(tour.elevation_gain, unit)}`}
testId="plan-tour-ascent"
/>
)}
</>
);
}
export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourListItem; mobile?: boolean }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) || 'metric';
@@ -1,4 +1,4 @@
import React, { useState } from 'react'
import { useState } from 'react'
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TourListItem } from '@trek/shared'
@@ -6,6 +6,7 @@ import { buildPlace } from '../../../tests/helpers/factories'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useSettingsStore } from '../../store/settingsStore'
import TourDetailDialog from './TourDetailDialog'
import { useTripStore } from '../../store/tripStore'
vi.mock('../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') })
vi.mock('../../../../server/src/db/database', () => { throw new Error('Legacy database imports are forbidden in client tests') })
@@ -96,16 +97,19 @@ describe('TourDetailDialog', () => {
expect(screen.getByText('Maximum altitude')).toBeInTheDocument()
expect(screen.getByText('Ascent')).toBeInTheDocument()
expect(screen.getByText('Descent')).toBeInTheDocument()
expect(screen.getByText('50 m')).toBeInTheDocument()
expect(screen.getByText('30 m')).toBeInTheDocument()
// The head's pills and the fields below both measure the line as it is now.
expect(screen.getAllByText('50 m').length).toBeGreaterThan(0)
expect(screen.getAllByText('30 m').length).toBeGreaterThan(0)
expect(screen.queryByText('999 m')).not.toBeInTheDocument()
expect(screen.queryByText('999 km')).not.toBeInTheDocument()
expect(screen.getByText('Bring water')).toBeInTheDocument()
expect(screen.queryByText('Opening Hours')).not.toBeInTheDocument()
expect(screen.queryByText('Participants')).not.toBeInTheDocument()
})
it('RS-01: shows Tour description and classified HTTPS information link without fetching it', () => {
it('RS-01: shows Tour description and opens the classified HTTPS information link without fetching it', () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch')
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
render(
<TourDetailDialog
{...defaultProps}
@@ -115,19 +119,26 @@ describe('TourDetailDialog', () => {
)
expect(screen.getByText('Ridge above the lake')).toBeInTheDocument()
const link = screen.getByRole('link', { name: 'Komoot · komoot.com' })
expect(link).toHaveAttribute('href', 'https://www.komoot.com/tour/42')
expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow')
// The footer names the provider; the domain rides along in the tooltip.
const website = screen.getByRole('button', { name: 'Website: Komoot' })
expect(website).toHaveTextContent('Komoot')
fireEvent.click(website)
expect(openSpy).toHaveBeenCalledWith('https://www.komoot.com/tour/42', '_blank', 'noopener,noreferrer')
expect(fetchSpy).not.toHaveBeenCalled()
fetchSpy.mockRestore()
openSpy.mockRestore()
})
it('RS-02: labels calculated walking time and planned total as separate values', () => {
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, duration: 60, break_additional_minutes: 35, planned_duration_minutes: 125 }} />)
expect(screen.getByText('Walking time: 1 h')).toBeInTheDocument()
expect(screen.getByText('Breaks / additional time: 35 min')).toBeInTheDocument()
expect(screen.getByText('Planned total duration: 2 h 5 min · Manual override')).toBeInTheDocument()
// One time in the head, its parts named in full behind it; the fields below keep them apart.
expect(screen.getByLabelText('Walk: 1 h, Breaks: 35 min, Planned (Manual override): 2 h 5 min')).toHaveTextContent(
'2 h 5 min'
)
expect(screen.getByText('Walk')).toBeInTheDocument()
expect(screen.getByText('Breaks')).toBeInTheDocument()
expect(screen.getByText('Planned (Manual override)')).toBeInTheDocument()
})
it('RS-01: rejects an unsafe legacy Tour website for rendering', () => {
@@ -144,7 +155,7 @@ describe('TourDetailDialog', () => {
it('uses existing assignment callbacks for add and remove actions', () => {
const { rerender } = render(<TourDetailDialog {...defaultProps} days={[{ id: 7 } as never]} selectedDayId={7} />)
fireEvent.click(screen.getByRole('button', { name: /Add to Day/i }))
expect(defaultProps.onAssignToDay).toHaveBeenCalledWith(42)
expect(defaultProps.onAssignToDay).toHaveBeenCalledWith(42, 7)
rerender(<TourDetailDialog {...defaultProps} days={[{ id: 7 } as never]} selectedDayId={7} assignments={{ '7': [{ id: 9, place }] as never }} />)
fireEvent.click(screen.getByRole('button', { name: /Remove from Day/i }))
@@ -176,10 +187,31 @@ describe('TourDetailDialog', () => {
expect(defaultProps.onUpdatePlace).toHaveBeenCalledWith(42, { name: 'New route name' })
})
it('labels GPX imports as view-only planner content', () => {
it('RC-03: names a GPX import as such, without a read-only notice in the plan', () => {
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, has_waypoints: false }} />)
expect(screen.getByText('GPX import')).toBeInTheDocument()
expect(screen.getByText('GPX tours are view-only and cannot be edited in the planner yet.')).toBeInTheDocument()
expect(screen.queryByText(/view-only/)).not.toBeInTheDocument()
})
it('PR1-05B: names the import a tour was planned from while it is still in the trip', () => {
seedStore(useTripStore, { places: [buildPlace({ id: 7, name: 'Recorded ridge' }), place] })
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, derived_from_place_id: 7 }} />)
expect(screen.getByText('Based on Recorded ridge')).toBeInTheDocument()
})
it('PR1-05B: says nothing of an origin that is gone from the trip', () => {
seedStore(useTripStore, { places: [place] })
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, derived_from_place_id: 7 }} />)
expect(screen.queryByText(/Based on/)).not.toBeInTheDocument()
})
it('shows the grade on the tile and no second difficulty scale', () => {
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, difficulty: 'easy', max_hiking_difficulty: 1 }} />)
expect(screen.getAllByText('T1').length).toBeGreaterThan(0)
expect(screen.queryByText(/easy/i)).not.toBeInTheDocument()
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,117 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import type { Day } from '../../types';
import { dayMenuLabel, tourDaysOf } from './tourDays';
import TourGpxDropZone from './TourGpxDropZone';
import type { TourGpxImport } from './useTourGpxImport';
function gpxImport(overrides: Partial<TourGpxImport> = {}): TourGpxImport {
return { importing: false, importFiles: vi.fn().mockResolvedValue(undefined), pick: vi.fn(), ...overrides };
}
const file = new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' });
const filesDrag = { dataTransfer: { types: ['Files'], files: [file], dropEffect: 'none' } };
const rowDrag = { dataTransfer: { types: ['text/plain'], files: [], dropEffect: 'none' } };
describe('TourGpxDropZone', () => {
it('RC-04: a file dragged over the workspace says it will be imported, and a drop imports it', () => {
const importer = gpxImport();
render(
<TourGpxDropZone gpxImport={importer} canEdit>
<p>workspace</p>
</TourGpxDropZone>
);
const zone = screen.getByText('workspace').parentElement as HTMLElement;
fireEvent.dragEnter(zone, filesDrag);
expect(screen.getByTestId('tour-gpx-drop')).toHaveTextContent('Drop GPX files to import them');
fireEvent.drop(zone, filesDrag);
expect(importer.importFiles).toHaveBeenCalledWith([file]);
expect(screen.queryByTestId('tour-gpx-drop')).not.toBeInTheDocument();
});
it('RC-04: a waypoint or tour row being dragged passes straight through', () => {
const importer = gpxImport();
render(
<TourGpxDropZone gpxImport={importer} canEdit>
<p>workspace</p>
</TourGpxDropZone>
);
const zone = screen.getByText('workspace').parentElement as HTMLElement;
fireEvent.dragEnter(zone, rowDrag);
fireEvent.drop(zone, rowDrag);
expect(screen.queryByTestId('tour-gpx-drop')).not.toBeInTheDocument();
expect(importer.importFiles).not.toHaveBeenCalled();
});
it('RC-04: leaving every child it entered takes the overlay away again', () => {
render(
<TourGpxDropZone gpxImport={gpxImport()} canEdit>
<p>workspace</p>
</TourGpxDropZone>
);
const zone = screen.getByText('workspace').parentElement as HTMLElement;
fireEvent.dragEnter(zone, filesDrag);
fireEvent.dragEnter(zone, filesDrag);
fireEvent.dragLeave(zone, filesDrag);
expect(screen.getByTestId('tour-gpx-drop')).toBeInTheDocument();
fireEvent.dragLeave(zone, filesDrag);
expect(screen.queryByTestId('tour-gpx-drop')).not.toBeInTheDocument();
});
it('RC-04: without the right to add tours it says so and imports nothing', () => {
const importer = gpxImport();
render(
<TourGpxDropZone gpxImport={importer} canEdit={false}>
<p>workspace</p>
</TourGpxDropZone>
);
const zone = screen.getByText('workspace').parentElement as HTMLElement;
fireEvent.dragEnter(zone, filesDrag);
expect(screen.getByTestId('tour-gpx-drop')).toHaveTextContent('You cannot add tours to this trip.');
fireEvent.drop(zone, filesDrag);
expect(importer.importFiles).not.toHaveBeenCalled();
});
it('RC-04: stays up and says so while an import runs', () => {
render(
<TourGpxDropZone gpxImport={gpxImport({ importing: true })} canEdit>
<p>workspace</p>
</TourGpxDropZone>
);
expect(screen.getByTestId('tour-gpx-drop')).toHaveTextContent('Importing GPX…');
});
});
describe('tourDays', () => {
const days = [
{ id: 1, title: null, date: '2026-07-08' },
{ id: 2, title: 'Lakes', date: null },
{ id: 3, title: null, date: null },
] as Day[];
it('RC-02: lists the days a tour is on in the order of the trip, each once', () => {
const assignments = {
'3': [{ id: 9, place_id: 42 }],
'1': [
{ id: 7, place_id: 42 },
{ id: 8, place_id: 42 },
],
'2': [{ id: 6, place_id: 5 }],
} as never;
expect(tourDaysOf(42, days, assignments).map(({ day, index }) => [day.id, index])).toEqual([
[1, 0],
[3, 2],
]);
expect(tourDaysOf(99, days, assignments)).toEqual([]);
});
it('RC-02: names a day by its number, its date and its title as far as it has them', () => {
const t = (key: string, params?: Record<string, unknown>) => (key === 'dayplan.dayN' ? `Day ${params?.n}` : key);
expect(dayMenuLabel(days[1], 1, t, 'en')).toBe('Day 2: Lakes');
expect(dayMenuLabel(days[2], 2, t, 'en')).toBe('Day 3');
expect(dayMenuLabel(days[0], 0, t, 'en')).toMatch(/^Day 1, .*8/);
});
});
@@ -0,0 +1,99 @@
import { Ban, FileDown, Loader2 } from 'lucide-react';
import { useRef, useState, type DragEvent, type ReactNode } from 'react';
import { useTranslation } from '../../i18n';
import { fs } from '../shared/DialogShell';
import type { TourGpxImport } from './useTourGpxImport';
/** Only a drag of files from the desktop; a waypoint or a tour row being dragged is not one. */
const carriesFiles = (event: DragEvent) => Array.from(event.dataTransfer.types).includes('Files');
/**
* The tours workspace as a drop target for GPX files: a file dragged over it
* dims the workspace and says what will happen, and a drop hands the files to
* the same import the button runs. While an import runs, the overlay stays and
* says so. A drag of anything else (a waypoint in the list, the map) passes
* straight through.
*/
export default function TourGpxDropZone({
gpxImport,
canEdit,
className = 'relative',
children,
}: {
gpxImport: TourGpxImport;
canEdit: boolean;
/** Positions the zone; it must be positioned for the overlay to cover it. */
className?: string;
children: ReactNode;
}) {
const { t } = useTranslation();
const [over, setOver] = useState(false);
// dragenter and dragleave fire for every child the pointer crosses, so the
// overlay goes only when the count of entered elements is back at zero.
const depth = useRef(0);
const reset = () => {
depth.current = 0;
setOver(false);
};
return (
<div
className={className}
onDragEnter={(event) => {
if (!carriesFiles(event)) return;
event.preventDefault();
depth.current += 1;
setOver(true);
}}
onDragOver={(event) => {
if (!carriesFiles(event)) return;
event.preventDefault();
event.dataTransfer.dropEffect = canEdit && !gpxImport.importing ? 'copy' : 'none';
}}
onDragLeave={(event) => {
if (!carriesFiles(event)) return;
depth.current = Math.max(0, depth.current - 1);
if (depth.current === 0) setOver(false);
}}
onDrop={(event) => {
if (!carriesFiles(event)) return;
event.preventDefault();
reset();
if (canEdit) void gpxImport.importFiles(Array.from(event.dataTransfer.files));
}}
>
{children}
{(over || gpxImport.importing) && (
<div
data-testid="tour-gpx-drop"
role="status"
className="pointer-events-none absolute inset-2.5 z-30 flex flex-col items-center justify-center gap-3 rounded-2xl border-2 border-dashed border-accent backdrop-blur-[2px]"
style={{ background: 'color-mix(in srgb, var(--bg-card) 82%, transparent)' }}
>
<span className="grid h-12 w-12 place-items-center rounded-[14px] bg-surface-card text-content shadow-sm">
{gpxImport.importing ? (
<Loader2 size={22} strokeWidth={1.9} className="animate-spin" />
) : canEdit ? (
<FileDown size={22} strokeWidth={1.9} />
) : (
<Ban size={22} strokeWidth={1.9} />
)}
</span>
<span className="font-semibold text-content" style={fs(14, 'body')}>
{gpxImport.importing
? t('tours.import.importing')
: canEdit
? t('tours.import.drop')
: t('tours.import.dropNoPermission')}
</span>
{!gpxImport.importing && canEdit && (
<span className="max-w-[320px] text-center text-content-muted" style={fs(12, 'body')}>
{t('tours.import.dropHint')}
</span>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,179 @@
import { Check, ChevronDown } from 'lucide-react';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from '../../i18n';
import { fs } from '../shared/DialogShell';
import { InfoDot } from './tourParts';
import { ORIGIN_LABEL_KEY, statusLabel } from './tourPresentation';
import type { TourListFilters } from './useTourListFilters';
function MenuRow({
active,
label,
count,
onClick,
}: {
active: boolean;
label: string;
count: number;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={`flex w-full items-center gap-2 rounded-[9px] px-2 py-1.5 text-start transition-colors ${active ? 'bg-surface-tertiary' : 'hover:bg-surface-hover'}`}
style={fs(12.5, 'body')}
>
<span className="min-w-0 flex-1 truncate text-content">{label}</span>
<span className="font-geist tabular-nums text-content-faint" style={fs(11)}>
{count}
</span>
<span className="grid w-3.5 flex-none place-items-center">
{active && <Check size={13} strokeWidth={2.4} className="text-content-muted" />}
</span>
</button>
);
}
function MenuSection({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<div
role="group"
aria-label={label}
className="flex flex-col gap-px border-t border-edge-faint pt-1.5 first:border-t-0 first:pt-0"
>
<div className="flex items-center gap-1 px-2 pb-0.5">
<span className="font-semibold uppercase tracking-[.06em] text-content-faint" style={fs(9.5)}>
{label}
</span>
{hint && <InfoDot label={label} description={hint} />}
</div>
{children}
</div>
);
}
/**
* The filter of a tour list (PR1-13): one button that says what is set, and a
* menu with the day status and, where the list varies in them, the source,
* the stored routing limit and the tour type, each with how many tours it
* keeps. Choosing a set value again clears it.
*/
export default function TourListFilterMenu({ filters }: { filters: TourListFilters }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const { facets, selection, statusCounts } = filters;
useEffect(() => {
if (!open) return;
const onDown = (event: MouseEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
};
const onKey = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
event.stopPropagation();
setOpen(false);
buttonRef.current?.focus();
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey, true);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey, true);
};
}, [open]);
return (
<div ref={rootRef} className="relative min-w-0">
<button
ref={buttonRef}
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-haspopup="true"
aria-label={t('places.filterShow')}
className="flex h-8 w-full min-w-0 items-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 font-semibold text-content-secondary shadow-sm transition-colors hover:text-content"
style={fs(12, 'body')}
>
<span className="min-w-0 truncate text-start">{statusLabel(filters.status, t)}</span>
<span className="font-geist tabular-nums text-content-faint" style={fs(10.5)}>
{filters.filtered.length}
</span>
{filters.activeFacets > 0 && (
<span
className="rounded-full bg-accent px-1.5 font-geist font-semibold leading-[16px] text-accent-text"
style={fs(10)}
>
+{filters.activeFacets}
</span>
)}
<ChevronDown
size={13}
strokeWidth={2.2}
className={`ms-auto flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
{open && (
<div
role="group"
aria-label={t('places.filterShow')}
className="trek-popover-enter absolute start-0 top-full z-50 mt-1.5 flex max-h-[min(420px,60vh)] w-full min-w-[200px] flex-col gap-1.5 overflow-y-auto rounded-[12px] border border-edge-secondary bg-surface-card p-1.5 shadow-popover"
>
<MenuSection label={t('tours.filter.status')}>
{(['all', 'unplanned', 'planned'] as const).map((status) => (
<MenuRow
key={status}
active={filters.status === status}
label={statusLabel(status, t)}
count={statusCounts[status]}
onClick={() => filters.setStatus(status)}
/>
))}
</MenuSection>
{facets.origin.length > 0 && (
<MenuSection label={t('tours.filter.source')}>
{facets.origin.map((option) => (
<MenuRow
key={option.value}
active={selection.origin === option.value}
label={t(ORIGIN_LABEL_KEY[option.value])}
count={option.count}
onClick={() => filters.select('origin', option.value)}
/>
))}
</MenuSection>
)}
{facets.grade.length > 0 && (
<MenuSection label={t('tours.filter.grade')} hint={t('tours.filter.gradeHint')}>
{facets.grade.map((option) => (
<MenuRow
key={option.value}
active={selection.grade === option.value}
label={`T${option.value}`}
count={option.count}
onClick={() => filters.select('grade', option.value)}
/>
))}
</MenuSection>
)}
{facets.type.length > 0 && (
<MenuSection label={t('tours.filter.type')}>
{facets.type.map((option) => (
<MenuRow
key={option.value}
active={selection.type === option.value}
label={t(`tourTypes.${option.value}`)}
count={option.count}
onClick={() => filters.select('type', option.value)}
/>
))}
</MenuSection>
)}
</div>
)}
</div>
);
}
+118 -30
View File
@@ -1,25 +1,126 @@
import '../../styles/fadeEnd.css'
import { useId, type ReactNode } from 'react'
import { Eye, EyeOff } from 'lucide-react'
import type { TourListItem } from '@trek/shared'
import { Link2 } from 'lucide-react'
import { fs } from '../shared/DialogShell'
import { SoftPill } from '../Planner/planParts'
import { TourFactPills, TourGradeTile } from './tourParts'
import { tourWebsitePresentation } from './tourPresentation'
import { TourCautionBadge, TourDaysBadge, TourGradeTile } from './tourParts'
import type { Day } from '../../types'
import { endPlaceDrag, startPlaceDrag } from '../Map/markerDrag'
import { TourDayRowPills } from './TourDayRowFacts'
import { useTourDays } from './tourDays'
import { Tooltip } from '../shared/Tooltip'
const NO_DAYS: Day[] = []
interface TourListRowProps {
tour: TourListItem
/** The trip's days, to name the ones the tour is on; without them the row names none. */
days?: Day[]
selected?: boolean
disabled?: boolean
/** The row can be picked up and dropped on a day of the plan, like a place. */
draggableToDay?: boolean
onSelect: (tour: TourListItem, opener: HTMLElement) => void
action?: ReactNode
children?: ReactNode
/** The colour the tour's line has on the map, shown beside it so the two read as one. */
trackColor?: string
/** PR1-07: the person's switch for this tour's line on the trip map. */
mapToggle?: { hidden: boolean; onToggle: () => void; showLabel: string; hideLabel: string }
}
/** A tour in a list, shaped like a place row: tile, name, its facts as pills and the day action on the right. */
export default function TourListRow({ tour, selected = false, disabled = false, onSelect, action, children }: TourListRowProps) {
/** The eye beside a row: shown on hover like the other actions, and always while the tour is hidden. */
function MapToggleButton({ toggle }: { toggle: NonNullable<TourListRowProps['mapToggle']> }) {
const label = toggle.hidden ? toggle.showLabel : toggle.hideLabel
return (
<Tooltip label={label}>
<button
type="button"
aria-label={label}
aria-pressed={toggle.hidden}
onClick={event => { event.stopPropagation(); toggle.onToggle() }}
className={`grid h-[26px] w-[26px] flex-none place-items-center rounded-full text-content-muted shadow-sm ring-1 ring-edge-faint transition-colors hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${toggle.hidden ? 'bg-surface-tertiary' : 'bg-surface-card'}`}
>
{toggle.hidden ? <EyeOff size={13} strokeWidth={2.2} /> : <Eye size={13} strokeWidth={2.2} />}
</button>
</Tooltip>
)
}
/** The tile, the name and one line of badges with the description trailing after them. */
function TourRowBody({ tour, nameId, tourDays, dimmed }: {
tour: TourListItem
nameId: string
tourDays: ReturnType<typeof useTourDays>
dimmed: boolean
}) {
return (
<div className={`flex min-w-0 flex-1 items-center gap-2.5 transition-opacity ${dimmed ? 'opacity-55' : ''}`}>
<TourGradeTile tour={tour} />
<div className="min-w-0 flex-1">
<span id={nameId} className="block min-w-0 truncate font-semibold leading-tight text-content"
style={fs(13, 'body')}>{tour.name}</span>
{/* One line like a plan row's: the badges keep their place, the description gives way,
and what does not fit fades out instead of ending at a hard edge. */}
<div
data-testid="tour-metrics"
className="trek-fade-end mt-0.5 flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden"
>
<TourCautionBadge tour={tour} />
<TourDayRowPills tour={tour} />
<TourDaysBadge tourDays={tourDays} />
{tour.description && (
<span
data-testid="tour-description-preview"
className="min-w-0 flex-1 truncate text-content-faint"
style={fs(10.5)}
>
{tour.description}
</span>
)}
</div>
</div>
</div>
)
}
/**
* The eye and the row's actions on the right. A hidden tour keeps its eye in
* sight; the rest come out on hover, focus or selection, as a plan row's
* controls do, so the name and badges have the room otherwise.
*/
function TourRowActions({ action, mapToggle, selected }: Pick<TourListRowProps, 'action' | 'mapToggle'> & { selected: boolean }) {
const hoverToggle = mapToggle && !mapToggle.hidden ? mapToggle : null
return (
<>
{mapToggle?.hidden && <MapToggleButton toggle={mapToggle} />}
{(action || hoverToggle) && (
<div data-testid="tour-action-row" role="presentation"
onClick={event => event.stopPropagation()}
className={`flex-none items-center gap-1.5 group-focus-within:flex group-hover:flex ${selected ? 'flex' : 'hidden'}`}>
{hoverToggle && <MapToggleButton toggle={hoverToggle} />}
{action}
</div>
)}
</>
)
}
/**
* A tour in a list, laid out like a stop in the day plan: its tile with the
* grade, the name, and one line of the plan's white badges (time, distance,
* ascent, the days it is on, a caution) with the description trailing after
* them. The day and delete actions sit on the right.
*/
export default function TourListRow({ tour,
days = NO_DAYS,
selected = false, disabled = false,
draggableToDay = false, onSelect, action, children, trackColor, mapToggle }: TourListRowProps) {
const nameId = useId()
const tourDays = useTourDays(tour.place_id, days)
const draggable = draggableToDay && !disabled
const tone = selected ? 'bg-surface-selected' : disabled ? '' : 'hover:bg-surface-hover'
const website = tourWebsitePresentation(tour.website)
const cursor = disabled ? 'cursor-default opacity-60' : draggable ? 'cursor-grab' : 'cursor-pointer'
return (
<li
role="option"
@@ -27,7 +128,11 @@ export default function TourListRow({ tour, selected = false, disabled = false,
aria-selected={selected}
aria-disabled={disabled || undefined}
aria-labelledby={nameId}
className={`group mb-px rounded-[12px] px-2.5 py-2 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)] ${tone} ${disabled ? 'cursor-default opacity-60' : 'cursor-pointer'}`}
data-place-id={tour.place_id}
draggable={draggable || undefined}
onDragStart={draggable ? (event) => startPlaceDrag(event.dataTransfer, tour.place_id) : undefined}
onDragEnd={draggable ? endPlaceDrag : undefined}
className={`group mx-1.5 rounded-[10px] px-1.5 py-2 outline-none transition-colors focus-visible:bg-surface-hover ${tone} ${cursor}`}
onClick={event => { if (!disabled) onSelect(tour, event.currentTarget) }}
onKeyDown={event => {
if (disabled || event.target !== event.currentTarget) return
@@ -37,31 +142,14 @@ export default function TourListRow({ tour, selected = false, disabled = false,
}
}}
>
{/* The tile, the name and the day actions share the head line; the rest runs the
full width under them, below the tile too. */}
<div className="flex items-center gap-2.5">
<TourGradeTile tour={tour} />
<span id={nameId} className="block min-w-0 flex-1 truncate font-semibold leading-tight text-content" style={fs(13, 'body')}>{tour.name}</span>
{action && (
<div data-testid="tour-action-row" role="presentation" className="flex flex-none items-center gap-1.5" onClick={event => event.stopPropagation()}>
{action}
</div>
{trackColor && (
// The line's colour on the map, as a plan row shows its category.
<span aria-hidden="true" className="-me-1 h-8 w-[3px] flex-none rounded-full" style={{ background: trackColor }} />
)}
<TourRowBody tour={tour} nameId={nameId} tourDays={tourDays} dimmed={!!mapToggle?.hidden} />
<TourRowActions action={action} mapToggle={mapToggle} selected={selected} />
</div>
{tour.description && (
<div data-testid="tour-description-preview" className="mt-2.5 rounded-[10px] bg-surface-card px-2 py-1.5 text-content-secondary shadow-sm ring-1 ring-inset ring-edge-faint" style={fs(11)}>
<span className="overflow-hidden text-ellipsis" style={{ lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
{tour.description}
</span>
</div>
)}
<TourFactPills tour={tour} className={tour.description ? 'mt-1.5' : 'mt-2.5'}>
{website && (
<SoftPill icon={<Link2 size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
<span className="min-w-0 truncate">{website.provider ?? website.domain}</span>
</SoftPill>
)}
</TourFactPills>
{children}
</li>
)
@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import TourWorkspaceRail from './TourWorkspaceRail';
import type { GpxConversion } from './conversion/useGpxConversion';
import type { TourPlannerController } from './planner/useTourPlanner';
import type { RecordedTrack } from './useRecordedTrack';
import type { WandererBrowser } from './wanderer/useWandererBrowser';
const seen = vi.hoisted(() => ({ rail: [] as Array<Record<string, unknown>> }));
vi.mock('./planner/TourPlannerPanels', () => ({
TourPlannerRail: (props: Record<string, unknown>) => {
seen.rail.push(props);
return <p>tour editor</p>;
},
}));
vi.mock('./wanderer/WandererBrowserRail', () => ({ default: () => <p>wanderer trails</p> }));
const planner = {} as TourPlannerController;
const conversion = {} as GpxConversion;
const recordedTrack = {} as RecordedTrack;
function wanderer(overrides: Partial<WandererBrowser>): WandererBrowser {
return { open: false, enabled: true, openBrowser: vi.fn(), ...overrides } as unknown as WandererBrowser;
}
describe('TourWorkspaceRail', () => {
it('RS-04: shows the wanderer trails in place of the editor while they are browsed', () => {
render(
<TourWorkspaceRail
planner={planner}
wanderer={wanderer({ open: true })}
conversion={conversion}
recordedTrack={recordedTrack}
canEdit
canAssign
/>
);
expect(screen.getByText('wanderer trails')).toBeInTheDocument();
expect(screen.queryByText('tour editor')).not.toBeInTheDocument();
});
it('RS-04: offers the wanderer trails from the editor only while wanderer may be used', () => {
const browser = wanderer({});
seen.rail.length = 0;
const view = render(
<TourWorkspaceRail
planner={planner}
wanderer={browser}
conversion={conversion}
recordedTrack={recordedTrack}
canEdit
canAssign={false}
/>
);
expect(screen.getByText('tour editor')).toBeInTheDocument();
expect(seen.rail[seen.rail.length - 1]).toMatchObject({
planner,
conversion,
recordedTrack,
canEdit: true,
canAssign: false,
onOpenWanderer: browser.openBrowser,
});
view.rerender(
<TourWorkspaceRail
planner={planner}
wanderer={wanderer({ enabled: false })}
conversion={conversion}
recordedTrack={recordedTrack}
canEdit
canAssign
/>
);
expect(seen.rail[seen.rail.length - 1]?.onOpenWanderer).toBeUndefined();
});
});
@@ -0,0 +1,36 @@
import type { GpxConversion } from './conversion/useGpxConversion';
import { TourPlannerRail } from './planner/TourPlannerPanels';
import type { TourPlannerController } from './planner/useTourPlanner';
import type { RecordedTrack } from './useRecordedTrack';
import WandererBrowserRail from './wanderer/WandererBrowserRail';
import type { WandererBrowser } from './wanderer/useWandererBrowser';
/** The tours workspace's left rail: the tour editor, or the wanderer trails while those are browsed. */
export default function TourWorkspaceRail({
planner,
wanderer,
conversion,
recordedTrack,
canEdit,
canAssign,
}: {
planner: TourPlannerController;
wanderer: WandererBrowser;
conversion: GpxConversion;
recordedTrack: RecordedTrack;
canEdit: boolean;
canAssign: boolean;
}) {
return wanderer.open ? (
<WandererBrowserRail browser={wanderer} />
) : (
<TourPlannerRail
planner={planner}
canEdit={canEdit}
canAssign={canAssign}
conversion={conversion}
recordedTrack={recordedTrack}
onOpenWanderer={wanderer.enabled ? wanderer.openBrowser : undefined}
/>
);
}
+118 -19
View File
@@ -3,9 +3,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TourListItem } from '@trek/shared'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useSettingsStore } from '../../store/settingsStore'
import { useAuthStore } from '../../store/authStore'
import { useTourMapVisibilityStore } from '../../store/tourMapVisibilityStore'
import { useTripStore } from '../../store/tripStore'
import { toursApi } from '../../api/client'
import ToursSidebar from './ToursSidebar'
import { pickFiles } from '../../../tests/helpers/filePicker'
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }))
@@ -32,6 +35,8 @@ const tour: TourListItem = {
}
beforeEach(() => {
vi.restoreAllMocks()
Object.values(toast).forEach((notify) => notify.mockClear())
resetAllStores()
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } })
})
@@ -56,15 +61,17 @@ describe('ToursSidebar', () => {
expect(row).toHaveClass('bg-surface-selected')
})
it('RS-01: previews the Tour description and classified information-link host', () => {
it('RS-01: trails the description after the badges on one line, as a plan row does', () => {
const listed = { ...tour, description: 'A quiet path above the lake', website: 'https://www.komoot.com/tour/42', planned_duration_minutes: 95 }
render(<ToursSidebar tripId={1} days={[]} tours={[listed]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
const row = screen.getByRole('option', { name: /Selected ridge walk/i })
expect(row).toHaveTextContent('A quiet path above the lake')
expect(row).toHaveTextContent('Komoot')
expect(row).not.toHaveTextContent('komoot.com')
expect(row).toHaveTextContent('1 h 35 min')
expect(row.querySelector('[data-testid="tour-description-preview"] > span')).toHaveStyle({ WebkitLineClamp: '2' })
const line = within(row).getByTestId('tour-metrics')
expect(line).toHaveClass('flex-nowrap', 'overflow-hidden')
expect(line).toHaveTextContent('1 h 35 min')
expect(within(line).getByTestId('tour-description-preview')).toHaveTextContent('A quiet path above the lake')
expect(within(line).getByTestId('tour-description-preview')).toHaveClass('truncate')
// The information link belongs to the detail, not the list row.
expect(row).not.toHaveTextContent('Komoot')
})
it('selects the tour from keyboard activation', () => {
@@ -98,15 +105,13 @@ describe('ToursSidebar', () => {
const metrics = row.querySelector('[data-testid="tour-metrics"]')!
expect(metrics).toHaveTextContent('1.9 km')
expect(metrics).toHaveTextContent('13 m')
expect(metrics).toHaveTextContent('11 m')
const labels = [...metrics.querySelectorAll('[aria-label]')].map(element => element.getAttribute('aria-label'))
expect(labels[0]).toContain('1.9 km')
expect(labels[1]).toContain('13 m')
expect(labels[2]).toContain('11 m')
expect(within(row).queryByText('TREK')).not.toBeInTheDocument()
expect(screen.getByLabelText(/Maximum trail difficulty|Maximale Wanderschwierigkeit|T2/)).toBeInTheDocument()
const actionRow = row.querySelector('[data-testid="tour-action-row"]')!
expect(actionRow.querySelector('button')).toHaveAttribute('aria-haspopup', 'menu')
expect(within(actionRow as HTMLElement).getByRole('button', { name: /Add to day|Zum Tag hinzufügen/ })).toHaveAttribute('aria-haspopup', 'menu')
expect(container.querySelector('[data-testid="tour-metrics"]')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /Add to day|Zum Tag hinzufügen/ }))
@@ -205,7 +210,9 @@ describe('ToursSidebar', () => {
})
const onToursChanged = vi.fn()
const loadTrip = vi.spyOn(useTripStore.getState(), 'loadTrip').mockResolvedValue(undefined)
const { container } = render(<ToursSidebar
const refreshPlaces = vi.fn().mockResolvedValue(undefined)
seedStore(useTripStore, { refreshPlaces })
render(<ToursSidebar
tripId={1}
days={[]}
tours={[]}
@@ -215,13 +222,14 @@ describe('ToursSidebar', () => {
onSelectTour={vi.fn()}
/>)
const input = container.querySelector('input[type="file"]')!
const file = new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })
fireEvent.change(input, { target: { files: [file] } })
expect(pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [file])).toHaveAttribute('accept', '.gpx')
await waitFor(() => expect(loadTrip).toHaveBeenCalledWith(1))
// Only the places are read again, not the whole trip: that would rebuild the workspace.
await waitFor(() => expect(refreshPlaces).toHaveBeenCalledWith(1))
expect(loadTrip).not.toHaveBeenCalled()
expect(onToursChanged).toHaveBeenCalledWith([42])
expect(onToursChanged.mock.invocationCallOrder[0]).toBeLessThan(loadTrip.mock.invocationCallOrder[0])
expect(onToursChanged.mock.invocationCallOrder[0]).toBeLessThan(refreshPlaces.mock.invocationCallOrder[0])
expect(importCall).toHaveBeenCalledOnce()
vi.restoreAllMocks()
})
@@ -230,7 +238,7 @@ describe('ToursSidebar', () => {
vi.spyOn(toursApi, 'importGpx').mockResolvedValue({ tours: [], caution: false, skipped: 3 })
const onToursChanged = vi.fn()
const loadTrip = vi.spyOn(useTripStore.getState(), 'loadTrip').mockResolvedValue(undefined)
const { container } = render(<ToursSidebar
render(<ToursSidebar
tripId={1}
days={[]}
tours={[]}
@@ -240,13 +248,104 @@ describe('ToursSidebar', () => {
onSelectTour={vi.fn()}
/>)
fireEvent.change(container.querySelector('input[type=file]')!, {
target: { files: [new File(['<gpx/>'], 'already-imported.gpx', { type: 'application/gpx+xml' })] },
})
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [new File(['<gpx/>'], 'already-imported.gpx', { type: 'application/gpx+xml' }),
])
await waitFor(() => expect(toast.warning).toHaveBeenCalledWith('All places were already in the trip.'))
await waitFor(() => expect(toast.warning).toHaveBeenCalledWith('Everything in that file is already in this trip.'))
expect(onToursChanged).not.toHaveBeenCalled()
expect(loadTrip).not.toHaveBeenCalled()
vi.restoreAllMocks()
})
it('RC-04: names each file that fails and still imports the rest', async () => {
vi.spyOn(toursApi, 'importGpx')
.mockRejectedValueOnce({ response: { data: { error: 'No track or route found in GPX file' } } })
.mockResolvedValueOnce({ tours: [tour], caution: true, skipped: 0 })
seedStore(useTripStore, { refreshPlaces: vi.fn().mockResolvedValue(undefined) })
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [
new File(['<gpx/>'], 'empty.gpx', { type: 'application/gpx+xml' }),
new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' }),
])
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 tour imported from GPX'))
expect(toast.error).toHaveBeenCalledWith('No track or route found in empty.gpx')
expect(toast.info).toHaveBeenCalledWith('Imported with caution — elevation/metrics incomplete')
vi.restoreAllMocks()
})
it('RC-04: refuses files that are not GPX before uploading anything', async () => {
const importCall = vi.spyOn(toursApi, 'importGpx')
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [
new File(['x'], 'notes.txt', { type: 'text/plain' }),
])
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Only GPX files can be imported as tours.'))
expect(importCall).not.toHaveBeenCalled()
vi.restoreAllMocks()
})
it('RC-02: names the days a tour is on in one badge, in the order of the trip', () => {
const days = [
{ id: 7, trip_id: 1, day_number: 1, title: null, date: null },
{ id: 8, trip_id: 1, day_number: 2, title: null, date: null },
{ id: 9, trip_id: 1, day_number: 3, title: 'Summit', date: null },
] as never[]
seedStore(useTripStore, {
assignments: { '9': [{ id: 2, place_id: 42 }], '7': [{ id: 1, place_id: 42 }] } as never,
})
render(<ToursSidebar tripId={1} days={days} tours={[tour]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
const badge = within(screen.getByRole('option', { name: /Selected ridge walk/ })).getByTestId('tour-days-badge')
expect(badge).toHaveTextContent('Days 1, 3')
expect(badge).toHaveAttribute('aria-label', 'Day 1, Day 3: Summit')
})
it('PR1-13: offers the source and routing limit only where the list varies, and narrows by them', () => {
render(<ToursSidebar tripId={1} days={[]} tours={[
{ ...tour, place_id: 1, name: 'Drawn', origin: 'trek', max_hiking_difficulty: 2 },
{ ...tour, place_id: 2, name: 'Imported', origin: 'gpx', max_hiking_difficulty: null },
{ ...tour, place_id: 3, name: 'Snapshot', origin: 'wanderer', max_hiking_difficulty: 3 },
]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Show' }))
const limit = () => within(screen.getByRole('group', { name: 'Routing limit' })).getByRole('button', { name: /T3/ })
fireEvent.click(limit())
expect(screen.getAllByRole('option').map(row => row.textContent)).toEqual([expect.stringContaining('Snapshot')])
// Under that limit only the wanderer snapshot is left, so the source offers nothing else.
expect(within(screen.getByRole('group', { name: 'Source' })).queryByRole('button', { name: /GPX import/ })).toBeNull()
fireEvent.click(limit())
fireEvent.click(within(screen.getByRole('group', { name: 'Source' })).getByRole('button', { name: /GPX import\s*1/ }))
expect(screen.getAllByRole('option').map(row => row.textContent)).toEqual([expect.stringContaining('Imported')])
expect(screen.getByRole('button', { name: 'Show' })).toHaveTextContent('+1')
// Only one tour type in the list: no type filter.
expect(screen.queryByRole('group', { name: 'Type' })).toBeNull()
// The import has no routing limit, so none is offered for it; the day status still narrows it away.
expect(screen.queryByRole('group', { name: 'Routing limit' })).toBeNull()
fireEvent.click(within(screen.getByRole('group', { name: 'Status' })).getByRole('button', { name: /^Planned/ }))
expect(screen.getByRole('status')).toHaveTextContent('No tour matches these filters.')
fireEvent.click(screen.getByRole('button', { name: 'Reset filters' }))
expect(screen.getAllByRole('option')).toHaveLength(3)
})
it('PR1-07/08: a row shows its line colour, and its eye hides the tour from the map until shown again', () => {
seedStore(useAuthStore, { user: { id: 3 } as never })
seedStore(useTripStore, { places: [{ id: 42, route_color: '#e11d48' }] as never })
render(<ToursSidebar tripId={1} days={[]} tours={[tour]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
const row = screen.getByRole('option', { name: /Selected ridge walk/ })
expect(row.querySelector('[aria-hidden="true"][style]')).toHaveStyle({ background: '#e11d48' })
fireEvent.click(within(row).getByRole('button', { name: 'Hide on the map' }))
expect(useTourMapVisibilityStore.getState().hidden).toEqual([42])
expect(screen.getByRole('status')).toHaveTextContent('Hidden on the map')
// A hidden tour keeps its eye in sight, not only on hover.
expect(within(row).getByRole('button', { name: 'Show on the map' })).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(screen.getByRole('button', { name: 'Show all' }))
expect(useTourMapVisibilityStore.getState().hidden).toEqual([])
})
})
+65 -116
View File
@@ -1,20 +1,21 @@
import React, { useEffect, useRef, useState } from 'react'
import { Check, ChevronDown, FileDown } from 'lucide-react'
import React, { useEffect, useMemo, useRef } from 'react'
import { EyeOff, FileDown } from 'lucide-react'
import type { TourListItem } from '@trek/shared'
import type { Day } from '../../types'
import { tourRepo } from '../../repo/tourRepo'
import { useTripStore } from '../../store/tripStore'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import TourListRow from './TourListRow'
import { useTourPermissions, type TourPermissionProps } from './useTourPermissions'
import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { Tooltip } from '../shared/Tooltip'
import EmptyState from '../shared/EmptyState'
import { TourDayMenu } from './tourParts'
import { filterTours, type TourFilter } from './tourPresentation'
const MAX_FILE_BYTES = 10 * 1024 * 1024
import TourListFilterMenu from './TourListFilterMenu'
import { useTourListFilters } from './useTourListFilters'
import { useTourGpxImport } from './useTourGpxImport'
import { type AssignTourToDay, useTourDayAssignment } from './useTourDayAssignment'
import { useTourMapVisibility } from './useTourMapVisibility'
import { useTripStore } from '../../store/tripStore'
import { resolveTrackColor } from '../Map/trackColors'
interface ToursSidebarProps extends TourPermissionProps {
tripId: number
@@ -22,7 +23,7 @@ interface ToursSidebarProps extends TourPermissionProps {
tours: TourListItem[]
loading?: boolean
selectedPlaceId?: number | null
onAssignToDay: (placeId: number, dayId: number) => void | boolean | Promise<void | boolean>
onAssignToDay: AssignTourToDay
pushUndo?: (label: string, undo: () => void | Promise<void>) => void
/** Imported place ids are excluded immediately; no ids means refresh derived Tour metadata. */
onToursChanged?: (placeIds?: number[]) => void | Promise<void>
@@ -43,82 +44,25 @@ interface ToursSidebarProps extends TourPermissionProps {
export default function ToursSidebar({ tripId, days, tours, loading = false, selectedPlaceId = null, onAssignToDay, onToursChanged, onSelectTour, canEdit: editPermission, canAssign: assignPermission }: ToursSidebarProps): React.ReactElement {
const { t } = useTranslation()
const { canEdit, canAssign } = useTourPermissions({ tripId, canEdit: editPermission, canAssign: assignPermission })
const toast = useToast()
const loadTrip = useTripStore(s => s.loadTrip)
const [filter, setFilter] = useState<TourFilter>('all')
const [importing, setImporting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const filterRef = useRef<HTMLDivElement>(null)
const filterButtonRef = useRef<HTMLButtonElement>(null)
const [filterOpen, setFilterOpen] = useState(false)
const handleImportClick = () => { if (canEdit) fileInputRef.current?.click() }
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ''
if (!canEdit || !file) return
const ext = file.name.toLowerCase().split('.').pop()
if (ext !== 'gpx') { toast.error(t('places.importFileUnsupported')); return }
if (file.size > MAX_FILE_BYTES) { toast.error(t('places.importFileTooLarge', { maxMb: 10 })); return }
setImporting(true)
try {
const result = await tourRepo.importGpx(tripId, file)
if (result.tours.length > 0) {
// The server publishes place:created with sender echo suppression.
// Reload the importing client's trip so new places and route geometry
// enter the shared store used by the day map.
await onToursChanged?.(result.tours.map(tour => tour.place_id))
await loadTrip(tripId)
toast.success(t('tours.import.success', { count: result.tours.length }))
if (result.caution) toast.info(t('tours.import.caution'))
} else {
toast.warning(t('places.importAllSkipped'))
}
} catch (err: any) {
const message = err?.response?.data?.error
toast.error(message === 'No track or route found in GPX file' ? t('tours.import.noTrack') : t('tours.import.error'))
} finally {
setImporting(false)
}
}
const handleAssign = async (tour: TourListItem, day: Day, index: number) => {
if (!canAssign) return
const assigned = await onAssignToDay(tour.place_id, day.id)
if (assigned === false) return
toast.success(t('tours.addedToDay', { n: index + 1 }))
}
const counts = {
all: tours.length,
unplanned: tours.filter(tr => !tr.planned).length,
planned: tours.filter(tr => tr.planned).length,
}
const filtered = filterTours(tours, filter)
const gpxImport = useTourGpxImport({ tripId, canEdit, onToursChanged })
const filters = useTourListFilters(tours)
const dayAssignment = useTourDayAssignment({ canAssign, onAssignToDay })
const tourIds = useMemo(() => new Set(tours.map(tour => tour.place_id)), [tours])
const visibility = useTourMapVisibility(tripId, tourIds)
// The colour each line has on the map, so a row and its line read as one (PR1-08).
const places = useTripStore(state => state.places)
const trackColors = useMemo(
() => new Map(places.filter(place => tourIds.has(place.id)).map(place => [place.id, resolveTrackColor(place)])),
[places, tourIds],
)
const listRef = useRef<HTMLUListElement>(null)
// A tour picked on the map shows in the list too, scrolled into view.
useEffect(() => {
if (!filterOpen) return
const onDown = (event: MouseEvent) => {
if (!filterRef.current?.contains(event.target as Node)) setFilterOpen(false)
}
const onKey = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return
event.stopPropagation()
setFilterOpen(false)
filterButtonRef.current?.focus()
}
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey, true)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey, true)
}
}, [filterOpen])
const filterLabel = filter === 'all' ? t('places.all') : filter === 'unplanned' ? t('places.unplanned') : t('places.planned')
const filterCount = counts[filter]
if (selectedPlaceId == null) return
const row = listRef.current?.querySelector<HTMLElement>(`[data-place-id="${selectedPlaceId}"]`)
row?.scrollIntoView?.({ block: 'nearest' })
}, [selectedPlaceId])
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: 'var(--font-system)' }}>
@@ -126,56 +70,61 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
{/* GPX import stays — the tour-appropriate import path. List Import
(place/route-of-driving oriented) is deliberately absent here. */}
<div>
<Tooltip label={t('places.importFile')}>
<button type="button" onClick={handleImportClick} disabled={!canEdit || importing}
<Tooltip label={t('tours.import.hint')}>
<button type="button" onClick={gpxImport.pick} disabled={!canEdit || gpxImport.importing}
className="flex h-8 w-full min-w-0 items-center justify-center gap-1.5 overflow-hidden rounded-[10px] bg-accent px-3 font-semibold text-accent-text shadow-sm transition-opacity hover:opacity-90 disabled:cursor-default disabled:opacity-40"
style={fs(12.5, 'body')}>
<FileDown size={14} strokeWidth={2.2} className="flex-none" />
<span className="truncate">{t('places.importFile')}</span>
<span className="truncate">{t('tours.import.button')}</span>
</button>
</Tooltip>
<input ref={fileInputRef} type="file" accept=".gpx" onChange={handleFileChange} style={{ display: 'none' }} />
</div>
<div ref={filterRef} className="relative min-w-0">
<button ref={filterButtonRef} type="button" onClick={() => setFilterOpen(value => !value)}
aria-expanded={filterOpen} aria-haspopup="listbox" aria-label={t('places.filterShow')}
className="flex h-8 w-full min-w-0 items-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 font-semibold text-content-secondary shadow-sm transition-colors hover:text-content"
style={fs(12, 'body')}>
<span className="min-w-0 truncate text-start">{filterLabel}</span>
<span className="font-geist tabular-nums text-content-faint" style={fs(10.5)}>{filterCount}</span>
<ChevronDown size={13} strokeWidth={2.2} className={`ms-auto flex-none text-content-faint transition-transform ${filterOpen ? 'rotate-180' : ''}`} />
</button>
{filterOpen && (
<div role="group" aria-label={t('places.filterShow')} className="trek-popover-enter absolute start-0 top-full z-50 mt-1.5 flex w-full min-w-[180px] flex-col gap-px rounded-[12px] border border-edge-secondary bg-surface-card p-1.5 shadow-popover">
{(['all', 'unplanned', 'planned'] as const).map(id => {
const label = id === 'all' ? t('places.all') : id === 'unplanned' ? t('places.unplanned') : t('places.planned')
const active = filter === id
return <button key={id} type="button" onClick={() => { setFilter(id); setFilterOpen(false) }} aria-pressed={active}
className={`flex w-full items-center gap-2 rounded-[9px] px-2 py-1.5 text-start transition-colors ${active ? 'bg-surface-tertiary' : 'hover:bg-surface-hover'}`}
style={fs(12.5, 'body')}>
<span className="min-w-0 flex-1 truncate text-content">{label}</span>
<span className="font-geist tabular-nums text-content-faint" style={fs(11)}>{counts[id]}</span>
<span className="grid w-3.5 flex-none place-items-center">{active && <Check size={13} strokeWidth={2.4} className="text-content-muted" />}</span>
</button>
})}
</div>
)}
</div>
<TourListFilterMenu filters={filters} />
{visibility.hiddenCount > 0 && (
<div role="status" className="flex min-w-0 items-center gap-2 rounded-[10px] bg-surface-card px-2.5 py-1.5 shadow-sm ring-1 ring-inset ring-edge-faint">
<EyeOff size={13} strokeWidth={2.2} className="flex-none text-content-faint" />
<span className="min-w-0 flex-1 truncate text-content-secondary" style={fs(12, 'body')}>{t('tours.visibility.hidden')}</span>
<span className="font-geist tabular-nums text-content-faint" style={fs(10.5)}>{visibility.hiddenCount}</span>
<button type="button" onClick={visibility.showAll}
className="flex-none rounded-full px-2 py-0.5 font-semibold text-content-secondary transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
style={fs(11.5, 'body')}>
{t('tours.visibility.showAll')}
</button>
</div>
)}
</div>
<div className="trek-stagger min-h-0 flex-1 overflow-y-auto px-2 pb-2 pt-1.5">
{!loading && filtered.length === 0 ? (
{!loading && filters.emptyByFilter ? (
<div role="status" className="mx-1.5 mt-2 flex flex-col items-center gap-2 rounded-[12px] bg-surface-card px-4 py-5 text-center shadow-sm ring-1 ring-inset ring-edge-faint">
<p className="m-0 text-content-muted" style={fs(12.5, 'body')}>{t('tours.filter.empty')}</p>
<button type="button" onClick={filters.reset}
className="rounded-full bg-surface-secondary px-3 py-1 font-semibold text-content-secondary transition-colors hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
style={fs(11.5, 'body')}>
{t('tours.filter.reset')}
</button>
</div>
) : !loading && filters.filtered.length === 0 ? (
<EmptyState scene="tours" size={92} fill surface="var(--bg-secondary)" title={t('tours.empty.title')}
action={<p className="m-0 max-w-[260px] text-content-muted" style={fs(12, 'body')}>{t('tours.empty.body')}</p>} />
) : (
<ul role="listbox" aria-label={t('tours.mode.tours')} className="m-0 list-none p-0">
{filtered.map(tour => (
<ul ref={listRef} role="listbox" aria-label={t('tours.mode.tours')} className="m-0 list-none p-0">
{filters.filtered.map(tour => (
<TourListRow
key={tour.place_id}
tour={tour}
days={days}
draggableToDay={canAssign && days.length > 0}
selected={tour.place_id === selectedPlaceId}
trackColor={trackColors.get(tour.place_id)}
mapToggle={{
hidden: visibility.isHidden(tour.place_id),
onToggle: () => visibility.toggle(tour.place_id),
showLabel: t('tours.visibility.show'),
hideLabel: t('tours.visibility.hide'),
}}
onSelect={(selectedTour, opener) => onSelectTour?.(selectedTour, opener)}
action={<TourDayMenu days={days} placeId={tour.place_id} label={t('tours.addToDay')} disabled={!canAssign} onPick={(day, index) => { void handleAssign(tour, day, index) }} />}
action={<TourDayMenu days={days} placeId={tour.place_id} label={t('tours.addToDay')} assignment={dayAssignment} />}
/>
))}
</ul>
@@ -0,0 +1,274 @@
import { act, renderHook } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { TourListItem } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { useSettingsStore } from '../../../store/settingsStore';
import type { TourPlannerController } from '../planner/useTourPlanner';
import TourConversionSection from './TourConversionSection';
import { useGpxConversion } from './useGpxConversion';
const routing = vi.hoisted(() => ({ route: vi.fn() }));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('../planner/tourRouting', () => ({ routeWalkingTour: routing.route }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
const imported: TourListItem = {
place_id: 14,
name: 'Recorded ridge',
description: 'Above the lake',
website: null,
tour_type: 'hike',
distance: 2,
elevation_gain: 100,
elevation_loss: 100,
duration: null,
difficulty: 'moderate',
wanderer_ref: 'https://wanderer.test#abc',
match_confidence: 1,
max_hiking_difficulty: 2,
planned: false,
caution: false,
has_waypoints: false,
};
/** A straight track north, about two kilometres. */
const track: [number, number][] = Array.from({ length: 181 }, (_, i) => [47 + (i * 2) / 180 / 111.2, 11]);
function planner(overrides: Partial<TourPlannerController> = {}): TourPlannerController {
return {
canEdit: true,
mode: { type: 'view-gpx', placeId: imported.place_id, tour: imported },
readOnlyGpxTour: { tour: imported, routeGeometry: null },
readOnlyGpxAnalysis: { routeCoordinates: track },
startDerivedTour: vi.fn().mockReturnValue(true),
...overrides,
} as unknown as TourPlannerController;
}
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } });
routing.route.mockResolvedValue({ coordinates: track, distanceMeters: 2000, durationSeconds: 1800 });
});
describe('useGpxConversion', () => {
it('PR1-05A: analyses only when asked, then compares the routed suggestion to the track', async () => {
const { result } = renderHook(() => useGpxConversion(planner()));
expect(result.current.status).toBe('idle');
expect(result.current.available).toBe(true);
expect(routing.route).not.toHaveBeenCalled();
await act(async () => {
await result.current.analyze();
});
expect(routing.route).toHaveBeenCalledWith(result.current.points, expect.any(AbortSignal), 2);
expect(result.current.status).toBe('ready');
expect(result.current.comparison?.withinCorridor).toBe(1);
expect(result.current.line).toEqual(track);
});
it('PR1-05A: routes an import without a grade with T3 paths allowed', async () => {
const ungraded = { ...imported, max_hiking_difficulty: null };
const { result } = renderHook(() =>
useGpxConversion(
planner({
mode: { type: 'view-gpx', placeId: ungraded.place_id, tour: ungraded },
readOnlyGpxTour: { tour: ungraded, routeGeometry: null },
} as Partial<TourPlannerController>)
)
);
await act(async () => {
await result.current.analyze();
});
expect(routing.route).toHaveBeenLastCalledWith(expect.any(Array), expect.any(AbortSignal), 3);
});
it('PR1-05A: says so when no route is found, and stays retryable', async () => {
routing.route.mockResolvedValueOnce(null);
const { result } = renderHook(() => useGpxConversion(planner()));
await act(async () => {
await result.current.analyze();
});
expect(result.current.status).toBe('failed');
expect(result.current.error).toBe('routing');
await act(async () => {
await result.current.analyze();
});
expect(result.current.status).toBe('ready');
});
it('PR1-05A: a track too short to plan from fails without asking the router', async () => {
const { result } = renderHook(() =>
useGpxConversion(
planner({
readOnlyGpxAnalysis: {
routeCoordinates: [
[47, 11],
[47, 11],
],
},
} as Partial<TourPlannerController>)
)
);
await act(async () => {
await result.current.analyze();
});
// Two identical points simplify to start and end, which the router may still connect;
// a single point has nothing to plan from.
const single = renderHook(() =>
useGpxConversion(
planner({ readOnlyGpxAnalysis: { routeCoordinates: [[47, 11]] } } as Partial<TourPlannerController>)
)
);
expect(single.result.current.available).toBe(false);
});
it('PR1-05B: replacing hands the points to the planner as the import planned over in place', async () => {
const controller = planner();
const { result } = renderHook(() => useGpxConversion(controller));
await act(async () => {
await result.current.analyze();
});
let started = false;
act(() => {
started = result.current.adopt('replace');
});
expect(started).toBe(true);
expect(controller.startDerivedTour).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Recorded ridge',
description: 'Above the lake',
difficulty: 2,
sourcePlaceId: 14,
inPlace: true,
})
);
expect(result.current.status).toBe('idle');
});
it('PR1-05B: a new tour beside the import remembers where it came from', async () => {
const controller = planner();
const { result } = renderHook(() => useGpxConversion(controller));
await act(async () => {
await result.current.analyze();
});
act(() => {
result.current.adopt('new');
});
expect(controller.startDerivedTour).toHaveBeenCalledWith(
expect.objectContaining({ sourcePlaceId: 14, inPlace: false })
);
});
it('PR1-05B: nothing is adopted before the suggestion is ready', () => {
const controller = planner();
const { result } = renderHook(() => useGpxConversion(controller));
expect(result.current.adopt('new')).toBe(false);
expect(controller.startDerivedTour).not.toHaveBeenCalled();
});
it('PR1-05A: another import in view starts idle again, and discarding forgets the analysis', async () => {
const { result, rerender } = renderHook(({ controller }) => useGpxConversion(controller), {
initialProps: { controller: planner() },
});
await act(async () => {
await result.current.analyze();
});
expect(result.current.status).toBe('ready');
const other = { ...imported, place_id: 15 };
rerender({
controller: planner({
mode: { type: 'view-gpx', placeId: 15, tour: other },
readOnlyGpxTour: { tour: other, routeGeometry: null },
} as Partial<TourPlannerController>),
});
expect(result.current.status).toBe('idle');
rerender({ controller: planner() });
await act(async () => {
await result.current.analyze();
});
act(() => result.current.cancel());
expect(result.current.status).toBe('idle');
});
it('PR1-05A: is not offered without the right to add tours', () => {
const { result } = renderHook(() => useGpxConversion(planner({ canEdit: false })));
expect(result.current.available).toBe(false);
});
});
describe('TourConversionSection', () => {
function Section({ controller }: { controller: TourPlannerController }) {
const conversion = useGpxConversion(controller);
return <TourConversionSection conversion={conversion} />;
}
it('PR1-05A: offers the analysis, then the comparison with both ways to take it over', async () => {
const controller = planner();
render(<Section controller={controller} />);
await userEvent.click(screen.getByRole('button', { name: 'Plan an editable version' }));
expect(await screen.findByRole('button', { name: 'Replace the import' })).toBeInTheDocument();
expect(screen.getByText('Within 50 m of the track')).toBeInTheDocument();
expect(screen.getByText('100 %')).toBeInTheDocument();
expect(
screen.getByText('The suggestion follows the track closely. It is routed anew, so it is not the same line.')
).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Replace the import' }));
expect(controller.startDerivedTour).toHaveBeenCalledWith(expect.objectContaining({ inPlace: true }));
expect(toast.info).toHaveBeenCalledWith(
'Opened in the editor. Save it to replace the import; the recorded track stays restorable.'
);
});
it('PR1-05B: plans a new tour beside the import on request', async () => {
const controller = planner();
render(<Section controller={controller} />);
await userEvent.click(screen.getByRole('button', { name: 'Plan an editable version' }));
await userEvent.click(await screen.findByRole('button', { name: 'Plan as new tour' }));
expect(controller.startDerivedTour).toHaveBeenCalledWith(expect.objectContaining({ inPlace: false }));
expect(toast.info).toHaveBeenCalledWith('Opened as a new tour. Save it to keep it; the import stays as it is.');
});
it('PR1-05A: warns when the suggestion leaves the track for longer stretches', async () => {
const elsewhere: [number, number][] = track.map(([lat, lng]) => [lat, lng + 0.01]);
routing.route.mockResolvedValue({ coordinates: elsewhere, distanceMeters: 2000, durationSeconds: 1800 });
render(<Section controller={planner()} />);
await userEvent.click(screen.getByRole('button', { name: 'Plan an editable version' }));
expect(
await screen.findByText(
'The suggestion leaves the track over longer stretches. Check it on the map before you plan with it.'
)
).toBeInTheDocument();
});
it('PR1-05A: reports a failed routing and retries it', async () => {
routing.route.mockResolvedValueOnce(null);
render(<Section controller={planner()} />);
await userEvent.click(screen.getByRole('button', { name: 'Plan an editable version' }));
expect(await screen.findByText('No route was found through the control points.')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(await screen.findByRole('button', { name: 'Replace the import' })).toBeInTheDocument();
});
it('PR1-05A: discarding returns to the offer', async () => {
render(<Section controller={planner()} />);
await userEvent.click(screen.getByRole('button', { name: 'Plan an editable version' }));
await userEvent.click(await screen.findByRole('button', { name: 'Discard' }));
expect(screen.getByRole('button', { name: 'Plan an editable version' })).toBeInTheDocument();
});
it('PR1-05A: shows nothing to someone who may not add tours', () => {
const { container } = render(<Section controller={planner({ canEdit: false })} />);
expect(container).toBeEmptyDOMElement();
});
});
@@ -0,0 +1,170 @@
import { AlertTriangle, CheckCircle2, CopyPlus, Loader2, Replace, Route, X } from 'lucide-react';
import type { ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDistance } from '../../../utils/units';
import { DialogButton, fs } from '../../shared/DialogShell';
import { useToast } from '../../shared/Toast';
import { Tooltip } from '../../shared/Tooltip';
import { CardAction, RailCard, RailFacts, type TourMetricField } from '../tourParts';
import { CORRIDOR_M, FAITHFUL_SHARE } from './gpxConversion';
import type { GpxConversion } from './useGpxConversion';
/** The two lines of the comparison as the map draws them: the import dotted, the suggestion solid. */
function LineLegend() {
const { t } = useTranslation();
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-content-muted" style={fs(11, 'body')}>
<span className="inline-flex items-center gap-1.5">
<span aria-hidden="true" className="w-5 border-t-[3px] border-dotted border-info" />
{t('tours.conversion.legendImport')}
</span>
<span className="inline-flex items-center gap-1.5">
<span aria-hidden="true" className="h-[3px] w-5 rounded-full bg-info" />
{t('tours.conversion.legendSuggestion')}
</span>
</div>
);
}
/** One line of status inside the card: an icon and a sentence. */
function StatusLine({
icon,
tone = 'muted',
children,
}: {
icon: ReactNode;
tone?: 'muted' | 'warning';
children: ReactNode;
}) {
return (
<p
role="status"
className={`m-0 flex items-start gap-2 leading-snug ${tone === 'warning' ? 'text-warning' : 'text-content-secondary'}`}
style={fs(12, 'body')}
>
<span className="mt-[2px] flex-none">{icon}</span>
<span className="min-w-0">{children}</span>
</p>
);
}
/**
* In the view of an import: plan an editable tour from it. Asked for, the
* import's track turns into control points routed anew, and the card compares
* that route to the track before anything is taken over. Taking it over either
* plans the import over in place (its days and notes stay, the recorded track
* is kept to go back to) or opens a new tour beside it. Both open in the editor
* and are stored only when saved.
*/
export default function TourConversionSection({ conversion }: { conversion: GpxConversion }) {
const { t } = useTranslation();
const toast = useToast();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
if (!conversion.available) return null;
const adopt = (how: 'replace' | 'new') => {
if (conversion.adopt(how))
toast.info(t(how === 'replace' ? 'tours.conversion.replacing' : 'tours.conversion.adopted'));
};
const card = (children: ReactNode) => (
<RailCard icon={<Route size={14} strokeWidth={2} />} title={t('tours.conversion.title')}>
{children}
</RailCard>
);
if (conversion.status === 'idle') {
return card(
<div className="flex flex-col items-start gap-2.5">
<p className="m-0 leading-snug text-content-secondary" style={fs(12, 'body')}>
{t('tours.conversion.intro')}
</p>
<CardAction onClick={() => void conversion.analyze()} icon={<Route size={12} strokeWidth={2.2} />}>
{t('tours.conversion.analyze')}
</CardAction>
</div>
);
}
if (conversion.status === 'analyzing') {
return card(
<StatusLine icon={<Loader2 size={13} strokeWidth={2} className="animate-spin text-content-faint" />}>
{t('tours.conversion.analyzing', { count: conversion.points.length })}
</StatusLine>
);
}
if (conversion.status === 'failed' || !conversion.comparison) {
return card(
<div className="flex flex-col items-start gap-2">
<StatusLine tone="warning" icon={<AlertTriangle size={13} strokeWidth={2.2} />}>
{t(conversion.error === 'track' ? 'tours.conversion.failedTrack' : 'tours.conversion.failedRouting')}
</StatusLine>
<button
type="button"
onClick={() => void conversion.analyze()}
className="font-semibold text-content underline"
style={fs(12, 'body')}
>
{t('tours.planner.retry')}
</button>
</div>
);
}
const { comparison } = conversion;
const faithful = comparison.withinCorridor >= FAITHFUL_SHARE;
const facts: TourMetricField[] = [
{ label: t('tours.conversion.importLength'), value: formatDistance(comparison.originalKm, unit) },
{ label: t('tours.conversion.suggestionLength'), value: formatDistance(comparison.suggestionKm, unit) },
{
label: t('tours.conversion.withinCorridor', { m: CORRIDOR_M }),
value: `${Math.round(comparison.withinCorridor * 100)} %`,
},
{ label: t('tours.conversion.maxDeviation'), value: `${Math.round(comparison.maxDeviationM)} m` },
{ label: t('tours.conversion.controlPoints'), value: String(conversion.points.length) },
];
return card(
<div className="flex flex-col gap-3">
<LineLegend />
<RailFacts facts={facts} />
<StatusLine
tone={faithful ? 'muted' : 'warning'}
icon={
faithful ? (
<CheckCircle2 size={13} strokeWidth={2.2} className="text-success" />
) : (
<AlertTriangle size={13} strokeWidth={2.2} />
)
}
>
{t(faithful ? 'tours.conversion.close' : 'tours.conversion.different')}
</StatusLine>
<div className="flex flex-col gap-2 border-t border-edge-faint pt-3">
<Tooltip label={t('tours.conversion.replaceHint')}>
<span className="flex [&>button]:flex-1">
<DialogButton
variant="primary"
onClick={() => adopt('replace')}
icon={<Replace size={14} strokeWidth={2} />}
>
{t('tours.conversion.replace')}
</DialogButton>
</span>
</Tooltip>
<div className="flex gap-2">
<Tooltip label={t('tours.conversion.adoptHint')}>
<span className="flex flex-1 [&>button]:flex-1">
<DialogButton onClick={() => adopt('new')} icon={<CopyPlus size={14} strokeWidth={2} />}>
{t('tours.conversion.adopt')}
</DialogButton>
</span>
</Tooltip>
<DialogButton onClick={conversion.cancel} icon={<X size={14} strokeWidth={2} />}>
{t('tours.conversion.discard')}
</DialogButton>
</div>
</div>
</div>
);
}
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest';
import { CORRIDOR_M, compareRoutes, deriveControlPoints, lineLengthMeters, type TrackPoint } from './gpxConversion';
/** About 111 m per 0.001 degree of latitude. */
const north = (lat: number, lng = 11): TrackPoint => ({ lat, lng });
/** A straight line north, one point every ~11 m, `km` long. */
function straightTrack(km: number): TrackPoint[] {
const steps = Math.round(km * 90);
return Array.from({ length: steps + 1 }, (_, i) => north(47 + (i * km) / steps / 111.2));
}
/** An L: north for `legKm`, then east for `legKm`. */
function bentTrack(legKm: number): TrackPoint[] {
const up = straightTrack(legKm);
const corner = up[up.length - 1];
const east = Array.from({ length: 90 }, (_, i) => ({
lat: corner.lat,
lng: corner.lng + ((i + 1) * legKm) / 90 / 75.8,
}));
return [...up, ...east];
}
describe('deriveControlPoints', () => {
it('PR1-05A: keeps start and end and nothing else on a short straight track', () => {
const track = straightTrack(1);
const points = deriveControlPoints(track);
expect(points).toEqual([track[0], track[track.length - 1]]);
});
it('PR1-05A: keeps the bend a track turns at', () => {
const track = bentTrack(1);
const points = deriveControlPoints(track);
expect(points.length).toBe(3);
const corner = track[90];
expect(points[1].lat).toBeCloseTo(corner.lat, 3);
expect(points[1].lng).toBeCloseTo(corner.lng, 3);
});
it('PR1-05A: adds a point wherever a track runs on longer than the longest gap', () => {
const track = straightTrack(10);
const points = deriveControlPoints(track, { maxGapM: 2_500 });
expect(points.length).toBeGreaterThanOrEqual(5);
for (let i = 1; i < points.length; i += 1) {
expect(lineLengthMeters([points[i - 1], points[i]])).toBeLessThanOrEqual(2_600);
}
});
it('PR1-05A: never keeps more points than asked for, start and end included', () => {
// A zigzag every point of which is a bend.
const zigzag = Array.from({ length: 400 }, (_, i) => ({ lat: 47 + i * 0.0009, lng: 11 + (i % 2) * 0.002 }));
const points = deriveControlPoints(zigzag, { maxPoints: 12 });
expect(points.length).toBeLessThanOrEqual(12);
expect(points[0]).toEqual(zigzag[0]);
expect(points[points.length - 1]).toEqual(zigzag[zigzag.length - 1]);
});
it('PR1-05A: merges points closer than the minimum spacing, keeping start and end', () => {
const track = [north(47), north(47.0002), north(47.0004), north(47.02)];
const points = deriveControlPoints(track, { minSpacingM: 150 });
expect(points[0]).toEqual(track[0]);
expect(points[points.length - 1]).toEqual(track[3]);
expect(points).not.toContainEqual(track[1]);
});
it('PR1-05A: has nothing to offer for a track of fewer than two points', () => {
expect(deriveControlPoints([])).toEqual([]);
expect(deriveControlPoints([north(47)])).toEqual([]);
});
});
describe('compareRoutes', () => {
it('PR1-05A: a suggestion on the track itself lies wholly in the corridor', () => {
const track = straightTrack(2);
const result = compareRoutes(track, [track[0], track[track.length - 1]]);
expect(result.withinCorridor).toBe(1);
expect(result.maxDeviationM).toBeLessThan(1);
expect(result.originalKm).toBeCloseTo(2, 1);
expect(result.suggestionKm).toBeCloseTo(2, 1);
});
it('PR1-05A: a suggestion that cuts a corner says how far it strays and for how much of the track', () => {
const track = bentTrack(1);
const shortcut = [track[0], track[track.length - 1]];
const result = compareRoutes(track, shortcut);
expect(result.maxDeviationM).toBeGreaterThan(500);
expect(result.withinCorridor).toBeLessThan(0.5);
expect(result.suggestionKm).toBeLessThan(result.originalKm);
});
it('PR1-05A: counts a stray of exactly the corridor width as followed', () => {
const track = [north(47), north(47.001)];
const offset = CORRIDOR_M / 75_800; // degrees of longitude for the corridor width at 47° N
const beside = track.map((point) => ({ lat: point.lat, lng: point.lng + offset * 0.99 }));
expect(compareRoutes(track, beside).withinCorridor).toBe(1);
});
it('PR1-05A: compares a one-point suggestion by plain distance', () => {
const result = compareRoutes([north(47), north(47.001)], [north(47)]);
expect(result.maxDeviationM).toBeGreaterThan(100);
});
});
@@ -0,0 +1,188 @@
/**
* Turning an imported track into control points a tour can be planned through,
* and measuring how far the line routed through them strays from the track.
*
* A recorded track is thousands of points, most of them noise of the walk, and
* none of them says where the walker meant to go. The points kept here are the
* ones the track's shape needs: a Douglas-Peucker pass keeps the bends, and a
* longest-gap rule adds a point wherever the track runs on too long without
* one, so the router is not left to guess across kilometres. Nothing here
* claims the result is the same tour; the comparison says how close it is.
*/
export interface TrackPoint {
lat: number;
lng: number;
}
export interface ControlPointOptions {
/** The most control points to keep, start and end included. */
maxPoints?: number;
/** The longest stretch of track, in metres, left without a control point. */
maxGapM?: number;
/** Control points closer than this, in metres, are merged (start and end stay). */
minSpacingM?: number;
}
export interface RouteComparison {
/** Length of the imported track, in kilometres. */
originalKm: number;
/** Length of the routed suggestion, in kilometres. */
suggestionKm: number;
/** The farthest any point of the track lies from the suggestion, in metres. */
maxDeviationM: number;
/** The share of the track within {@link CORRIDOR_M} of the suggestion, from 0 to 1. */
withinCorridor: number;
}
/** How far from the suggestion a stretch of track still counts as followed, in metres. */
export const CORRIDOR_M = 50;
/** Below this share of the track in the corridor the suggestion is called a different route. */
export const FAITHFUL_SHARE = 0.85;
const EARTH_M = 6_371_000;
const RAD = Math.PI / 180;
/** The track's points compared against the suggestion, at most; a long track is sampled evenly. */
const COMPARE_SAMPLES = 600;
/** Points on a local plane in metres, good enough over the span of one tour. */
function projector(points: readonly TrackPoint[]) {
const meanLat = points.reduce((sum, p) => sum + p.lat, 0) / Math.max(points.length, 1);
const kx = Math.cos(meanLat * RAD) * EARTH_M * RAD;
const ky = EARTH_M * RAD;
return (p: TrackPoint): [number, number] => [p.lng * kx, p.lat * ky];
}
function distanceToSegment(p: [number, number], a: [number, number], b: [number, number]): number {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const length2 = dx * dx + dy * dy;
const t = length2 === 0 ? 0 : Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / length2));
return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
}
/** The length of a line, in metres. */
export function lineLengthMeters(points: readonly TrackPoint[]): number {
const project = projector(points);
let total = 0;
for (let i = 1; i < points.length; i += 1) {
const [ax, ay] = project(points[i - 1]);
const [bx, by] = project(points[i]);
total += Math.hypot(bx - ax, by - ay);
}
return total;
}
/** Douglas-Peucker on projected points: the indices a line of the given tolerance keeps. */
function simplify(xy: readonly [number, number][], tolerance: number): number[] {
const keep = new Uint8Array(xy.length);
keep[0] = 1;
keep[xy.length - 1] = 1;
const stack: Array<[number, number]> = [[0, xy.length - 1]];
for (let range = stack.pop(); range; range = stack.pop()) {
const [first, last] = range;
let farthest = -1;
let farthestDistance = tolerance;
for (let i = first + 1; i < last; i += 1) {
const distance = distanceToSegment(xy[i], xy[first], xy[last]);
if (distance > farthestDistance) {
farthest = i;
farthestDistance = distance;
}
}
if (farthest !== -1) {
keep[farthest] = 1;
stack.push([first, farthest], [farthest, last]);
}
}
return xy.flatMap((_, index) => (keep[index] ? [index] : []));
}
/** Adds a kept index wherever the track runs longer than `maxGapM` between two kept ones. */
function fillGaps(kept: number[], along: readonly number[], maxGapM: number): number[] {
const result: number[] = [kept[0]];
for (let k = 1; k < kept.length; k += 1) {
const from = kept[k - 1];
const to = kept[k];
let last = from;
for (let i = from + 1; i < to; i += 1) {
if (along[i] - along[last] >= maxGapM && along[to] - along[i] >= maxGapM / 4) {
result.push(i);
last = i;
}
}
result.push(to);
}
return result;
}
/** Drops kept indices closer than `minSpacingM` along the track to the one before; the end always stays. */
function spaceOut(kept: number[], along: readonly number[], minSpacingM: number): number[] {
const result: number[] = [kept[0]];
for (let k = 1; k < kept.length - 1; k += 1) {
if (along[kept[k]] - along[result[result.length - 1]] >= minSpacingM) result.push(kept[k]);
}
const end = kept[kept.length - 1];
if (result.length > 1 && along[end] - along[result[result.length - 1]] < minSpacingM) result.pop();
result.push(end);
return result;
}
/**
* The control points a tour planned from this track would run through: start
* and end, the bends the track's shape needs, and a point wherever it runs on
* longer than `maxGapM` without one. A tolerance that keeps too many is widened
* until the points fit `maxPoints`. A track with fewer than two points has none.
*/
export function deriveControlPoints(track: readonly TrackPoint[], options: ControlPointOptions = {}): TrackPoint[] {
const { maxPoints = 25, maxGapM = 2_500, minSpacingM = 150 } = options;
if (track.length < 2) return [];
const project = projector(track);
const xy = track.map(project);
const along = [0];
for (let i = 1; i < xy.length; i += 1)
along.push(along[i - 1] + Math.hypot(xy[i][0] - xy[i - 1][0], xy[i][1] - xy[i - 1][1]));
let tolerance = 25;
let kept: number[] = [];
for (let round = 0; round < 24; round += 1) {
kept = spaceOut(fillGaps(simplify(xy, tolerance), along, maxGapM), along, minSpacingM);
if (kept.length <= maxPoints) break;
tolerance *= 1.4;
}
if (kept.length > maxPoints) {
// Even a wide tolerance keeps too many (a very long track): evenly along it instead.
const total = along[along.length - 1];
kept = Array.from({ length: maxPoints }, (_, n) => {
const target = (total * n) / (maxPoints - 1);
return Math.max(
0,
along.findIndex((distance) => distance >= target)
);
});
}
return kept.map((index) => ({ lat: track[index].lat, lng: track[index].lng }));
}
/** How closely the suggestion follows the track: lengths, the farthest stray and the share inside the corridor. */
export function compareRoutes(track: readonly TrackPoint[], suggestion: readonly TrackPoint[]): RouteComparison {
const project = projector([...track, ...suggestion]);
const line = suggestion.map(project);
const step = Math.max(1, Math.ceil(track.length / COMPARE_SAMPLES));
const samples = track.filter((_, index) => index % step === 0 || index === track.length - 1).map(project);
let maxDeviationM = 0;
let within = 0;
for (const point of samples) {
let nearest = Infinity;
for (let i = 1; i < line.length && nearest > 0; i += 1)
nearest = Math.min(nearest, distanceToSegment(point, line[i - 1], line[i]));
if (line.length === 1) nearest = Math.hypot(point[0] - line[0][0], point[1] - line[0][1]);
maxDeviationM = Math.max(maxDeviationM, nearest);
if (nearest <= CORRIDOR_M) within += 1;
}
return {
originalKm: lineLengthMeters(track) / 1000,
suggestionKm: lineLengthMeters(suggestion) / 1000,
maxDeviationM,
withinCorridor: samples.length ? within / samples.length : 0,
};
}
@@ -0,0 +1,113 @@
import type { TourMaxHikingDifficulty } from '@trek/shared';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { routeWalkingTour } from '../planner/tourRouting';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { compareRoutes, deriveControlPoints, type RouteComparison, type TrackPoint } from './gpxConversion';
/** The grade a planned version routes with when the import carries none: the paths a T3 walk may take. */
const DEFAULT_DIFFICULTY: TourMaxHikingDifficulty = 3;
type GpxConversionStatus = 'idle' | 'analyzing' | 'ready' | 'failed';
interface ConversionState {
/** The import the analysis belongs to; another import shown means it is idle again. */
placeId: number | null;
status: GpxConversionStatus;
points: TrackPoint[];
line: [number, number][] | null;
comparison: RouteComparison | null;
/** Why it failed: the track is too short to plan from, or the router found no way. */
error: 'track' | 'routing' | null;
}
const IDLE: ConversionState = { placeId: null, status: 'idle', points: [], line: null, comparison: null, error: null };
/**
* Planning an editable tour from an import shown in the tour planner (PR1-05A/B).
* The analysis starts only when asked: control points from the track, a route
* through them by the planner's own router, and how far that route strays from
* the track. Nothing is stored. Adopting hands the points to the planner as a
* new draft that remembers the import; only saving that draft creates a tour,
* and the import stays as it is.
*/
export function useGpxConversion(planner: TourPlannerController) {
const [state, setState] = useState<ConversionState>(IDLE);
const abort = useRef<AbortController | null>(null);
const tour = planner.mode.type === 'view-gpx' ? (planner.readOnlyGpxTour?.tour ?? null) : null;
const track = planner.readOnlyGpxAnalysis?.routeCoordinates ?? null;
const current = tour !== null && state.placeId === tour.place_id ? state : IDLE;
const difficulty = (tour?.max_hiking_difficulty ?? DEFAULT_DIFFICULTY) as TourMaxHikingDifficulty;
useEffect(() => () => abort.current?.abort(), []);
const analyze = useCallback(async () => {
if (!tour || !track) return;
abort.current?.abort();
const controller = new AbortController();
abort.current = controller;
const trackPoints = track.map(([lat, lng]) => ({ lat, lng }));
const points = deriveControlPoints(trackPoints);
const base = { ...IDLE, placeId: tour.place_id, points };
if (points.length < 2) {
setState({ ...base, status: 'failed', error: 'track' });
return;
}
setState({ ...base, status: 'analyzing' });
const routed = await routeWalkingTour(points, controller.signal, difficulty);
if (controller.signal.aborted) return;
if (!routed) {
setState({ ...base, status: 'failed', error: 'routing' });
return;
}
const suggestion = routed.coordinates.map(([lat, lng]) => ({ lat, lng }));
setState({
...base,
status: 'ready',
line: routed.coordinates,
comparison: compareRoutes(trackPoints, suggestion),
});
}, [difficulty, tour, track]);
const cancel = useCallback(() => {
abort.current?.abort();
setState(IDLE);
}, []);
const { startDerivedTour } = planner;
/**
* The suggestion as a draft in the planner: the import planned over in place
* (`replace`), or a new tour beside it. True once the planner took it.
*/
const adopt = useCallback(
(how: 'replace' | 'new') => {
if (!tour || current.status !== 'ready') return false;
const started = startDerivedTour({
name: tour.name,
description: tour.description ?? null,
website: tour.website ?? null,
difficulty,
points: current.points,
sourcePlaceId: tour.place_id,
inPlace: how === 'replace',
});
if (started) setState(IDLE);
return started;
},
[current.points, current.status, difficulty, startDerivedTour, tour]
);
return useMemo(
() => ({
...current,
/** The import shown can be planned from: it has a track and the user may add tours. */
available: tour !== null && (track?.length ?? 0) >= 2 && planner.canEdit,
difficulty,
analyze,
cancel,
adopt,
}),
[adopt, analyze, cancel, current, difficulty, planner.canEdit, tour, track]
);
}
export type GpxConversion = ReturnType<typeof useGpxConversion>;
@@ -612,7 +612,11 @@ describe('TourPlannerRail', () => {
expect(firstNumber).toHaveAttribute('data-waypoint-number', '1')
expect(secondNumber).toHaveAttribute('data-waypoint-number', '2')
expect(firstNumber).toHaveClass('bg-accent', 'text-accent-text', 'font-geist', 'tabular-nums', 'h-7', 'w-7')
expect(secondNumber).toHaveClass('bg-surface-card', 'text-content', 'font-geist', 'tabular-nums', 'h-7', 'w-7')
// Round like the map's pins and the plan's avatars.
expect(secondNumber).toHaveClass(
'bg-edge', 'text-content', 'font-geist', 'tabular-nums', 'h-7', 'w-7',
'rounded-full'
)
})
it('TOUR-PLANNER-RAIL-007: acknowledges alpine routing once per planner session', () => {
@@ -761,17 +765,47 @@ describe('TourPlannerRail', () => {
expect(screen.queryByRole('button', { name: 'Plan new tour' })).not.toBeInTheDocument()
})
it('PR1-05B: an import planned over in place offers its recorded track back, after a confirm', async () => {
const restore = vi.fn()
const controller = planner({
mode: { type: 'edit-saved', placeId: tour.place_id },
waypoints: [],
} as Partial<TourPlannerController>)
const recordedTrack = { available: true, restoring: false, restore }
render(<TourPlannerRail planner={controller} recordedTrack={recordedTrack} />)
expect(screen.getByRole('region', { name: 'Recorded track' })).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Restore recorded track' }))
expect(restore).not.toHaveBeenCalled()
expect(screen.getByText('Restore the recorded track?')).toBeInTheDocument()
const confirms = screen.getAllByRole('button', { name: 'Restore recorded track' })
await userEvent.click(confirms[confirms.length - 1])
expect(restore).toHaveBeenCalledOnce()
})
it('PR1-05B: a tour without a kept track shows no way back', () => {
const controller = planner({
mode: { type: 'edit-saved', placeId: tour.place_id },
waypoints: [],
} as Partial<TourPlannerController>)
render(
<TourPlannerRail planner={controller} recordedTrack={{ available: false, restoring: false, restore: vi.fn() }} />
)
expect(screen.queryByRole('region', { name: 'Recorded track' })).not.toBeInTheDocument()
})
it('TOUR-PLANNER-RAIL-015: returns from GPX view to the blank editable Planner', () => {
const controller = planner({
readOnlyGpxTour: { tour: { ...tour, has_waypoints: false }, routeGeometry: null },
readOnlyGpxAnalysis: null,
})
const { rerender } = render(<TourPlannerRail planner={controller} />)
expect(screen.getByText('GPX tours are view-only and cannot be edited in the planner yet.')).toBeInTheDocument()
expect(screen.getByText('Route geometry is unavailable for this GPX import.')).toBeInTheDocument()
rerender(<TourPlannerRail planner={planner({ mode: { type: 'neutral' } })} />)
expect(screen.getByText('Plan a tour')).toBeInTheDocument()
expect(screen.queryByText('GPX tours are view-only and cannot be edited in the planner yet.')).not.toBeInTheDocument()
expect(screen.queryByText('Route geometry is unavailable for this GPX import.')).not.toBeInTheDocument()
})
it('TOUR-PLANNER-RAIL-016: the master list reflects neutral, saved-edit, and GPX selection modes', () => {
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import type { TourListItem } from '@trek/shared'
import { AlertTriangle, ArrowLeft, Circle, Info, Loader2, Route, Plus, Redo2, RotateCcw, Save, Trash2, Undo2 } from 'lucide-react'
import { AlertTriangle, ArrowLeft, Circle, FileDown, History, Loader2, Mountain, Plus, Redo2, RotateCcw, Route, Save, Trash2, Undo2 } from 'lucide-react'
import type { Day } from '../../../types'
import { useTranslation } from '../../../i18n'
import { useSettingsStore } from '../../../store/settingsStore'
@@ -16,12 +16,19 @@ import { BarButton, SoftPill } from '../../Planner/planParts'
import TourListRow from '../TourListRow'
import ElevationProfile from '../../shared/ElevationProfile'
import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourSource } from '../tourPresentation'
import { FoldButton, TourDayMenu, TourMetricFields, TourNotice, TourSection } from '../tourParts'
import { CardAction, FoldButton, RailCard, RailFacts, TourDayMenu, TourListCard, TourNotice, TourSection } from '../tourParts'
import { tourMetricFacts } from '../tourFacts'
import { TourDetailsSection, TourNameField } from './TourEditorFields'
import TourWaypointList from './TourWaypointList'
import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry'
import type { TourPlannerController, TourPlannerStatus } from './useTourPlanner'
import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions'
import TourConversionSection from '../conversion/TourConversionSection'
import type { GpxConversion } from '../conversion/useGpxConversion'
import type { RecordedTrack } from '../useRecordedTrack'
import type { TourGpxImport } from '../useTourGpxImport'
import { type AssignTourToDay, useTourDayAssignment } from '../useTourDayAssignment'
import WandererMark from '../wanderer/WandererMark'
const STATUS_KEY: Partial<Record<TourPlannerStatus, string>> = {
dirty: 'tours.planner.status.dirty',
@@ -33,7 +40,7 @@ const STATUS_KEY: Partial<Record<TourPlannerStatus, string>> = {
}
/** The tinted head band both rails open with, like every panel head in the planner. */
const RAIL_HEAD = 'flex flex-none items-start gap-2 border-b border-edge-faint px-3 py-2.5'
export const RAIL_HEAD = 'flex flex-none items-start gap-2 border-b border-edge-faint px-3 py-2.5'
type Translate = (key: string, params?: Record<string, string | number | null>) => string
@@ -45,7 +52,7 @@ function profileFocusLabel(focus: RouteProfileFocus, t: Translate, unit: 'metric
}
/** The title of a rail's head band, with an optional line under it. */
function RailTitle({ title, sub, aside }: { title: string; sub?: ReactNode; aside?: ReactNode }) {
export function RailTitle({ title, sub, aside }: { title: string; sub?: ReactNode; aside?: ReactNode }) {
return (
<div className="min-w-0 flex-1 pt-[5px]">
<div className="flex min-w-0 items-center gap-1.5">
@@ -152,10 +159,147 @@ function RailStatusBadges({ planner }: { planner: TourPlannerController }) {
)
}
export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: assignPermission }: { planner: TourPlannerController } & TourPermissionProps) {
/** The rail while nothing is open: a new tour, or the wanderer trails. */
function NeutralRail({
planner,
canEdit,
onOpenWanderer,
}: {
planner: TourPlannerController
canEdit: boolean
onOpenWanderer?: () => void
}) {
const { t } = useTranslation()
return (
<section className="flex h-full min-h-0 flex-col" aria-label={t('tours.planner.title')}>
<div className={RAIL_HEAD} style={{ background: NEUTRAL_TINT }}>
<button
type="button"
disabled={!canEdit}
onClick={() => {
if (canEdit) planner.startNewTour()
}}
className="flex h-8 min-w-0 flex-1 items-center justify-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[10px] bg-accent px-3 font-semibold text-accent-text shadow-sm transition-opacity hover:opacity-90 disabled:cursor-default disabled:opacity-40"
style={fs(12.5, 'body')}
>
<Plus size={14} strokeWidth={2.2} className="flex-none" />
<span className="truncate">{t('tours.planner.newTour')}</span>
</button>
{canEdit && onOpenWanderer && (
<Tooltip label={t('wanderer.browser.openHint')}>
<button
type="button"
onClick={onOpenWanderer}
data-testid="tour-rail-open-wanderer"
className="flex h-8 flex-none items-center justify-center gap-1.5 whitespace-nowrap rounded-[10px] bg-surface-card px-3 font-semibold text-content-secondary shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:text-content"
style={fs(12.5, 'body')}
>
<WandererMark size={14} className="flex-none" />
{t('wanderer.browser.open')}
</button>
</Tooltip>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
<EmptyState
scene="tours"
size={116}
fill
surface="var(--bg-secondary)"
title={t('tours.planner.neutralTitle')}
action={
<p className="m-0 max-w-[260px] text-content-muted" style={fs(12, 'body')}>
{t('tours.planner.neutralBody')}
</p>
}
/>
</div>
</section>
)
}
/** The rail for an imported tour: shown as it came in, measured, never edited. */
function GpxViewRail({
planner,
canEdit,
conversion,
}: {
planner: TourPlannerController
canEdit: boolean
conversion?: GpxConversion
}) {
const { t } = useTranslation()
const distanceUnit = useSettingsStore((state) => state.settings.distance_unit)
if (planner.mode.type !== 'view-gpx') return null
const { readOnlyGpxTour } = planner
const { tour } = readOnlyGpxTour
const fromWanderer = tourSource(tour) === 'wanderer'
const analysis = planner.readOnlyGpxAnalysis
const extra =
tour.duration != null
? [
{
label: t('tours.planner.inspector.duration'),
value: t('tours.durationMinutes', { count: Math.max(1, Math.round(tour.duration)) }),
},
]
: []
return (
<>
<section className="flex h-full min-h-0 flex-col" aria-label={tour.name}>
<div className={RAIL_HEAD} style={{ background: NEUTRAL_TINT }}>
<BarButton
label={t('tours.planner.backToTours')}
onClick={() => planner.returnToNeutral()}
className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<ArrowLeft size={16} strokeWidth={2} />
</BarButton>
<RailTitle
title={t(fromWanderer ? 'wanderer.tourTitle' : 'tours.planner.gpxTitle')}
sub={tour.name}
aside={<SoftPill>{hikeSourceBadgeLabel(tour, t)}</SoftPill>}
/>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-3 py-3">
{conversion && <TourConversionSection conversion={conversion} />}
<RailCard icon={<Mountain size={14} strokeWidth={2} />} title={t('tours.planner.inspector.title')}>
{analysis ? (
<div className="flex flex-col gap-3">
<RailFacts facts={tourMetricFacts(analysis, distanceUnit, t, extra)} />
<ElevationSection
id="tour-planner-gpx-elevation-profile"
samples={analysis.distanceIndexedProfileSamples}
gradientId={`planner-gpx-elevation-${tour.place_id}`}
planner={planner}
/>
</div>
) : (
<p className="m-0 text-content-muted" style={fs(12, 'body')}>
{t('tours.planner.gpxNoGeometry')}
</p>
)}
</RailCard>
</div>
</section>
<NewTourConfirmDialog planner={planner} canEdit={canEdit} />
</>
)
}
export function TourPlannerRail({ planner,
onOpenWanderer,
conversion,
recordedTrack, canEdit: editPermission, canAssign: assignPermission }: { planner: TourPlannerController
/** Planning an editable tour from the import in view. */
conversion?: GpxConversion
/** The way back to the recorded track of an import planned over in place. */
recordedTrack?: RecordedTrack
/** Browse the user's wanderer trails instead; offered while nothing is being edited. */
onOpenWanderer?: () => void
} & TourPermissionProps) {
const { canEdit } = useTourPermissions({ canEdit: editPermission ?? planner.canEdit, canAssign: assignPermission ?? planner.canAssign })
const { t } = useTranslation()
const distanceUnit = useSettingsStore(state => state.settings.distance_unit)
const [confirmDiscard, setConfirmDiscard] = useState(false)
const [pendingDifficulty, setPendingDifficulty] = useState<1 | 2 | 3 | 4 | 5 | 6 | null>(null)
const alpineAcknowledged = useRef(false)
@@ -170,69 +314,11 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
else planner.setMaxHikingDifficulty(value)
}
if (planner.mode.type === 'neutral') {
return (
<section className="flex h-full min-h-0 flex-col" aria-label={t('tours.planner.title')}>
<div className={RAIL_HEAD} style={{ background: NEUTRAL_TINT }}>
<button type="button" disabled={!canEdit} onClick={() => { if (canEdit) planner.startNewTour() }}
className="flex h-8 min-w-0 flex-1 items-center justify-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[10px] bg-accent px-3 font-semibold text-accent-text shadow-sm transition-opacity hover:opacity-90 disabled:cursor-default disabled:opacity-40"
style={fs(12.5, 'body')}>
<Plus size={14} strokeWidth={2.2} className="flex-none" />
<span className="truncate">{t('tours.planner.newTour')}</span>
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
<EmptyState
scene="tours"
size={116}
fill
surface="var(--bg-secondary)"
title={t('tours.planner.neutralTitle')}
action={<p className="m-0 max-w-[260px] text-content-muted" style={fs(12, 'body')}>{t('tours.planner.neutralBody')}</p>}
if (planner.mode.type === 'neutral')
return <NeutralRail planner={planner} canEdit={canEdit} onOpenWanderer={onOpenWanderer}
/>
</div>
</section>
)
}
if (planner.mode.type === 'view-gpx') {
const { tour } = planner.readOnlyGpxTour
const analysis = planner.readOnlyGpxAnalysis
const extra = tour.duration != null
? [{ label: t('tours.planner.inspector.duration'), value: t('tours.durationMinutes', { count: Math.max(1, Math.round(tour.duration)) }) }]
: []
return (
<>
<section className="flex h-full min-h-0 flex-col" aria-label={tour.name}>
<div className={RAIL_HEAD} style={{ background: NEUTRAL_TINT }}>
<BarButton label={t('tours.planner.backToTours')} onClick={() => planner.returnToNeutral()} className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent">
<ArrowLeft size={16} strokeWidth={2} />
</BarButton>
<RailTitle title={t('tours.planner.gpxTitle')} sub={tour.name} aside={<SoftPill>{hikeSourceBadgeLabel(tour, t)}</SoftPill>} />
</div>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-3 py-3.5">
<TourNotice icon={<Info size={13} strokeWidth={2} className="text-content-faint" />}>{t('tours.detail.gpxReadOnly')}</TourNotice>
{analysis ? (
<>
<TourSection label={t('tours.planner.inspector.title')}>
<TourMetricFields analysis={analysis} unit={distanceUnit} extra={extra} columns={2} />
</TourSection>
<ElevationSection
id="tour-planner-gpx-elevation-profile"
samples={analysis.distanceIndexedProfileSamples}
gradientId={`planner-gpx-elevation-${tour.place_id}`}
planner={planner}
/>
</>
) : (
<TourNotice>{t('tours.planner.gpxNoGeometry')}</TourNotice>
)}
</div>
</section>
<NewTourConfirmDialog planner={planner} canEdit={canEdit} />
</>
)
}
if (planner.mode.type === 'view-gpx')
return <GpxViewRail planner={planner} canEdit={canEdit} conversion={conversion} />
const isSavedEdit = planner.mode.type === 'edit-saved'
@@ -255,6 +341,8 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-3 py-3.5">
<TourNameField planner={planner} locked={!canEdit || isSaving} />
{recordedTrack?.available && <RecordedTrackCard recordedTrack={recordedTrack} disabled={isSaving} />}
<TourWaypointList planner={planner} canEdit={canEdit} isSaving={isSaving} />
<TourDetailsSection planner={planner} locked={!canEdit || isSaving} onDifficultyChange={changeDifficulty} />
@@ -299,6 +387,48 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
)
}
/**
* In the editor of an import planned over in place: the recorded track is still
* kept, and one press (after a confirm) takes the tour back to it.
*/
function RecordedTrackCard({ recordedTrack, disabled }: { recordedTrack: RecordedTrack; disabled: boolean }) {
const { t } = useTranslation()
const [confirming, setConfirming] = useState(false)
return (
<RailCard icon={<History size={14} strokeWidth={2} />} title={t('tours.recorded.title')}>
<div className="flex flex-col items-start gap-2.5">
<p className="m-0 leading-snug text-content-secondary" style={fs(12, 'body')}>
{t('tours.recorded.hint')}
</p>
<CardAction
onClick={() => setConfirming(true)}
disabled={disabled || recordedTrack.restoring}
icon={
recordedTrack.restoring ? (
<Loader2 size={12} strokeWidth={2.2} className="animate-spin" />
) : (
<History size={12} strokeWidth={2.2} />
)
}
>
{t('tours.recorded.restore')}
</CardAction>
</div>
<ConfirmDialog
isOpen={confirming}
onClose={() => setConfirming(false)}
onConfirm={() => {
setConfirming(false)
void recordedTrack.restore()
}}
title={t('tours.recorded.confirmTitle')}
message={t('tours.recorded.confirmBody')}
confirmLabel={t('tours.recorded.restore')}
/>
</RailCard>
)
}
/** The elevation profile under its label, folded away with the chevron beside it. */
function ElevationSection({ id, samples, gradientId, planner }: {
id: string
@@ -353,22 +483,49 @@ function NewTourConfirmDialog({ planner, canEdit }: { planner: TourPlannerContro
)
}
/** The round GPX import button in the tours rail's head; the file drop on the workspace does the same. */
function RailImportButton({ gpxImport, disabled }: { gpxImport: TourGpxImport; disabled: boolean }) {
const { t } = useTranslation()
return (
<Tooltip label={t('tours.import.hint')}>
<button
type="button"
aria-label={t('tours.import.button')}
data-testid="tour-rail-import-gpx"
disabled={gpxImport.importing || disabled}
onClick={gpxImport.pick}
className="mt-1 grid h-[26px] w-[26px] flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm ring-1 ring-edge-faint transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent enabled:hover:bg-accent enabled:hover:text-accent-text enabled:hover:ring-transparent disabled:cursor-default disabled:opacity-40"
>
{gpxImport.importing ? (
<Loader2 size={13} strokeWidth={2.4} className="animate-spin" />
) : (
<FileDown size={13} strokeWidth={2.4} />
)}
</button>
</Tooltip>
)
}
interface TourPlannerToursRailProps extends TourPermissionProps {
planner: TourPlannerController
tours: TourListItem[]
days: Day[]
loading: boolean
onAssignToDay: (placeId: number, dayId: number) => void | boolean | Promise<void | boolean>
onAssignToDay: AssignTourToDay
onViewGpxTour: (tour: TourListItem) => void
onDeleteTour?: (placeId: number) => void
/** The workspace's GPX import; its button sits beside "New tour". */
gpxImport?: TourGpxImport
}
export function TourPlannerToursRail({ planner, tours, days, loading, onAssignToDay, onViewGpxTour, onDeleteTour, canEdit: editPermission, canAssign: assignPermission }: TourPlannerToursRailProps) {
export function TourPlannerToursRail({ planner, tours, days, loading, onAssignToDay, onViewGpxTour, onDeleteTour,
gpxImport, canEdit: editPermission, canAssign: assignPermission }: TourPlannerToursRailProps) {
const { canEdit, canAssign } = useTourPermissions({ canEdit: editPermission ?? planner.canEdit, canAssign: assignPermission ?? planner.canAssign })
const { t } = useTranslation()
const toast = useToast()
const distanceUnit = useSettingsStore(state => state.settings.distance_unit)
const [pendingTour, setPendingTour] = useState<TourListItem | null>(null)
const dayAssignment = useTourDayAssignment({ canAssign, onAssignToDay })
const isSaving = planner.isSaving
const hasRoute = (planner.mode.type === 'new-draft' || planner.mode.type === 'edit-saved') && planner.route !== null
const routeAnalysis = planner.routeAnalysis
@@ -390,7 +547,8 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
}
const selectTour = (tour: TourListItem) => {
if (tourSource(tour) === 'gpx') onViewGpxTour(tour)
// An import (a GPX file, a wanderer snapshot) has no waypoints of its own and is viewed as it came in.
if (tour.has_waypoints === false) onViewGpxTour(tour)
else void openTour(tour)
}
@@ -426,6 +584,7 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
<aside className="flex h-full min-h-0 flex-col" aria-label={t('tours.planner.tripTours')}>
<div className={RAIL_HEAD} style={{ background: NEUTRAL_TINT }}>
<RailTitle title={t('tours.planner.tripTours')} />
{canEdit && gpxImport && <RailImportButton gpxImport={gpxImport} disabled={isSaving} />}
{canEdit && planner.mode.type !== 'neutral' && (
<Tooltip label={t('tours.planner.newTour')}>
<button type="button" aria-label={t('tours.planner.newTour')} data-testid="tour-rail-new-tour"
@@ -439,11 +598,11 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-3 py-3.5">
{hasRoute && (
<div data-testid="tour-planner-overview" className="flex flex-col gap-4 rounded-[14px] border border-edge-faint bg-surface-secondary p-3">
<TourSection label={t('tours.planner.inspector.title')}>
<FerrataCrossingBadges />
<TourMetricFields analysis={routeAnalysis} unit={distanceUnit} extra={tourTimeFields} columns={2} />
</TourSection>
<div data-testid="tour-planner-overview">
<RailCard icon={<Route size={14} strokeWidth={2} />} title={t('tours.planner.inspector.title')}>
<div className="flex flex-col gap-3">
<FerrataCrossingBadges />
<RailFacts facts={tourMetricFacts(routeAnalysis, distanceUnit, t, tourTimeFields)} />
{routeAnalysis && (
<ElevationSection
id="tour-planner-editable-elevation-profile"
@@ -452,22 +611,32 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
planner={planner}
/>
)}
</div>
</RailCard>
</div>
)}
{!loading && tours.length === 0 ? (
<EmptyState scene="tours" size={116} fill surface="var(--bg-secondary)" title={t('tours.empty.title')} className="flex-1"
action={<p className="m-0 max-w-[260px] text-content-muted" style={fs(12, 'body')}>{t('tours.empty.body')}</p>} />
) : (
<TourSection label={t('tours.mode.tours')}>
{tours.length === 0 ? (
) : tours.length === 0 ? (
<p className="m-0 py-6 text-center text-content-faint" style={fs(12, 'body')}>{t('common.loading')}</p>
) : (
<ul role="listbox" aria-label={t('tours.planner.tripTours')} className="-mx-1 m-0 list-none p-0">
<TourListCard
icon={<Mountain size={14} strokeWidth={2} />}
title={t('tours.mode.tours')}
aside={
<span className="flex-none tabular-nums text-content-faint" style={fs(11.5, 'body')}>
{tours.length}
</span>
}
label={t('tours.planner.tripTours')}
>
{tours.map(tour => (
<TourListRow
key={tour.place_id}
tour={tour}
days={days}
disabled={isSaving}
selected={(planner.mode.type === 'edit-saved' || planner.mode.type === 'view-gpx') && planner.mode.placeId === tour.place_id}
onSelect={requestOpenTour}
@@ -478,7 +647,7 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
days={days}
placeId={tour.place_id}
label={`${t('tours.addToDay')}: ${tour.name}`}
onPick={day => { if (canAssign) void onAssignToDay(tour.place_id, day.id) }}
assignment={dayAssignment}
/>
)}
{canEdit && onDeleteTour && (
@@ -498,9 +667,7 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
) : undefined}
/>
))}
</ul>
)}
</TourSection>
</TourListCard>
)}
</div>
@@ -13,7 +13,7 @@ import { FerrataGlyph } from '../../Ferratas/FerrataGlyph';
import { SoftPill } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { TourSection } from '../tourParts';
import { RailCard } from '../tourParts';
import type { TourPlannerController } from './useTourPlanner';
import { useWaypointPlaceNames } from './useWaypointPlaceNames';
@@ -133,7 +133,7 @@ function WaypointNumber({
aria-label={ferrata ? `${label}, ${t('tours.ferrata.waypointAtEnd', { name: ferrata.name })}` : label}
data-waypoint-number={index + 1}
data-ferrata-end={ferrata ? 'true' : undefined}
className={`relative grid h-7 w-7 flex-none place-items-center rounded-[9px] font-geist font-semibold tabular-nums shadow-sm ${selected ? 'bg-accent text-accent-text' : 'bg-surface-card text-content'} ${ferrata ? 'ring-2 ring-inset ring-[color:var(--map-ferrata)]' : ''}`}
className={`relative grid h-7 w-7 flex-none place-items-center rounded-full font-geist font-semibold tabular-nums ${selected ? 'bg-accent text-accent-text' : 'bg-edge text-content'} ${ferrata ? 'ring-2 ring-inset ring-[color:var(--map-ferrata)]' : ''}`}
style={fs(11.5)}
>
{index + 1}
@@ -201,18 +201,35 @@ export default function TourWaypointList({
setDropTarget(null);
};
// One card like a day in the plan: the head counts the points, the rows sit below.
const card = (children: ReactNode) => (
<RailCard
icon={<MapPin size={14} strokeWidth={2} />}
title={t('tours.planner.waypoints')}
aside={
planner.waypoints.length > 0 && (
<span className="flex-none tabular-nums text-content-faint" style={fs(11.5, 'body')}>
{planner.waypoints.length}
</span>
)
}
>
{children}
</RailCard>
);
if (planner.waypoints.length === 0) {
return (
<TourSection label={t('tours.planner.waypoints')}>
return card(
<>
<WaypointEmptyState />
{canEdit && <AddPointRow disabled={isSaving} />}
</TourSection>
</>
);
}
return (
<TourSection label={t('tours.planner.waypoints')}>
<ol data-touch-drag className="m-0 flex list-none flex-col gap-px p-0">
return card(
<>
<ol data-touch-drag className="m-0 -mx-1.5 flex list-none flex-col gap-px p-0">
{planner.waypoints.map((point, index) => {
const selected = planner.selectedWaypointId === point.id;
const isDropTarget = dropTarget?.id === point.id && draggedWaypointId !== point.id;
@@ -253,7 +270,7 @@ export default function TourWaypointList({
endDrag();
}}
onDragEnd={endDrag}
className={`relative rounded-[12px] ${draggedWaypointId === point.id ? 'opacity-45' : ''} ${isDropTarget ? 'bg-surface-hover' : ''}`}
className={`relative rounded-[10px] ${draggedWaypointId === point.id ? 'opacity-45' : ''} ${isDropTarget ? 'bg-surface-hover' : ''}`}
>
{isDropTarget && (
<span
@@ -261,12 +278,15 @@ export default function TourWaypointList({
className={`pointer-events-none absolute inset-x-2 z-10 h-0.5 rounded bg-accent ${dropTarget.placement === 'before' ? 'top-0' : 'bottom-0'}`}
/>
)}
<div className="group flex w-full items-center gap-2.5 rounded-[12px] px-2 py-1.5">
<div
className={`group flex w-full items-center gap-2 rounded-[10px] px-1.5 py-1.5 transition-colors ${selected ? 'bg-surface-selected' : 'hover:bg-surface-hover'}`}
>
{/* Out on hover, as a plan row's controls are; the row keeps its place for it. */}
<span
draggable={movable}
aria-hidden="true"
data-testid="tour-planner-waypoint-drag-handle"
className={`grid h-6 w-5 flex-none place-items-center text-content-faint ${movable ? 'cursor-grab active:cursor-grabbing' : 'opacity-35'}`}
className={`grid h-6 w-4 flex-none place-items-center text-content-faint transition-opacity ${movable ? 'cursor-grab opacity-0 active:cursor-grabbing group-focus-within:opacity-100 group-hover:opacity-100' : 'opacity-0'}`}
>
<GripVertical size={14} strokeWidth={1.8} />
</span>
@@ -285,7 +305,7 @@ export default function TourWaypointList({
>
{placeName ?? coordinates}
</span>
<SoftPill>{t(ROLE_KEY[point.role])}</SoftPill>
{point.role !== 'via' && <SoftPill>{t(ROLE_KEY[point.role])}</SoftPill>}
</span>
{placeName && (
<span
@@ -299,7 +319,7 @@ export default function TourWaypointList({
</button>
{canEdit && (
<span
className={`flex flex-none items-center gap-0.5 transition-opacity ${selected ? '' : 'opacity-50 group-focus-within:opacity-100 group-hover:opacity-100'}`}
className={`flex-none items-center gap-0.5 group-focus-within:flex group-hover:flex ${selected ? 'flex' : 'hidden'}`}
>
<WaypointAction
label={t('tours.planner.moveUp')}
@@ -335,6 +355,6 @@ export default function TourWaypointList({
})}
</ol>
{canEdit && <AddPointRow disabled={isSaving} />}
</TourSection>
</>
);
}
@@ -1,4 +1,3 @@
// @vitest-environment jsdom
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen } from '../../../../tests/helpers/render'
@@ -71,6 +70,110 @@ describe('useTourPlanner', () => {
afterEach(() => vi.useRealTimers())
it('PR1-05B: a new tour planned from an import remembers it, and only a create sends that', async () => {
createTour.mockResolvedValue({ tour: { ...savedTour, place_id: 90 }, waypoints: [] })
const { result } = renderHook(() => useTourPlanner({ tripId: 7 }))
act(() => {
expect(
result.current.startDerivedTour({
name: 'Recorded ridge',
description: 'Above the lake',
website: null,
difficulty: 3,
points: [
{ lat: 48, lng: 11 },
{ lat: 48.005, lng: 11.01 },
{ lat: 48.01, lng: 11.02 },
],
sourcePlaceId: 14,
inPlace: false,
})
).toBe(true)
})
expect(result.current.mode.type).toBe('new-draft')
expect(result.current.waypoints.map((point) => point.role)).toEqual(['start', 'via', 'end'])
expect(result.current.name).toBe('Recorded ridge')
expect(result.current.maxHikingDifficulty).toBe(3)
expect(result.current.derivedFromPlaceId).toBe(14)
// The draft keeps where it came from across a reload.
expect(JSON.parse(localStorage.getItem('tour-draft-7') ?? '{}').derivedFromPlaceId).toBe(14)
await act(async () => {
await vi.advanceTimersByTimeAsync(450)
})
await act(async () => {
await result.current.save()
})
expect(createTour).toHaveBeenCalledWith(
7,
expect.objectContaining({ derived_from_place_id: 14, name: 'Recorded ridge' })
)
expect(updateTour).not.toHaveBeenCalled()
})
it('PR1-05B: replacing an import edits that same tour, without claiming it was derived', async () => {
updateTour.mockResolvedValue({ tour: { ...savedTour, place_id: 14 }, waypoints: [] })
const { result } = renderHook(() => useTourPlanner({ tripId: 7 }))
act(() => {
result.current.startDerivedTour({
name: 'Recorded ridge',
difficulty: 1,
points: [
{ lat: 48, lng: 11 },
{ lat: 48.01, lng: 11.02 },
],
sourcePlaceId: 14,
inPlace: true,
})
})
expect(result.current.mode).toMatchObject({ type: 'edit-saved', placeId: 14 })
expect(result.current.derivedFromPlaceId).toBeNull()
await act(async () => {
await vi.advanceTimersByTimeAsync(450)
})
await act(async () => {
await result.current.save()
})
expect(updateTour).toHaveBeenCalledWith(
7,
14,
expect.not.objectContaining({ derived_from_place_id: expect.anything() })
)
expect(createTour).not.toHaveBeenCalled()
})
it('PR1-05B: takes no import draft without the right to edit or with fewer than two points', () => {
const readOnly = renderHook(() => useTourPlanner({ tripId: 7, canEdit: false }))
act(() => {
expect(
readOnly.result.current.startDerivedTour({
name: 'x',
difficulty: 2,
points: [
{ lat: 48, lng: 11 },
{ lat: 48.01, lng: 11 },
],
sourcePlaceId: 1,
inPlace: false,
})
).toBe(false)
})
const { result } = renderHook(() => useTourPlanner({ tripId: 7 }))
act(() => {
expect(
result.current.startDerivedTour({
name: 'x',
difficulty: 2,
points: [{ lat: 48, lng: 11 }],
sourcePlaceId: 1,
inPlace: false,
})
).toBe(false)
})
expect(result.current.waypoints).toHaveLength(0)
})
it('TOUR-PLANNER-001: normalizes roles and stores settled waypoint undo/redo snapshots', () => {
const { result } = renderHook(() => useTourPlanner({ tripId: 7 }))
act(() => result.current.addWaypoint(48, 11))
@@ -18,6 +18,22 @@ export interface TourPlannerWaypoint {
fixedLeg?: TourFixedLeg | null
}
/** What a tour planned from an import starts with. */
export interface DerivedTourDraft {
name: string
description?: string | null
website?: string | null
difficulty: TourMaxHikingDifficulty
points: Array<{ lat: number; lng: number }>
/** The import the points were taken from. */
sourcePlaceId: number
/**
* Plan the import over in place, keeping its id, days and notes (the server
* keeps the recorded track to go back to), rather than as a new tour beside it.
*/
inPlace: boolean
}
export type TourPlannerStatus =
| 'empty'
| 'dirty'
@@ -39,6 +55,8 @@ interface DraftPayload {
waypoints: TourPlannerWaypoint[]
maxHikingDifficulty?: TourMaxHikingDifficulty
editingPlaceId?: number | null
/** The import a new tour is being planned from; carried until it is saved. */
derivedFromPlaceId?: number | null
}
interface HistoryState {
@@ -83,6 +101,11 @@ function optionalPlaceId(value: number | null | undefined): boolean {
return value == null || (Number.isInteger(value) && value > 0)
}
/** Where a new tour was planned from; an edit leaves it as it was saved. */
function derivedFromField(placeId: number | null, derivedFrom: number | null): { derived_from_place_id?: number } {
return placeId === null && derivedFrom !== null ? { derived_from_place_id: derivedFrom } : {}
}
function optionalDifficulty(value: number | null | undefined): boolean {
return value == null || (Number.isInteger(value) && value >= 1 && value <= 6)
}
@@ -109,7 +132,8 @@ function validDraft(value: unknown): value is DraftPayload {
&& optionalString(draft.website)
&& optionalMinutes(draft.plannedDurationMinutes)
&& optionalMinutes(draft.breakAdditionalMinutes)
&& optionalPlaceId(draft.editingPlaceId)
&& optionalPlaceId(draft.editingPlaceId) &&
optionalPlaceId(draft.derivedFromPlaceId)
&& optionalDifficulty(draft.maxHikingDifficulty)
&& Array.isArray(draft.waypoints)
&& draft.waypoints.every(validDraftWaypoint)
@@ -174,6 +198,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
const [isSaving, setIsSaving] = useState(false)
const [draftRestored, setDraftRestored] = useState(Boolean(restored))
const [editingPlaceId, setEditingPlaceId] = useState<number | null>(restored?.editingPlaceId ?? null)
const [derivedFromPlaceId, setDerivedFromPlaceId] = useState<number | null>(restored?.derivedFromPlaceId ?? null)
const [openingTourId, setOpeningTourId] = useState<number | null>(null)
const [saveOutcome, setSaveOutcome] = useState<TourListItem | null>(null)
const [retryToken, setRetryToken] = useState(0)
@@ -489,6 +514,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setDraftRestored(false)
setEditingPlaceId(null)
editingPlaceIdRef.current = null
setDerivedFromPlaceId(null)
setOpeningTourId(null)
setSaveOutcome(null)
setReadOnlyGpxTour(null)
@@ -521,7 +547,40 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
}
returnToNeutral()
setHasUnsavedChanges(true)
}, [editingPlaceId, hasUnsavedChanges, name, returnToNeutral, waypoints.length])
}, [editingPlaceId, hasUnsavedChanges, name, returnToNeutral, waypoints.length]
)
/**
* A draft through the control points taken from an import: a new tour that
* remembers the import, or the import itself planned over in place. Nothing
* is stored until the draft is saved like any other.
*/
const startDerivedTour = useCallback(
(draft: DerivedTourDraft) => {
if (!editPermissionRef.current || saveInFlightRef.current || draft.points.length < 2) return false
returnToNeutral()
const points = normalizePlannerWaypoints(
draft.points.map(({ lat, lng }) => ({ id: randomId(), lat, lng, role: 'via' as const }))
)
setNameState(draft.name)
setDescriptionState(draft.description ?? '')
setWebsiteState(draft.website ?? '')
setWaypoints(points)
waypointsRef.current = points
setMaxHikingDifficultyState(draft.difficulty)
difficultyRef.current = draft.difficulty
setSelectedWaypointId(points[0].id)
if (draft.inPlace) {
setEditingPlaceId(draft.sourcePlaceId)
editingPlaceIdRef.current = draft.sourcePlaceId
} else {
setDerivedFromPlaceId(draft.sourcePlaceId)
}
setStatus('dirty')
setHasUnsavedChanges(true)
return true
},
[returnToNeutral])
const cancelNewTour = useCallback(() => setNewTourConfirmationOpen(false), [])
const toggleElevationProfile = useCallback(() => {
@@ -703,9 +762,12 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
useEffect(() => {
if (!hasUnsavedChanges) return
const draft: DraftPayload = { version: 6, name, description, website, plannedDurationMinutes, breakAdditionalMinutes, waypoints, maxHikingDifficulty, editingPlaceId }
const draft: DraftPayload = { version: 6, name, description, website, plannedDurationMinutes, breakAdditionalMinutes, waypoints, maxHikingDifficulty, editingPlaceId,
derivedFromPlaceId,
}
localStorage.setItem(storageKey, JSON.stringify(draft))
}, [breakAdditionalMinutes, description, editingPlaceId, hasUnsavedChanges, maxHikingDifficulty, name, plannedDurationMinutes, storageKey, waypoints, website])
}, [breakAdditionalMinutes,
derivedFromPlaceId, description, editingPlaceId, hasUnsavedChanges, maxHikingDifficulty, name, plannedDurationMinutes, storageKey, waypoints, website])
useEffect(() => {
if (!hasUnsavedChanges) return
@@ -745,6 +807,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
duration_seconds: walkingDurationMinutes === null ? durationSeconds : walkingDurationMinutes * 60,
planned_duration_minutes: plannedDurationMinutes,
break_additional_minutes: breakAdditionalMinutes,
...derivedFromField(submittedPlaceId, derivedFromPlaceId),
} as const
setStatus('saving')
setIsSaving(true)
@@ -784,7 +847,9 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setIsSaving(false)
}
}
}, [breakAdditionalMinutes, canSave, description, durationSeconds, walkingDurationMinutes, editingPlaceId, enrichedGeometry, invalidateDetailRequest, maxHikingDifficulty, name, onSaved, plannedDurationMinutes, storageKey, tripId, waypoints, website])
}, [breakAdditionalMinutes, canSave,
derivedFromPlaceId,
description, durationSeconds, walkingDurationMinutes, editingPlaceId, enrichedGeometry, invalidateDetailRequest, maxHikingDifficulty, name, onSaved, plannedDurationMinutes, storageKey, tripId, waypoints, website])
return {
canEdit, canAssign,
@@ -801,7 +866,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
routeProfileFocus, setRouteProfileFocus,
mapBaseLayer, setMapBaseLayer,
mode, readOnlyGpxTour, readOnlyGpxAnalysis, viewGpxTour, closeGpxTour,
startNewTour, newTourConfirmationOpen, cancelNewTour, forgetDeletedTour,
startNewTour,
startDerivedTour,
derivedFromPlaceId,
newTourConfirmationOpen, cancelNewTour, forgetDeletedTour,
elevationProfileExpanded, toggleElevationProfile, newDraftGeneration, draftRestored, mapFocusKey,
editingPlaceId, openingTourId, openTour,
saveOutcome, hasUnsavedChanges, canSave, save, discard, returnToNeutral,
@@ -0,0 +1,68 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useWaypointPlaceNames } from './useWaypointPlaceNames';
const geocode = vi.hoisted(() => ({ placeName: vi.fn(), knownName: vi.fn() }));
vi.mock('../../../repo/reverseGeocodeRepo', () => ({ reverseGeocodeRepo: geocode }));
vi.mock('../../../hooks/usePlaceLanguage', () => ({ usePlaceLanguage: () => 'de' }));
beforeEach(() => {
vi.useFakeTimers();
geocode.placeName.mockReset().mockResolvedValue('Fresh lookup');
geocode.knownName.mockReset().mockReturnValue(undefined);
});
afterEach(() => vi.useRealTimers());
describe('useWaypointPlaceNames', () => {
it('shows names answered before at once, without asking again', () => {
geocode.knownName.mockImplementation((lat: number) => (lat === 47 ? 'Kramerplateau' : undefined));
const { result } = renderHook(() => useWaypointPlaceNames([{ id: 'a', lat: 47, lng: 11 }]));
expect(result.current).toEqual({ a: 'Kramerplateau' });
act(() => {
vi.advanceTimersByTime(1000);
});
expect(geocode.placeName).not.toHaveBeenCalled();
});
it('asks only for the points it does not know, once they rest', async () => {
geocode.knownName.mockImplementation((lat: number) => (lat === 47 ? 'Kramerplateau' : undefined));
const points = [
{ id: 'a', lat: 47, lng: 11 },
{ id: 'b', lat: 47.1, lng: 11 },
];
const { result } = renderHook(() => useWaypointPlaceNames(points));
expect(geocode.placeName).not.toHaveBeenCalled();
await act(async () => {
await vi.advanceTimersByTimeAsync(400);
});
expect(geocode.placeName).toHaveBeenCalledTimes(1);
expect(geocode.placeName).toHaveBeenCalledWith(47.1, 11, 'de');
expect(result.current).toEqual({ a: 'Kramerplateau', b: 'Fresh lookup' });
});
it('never shows a moved point under the name of where it was', async () => {
const { result, rerender } = renderHook(({ points }) => useWaypointPlaceNames(points), {
initialProps: { points: [{ id: 'a', lat: 47.2, lng: 11 }] },
});
await act(async () => {
await vi.advanceTimersByTimeAsync(400);
});
expect(result.current).toEqual({ a: 'Fresh lookup' });
geocode.placeName.mockReturnValue(new Promise(() => undefined));
rerender({ points: [{ id: 'a', lat: 47.3, lng: 11 }] });
expect(result.current).toEqual({});
});
it('keeps the coordinates when a lookup fails', async () => {
geocode.placeName.mockRejectedValue(new Error('Offline'));
const { result } = renderHook(() => useWaypointPlaceNames([{ id: 'a', lat: 47.4, lng: 11 }]));
await act(async () => {
await vi.advanceTimersByTimeAsync(400);
});
expect(result.current).toEqual({});
});
});
@@ -22,16 +22,29 @@ const coordinateKey = (point: WaypointCoordinate) => `${point.lat.toFixed(5)},${
export function useWaypointPlaceNames(waypoints: WaypointCoordinate[]): Record<string, string> {
const lang = usePlaceLanguage();
const [names, setNames] = useState<Record<string, { key: string; name: string }>>({});
// Names answered before (on this visit or an earlier one) show at once; only new points wait for the lookup.
const known = useMemo(() => {
const result: Record<string, { key: string; name: string }> = {};
for (const point of waypoints) {
const name = reverseGeocodeRepo.knownName(point.lat, point.lng, lang);
if (name) result[point.id] = { key: coordinateKey(point), name };
}
return result;
}, [lang, waypoints]);
const signature = waypoints.map((point) => `${point.id}@${coordinateKey(point)}`).join('|');
useEffect(() => {
if (!signature) return undefined;
let cancelled = false;
const points = signature.split('|').map((entry) => {
const [id, key] = entry.split('@');
const [lat, lng] = key.split(',').map(Number);
return { id, key, lat, lng };
});
const points = signature
.split('|')
.map((entry) => {
const [id, key] = entry.split('@');
const [lat, lng] = key.split(',').map(Number);
return { id, key, lat, lng };
})
.filter((point) => reverseGeocodeRepo.knownName(point.lat, point.lng, lang) === undefined);
if (points.length === 0) return undefined;
const timer = window.setTimeout(() => {
for (const point of points) {
reverseGeocodeRepo.placeName(point.lat, point.lng, lang).then(
@@ -59,9 +72,9 @@ export function useWaypointPlaceNames(waypoints: WaypointCoordinate[]): Record<s
return useMemo(() => {
const result: Record<string, string> = {};
for (const point of waypoints) {
const entry = names[point.id];
const entry = known[point.id] ?? names[point.id];
if (entry && entry.key === coordinateKey(point)) result[point.id] = entry.name;
}
return result;
}, [names, waypoints]);
}, [known, names, waypoints]);
}
@@ -0,0 +1,138 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type { TourProvenance, TourSourceCheckResponse } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import TourProvenanceSection from './TourProvenanceSection';
import type { SourceCheck } from './useSourceCheck';
const base: TourProvenance = {
origin: 'trek',
createdAt: '2026-07-01 10:00:00',
source: { kind: 'trek' },
derivedFrom: null,
plannedOver: false,
editedLocally: null,
};
function check(over: Partial<SourceCheck> = {}): SourceCheck {
return {
checking: false,
result: null,
check: vi.fn(),
canReimport: false,
confirming: false,
askReimport: vi.fn(),
cancelReimport: vi.fn(),
reimporting: false,
reimport: vi.fn(),
...over,
} as SourceCheck;
}
// Rendered without the translation provider: every text reads as its key, so the test pins which string each part shows.
describe('TourProvenanceSection (P2C-02, MF-03)', () => {
it('names a planned tour as planned here, with no local state and no check', () => {
render(<TourProvenanceSection provenance={base} />);
expect(screen.getByText('tours.provenance.label')).toBeInTheDocument();
expect(screen.getByText('tours.badge.planned')).toBeInTheDocument();
expect(screen.getByText('tours.provenance.createdAt')).toBeInTheDocument();
expect(screen.queryByText('tours.provenance.localState')).toBeNull();
expect(screen.queryByRole('button', { name: /tours.provenance.check/ })).toBeNull();
});
it('shows a GPX import with its file, and that it was planned over keeping its track', () => {
render(
<TourProvenanceSection
provenance={{ ...base, origin: 'gpx', source: { kind: 'gpx', fileName: 'ridge.gpx' }, plannedOver: true }}
/>
);
expect(screen.getByText('tours.badge.gpx')).toBeInTheDocument();
expect(screen.getByText('ridge.gpx')).toBeInTheDocument();
expect(screen.getByText('tours.provenance.importedAt')).toBeInTheDocument();
expect(screen.getByText('tours.provenance.plannedOver')).toBeInTheDocument();
});
it('links a wanderer snapshot to its trail, tells local edits apart and checks the source on purpose', () => {
const onCheck = vi.fn();
render(
<TourProvenanceSection
provenance={{
...base,
origin: 'wanderer',
source: {
kind: 'wanderer',
instance: 'https://trails.example',
externalId: 't1',
externalUrl: 'https://trails.example/trail/view/@me/t1',
externalUpdated: '2026-10-01 08:00:00Z',
importedAt: '2026-10-02 09:00:00',
},
editedLocally: true,
}}
check={check({ check: onCheck })}
/>
);
expect(screen.getByText('trails.example')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'tours.provenance.openSource' })).toHaveAttribute(
'href',
'https://trails.example/trail/view/@me/t1'
);
expect(screen.getByText('tours.provenance.externalUpdated')).toBeInTheDocument();
expect(screen.getByText('tours.provenance.editedLocally')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /tours.provenance.check$/ }));
expect(onCheck).toHaveBeenCalledTimes(1);
});
it('says what the check found, the parts that changed, and asks before the reimport', () => {
const result: TourSourceCheckResponse = {
state: 'changed',
changes: ['name', 'route'],
externalUpdated: '2026-10-14 12:00:00Z',
importedUpdated: null,
editedLocally: false,
reimport: { trailId: 't1', contentHash: 'h' },
};
const askReimport = vi.fn();
const { rerender } = render(
<TourProvenanceSection
provenance={{
...base,
origin: 'wanderer',
source: {
kind: 'wanderer',
instance: 'https://t.example',
externalId: 't1',
externalUrl: null,
externalUpdated: null,
importedAt: null,
},
}}
check={check({ result, canReimport: true, askReimport })}
/>
);
expect(screen.getByRole('status')).toHaveTextContent('tours.provenance.state.changed');
expect(screen.getByText('tours.provenance.change.name')).toBeInTheDocument();
expect(screen.getByText('tours.provenance.change.route')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /tours.provenance.reimport/ }));
expect(askReimport).toHaveBeenCalledTimes(1);
rerender(
<TourProvenanceSection
provenance={{
...base,
origin: 'wanderer',
source: {
kind: 'wanderer',
instance: 'https://t.example',
externalId: 't1',
externalUrl: null,
externalUpdated: null,
importedAt: null,
},
}}
check={check({ result: { ...result, state: 'unavailable', changes: [], reimport: null } })}
/>
);
expect(screen.getByText('tours.provenance.body.unavailable')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /tours.provenance.reimport/ })).toBeNull();
});
});
@@ -0,0 +1,287 @@
import type { TourListItem, TourProvenance, TourSourceCheckResponse } from '@trek/shared';
import { Download, ExternalLink, FileDown, Loader2, PenLine, RefreshCw } from 'lucide-react';
import type { ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { formatDate } from '../../../utils/formatters';
import { BOX } from '../../Planner/bookings/bookingParts';
import { SoftPill } from '../../Planner/planParts';
import ConfirmDialog from '../../shared/ConfirmDialog';
import { DialogSection, fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { CardAction, InfoDot } from '../tourParts';
import WandererMark from '../wanderer/WandererMark';
import { type SourceCheck, useSourceCheck } from './useSourceCheck';
import { useTourProvenance } from './useTourProvenance';
type Translate = (key: string, params?: Record<string, unknown>) => string;
/** A stored time as the reader's date; the time of day says nothing here. */
function dayOf(value: string | null, locale: string): string | null {
return value ? formatDate(value.slice(0, 10), locale) : null;
}
function hostOf(instance: string): string {
try {
return new URL(instance).host;
} catch {
return instance;
}
}
/** One line of the card: a round icon, what it is, and the value beside it. */
function ProvenanceRow({ icon, label, children }: { icon: ReactNode; label: string; children: ReactNode }) {
return (
<div className="flex min-w-0 items-center gap-2.5 px-3 py-2">
<span className="grid h-7 w-7 flex-none place-items-center rounded-full bg-surface-secondary text-content-muted">
{icon}
</span>
<span className="w-[92px] flex-none truncate text-content-faint" style={fs(11)}>
{label}
</span>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5 text-content" style={fs(12.5, 'body')}>
{children}
</div>
</div>
);
}
function SourceValue({ provenance, t }: { provenance: TourProvenance; t: Translate }) {
const { source } = provenance;
if (source.kind === 'gpx') {
return (
<>
<span className="font-semibold">{t('tours.badge.gpx')}</span>
{source.fileName && (
<span className="min-w-0 truncate font-geist text-content-muted" style={fs(11.5)}>
{source.fileName}
</span>
)}
</>
);
}
if (source.kind === 'wanderer') {
return (
<>
<span className="font-semibold">{t('tours.badge.wanderer')}</span>
<span className="min-w-0 truncate font-geist text-content-muted" style={fs(11.5)}>
{hostOf(source.instance)}
</span>
{source.externalUrl && (
<Tooltip label={t('tours.provenance.openSource')}>
<a
href={source.externalUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={t('tours.provenance.openSource')}
className="grid h-6 w-6 flex-none place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<ExternalLink size={12} strokeWidth={2.2} />
</a>
</Tooltip>
)}
</>
);
}
return <span className="font-semibold">{t('tours.badge.planned')}</span>;
}
/** How the tour stands here against what came in: planned over, edited, or as it was. */
function LocalState({ provenance, t }: { provenance: TourProvenance; t: Translate }) {
const pills: ReactNode[] = [];
if (provenance.plannedOver) {
pills.push(
<SoftPill key="planned" tone="info">
{t('tours.provenance.plannedOver')}
</SoftPill>
);
}
if (provenance.editedLocally === true) {
pills.push(
<SoftPill key="edited" tone="warning">
{t('tours.provenance.editedLocally')}
</SoftPill>
);
} else if (provenance.editedLocally === false && !provenance.plannedOver) {
pills.push(
<SoftPill key="same" tone="success">
{t('tours.provenance.asImported')}
</SoftPill>
);
}
return pills.length ? <>{pills}</> : null;
}
const STATE_TONE: Record<TourSourceCheckResponse['state'], 'success' | 'warning' | 'neutral'> = {
unchanged: 'success',
changed: 'warning',
unavailable: 'neutral',
not_connected: 'neutral',
other_instance: 'neutral',
unknown: 'neutral',
};
/** What the check found, the parts that differ, and the way to take the change in. */
function CheckResult({ check, locale, t }: { check: SourceCheck; locale: string; t: Translate }) {
const { result } = check;
if (!result) return null;
const body =
result.state === 'unchanged' || result.state === 'changed' ? null : t(`tours.provenance.body.${result.state}`);
const nowUpdated = dayOf(result.externalUpdated, locale);
return (
<div role="status" className="flex flex-col gap-2 border-t border-edge-faint px-3 py-2.5">
<div className="flex flex-wrap items-center gap-1.5">
<SoftPill tone={STATE_TONE[result.state]}>{t(`tours.provenance.state.${result.state}`)}</SoftPill>
{result.changes.map((part) => (
<SoftPill key={part}>{t(`tours.provenance.change.${part}`)}</SoftPill>
))}
</div>
{body && (
<p className="m-0 leading-snug text-content-muted" style={fs(12, 'body')}>
{body}
</p>
)}
{nowUpdated && (
<p className="m-0 leading-snug text-content-faint" style={fs(11)}>
{t('tours.provenance.nowUpdated', { date: nowUpdated })}
</p>
)}
{check.canReimport && (
<div>
<CardAction
icon={
check.reimporting ? (
<Loader2 size={12} className="animate-spin" />
) : (
<Download size={12} strokeWidth={2.2} />
)
}
onClick={check.askReimport}
disabled={check.reimporting}
>
{t('tours.provenance.reimport')}
</CardAction>
</div>
)}
</div>
);
}
interface TourProvenanceSectionProps {
provenance: TourProvenance;
/** Given for a wanderer snapshot the user may change: the check of its source and the reimport. */
check?: SourceCheck;
}
/**
* P2C-02 in the tour's detail: where it came from (planned here, a GPX file,
* a wanderer trail with its link), when, and how it stands here against what
* came in. For a wanderer snapshot, MF-03: the check of its source, on purpose
* and read only, and from a change the confirmed reimport as a new snapshot.
*/
export default function TourProvenanceSection({ provenance, check }: TourProvenanceSectionProps) {
const { t, locale } = useTranslation();
const { source } = provenance;
const imported = source.kind !== 'trek';
const importedAt = dayOf((source.kind === 'wanderer' ? source.importedAt : null) ?? provenance.createdAt, locale);
const externalUpdated = source.kind === 'wanderer' ? dayOf(source.externalUpdated, locale) : null;
const local = <LocalState provenance={provenance} t={t} />;
const sourceIcon =
source.kind === 'gpx' ? (
<FileDown size={13} strokeWidth={2} />
) : source.kind === 'wanderer' ? (
<WandererMark size={13} />
) : (
<PenLine size={13} strokeWidth={2} />
);
return (
<DialogSection
className="flex-none"
label={t('tours.provenance.label')}
action={
check && (
<Tooltip label={t('tours.provenance.checkHint')}>
<span className="inline-flex">
<CardAction
icon={
check.checking ? (
<Loader2 size={12} className="animate-spin" />
) : (
<RefreshCw size={12} strokeWidth={2.2} />
)
}
onClick={() => void check.check()}
disabled={check.checking}
>
{t('tours.provenance.check')}
</CardAction>
</span>
</Tooltip>
)
}
>
<div className={`${BOX} divide-y divide-edge-faint`}>
<ProvenanceRow icon={sourceIcon} label={t('tours.provenance.source')}>
<SourceValue provenance={provenance} t={t} />
</ProvenanceRow>
{importedAt && (
<ProvenanceRow
icon={<Download size={13} strokeWidth={2} />}
label={t(imported ? 'tours.provenance.importedAt' : 'tours.provenance.createdAt')}
>
<span>{importedAt}</span>
</ProvenanceRow>
)}
{externalUpdated && (
<ProvenanceRow icon={<RefreshCw size={13} strokeWidth={2} />} label={t('tours.provenance.externalUpdated')}>
<span>{externalUpdated}</span>
<InfoDot
label={t('tours.provenance.externalUpdated')}
description={t('tours.provenance.externalUpdatedHint')}
/>
</ProvenanceRow>
)}
{imported && (provenance.plannedOver || provenance.editedLocally !== null) && (
<ProvenanceRow icon={<PenLine size={13} strokeWidth={2} />} label={t('tours.provenance.localState')}>
{local}
</ProvenanceRow>
)}
{check && <CheckResult check={check} locale={locale} t={t} />}
</div>
{check && (
<ConfirmDialog
isOpen={check.confirming}
onClose={check.cancelReimport}
onConfirm={() => void check.reimport()}
title={t('tours.provenance.reimportTitle')}
message={t('tours.provenance.reimportBody')}
confirmLabel={t('tours.provenance.reimport')}
danger={false}
/>
)}
</DialogSection>
);
}
/**
* The section as the tour's detail places it: reads the provenance with the
* detail, and offers the source check only on a wanderer snapshot the user
* may change, where a reimport has somewhere to go.
*/
export function TourProvenanceBlock({
tripId,
tour,
canCheck,
onReimported,
}: {
tripId: number;
tour: TourListItem;
canCheck: boolean;
onReimported?: (tour: TourListItem) => Promise<void> | void;
}) {
const provenance = useTourProvenance(tripId, tour);
const check = useSourceCheck({ tripId, placeId: tour.place_id, onReimported });
if (!provenance) return null;
const checkable = canCheck && provenance.origin === 'wanderer';
return <TourProvenanceSection provenance={provenance} check={checkable ? check : undefined} />;
}
@@ -0,0 +1,103 @@
import { act, renderHook } from '@testing-library/react';
import type { TourListItem, TourSourceCheckResponse } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useSourceCheck } from './useSourceCheck';
const repo = vi.hoisted(() => ({ checkSource: vi.fn(), importTrail: vi.fn() }));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('../../../repo/wandererRepo', () => ({
wandererRepo: repo,
wandererErrorCode: (error: unknown) => (error as { code?: string })?.code ?? 'unknown',
}));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
const changed: TourSourceCheckResponse = {
state: 'changed',
changes: ['route'],
externalUpdated: '2026-10-14',
importedUpdated: '2026-10-01',
editedLocally: false,
reimport: { trailId: 't1', contentHash: 'h' },
};
const newTour = { place_id: 99, name: 'Ridge' } as TourListItem;
beforeEach(() => {
vi.clearAllMocks();
repo.checkSource.mockResolvedValue(changed);
repo.importTrail.mockResolvedValue({ tour: newTour });
});
describe('useSourceCheck (MF-03)', () => {
it('asks only on purpose, and offers the reimport for a change when someone takes it in', async () => {
const onReimported = vi.fn();
const { result } = renderHook(() => useSourceCheck({ tripId: 1, placeId: 7, onReimported }));
expect(repo.checkSource).not.toHaveBeenCalled();
expect(result.current.result).toBeNull();
await act(async () => {
await result.current.check();
});
expect(repo.checkSource).toHaveBeenCalledWith(1, 7);
expect(result.current.result).toEqual(changed);
expect(result.current.canReimport).toBe(true);
act(() => result.current.askReimport());
expect(result.current.confirming).toBe(true);
await act(async () => {
await result.current.reimport();
});
expect(repo.importTrail).toHaveBeenCalledWith(1, { trailId: 't1', contentHash: 'h' });
expect(onReimported).toHaveBeenCalledWith(newTour);
expect(toast.success).toHaveBeenCalled();
expect(result.current.result).toBeNull();
expect(result.current.confirming).toBe(false);
});
it('without anyone to take the new snapshot in, or for an unchanged trail, offers no reimport', async () => {
repo.checkSource.mockResolvedValue({ ...changed, state: 'unchanged', changes: [], reimport: null });
const unchanged = renderHook(() => useSourceCheck({ tripId: 1, placeId: 7, onReimported: vi.fn() }));
await act(async () => {
await unchanged.result.current.check();
});
expect(unchanged.result.current.canReimport).toBe(false);
repo.checkSource.mockResolvedValue(changed);
const nobody = renderHook(() => useSourceCheck({ tripId: 1, placeId: 7 }));
await act(async () => {
await nobody.result.current.check();
});
expect(nobody.result.current.canReimport).toBe(false);
});
it('a failed check or reimport says so and changes nothing', async () => {
repo.checkSource.mockRejectedValueOnce({ code: 'unreachable' });
const onReimported = vi.fn();
const { result } = renderHook(() => useSourceCheck({ tripId: 1, placeId: 7, onReimported }));
await act(async () => {
await result.current.check();
});
expect(result.current.result).toBeNull();
expect(toast.error).toHaveBeenCalledTimes(1);
await act(async () => {
await result.current.check();
});
repo.importTrail.mockRejectedValueOnce({ code: 'preview_stale' });
await act(async () => {
await result.current.reimport();
});
expect(onReimported).not.toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledTimes(2);
});
it('another tour in the same dialog starts unchecked', async () => {
const { result, rerender } = renderHook(({ placeId }) => useSourceCheck({ tripId: 1, placeId }), {
initialProps: { placeId: 7 },
});
await act(async () => {
await result.current.check();
});
rerender({ placeId: 8 });
expect(result.current.result).toBeNull();
});
});
@@ -0,0 +1,89 @@
import type { TourListItem, TourSourceCheckResponse } from '@trek/shared';
import { useCallback, useState } from 'react';
import { useTranslation } from '../../../i18n';
import { wandererErrorCode, wandererRepo } from '../../../repo/wandererRepo';
import { useToast } from '../../shared/Toast';
import { wandererErrorText } from '../wanderer/wandererErrors';
interface SourceCheckOptions {
tripId: number;
placeId: number;
/** The new snapshot a confirmed reimport brought in, for the lists and the map to take it in. */
onReimported?: (tour: TourListItem) => Promise<void> | void;
}
/**
* MF-03: asking the source of a wanderer snapshot, on purpose, whether the
* trail changed since it came in. The check only reads; nothing in the trip
* changes until the user confirms a reimport, which goes the way every
* wanderer import goes and comes in as a new snapshot beside this one.
*/
export function useSourceCheck({ tripId, placeId, onReimported }: SourceCheckOptions) {
const { t } = useTranslation();
const toast = useToast();
const [checking, setChecking] = useState(false);
const [reimporting, setReimporting] = useState(false);
// The answer and the question belong to the tour they were asked for:
// another tour shown in the same dialog starts unchecked.
const [state, setState] = useState<{ placeId: number; result: TourSourceCheckResponse | null; confirming: boolean }>({
placeId,
result: null,
confirming: false,
});
const result = state.placeId === placeId ? state.result : null;
const confirming = state.placeId === placeId && state.confirming;
const setResult = useCallback(
(next: TourSourceCheckResponse | null) => setState({ placeId, result: next, confirming: false }),
[placeId]
);
const setConfirming = useCallback(
(next: boolean) => setState((current) => ({ ...current, placeId, confirming: next })),
[placeId]
);
const check = useCallback(async () => {
if (checking) return;
setChecking(true);
try {
setResult(await wandererRepo.checkSource(tripId, placeId));
} catch (error: unknown) {
setResult(null);
toast.error(wandererErrorText(t, wandererErrorCode(error)));
} finally {
setChecking(false);
}
}, [checking, placeId, setResult, t, toast, tripId]);
const reimport = useCallback(async () => {
const payload = result?.reimport;
if (!payload || reimporting) return;
setReimporting(true);
try {
const { tour } = await wandererRepo.importTrail(tripId, payload);
setConfirming(false);
setResult(null);
await onReimported?.(tour);
toast.success(t('tours.provenance.reimported'));
} catch (error: unknown) {
setConfirming(false);
toast.error(wandererErrorText(t, wandererErrorCode(error)));
} finally {
setReimporting(false);
}
}, [onReimported, reimporting, result, setConfirming, setResult, t, toast, tripId]);
return {
checking,
result,
check,
/** A reimport can be offered: the trail was read and differs, and someone takes the new snapshot in. */
canReimport: !!result?.reimport && !!onReimported,
confirming,
askReimport: useCallback(() => setConfirming(true), [setConfirming]),
cancelReimport: useCallback(() => setConfirming(false), [setConfirming]),
reimporting,
reimport,
};
}
export type SourceCheck = ReturnType<typeof useSourceCheck>;
@@ -0,0 +1,48 @@
import { renderHook, waitFor } from '@testing-library/react';
import type { TourListItem, TourProvenance } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useTourProvenance } from './useTourProvenance';
const repo = vi.hoisted(() => ({ detail: vi.fn() }));
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: repo }));
const provenance: TourProvenance = {
origin: 'trek',
createdAt: null,
source: { kind: 'trek' },
derivedFrom: null,
plannedOver: false,
editedLocally: null,
};
const tour = { place_id: 7, name: 'Ridge', distance: 4 } as TourListItem;
beforeEach(() => {
vi.clearAllMocks();
repo.detail.mockResolvedValue({ tour, waypoints: [], provenance });
});
describe('useTourProvenance (P2C-02)', () => {
it('reads the provenance with the detail, and asks again when the tour changes', async () => {
const { result, rerender } = renderHook(({ current }) => useTourProvenance(1, current), {
initialProps: { current: tour },
});
expect(result.current).toBeNull();
await waitFor(() => expect(result.current).toEqual(provenance));
expect(repo.detail).toHaveBeenCalledWith(1, 7, expect.any(AbortSignal));
rerender({ current: { ...tour, name: 'Renamed' } });
await waitFor(() => expect(repo.detail).toHaveBeenCalledTimes(2));
});
it('shows none when the detail cannot be had, or carries none', async () => {
repo.detail.mockRejectedValueOnce(new Error('offline'));
const failed = renderHook(() => useTourProvenance(1, tour));
await waitFor(() => expect(repo.detail).toHaveBeenCalled());
expect(failed.result.current).toBeNull();
repo.detail.mockResolvedValueOnce({ tour, waypoints: [] });
const none = renderHook(() => useTourProvenance(1, { ...tour, place_id: 8 }));
await waitFor(() => expect(repo.detail).toHaveBeenCalledTimes(2));
expect(none.result.current).toBeNull();
});
});
@@ -0,0 +1,39 @@
import type { TourListItem, TourProvenance } from '@trek/shared';
import { useEffect, useState } from 'react';
import { tourRepo } from '../../../repo/tourRepo';
/**
* Where a tour came from (P2C-02), read with its detail: online from the
* server, offline from the copy the last detail left. Asked again whenever a
* field it depends on changes (the name and line for the local edit state, the
* kept track, the import it was planned from). Null while it loads and when no
* copy is to be had; the caller then simply shows no provenance.
*/
export function useTourProvenance(tripId: number, tour: TourListItem): TourProvenance | null {
const [state, setState] = useState<{ key: string; provenance: TourProvenance | null } | null>(null);
const key = [
tripId,
tour.place_id,
tour.name,
tour.distance,
tour.has_recorded_track,
tour.derived_from_place_id,
tour.origin,
].join('|');
useEffect(() => {
const controller = new AbortController();
tourRepo.detail(tripId, tour.place_id, controller.signal).then(
(detail) => {
if (!controller.signal.aborted) setState({ key, provenance: detail.provenance ?? null });
},
() => {
if (!controller.signal.aborted) setState({ key, provenance: null });
}
);
return () => controller.abort();
// `key` also carries the fields the detail is asked again for.
}, [key, tripId, tour.place_id]);
return state?.key === key ? state.provenance : null;
}
+36
View File
@@ -0,0 +1,36 @@
import { useMemo } from 'react';
import { useTripStore } from '../../store/tripStore';
import type { AssignmentsMap, Day } from '../../types';
import { formatDate } from '../../utils/formatters';
type Translate = (key: string, params?: Record<string, unknown>) => string;
export interface TourDay {
day: Day;
/** The day's position in the trip, so `index + 1` is the number the plan shows. */
index: number;
}
/**
* The days of the trip a tour is on, in the order of the trip and each once,
* read from the day lists themselves rather than from the tour list's
* `planned` flag, so a tour put on a day or taken off one shows it at once.
*/
export function tourDaysOf(placeId: number, days: readonly Day[], assignments: AssignmentsMap): TourDay[] {
return days.flatMap((day, index) =>
(assignments[String(day.id)] ?? []).some((assignment) => assignment.place_id === placeId) ? [{ day, index }] : []
);
}
/** `tourDaysOf` for one tour, following the store's day lists. */
export function useTourDays(placeId: number, days: readonly Day[]): TourDay[] {
const assignments = useTripStore((state) => state.assignments);
return useMemo(() => tourDaysOf(placeId, days, assignments ?? {}), [placeId, days, assignments]);
}
/** "Day 2, Tue, Jul 8: Lakes" as far as the day has a date and a title. */
export function dayMenuLabel(day: Day, index: number, t: Translate, locale: string): string {
const date = formatDate(day.date, locale);
const head = date ? `${t('dayplan.dayN', { n: index + 1 })}, ${date}` : t('dayplan.dayN', { n: index + 1 });
return day.title ? `${head}: ${day.title}` : head;
}
+28
View File
@@ -0,0 +1,28 @@
import type { DistanceUnit } from '../../types';
import type { RouteGeometryAnalysis } from '../../utils/routeGeometry';
import { formatDistance, formatElevation } from '../../utils/units';
import type { TourMetricField } from './tourParts';
/** A line's measures as label and value: distance, the extras, lowest and highest point, ascent, descent. */
export function tourMetricFacts(
analysis: Pick<RouteGeometryAnalysis, 'distanceKm' | 'minEle' | 'maxEle' | 'gain' | 'loss'> | null,
unitSetting: DistanceUnit | undefined,
t: (key: string, params?: Record<string, unknown>) => string,
extra: readonly TourMetricField[] = []
): TourMetricField[] {
// The setting is unset until the user picks one; metric is what the app shows then.
const unit = unitSetting ?? 'metric';
const fields: TourMetricField[] = [
{ label: t('tours.detail.distance'), value: analysis ? formatDistance(analysis.distanceKm, unit) : '-' },
...extra,
];
if (analysis?.minEle != null)
fields.push({ label: t('tours.detail.minAltitude'), value: formatElevation(analysis.minEle, unit) });
if (analysis?.maxEle != null)
fields.push({ label: t('tours.detail.maxAltitude'), value: formatElevation(analysis.maxEle, unit) });
if (analysis?.gain != null)
fields.push({ label: t('tours.detail.ascent'), value: formatElevation(analysis.gain, unit) });
if (analysis?.loss != null)
fields.push({ label: t('tours.detail.descent'), value: formatElevation(analysis.loss, unit) });
return fields;
}
+230 -95
View File
@@ -1,30 +1,18 @@
import { useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
import type { TourListItem } from '@trek/shared'
import { AlertTriangle, ArrowDown, ArrowUp, ChevronDown, Clock, Info, Mountain, Plus, Ruler, type LucideIcon } from 'lucide-react'
import { AlertTriangle, CalendarDays, ChevronDown, Info, Mountain, Plus } from 'lucide-react'
import type { Day, DistanceUnit } from '../../types'
import type { RouteGeometryAnalysis } from '../../utils/routeGeometry'
import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { formatDistance, formatElevation } from '../../utils/units'
import { formatDate } from '../../utils/formatters'
import { ContextMenu, useContextMenu } from '../shared/ContextMenu'
import { DialogButton, fs } from '../shared/DialogShell'
import { Tooltip } from '../shared/Tooltip'
import { useTripStore } from '../../store/tripStore'
import { BOX, Eyebrow } from '../Planner/bookings/bookingParts'
import { SoftPill } from '../Planner/planParts'
import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourPlannedTimes, tourSource } from './tourPresentation'
type Translate = (key: string, params?: Record<string, unknown>) => string
/** The round tile a tour stands behind in a list, where a place has its avatar. */
export function TourTile({ size = 34, color, raised = false }: { size?: number; color?: string; raised?: boolean }) {
return (
<span className={`grid flex-none place-items-center rounded-full ${raised ? 'bg-surface-card shadow-sm' : 'bg-surface-tertiary'}`} style={{ width: size, height: size }}>
<Mountain size={Math.round(size * 0.44)} strokeWidth={1.9} style={{ color: color ?? 'var(--text-muted)' }} />
</span>
)
}
import { SoftPill, WhiteBadge } from '../Planner/planParts'
import { hikeSourceBadgeLabel, tourSource } from './tourPresentation'
import { type TourDay, dayMenuLabel } from './tourDays'
import { tourMetricFacts } from './tourFacts'
import type { TourDayAssignment } from './useTourDayAssignment'
/**
* A tour's tile in a list: its T-scale grade in place of the mountain, named in
@@ -44,11 +32,11 @@ export function TourGradeTile({ tour }: { tour: Pick<TourListItem, 'max_hiking_d
</span>
</Tooltip>
{source !== 'trek' && (
<span className="absolute inset-x-0 -bottom-1.5 flex justify-center">
<span className="absolute inset-x-0 -bottom-1 flex justify-center">
<Tooltip label={hikeSourceBadgeLabel(tour, t)}>
<span data-testid="tour-source-badge" aria-label={hikeSourceBadgeLabel(tour, t)}
className="rounded-full bg-surface-card px-1 font-geist font-semibold uppercase leading-[14px] tracking-[.04em] text-content-secondary shadow-sm ring-1 ring-inset ring-edge-faint"
style={fs(8.5)}>
className="rounded-full bg-surface-card px-[3px] font-geist font-semibold uppercase leading-[11px] tracking-[.02em] text-content-secondary shadow-sm ring-1 ring-inset ring-edge-faint"
style={fs(7)}>
{t(`tours.badgeShort.${source}`)}
</span>
</Tooltip>
@@ -58,80 +46,134 @@ export function TourGradeTile({ tour }: { tour: Pick<TourListItem, 'max_hiking_d
)
}
/** One measured fact of a tour as a white pill: its icon, an optional short word and the value, named in full for a screen reader. */
function TourMetricPill({ icon: Icon, label, value, prefix }: { icon: LucideIcon; label: string; value: string; prefix?: string }) {
/**
* A list of tours as one card, the way the plan shows a day: a head band with
* an icon, a title and what it counts, the rows below it.
*/
export function TourListCard({ icon,
title,
aside, label,
children,
}: { icon: ReactNode
title: string
aside?: ReactNode
/** The list's name for a screen reader. */
label: string
children: ReactNode
}) {
return (
<SoftPill className="tabular-nums" icon={<Icon size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
<span aria-label={`${label}: ${value}`}>{prefix ? `${prefix} ${value}` : value}</span>
</SoftPill>
)
}
/** The facts of a tour as white pills, kept to one short row: how far, up and down, how long, how hard, and where it came from when that was not TREK. */
export function TourFactPills({ tour, className = '', children }: { tour: TourListItem; className?: string; children?: ReactNode }) {
const { t } = useTranslation()
const unit = useSettingsStore(state => state.settings.distance_unit)
const times = tourPlannedTimes(tour)
const shownMinutes = times.plannedTotalMinutes ?? times.walkingMinutes
const timeParts = [
times.walkingMinutes != null ? `${t('tours.planner.walkingShort')} ${formatPlannedTourDuration(times.walkingMinutes)}` : null,
times.breakMinutes != null ? `${t('tours.planner.breaksShort')} ${formatPlannedTourDuration(times.breakMinutes)}` : null,
times.plannedTotalMinutes != null
? `${t('tours.planner.plannedShort')} ${formatPlannedTourDuration(times.plannedTotalMinutes)}${times.manuallyOverridden ? ` (${t('tours.planner.plannedTotalManual')})` : ''}`
: null,
].filter((part): part is string => part !== null)
return (
<div data-testid="tour-metrics" className={`flex min-w-0 flex-wrap items-center gap-1 ${className}`}>
<TourMetricPill icon={Ruler} label={t('tours.detail.distance')} value={formatDistance(tour.distance ?? 0, unit)} />
{tour.elevation_gain != null && <TourMetricPill icon={ArrowUp} label={t('tours.detail.ascent')} value={formatElevation(tour.elevation_gain, unit)} />}
{tour.elevation_loss != null && <TourMetricPill icon={ArrowDown} label={t('tours.detail.descent')} value={formatElevation(tour.elevation_loss, unit)} />}
{shownMinutes != null && (
<Tooltip label={timeParts.join(', ')}>
<span className="inline-flex">
<TourMetricPill icon={Clock} label={timeParts.join(', ')} value={formatPlannedTourDuration(shownMinutes)} />
</span>
</Tooltip>
)}
{tour.caution && (
<Tooltip label={t('tours.caution.tooltip')}>
<span aria-label={t('tours.caution.badge')} className="inline-flex">
<SoftPill tone="warning" icon={<AlertTriangle size={10} strokeWidth={2.2} className="flex-none" />}>{t('tours.caution.badge')}</SoftPill>
</span>
</Tooltip>
)}
{children}
<div className="flex-none overflow-hidden rounded-2xl border border-edge-faint bg-surface-card">
<div className="flex items-center gap-2 border-b border-edge-faint bg-surface-secondary px-3 py-2">
<span className="grid h-4 w-4 flex-none place-items-center text-content-muted">{icon}</span>
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(12.5, 'body')}>
{title}
</span>
{aside}
</div>
<ul role="listbox" aria-label={label} className="m-0 list-none py-1">
{children}
</ul>
</div>
)
}
/** "Day 2, Tue, Jul 8: Lakes" as far as the day has a date and a title. */
function dayMenuLabel(day: Day, index: number, t: Translate, locale: string): string {
const date = formatDate(day.date, locale)
const head = date ? `${t('dayplan.dayN', { n: index + 1 })}, ${date}` : t('dayplan.dayN', { n: index + 1 })
return day.title ? `${head}: ${day.title}` : head
/** How many days a list row names before the rest fold into one "+n" pill. */
const SHOWN_TOUR_DAYS = 3
/** The days a tour is on as one white badge of a plan row: "Days 2, 4", each day named in full in the tooltip. */
export function TourDaysBadge({ tourDays }: { tourDays: readonly TourDay[] }) {
const { t, locale } = useTranslation()
if (tourDays.length === 0) return null
const label = tourDays.map(({ day, index }) => dayMenuLabel(day, index, t, locale)).join(', ')
const numbers = tourDays.map(({ index }) => index + 1).join(', ')
return (
<Tooltip label={label}>
<span data-testid="tour-days-badge" aria-label={label} className="inline-flex flex-none">
<WhiteBadge icon={<CalendarDays size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
{t('tours.days.short', { count: tourDays.length, days: numbers })}
</WhiteBadge>
</span>
</Tooltip>
)
}
/** A tour that asks for care, as an icon-only warning badge whose tooltip says why. */
export function TourCautionBadge({ tour }: { tour: Pick<TourListItem, 'caution'> }) {
const { t } = useTranslation()
if (!tour.caution) return null
return (
<Tooltip label={t('tours.caution.tooltip')}>
<span aria-label={t('tours.caution.badge')} className="inline-flex flex-none">
<WhiteBadge className="text-warning" icon={<AlertTriangle size={10} strokeWidth={2.2} className="flex-none" />}>{null}
</WhiteBadge>
</span>
</Tooltip>
)
}
/**
* The days a tour is on as small pills, "Day 2" each, the date and title in the
* tooltip. A tour on many days names the first ones and folds the rest into "+n",
* whose tooltip lists them. The pills come without a box of their own, so they
* flow on in the row of pills they are put into.
*/
export function TourDayPills({
tourDays,
shown: limit = SHOWN_TOUR_DAYS,
}: {
tourDays: readonly TourDay[]
/** How many days to name before folding the rest; a detail with room names them all. */
shown?: number
}) {
const { t, locale } = useTranslation()
if (tourDays.length === 0) return null
const fullLabel = ({ day, index }: TourDay) => dayMenuLabel(day, index, t, locale)
const shown = tourDays.length > limit ? tourDays.slice(0, limit - 1) : tourDays
const folded = tourDays.slice(shown.length)
const foldedLabel = folded.map(fullLabel).join(', ')
return (
<>
{shown.map((tourDay) => (
<Tooltip key={tourDay.day.id} label={fullLabel(tourDay)}>
<span data-testid="tour-day" aria-label={fullLabel(tourDay)} className="inline-flex">
<SoftPill icon={<CalendarDays size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
{t('dayplan.dayN', { n: tourDay.index + 1 })}</SoftPill>
</span>
</Tooltip>
))}
{folded.length > 0 && (
<Tooltip label={foldedLabel}>
<span data-testid="tour-day" aria-label={foldedLabel} className="inline-flex">
<SoftPill className="tabular-nums">+{folded.length}
</SoftPill>
</span>
</Tooltip>
)}
</>
)
}
/**
* Putting a tour on a day: a round "+" on a row, or a footer button with a
* chevron, both opening the trip's days as a menu.
* chevron, both opening the trip's days as a menu. Which days it offers and
* the write itself come from the shared assignment rules (PR1-18): a day that
* has the tour, or is getting it right now, is greyed out.
*/
export function TourDayMenu({ days, placeId, onPick, disabled = false, label, variant = 'round', primary = false }: {
export function TourDayMenu({ days, placeId, assignment, label, variant = 'round', primary = false }: {
days: Day[]
placeId?: number
onPick: (day: Day, index: number) => void
disabled?: boolean
placeId: number
assignment: TourDayAssignment
label: string
variant?: 'round' | 'button'
primary?: boolean
}) {
const { t, locale } = useTranslation()
const menu = useContextMenu()
const assignments = useTripStore(state => state.assignments ?? {})
const assigned = (dayId: number) => placeId != null && (assignments[String(dayId)] ?? []).some(assignment => assignment.place_id === placeId)
const disabled = !assignment.canAssign
const items = days.map((day, index) => ({
label: dayMenuLabel(day, index, t, locale),
disabled: assigned(day.id),
onClick: () => onPick(day, index),
disabled: !assignment.isAvailable(placeId, day.id),
onClick: () => void assignment.assign(placeId, day.id),
}))
const hasAvailableDay = items.some(item => !item.disabled)
const open = (event: React.MouseEvent<HTMLButtonElement>) => {
@@ -212,7 +254,7 @@ export function FoldButton({ expanded, onToggle, controls, label }: { expanded:
)
}
interface TourMetricField {
export interface TourMetricField {
label: string
/** Said instead of `label` where the field is too narrow for it, rather than cutting it off. */
shortLabel?: string
@@ -248,6 +290,43 @@ function FitLabel({ full, short }: { full: string; short?: string }) {
)
}
/** Fixed field rows; five only from the small breakpoint up, a phone gets two. */
const FIELD_COLUMNS = { 2: 'grid-cols-2', 3: 'grid-cols-3', 5: 'grid-cols-2 sm:grid-cols-5' } as const
type FieldColumns = keyof typeof FIELD_COLUMNS
/**
* Labelled values in the booking-card look: a fixed number of columns where the
* caller wants one row per group, otherwise as many as fit.
*/
export function TourFactFields({ fields, columns }: { fields: readonly TourMetricField[]; columns?: FieldColumns }) {
return (
<dl
className={`m-0 grid gap-2 ${columns ? FIELD_COLUMNS[columns] : 'grid-cols-[repeat(auto-fit,minmax(104px,1fr))]'}`}
>
{fields.map((field) => (
<div key={field.label} className="min-w-0">
<dt className="mb-[3px] flex min-w-0 items-center gap-0.5">
<Eyebrow className="relative min-w-0 truncate">
<FitLabel full={field.label} short={field.shortLabel} />
</Eyebrow>
{field.info && (
<span className="-my-1 flex-none">
<InfoDot label={field.info.label} description={field.info.description} />
</span>
)}
</dt>
<dd
className={`${BOX} m-0 truncate px-[10px] py-[7px] text-center font-semibold tabular-nums text-content`}
style={fs(12.5, 'body')}
>
{field.value}
</dd>
</div>
))}
</dl>
)
}
/**
* A tour's numbers as labelled fields, the way a booking card shows its facts:
* distance, any extra the caller has (the walking time), then the altitudes.
@@ -256,28 +335,84 @@ export function TourMetricFields({ analysis, unit, extra = [], columns }: {
analysis: Pick<RouteGeometryAnalysis, 'distanceKm' | 'minEle' | 'maxEle' | 'gain' | 'loss'> | null
unit?: DistanceUnit
extra?: Array<TourMetricField>
/** A fixed column count for a narrow rail; the inspector lets the fields flow. */
columns?: 2
/** A fixed column count: two in a narrow rail, one row in the inspector. */
columns?: FieldColumns
}) {
const { t } = useTranslation()
const fields: TourMetricField[] = [
{ label: t('tours.detail.distance'), value: analysis ? formatDistance(analysis.distanceKm, unit) : '-' },
...extra,
]
if (analysis?.minEle != null) fields.push({ label: t('tours.detail.minAltitude'), value: formatElevation(analysis.minEle, unit) })
if (analysis?.maxEle != null) fields.push({ label: t('tours.detail.maxAltitude'), value: formatElevation(analysis.maxEle, unit) })
if (analysis?.gain != null) fields.push({ label: t('tours.detail.ascent'), value: formatElevation(analysis.gain, unit) })
if (analysis?.loss != null) fields.push({ label: t('tours.detail.descent'), value: formatElevation(analysis.loss, unit) })
const grid = columns === 2 ? 'grid-cols-2' : 'grid-cols-[repeat(auto-fit,minmax(104px,1fr))]'
return <TourFactFields fields={tourMetricFacts(analysis, unit, t, extra)} columns={columns} />
}
/**
* A card of a rail, the way the plan shows a day: a head band with an icon and
* a title, the content below. Rails stack these instead of loose boxes.
*/
export function RailCard({
icon,
title,
aside,
children,
className = '',
}: {
icon: ReactNode
title: string
aside?: ReactNode
children: ReactNode
className?: string
}) {
return (
<dl className={`m-0 grid gap-2 ${grid}`}>
{fields.map(field => (
<div key={field.label} className="min-w-0">
<dt className="mb-[3px] flex min-w-0 items-center gap-0.5">
<Eyebrow className="relative min-w-0 truncate"><FitLabel full={field.label} short={field.shortLabel} /></Eyebrow>
{field.info && <span className="-my-1 flex-none"><InfoDot label={field.info.label} description={field.info.description} /></span>}
<section
className={`flex-none overflow-hidden rounded-2xl border border-edge-faint bg-surface-card ${className}`}
aria-label={title}
>
<div className="flex items-center gap-2 border-b border-edge-faint bg-surface-secondary px-3 py-2">
<span className="grid h-4 w-4 flex-none place-items-center text-content-muted">{icon}</span>
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(12.5, 'body')}>
{title}
</span>
{aside}
</div>
<div className="px-3 py-3">{children}</div>
</section>
)
}
/** The one action of a rail card's text, a small pill rather than a dialog's full-size button. */
export function CardAction({
icon,
onClick,
disabled = false,
children,
}: {
icon: ReactNode
onClick: () => void
disabled?: boolean
children: ReactNode
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="inline-flex h-7 max-w-full items-center gap-1.5 rounded-full bg-surface-card px-2.5 font-semibold text-content-secondary shadow-sm ring-1 ring-inset ring-edge-faint transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent enabled:hover:text-content disabled:cursor-default disabled:opacity-40"
style={fs(11.5, 'body')}
>
<span className="flex-none">{icon}</span>
<span className="min-w-0 truncate">{children}</span>
</button>
)
}
/** Facts as label over value, two to a row, without a box each: the compact form a rail card uses. */
export function RailFacts({ facts }: { facts: readonly TourMetricField[] }) {
return (
<dl className="m-0 grid grid-cols-2 gap-x-4 gap-y-2.5">
{facts.map((fact) => (
<div key={fact.label} className="min-w-0">
<dt className="flex min-w-0 items-center gap-0.5 text-content-faint" style={fs(10.5, 'body')}>
<span className="relative min-w-0 truncate"><FitLabel full={fact.label} short={fact.shortLabel} /></span>
{fact.info && <span className="-my-1 flex-none"><InfoDot label={fact.info.label} description={fact.info.description} /></span>}
</dt>
<dd className={`${BOX} m-0 truncate px-[10px] py-[7px] text-center font-semibold tabular-nums text-content`} style={fs(12.5, 'body')}>{field.value}</dd>
<dd className="m-0 truncate font-semibold tabular-nums text-content" style={fs(13, 'body')}>{fact.value}</dd>
</div>
))}
</dl>
@@ -29,6 +29,13 @@ describe('tourPresentation', () => {
expect(hikeSourceBadgeLabel({ wanderer_ref: 'wanderer:123' }, t)).toBe('tours.badge.wanderer')
})
it('P2C-02: takes the origin the server recorded over the shape of the tour', () => {
// An import planned over in place has waypoints, and is still an import.
expect(tourSource({ wanderer_ref: null, has_waypoints: true, origin: 'gpx' })).toBe('gpx')
expect(tourSource({ wanderer_ref: null, has_waypoints: false, origin: 'trek' })).toBe('trek')
expect(tourSource({ wanderer_ref: 'w#1', origin: 'wanderer' })).toBe('wanderer')
})
it('filters tours by All, Unplanned and Planned, keeping their order', () => {
const tours = [
{ id: 1, planned: true },
@@ -1,8 +1,8 @@
import type { TourListItem } from '@trek/shared'
import type { TourListItem, TourOrigin } from '@trek/shared'
import { tourWebsiteSchema } from '@trek/shared'
import { formatDurationShort } from '@trek/shared/roadtrip'
export type TourSource = 'trek' | 'gpx' | 'wanderer'
export type TourSource = TourOrigin
/** All / Unplanned / Planned, the same three states as the Places pool. */
export type TourFilter = 'all' | 'unplanned' | 'planned'
@@ -64,13 +64,15 @@ type Translate = (key: string, params?: Record<string, unknown>) => string
* A wanderer reference is authoritative. Planner-authored TREK tours persist
* routing controls, while GPX imports intentionally remain geometry-only.
*/
export function tourSource(tour: Pick<TourListItem, 'wanderer_ref' | 'has_waypoints'>): TourSource {
export function tourSource(tour: Pick<TourListItem, 'wanderer_ref' | 'has_waypoints' | 'origin'>): TourSource {
// The origin the server recorded; a list cached before it existed still reads off the tour's shape.
if (tour.origin) return tour.origin
if (tour.wanderer_ref) return 'wanderer'
return tour.has_waypoints === false ? 'gpx' : 'trek'
}
export function hikeSourceBadgeLabel(
tour: Pick<TourListItem, 'wanderer_ref' | 'has_waypoints'>,
tour: Pick<TourListItem, 'wanderer_ref' | 'has_waypoints' | 'origin'>,
t: Translate,
): string {
return t(`tours.badge.${tourSource(tour)}`)
@@ -84,3 +86,13 @@ export function filterTours<T extends Pick<TourListItem, 'planned'>>(tours: T[],
return true
})
}
/** How each origin is named in a list, the words the source badge uses. */
export const ORIGIN_LABEL_KEY = { trek: 'tours.badge.trek', gpx: 'tours.badge.gpx', wanderer: 'tours.badge.wanderer' } as const
/** The day status a tour list is filtered by, in words. */
export function statusLabel(status: TourFilter, t: Translate): string {
if (status === 'unplanned') return t('places.unplanned')
if (status === 'planned') return t('places.planned')
return t('places.all')
}
@@ -171,7 +171,6 @@ describe('Tours English and German copy', () => {
readOnlyGpxTour: { tour, routeGeometry: null },
})} />)
expect(screen.getByText(toursText(de, 'tours.planner.gpxNoGeometry'))).toBeInTheDocument()
expect(screen.getByText(toursText(de, 'tours.detail.gpxReadOnly'))).toBeInTheDocument()
assertNoRawToursKeys()
})
@@ -11,6 +11,7 @@ const state = vi.hoisted(() => ({
user: { id: 2, role: 'user' } as { id: number; role: string } | null,
importGpx: vi.fn(),
loadTrip: vi.fn(),
refreshPlaces: vi.fn(),
create: vi.fn(),
update: vi.fn(),
detail: vi.fn(),
@@ -18,7 +19,7 @@ const state = vi.hoisted(() => ({
elevation: vi.fn(),
}))
vi.mock('../../store/tripStore', () => ({ useTripStore: (selector: (store: unknown) => unknown) => selector({ trip: state.trip, loadTrip: state.loadTrip }) }))
vi.mock('../../store/tripStore', () => ({ useTripStore: (selector: (store: unknown) => unknown) => selector({ trip: state.trip, loadTrip: state.loadTrip, refreshPlaces: state.refreshPlaces, places: [] }) }))
vi.mock('../../store/authStore', () => ({ useAuthStore: (selector: (store: unknown) => unknown) => selector({ user: state.user }) }))
vi.mock('../../store/settingsStore', () => ({ useSettingsStore: (selector: (store: unknown) => unknown) => selector({ settings: { distance_unit: 'metric' } }) }))
vi.mock('../../repo/tourRepo', () => ({ tourRepo: { importGpx: state.importGpx, create: state.create, update: state.update, detail: state.detail } }))
@@ -36,6 +37,7 @@ vi.mock('../shared/markdownLink', () => ({ markdownLinkComponents: {} }))
vi.mock('../../utils/fileDownload', () => ({ openFile: vi.fn() }))
vi.mock('../Planner/placeNavigation', () => ({ getNavigationTargets: () => [], openNavigationTarget: vi.fn() }))
import { pickFiles } from '../../../tests/helpers/filePicker'
import { usePermissionsStore } from '../../store/permissionsStore'
import { useTourPermissions } from './useTourPermissions'
import ToursSidebar from './ToursSidebar'
@@ -44,6 +46,14 @@ import type { TourPlannerController } from './planner/useTourPlanner'
import { TourPlannerRail, TourPlannerToursRail } from './planner/TourPlannerPanels'
import TourDetailDialog from './TourDetailDialog'
import MToursSelectionList from '../../mobile/screens/trip/places/MToursSelectionList'
import type { ComponentProps } from 'react'
import { useTourListFilters } from './useTourListFilters'
/** The list as the places browser mounts it: its filters from the shared hook (PR1-13). */
function List(props: Omit<ComponentProps<typeof MToursSelectionList>, 'filters'>) {
const filters = useTourListFilters(props.planner.tours)
return <MToursSelectionList {...props} filters={filters} />
}
const tour = { place_id: 42, name: 'Ridge', tour_type: 'hike', max_hiking_difficulty: 2, planned: false } as TourListItem
const days = [{ id: 3, title: 'Summit' }] as never
@@ -61,12 +71,16 @@ describe('Tours permissions without database setup', () => {
it('revoking sidebar rights closes assignment choices and blocks file-input invocation', async () => {
const assign = vi.fn()
const props = { tripId: 7, days, tours: [tour], onAssignToDay: assign }
const { container, rerender } = render(<ToursSidebar {...props} canEdit canAssign />)
const { rerender } = render(<ToursSidebar {...props} canEdit canAssign />)
fireEvent.click(screen.getByRole('button', { name: 'tours.addToDay' }))
expect(screen.getByRole('button', { name: /Summit/ })).toBeTruthy()
rerender(<ToursSidebar {...props} canEdit={false} canAssign={false} />)
expect(screen.queryByRole('button', { name: /Summit/ })).toBeNull()
await act(async () => { fireEvent.change(container.querySelector('input[type=file]')!, { target: { files: [new File(['gpx'], 'ridge.gpx')] } }) })
// Without the right the import button opens no file picker at all.
await act(async () => {
expect(
pickFiles(screen.getByText('tours.import.button').closest('button')!, [new File(['gpx'], 'ridge.gpx')])
).toBeNull() })
expect(state.importGpx).not.toHaveBeenCalled()
expect(assign).not.toHaveBeenCalled()
})
@@ -105,8 +119,11 @@ describe('Tours permissions without database setup', () => {
expect(Boolean(screen.queryByRole('button', { name: 'common.edit' }))).toBe(canEdit)
expect(Boolean(screen.queryByRole('button', { name: 'common.delete' }))).toBe(canEdit)
expect(Boolean(screen.queryByRole('button', { name: 'inspector.addToDay' }))).toBe(canAssign)
expect((screen.getByRole('button', { name: 'tours.detail.trackColor' }) as HTMLButtonElement).disabled).toBe(
!canEdit
)
if (canEdit) {
fireEvent.click(screen.getByRole('button', { name: 'inspector.trackColorAuto' }))
fireEvent.click(screen.getByRole('button', { name: 'tours.detail.trackColor' }))
fireEvent.click(screen.getByRole('button', { name: 'color' }))
expect(update).toHaveBeenCalledWith(42, { route_color: '#ff0000' })
fireEvent.click(screen.getByRole('button', { name: 'common.edit' }))
@@ -124,7 +141,7 @@ describe('Tours permissions without database setup', () => {
expect(remove).toHaveBeenCalledTimes(canAssign ? 1 : 0)
const view = vi.fn()
const openSheet = vi.fn()
rerender(<MToursSelectionList planner={{ t: (key: string) => key, tours: [tour], handlePlaceClick: view, reloadTourPlaceIds: vi.fn() } as never} shell={{ sheet: 'other', openSheet } as never} filter="all" canEdit={canEdit} canAssign={canAssign} />)
rerender(<List planner={{ t: (key: string) => key, tours: [tour], handlePlaceClick: view, reloadTourPlaceIds: vi.fn() } as never} shell={{ sheet: 'other', openSheet } as never} canEdit={canEdit} canAssign={canAssign} />)
fireEvent.click(screen.getByRole('option'))
expect(view).toHaveBeenCalledWith(42)
const attach = screen.getByRole('button', { name: 'tours.addToDay' }) as HTMLButtonElement
@@ -181,17 +198,19 @@ describe('Tours permissions without database setup', () => {
it.each([[false, false], [true, false], [false, true], [true, true]])('sidebar edit=%s assign=%s keeps reads available and gates writes', async (canEdit, canAssign) => {
const assign = vi.fn()
const select = vi.fn()
const { container } = render(<ToursSidebar tripId={7} days={days} tours={[tour]} canEdit={canEdit} canAssign={canAssign} onAssignToDay={assign} onSelectTour={select} />)
render(<ToursSidebar tripId={7} days={days} tours={[tour]} canEdit={canEdit} canAssign={canAssign} onAssignToDay={assign} onSelectTour={select} />)
const row = screen.getByRole('option')
fireEvent.click(row)
expect(select).toHaveBeenCalledWith(tour, row)
expect((screen.getByText('places.importFile').closest('button') as HTMLButtonElement).disabled).toBe(!canEdit)
const importButton = screen.getByText('tours.import.button').closest('button') as HTMLButtonElement
expect(importButton.disabled).toBe(!canEdit)
const attach = screen.getByRole('button', { name: 'tours.addToDay' }) as HTMLButtonElement
expect(attach.disabled).toBe(!canAssign)
fireEvent.click(attach)
if (canAssign) fireEvent.click(screen.getByRole('button', { name: /Summit/ }))
expect(assign).toHaveBeenCalledTimes(canAssign ? 1 : 0)
await act(async () => { fireEvent.change(container.querySelector('input[type=file]')!, { target: { files: [new File(['gpx'], 'ridge.gpx')] } }) })
await act(async () => {
pickFiles(importButton, [new File(['gpx'], 'ridge.gpx')]) })
expect(state.importGpx).toHaveBeenCalledTimes(canEdit ? 1 : 0)
})
@@ -0,0 +1,90 @@
import { act, renderHook } from '@testing-library/react';
import type { TourListItem } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { TourPlannerController } from './planner/useTourPlanner';
import { useRecordedTrack } from './useRecordedTrack';
const repo = vi.hoisted(() => ({ restoreRecorded: vi.fn() }));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('../../repo/tourRepo', () => ({ tourRepo: repo }));
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
const replaced = { place_id: 7, name: 'Kramer', has_waypoints: true, has_recorded_track: true } as TourListItem;
const restored = { ...replaced, has_waypoints: false, has_recorded_track: false };
function planner(placeId: number | null): TourPlannerController {
return {
mode: placeId === null ? { type: 'neutral' } : { type: 'edit-saved', placeId },
returnToNeutral: vi.fn(),
} as unknown as TourPlannerController;
}
beforeEach(() => {
vi.clearAllMocks();
repo.restoreRecorded.mockResolvedValue({ tour: restored, waypoints: [] });
});
describe('useRecordedTrack', () => {
it('is offered for a tour in the editor that keeps its recorded track, to someone who may change it', () => {
const offered = renderHook(() =>
useRecordedTrack({ tripId: 1, planner: planner(7), tours: [replaced], canEdit: true, onRestored: vi.fn() })
);
expect(offered.result.current.available).toBe(true);
const readOnly = renderHook(() =>
useRecordedTrack({ tripId: 1, planner: planner(7), tours: [replaced], canEdit: false, onRestored: vi.fn() })
);
expect(readOnly.result.current.available).toBe(false);
const planned = renderHook(() =>
useRecordedTrack({
tripId: 1,
planner: planner(7),
tours: [{ ...replaced, has_recorded_track: false }],
canEdit: true,
onRestored: vi.fn(),
})
);
expect(planned.result.current.available).toBe(false);
const nothingOpen = renderHook(() =>
useRecordedTrack({ tripId: 1, planner: planner(null), tours: [replaced], canEdit: true, onRestored: vi.fn() })
);
expect(nothingOpen.result.current.available).toBe(false);
});
it('goes back on the server, leaves the editor and hands the restored tour on', async () => {
const controller = planner(7);
const onRestored = vi.fn();
const { result } = renderHook(() =>
useRecordedTrack({ tripId: 1, planner: controller, tours: [replaced], canEdit: true, onRestored })
);
await act(async () => {
await result.current.restore();
});
expect(repo.restoreRecorded).toHaveBeenCalledWith(1, 7);
expect(controller.returnToNeutral).toHaveBeenCalled();
expect(onRestored).toHaveBeenCalledWith(restored);
expect(toast.success).toHaveBeenCalledWith('tours.recorded.restored');
expect(result.current.restoring).toBe(false);
});
it('says so when it could not go back, and leaves the editor as it is', async () => {
repo.restoreRecorded.mockRejectedValueOnce(new Error('offline'));
const controller = planner(7);
const onRestored = vi.fn();
const { result } = renderHook(() =>
useRecordedTrack({ tripId: 1, planner: controller, tours: [replaced], canEdit: true, onRestored })
);
await act(async () => {
await result.current.restore();
});
expect(toast.error).toHaveBeenCalledWith('tours.recorded.failed');
expect(controller.returnToNeutral).not.toHaveBeenCalled();
expect(onRestored).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,54 @@
import type { TourListItem } from '@trek/shared';
import { useCallback, useState } from 'react';
import { useTranslation } from '../../i18n';
import { tourRepo } from '../../repo/tourRepo';
import { useToast } from '../shared/Toast';
import type { TourPlannerController } from './planner/useTourPlanner';
interface RecordedTrackOptions {
tripId: number;
planner: TourPlannerController;
tours: readonly TourListItem[];
canEdit: boolean;
/** The tour as it stands after going back, for the lists and the map to take it in. */
onRestored: (tour: TourListItem) => Promise<void> | void;
}
/**
* Going back from a planned-over import to the track it was recorded with: the
* undo of "replace the import", offered for as long as the tour keeps that
* track, not only right after. The planning is dropped on the server; the tour
* keeps its id, days and notes.
*/
export function useRecordedTrack({ tripId, planner, tours, canEdit, onRestored }: RecordedTrackOptions) {
const { t } = useTranslation();
const toast = useToast();
const [restoring, setRestoring] = useState(false);
const placeId = planner.mode.type === 'edit-saved' ? planner.mode.placeId : null;
const tour = placeId === null ? null : (tours.find((item) => item.place_id === placeId) ?? null);
const { returnToNeutral } = planner;
const restore = useCallback(async () => {
if (!tour || restoring) return;
setRestoring(true);
try {
const result = await tourRepo.restoreRecorded(tripId, tour.place_id);
returnToNeutral();
await onRestored(result.tour);
toast.success(t('tours.recorded.restored'));
} catch {
toast.error(t('tours.recorded.failed'));
} finally {
setRestoring(false);
}
}, [onRestored, restoring, returnToNeutral, t, toast, tour, tripId]);
return {
/** The tour in the editor keeps a recorded track and the user may change it. */
available: canEdit && !!tour?.has_recorded_track,
restoring,
restore,
};
}
export type RecordedTrack = ReturnType<typeof useRecordedTrack>;
@@ -0,0 +1,68 @@
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useTripStore } from '../../store/tripStore';
import type { AssignmentsMap } from '../../types';
import { isTourOnDay, useTourDayAssignment } from './useTourDayAssignment';
const assignments = { '3': [{ id: 31, place_id: 42 }], '4': [] } as unknown as AssignmentsMap;
beforeEach(() => {
resetAllStores();
seedStore(useTripStore, { assignments });
});
describe('isTourOnDay (PR1-18)', () => {
it('reads the day lists: a tour is on a day once it has an assignment there', () => {
expect(isTourOnDay(assignments, 42, 3)).toBe(true);
expect(isTourOnDay(assignments, 42, 4)).toBe(false);
expect(isTourOnDay(assignments, 42, 99)).toBe(false);
});
});
describe('useTourDayAssignment (PR1-18)', () => {
it('offers only a day that does not have the tour yet, and nothing without the right', () => {
const allowed = renderHook(() => useTourDayAssignment({ canAssign: true, onAssignToDay: vi.fn() }));
expect(allowed.result.current.isOnDay(42, 3)).toBe(true);
expect(allowed.result.current.isAvailable(42, 3)).toBe(false);
expect(allowed.result.current.isAvailable(42, 4)).toBe(true);
const denied = renderHook(() => useTourDayAssignment({ canAssign: false, onAssignToDay: vi.fn() }));
expect(denied.result.current.isAvailable(42, 4)).toBe(false);
});
it('sends one write per tour and day while it is on its way, and says whether it happened', async () => {
let finish: (value: boolean) => void = () => undefined;
const onAssignToDay = vi.fn(() => new Promise<boolean>((resolve) => (finish = resolve)));
const { result } = renderHook(() => useTourDayAssignment({ canAssign: true, onAssignToDay }));
let first: Promise<boolean> = Promise.resolve(false);
let second: Promise<boolean> = Promise.resolve(false);
act(() => {
first = result.current.assign(42, 4);
second = result.current.assign(42, 4);
});
expect(onAssignToDay).toHaveBeenCalledTimes(1);
expect(onAssignToDay).toHaveBeenCalledWith(42, 4);
expect(await second).toBe(false);
expect(result.current.isPending(42, 4)).toBe(true);
expect(result.current.isAvailable(42, 4)).toBe(false);
await act(async () => {
finish(true);
expect(await first).toBe(true);
});
expect(result.current.isPending(42, 4)).toBe(false);
});
it('reports a refused write as not done, and a day that has the tour is not even asked', async () => {
const onAssignToDay = vi.fn(async () => false);
const { result } = renderHook(() => useTourDayAssignment({ canAssign: true, onAssignToDay }));
await act(async () => {
expect(await result.current.assign(42, 4)).toBe(false);
expect(await result.current.assign(42, 3)).toBe(false);
});
expect(onAssignToDay).toHaveBeenCalledTimes(1);
expect(onAssignToDay).toHaveBeenCalledWith(42, 4);
});
});
@@ -0,0 +1,70 @@
import { useCallback, useRef, useState } from 'react';
import { useTripStore } from '../../store/tripStore';
import type { AssignmentsMap } from '../../types';
/** A tour fills its day, so a day holds it at most once; the server refuses a second time the same way. */
export function isTourOnDay(assignments: AssignmentsMap, placeId: number, dayId: number): boolean {
return (assignments[String(dayId)] ?? []).some((assignment) => assignment.place_id === placeId);
}
/** The one write a tour goes onto a day through; `false` when it did not happen. */
export type AssignTourToDay = (
placeId: number,
dayId: number,
position?: number
) => void | boolean | Promise<void | boolean>;
interface TourDayAssignmentOptions {
/** Whether the user may change the days of the trip. */
canAssign: boolean;
onAssignToDay: AssignTourToDay;
}
/**
* PR1-18: the rules every way of putting a tour on a day shares, the day menu
* of a tour list, the tour's detail and the phone's action sheet alike: the
* right to change the days, a day that already has the tour (from the store,
* so a change shows at once), and a request still on its way, so a second
* click cannot send it twice. The write itself is the plan's one assignment
* path, which also says what happened.
*/
export function useTourDayAssignment({ canAssign, onAssignToDay }: TourDayAssignmentOptions) {
const assignments = useTripStore((state) => state.assignments);
const [pending, setPending] = useState<ReadonlySet<string>>(() => new Set());
// Read before the state has rendered: two clicks in one frame still send once.
const inFlight = useRef(new Set<string>());
const isOnDay = useCallback(
(placeId: number, dayId: number) => isTourOnDay(assignments ?? {}, placeId, dayId),
[assignments]
);
const isPending = useCallback((placeId: number, dayId: number) => pending.has(`${placeId}:${dayId}`), [pending]);
const isAvailable = useCallback(
(placeId: number, dayId: number) => canAssign && !isOnDay(placeId, dayId) && !isPending(placeId, dayId),
[canAssign, isOnDay, isPending]
);
const assign = useCallback(
async (placeId: number, dayId: number): Promise<boolean> => {
const key = `${placeId}:${dayId}`;
if (!isAvailable(placeId, dayId) || inFlight.current.has(key)) return false;
inFlight.current.add(key);
setPending((current) => new Set(current).add(key));
try {
return (await onAssignToDay(placeId, dayId)) !== false;
} finally {
inFlight.current.delete(key);
setPending((current) => {
const next = new Set(current);
next.delete(key);
return next;
});
}
},
[isAvailable, onAssignToDay]
);
return { canAssign, isOnDay, isPending, isAvailable, assign };
}
export type TourDayAssignment = ReturnType<typeof useTourDayAssignment>;
@@ -0,0 +1,101 @@
import type { TourListItem } from '@trek/shared';
import { useCallback, useState } from 'react';
import { useTranslation } from '../../i18n';
import { tourRepo } from '../../repo/tourRepo';
import { useTripStore } from '../../store/tripStore';
import { getApiErrorMessage } from '../../utils/apiError';
import { useToast } from '../shared/Toast';
/** The server refuses anything larger; saying so first spares the upload. */
const MAX_TOUR_GPX_BYTES = 10 * 1024 * 1024;
const isGpxFile = (file: Pick<File, 'name'>) => file.name.toLowerCase().endsWith('.gpx');
interface TourGpxImportOptions {
tripId: number;
canEdit: boolean;
/** The imported place ids, so lists can leave them out of the places at once. */
onToursChanged?: (placeIds?: number[]) => void | Promise<void>;
/** The new tours, once the trip has been read again with their places. */
onImported?: (tours: TourListItem[]) => void;
}
/**
* The one way a GPX file becomes a tour, whichever control it came through:
* the import button's file picker or a file dropped onto the tours workspace.
* Files are checked, uploaded one after the other and summed up in one set of
* toasts: how many came in, how many were already in the trip, which file had
* no track and which one failed.
*/
export function useTourGpxImport({ tripId, canEdit, onToursChanged, onImported }: TourGpxImportOptions) {
const { t } = useTranslation();
const toast = useToast();
const refreshPlaces = useTripStore((state) => state.refreshPlaces);
const [importing, setImporting] = useState(false);
const importFiles = useCallback(
async (files: readonly File[]) => {
if (!canEdit || files.length === 0 || importing) return;
const gpx = files.filter(isGpxFile);
if (gpx.length < files.length) toast.error(t('tours.import.unsupported'));
const fitting = gpx.filter((file) => file.size <= MAX_TOUR_GPX_BYTES);
if (fitting.length < gpx.length)
toast.error(t('places.importFileTooLarge', { maxMb: MAX_TOUR_GPX_BYTES / 1024 / 1024 }));
if (fitting.length === 0) return;
setImporting(true);
const imported: TourListItem[] = [];
let skipped = 0;
let caution = false;
try {
for (const file of fitting) {
try {
const result = await tourRepo.importGpx(tripId, file);
imported.push(...result.tours);
skipped += result.skipped;
caution ||= result.caution;
} catch (error: unknown) {
toast.error(
getApiErrorMessage(error, '') === 'No track or route found in GPX file'
? t('tours.import.noTrackIn', { name: file.name })
: t('tours.import.errorIn', { name: file.name })
);
}
}
if (imported.length > 0) {
// The server's place:created skips this socket, so the places are read
// again here for the new ones and their lines. Only the places: a whole
// trip reload would blank the workspace and rebuild its map.
await onToursChanged?.(imported.map((tour) => tour.place_id));
await refreshPlaces(tripId);
toast.success(t('tours.import.success', { count: imported.length }));
if (caution) toast.info(t('tours.import.caution'));
onImported?.(imported);
}
if (skipped > 0) {
toast.warning(
imported.length > 0 ? t('tours.import.skipped', { count: skipped }) : t('tours.import.allSkipped')
);
}
} finally {
setImporting(false);
}
},
[canEdit, importing, onImported, onToursChanged, refreshPlaces, t, toast, tripId]
);
/** Opens the file picker; built per click, so no view carries a hidden input for it. */
const pick = useCallback(() => {
if (!canEdit || importing) return;
const input = document.createElement('input');
input.type = 'file';
input.accept = '.gpx';
input.multiple = true;
input.addEventListener('change', () => void importFiles(Array.from(input.files ?? [])), { once: true });
input.click();
}, [canEdit, importFiles, importing]);
return { importing, importFiles, pick };
}
export type TourGpxImport = ReturnType<typeof useTourGpxImport>;
@@ -0,0 +1,76 @@
import { act, renderHook } from '@testing-library/react';
import type { TourListItem } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { useTourListFilters } from './useTourListFilters';
const tour = (over: Partial<TourListItem>): TourListItem =>
({
place_id: 1,
name: 'Ridge',
tour_type: 'hike',
distance: 5,
elevation_gain: null,
elevation_loss: null,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: 2,
planned: false,
caution: false,
origin: 'trek',
...over,
}) as TourListItem;
const tours = [
tour({ place_id: 1, origin: 'trek', max_hiking_difficulty: 2, planned: true }),
tour({ place_id: 2, origin: 'gpx', max_hiking_difficulty: null }),
tour({ place_id: 3, origin: 'wanderer', max_hiking_difficulty: 1 }),
tour({ place_id: 4, origin: 'gpx', max_hiking_difficulty: 3, planned: true }),
];
const ids = (list: readonly TourListItem[]) => list.map((item) => item.place_id);
describe('useTourListFilters (PR1-13)', () => {
it('offers only the dimensions the list varies in, each value with its count', () => {
const { result } = renderHook(() => useTourListFilters(tours));
expect(result.current.facets.origin).toEqual([
{ value: 'trek', count: 1 },
{ value: 'gpx', count: 2 },
{ value: 'wanderer', count: 1 },
]);
expect(result.current.facets.grade.map((option) => option.value)).toEqual([1, 2, 3]);
// One tour type only: no type filter.
expect(result.current.facets.type).toEqual([]);
expect(result.current.hasFacets).toBe(true);
});
it('a small list that does not vary keeps its single status filter', () => {
const { result } = renderHook(() => useTourListFilters([tour({ place_id: 1 }), tour({ place_id: 2 })]));
expect(result.current.hasFacets).toBe(false);
});
it('narrows by status and dimensions together, counts follow, a second choice of a value clears it', () => {
const { result } = renderHook(() => useTourListFilters(tours));
act(() => result.current.select('origin', 'gpx'));
expect(ids(result.current.filtered)).toEqual([2, 4]);
expect(result.current.activeFacets).toBe(1);
// A GPX import without a grade drops out under a grade, it is not counted as one.
act(() => result.current.select('grade', 3));
expect(ids(result.current.filtered)).toEqual([4]);
act(() => result.current.setStatus('unplanned'));
expect(result.current.filtered).toEqual([]);
expect(result.current.emptyByFilter).toBe(true);
expect(result.current.statusCounts).toEqual({ all: 4, unplanned: 2, planned: 2 });
act(() => result.current.select('grade', 3));
expect(ids(result.current.filtered)).toEqual([2]);
act(() => result.current.reset());
expect(ids(result.current.filtered)).toEqual([1, 2, 3, 4]);
expect(result.current.activeFacets).toBe(0);
});
it('an empty list is empty, not empty by a filter', () => {
const { result } = renderHook(() => useTourListFilters([]));
expect(result.current.emptyByFilter).toBe(false);
});
});
@@ -0,0 +1,117 @@
import type { TourListItem, TourOrigin, TourTypeKey } from '@trek/shared';
import { useCallback, useMemo, useState } from 'react';
import { filterTours, tourSource, type TourFilter } from './tourPresentation';
export type TourFacet = 'origin' | 'grade' | 'type';
export interface TourFacetOption<V> {
value: V;
/** How many tours of the list carry it, under the other filters as they are set. */
count: number;
}
interface TourFacetSelection {
origin: TourOrigin | null;
grade: number | null;
type: TourTypeKey | null;
}
const NO_SELECTION: TourFacetSelection = { origin: null, grade: null, type: null };
const ORIGIN_ORDER: TourOrigin[] = ['trek', 'gpx', 'wanderer'];
function facetValue(tour: TourListItem, facet: TourFacet): string | number | null {
if (facet === 'origin') return tourSource(tour);
if (facet === 'grade') return tour.max_hiking_difficulty;
return tour.tour_type;
}
function matches(tour: TourListItem, selection: TourFacetSelection, except?: TourFacet): boolean {
return (['origin', 'grade', 'type'] as const).every(
(facet) => facet === except || selection[facet] === null || facetValue(tour, facet) === selection[facet]
);
}
/** The values a facet takes in the list, each with its count; a value no tour carries is not offered. */
function optionsOf<V extends string | number>(
tours: readonly TourListItem[],
facet: TourFacet,
order: (a: V, b: V) => number
): TourFacetOption<V>[] {
const counts = new Map<V, number>();
for (const tour of tours) {
const value = facetValue(tour, facet) as V | null;
if (value !== null) counts.set(value, (counts.get(value) ?? 0) + 1);
}
return [...counts.entries()].sort(([a], [b]) => order(a, b)).map(([value, count]) => ({ value, count }));
}
/**
* PR1-13: the filters of a tour list, the same on the desktop rail and the
* phone list. Next to the day status (all, not yet on a day, on a day) it
* offers the source, the stored routing limit and the tour type, but only a
* dimension the list really varies in (two values or more), so a small list
* keeps its single filter. Filtering only narrows what the list shows; tours
* and their days are not touched.
*/
export function useTourListFilters(tours: readonly TourListItem[]) {
const [status, setStatus] = useState<TourFilter>('all');
const [selection, setSelection] = useState<TourFacetSelection>(NO_SELECTION);
const byStatus = useMemo(() => filterTours([...tours], status), [tours, status]);
const facets = useMemo(() => {
// Each dimension counts under the others as set, so its numbers say what choosing it would show.
const base = (facet: TourFacet) => byStatus.filter((tour) => matches(tour, selection, facet));
const origin = optionsOf<TourOrigin>(
base('origin'),
'origin',
(a, b) => ORIGIN_ORDER.indexOf(a) - ORIGIN_ORDER.indexOf(b)
);
const grade = optionsOf<number>(base('grade'), 'grade', (a, b) => a - b);
const type = optionsOf<TourTypeKey>(base('type'), 'type', (a, b) => a.localeCompare(b));
// A dimension the whole list does not vary in is no choice at all.
const varies = (facet: TourFacet) =>
new Set(tours.map((tour) => facetValue(tour, facet)).filter((v) => v !== null)).size >= 2;
return {
origin: varies('origin') ? origin : [],
grade: varies('grade') ? grade : [],
type: varies('type') ? type : [],
};
}, [byStatus, selection, tours]);
const filtered = useMemo(() => byStatus.filter((tour) => matches(tour, selection)), [byStatus, selection]);
const select = useCallback(<F extends TourFacet>(facet: F, value: TourFacetSelection[F]) => {
setSelection((current) => ({ ...current, [facet]: current[facet] === value ? null : value }));
}, []);
const reset = useCallback(() => {
setStatus('all');
setSelection(NO_SELECTION);
}, []);
const activeFacets = (['origin', 'grade', 'type'] as const).filter((facet) => selection[facet] !== null).length;
return {
status,
setStatus,
selection,
select,
facets,
/** The list has more to choose by than the day status. */
hasFacets: facets.origin.length > 0 || facets.grade.length > 0 || facets.type.length > 0,
filtered,
statusCounts: {
all: tours.length,
unplanned: tours.filter((tour) => !tour.planned).length,
planned: tours.filter((tour) => tour.planned).length,
},
/** How many filters beyond the status narrow the list. */
activeFacets,
/** Something is set and nothing is left: the list says so and offers to clear it. */
emptyByFilter: tours.length > 0 && filtered.length === 0 && (status !== 'all' || activeFacets > 0),
reset,
};
}
export type TourListFilters = ReturnType<typeof useTourListFilters>;
@@ -0,0 +1,62 @@
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import { tourVisibilityScope, useTourMapVisibilityStore } from '../../store/tourMapVisibilityStore';
import { useTourMapVisibility } from './useTourMapVisibility';
const KEY = (userId: number, tripId: number) => `trek:hidden-tours:${tourVisibilityScope(userId, tripId)}`;
beforeEach(() => {
resetAllStores();
window.localStorage.clear();
useTourMapVisibilityStore.setState({ scope: null, hidden: [] });
seedStore(useAuthStore, { user: { id: 3 } as never });
});
describe('useTourMapVisibility (PR1-07)', () => {
it('hides and shows a tour for this person in this trip, and keeps it over a reload', () => {
const tours = new Set([10, 11]);
const first = renderHook(() => useTourMapVisibility(7, tours));
act(() => first.result.current.toggle(10));
expect(first.result.current.isHidden(10)).toBe(true);
expect(first.result.current.hiddenCount).toBe(1);
expect(JSON.parse(window.localStorage.getItem(KEY(3, 7)) ?? '[]')).toEqual([10]);
// A reload reads it back.
useTourMapVisibilityStore.setState({ scope: null, hidden: [] });
const reloaded = renderHook(() => useTourMapVisibility(7, tours));
expect(reloaded.result.current.isHidden(10)).toBe(true);
act(() => reloaded.result.current.toggle(10));
expect(reloaded.result.current.isHidden(10)).toBe(false);
expect(window.localStorage.getItem(KEY(3, 7))).toBeNull();
});
it('belongs to one user and one trip only', () => {
window.localStorage.setItem(KEY(3, 7), JSON.stringify([10]));
expect(renderHook(() => useTourMapVisibility(8, new Set([10]))).result.current.hiddenCount).toBe(0);
useTourMapVisibilityStore.setState({ scope: null, hidden: [] });
seedStore(useAuthStore, { user: { id: 4 } as never });
expect(renderHook(() => useTourMapVisibility(7, new Set([10]))).result.current.hiddenCount).toBe(0);
});
it('a tour that is gone hides nothing, and show all clears the choice', () => {
window.localStorage.setItem(KEY(3, 7), JSON.stringify([10, 99, 'x', -1]));
const { result } = renderHook(() => useTourMapVisibility(7, new Set([10, 11])));
expect([...result.current.hiddenIds]).toEqual([10]);
act(() => result.current.showAll());
expect(result.current.hiddenCount).toBe(0);
expect(window.localStorage.getItem(KEY(3, 7))).toBeNull();
});
it('a broken stored entry or no signed-in user shows every tour', () => {
window.localStorage.setItem(KEY(3, 7), '{not json');
expect(renderHook(() => useTourMapVisibility(7, new Set([10]))).result.current.hiddenCount).toBe(0);
useTourMapVisibilityStore.setState({ scope: null, hidden: [] });
seedStore(useAuthStore, { user: null });
const { result } = renderHook(() => useTourMapVisibility(7, new Set([10])));
act(() => result.current.toggle(10));
expect(result.current.hiddenCount).toBe(0);
});
});
@@ -0,0 +1,38 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useAuthStore } from '../../store/authStore';
import { tourVisibilityScope, useTourMapVisibilityStore } from '../../store/tourMapVisibilityStore';
const NONE: ReadonlySet<number> = new Set();
/**
* PR1-07/08: the tours the signed-in person hid from this trip's map, read
* against the tours the trip has now, so a deleted tour hides nothing. The
* list and the map read the same store, so a toggle shows on both at once.
*/
export function useTourMapVisibility(tripId: number, tourIds: ReadonlySet<number>) {
const userId = useAuthStore((state) => state.user?.id ?? null);
const scope = userId === null ? null : tourVisibilityScope(userId, tripId);
const load = useTourMapVisibilityStore((state) => state.load);
const loaded = useTourMapVisibilityStore((state) => scope !== null && state.scope === scope);
const hidden = useTourMapVisibilityStore((state) => state.hidden);
const toggleStored = useTourMapVisibilityStore((state) => state.toggle);
const showAll = useTourMapVisibilityStore((state) => state.showAll);
useEffect(() => {
if (scope) load(scope);
}, [load, scope]);
const hiddenIds = useMemo(
() => (loaded ? new Set(hidden.filter((id) => tourIds.has(id))) : NONE),
[hidden, loaded, tourIds]
);
const isHidden = useCallback((placeId: number) => hiddenIds.has(placeId), [hiddenIds]);
const toggle = useCallback(
(placeId: number) => {
if (loaded) toggleStored(placeId);
},
[loaded, toggleStored]
);
return { hiddenIds, isHidden, toggle, showAll, hiddenCount: hiddenIds.size };
}
@@ -0,0 +1,280 @@
import userEvent from '@testing-library/user-event';
import type { TourListItem, WandererImportPreview, WandererTrailSummary } from '@trek/shared';
import { http, HttpResponse } from 'msw';
import { useEffect } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { server } from '../../../../tests/helpers/msw/server';
import { render, screen, waitFor, within } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { useSettingsStore } from '../../../store/settingsStore';
import { useWandererBrowser, type WandererBrowser } from './useWandererBrowser';
import WandererBrowserRail from './WandererBrowserRail';
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
function trail(overrides: Partial<WandererTrailSummary> = {}): WandererTrailSummary {
return {
id: 'abc',
name: 'Partnachklamm',
description: 'Through the gorge',
location: 'Garmisch-Partenkirchen',
distanceM: 19_700,
durationS: 18_000,
elevationGainM: 929,
elevationLossM: 929,
difficulty: 'moderate',
public: true,
author: 'maurice',
line: [
[47.4, 11.1],
[47.41, 11.11],
],
externalUrl: 'https://wanderer.test/trail/view/@maurice/abc',
importedAs: [],
...overrides,
};
}
function preview(overrides: Partial<WandererImportPreview> = {}): WandererImportPreview {
return {
trail: trail(),
distanceKm: 19.8,
elevationGainM: 1152,
elevationLossM: 1152,
hasElevation: true,
points: 812,
line: [
[47.4, 11.1],
[47.42, 11.12],
],
contentHash: '0123456789abcdef0123',
state: 'new',
snapshot: null,
changes: [],
...overrides,
} as WandererImportPreview;
}
const imported = { place_id: 70, name: 'Partnachklamm', caution: false } as TourListItem;
function Harness({
onImported = vi.fn(),
onOpenTour = vi.fn(),
expose,
}: {
onImported?: (tour: TourListItem) => void;
onOpenTour?: (placeId: number) => void;
expose?: (browser: WandererBrowser) => void;
}) {
const browser = useWandererBrowser({ tripId: 3, enabled: true, onImported, onOpenTour });
useEffect(() => {
expose?.(browser);
});
return browser.open ? (
<WandererBrowserRail browser={browser} />
) : (
<button type="button" onClick={browser.openBrowser}>
open
</button>
);
}
let searches: unknown[];
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } });
searches = [];
server.use(
http.post('/api/trips/3/tours/wanderer/search', async ({ request }) => {
searches.push(await request.json());
return HttpResponse.json({
instance: 'https://wanderer.test',
page: 1,
pageSize: 20,
total: 2,
trails: [
trail(),
trail({ id: 'def', name: 'Kramerplateauweg', public: false, importedAs: [7], difficulty: 'easy' }),
],
});
}),
http.get('/api/trips/3/tours/wanderer/trails/:id', () => HttpResponse.json(preview()))
);
});
describe('WandererBrowserRail', () => {
it('RS-04: lists the own trails like plan rows, with their grade on the tile and a mark for those in the trip', async () => {
render(<Harness />);
await userEvent.click(screen.getByRole('button', { name: 'open' }));
const list = await screen.findByRole('listbox', { name: 'wanderer' });
const rows = within(list).getAllByRole('option');
expect(rows).toHaveLength(2);
expect(within(rows[0]).getByText('T2')).toBeInTheDocument();
expect(within(rows[1]).getByText('T1')).toBeInTheDocument();
expect(within(rows[1]).getByLabelText('In this trip')).toBeInTheDocument();
expect(within(rows[0]).queryByLabelText('In this trip')).not.toBeInTheDocument();
expect(screen.getByText('2 trails')).toBeInTheDocument();
expect(searches[0]).toEqual({ q: '', page: 1 });
});
it('MF-02: searches the part of the map in view on request, and everywhere again on the second press', async () => {
let browser: WandererBrowser | undefined;
render(
<Harness
expose={(value) => {
browser = value;
}}
/>
);
await userEvent.click(screen.getByRole('button', { name: 'open' }));
await screen.findByRole('listbox', { name: 'wanderer' });
browser?.onViewportChange({ south: 47, west: 11, north: 48, east: 12 });
await userEvent.click(screen.getByRole('button', { name: 'Search this map area' }));
await waitFor(() =>
expect(searches[searches.length - 1]).toEqual({
q: '',
page: 1,
bbox: { south: 47, west: 11, north: 48, east: 12 },
})
);
expect(screen.getByText('Map area')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Searching this map area' }));
await waitFor(() => expect(searches[searches.length - 1]).toEqual({ q: '', page: 1 }));
});
it('RS-04: previews a trail with its measured facts and its own visibility before anything is imported', async () => {
render(<Harness />);
await userEvent.click(screen.getByRole('button', { name: 'open' }));
await userEvent.click(within(await screen.findByRole('listbox', { name: 'wanderer' })).getAllByRole('option')[0]);
expect(await screen.findByRole('button', { name: 'Import into trip' })).toBeEnabled();
expect(screen.getByText('Garmisch-Partenkirchen')).toBeInTheDocument();
expect(screen.getByLabelText('Public in wanderer: everyone on your instance can see it there')).toBeInTheDocument();
expect(screen.getByText('T2')).toBeInTheDocument();
});
it('RS-05: imports the previewed trail with its fingerprint and closes', async () => {
const bodies: unknown[] = [];
server.use(
http.post('/api/trips/3/tours/wanderer/import', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json({ tour: imported });
})
);
const onImported = vi.fn();
render(<Harness onImported={onImported} />);
await userEvent.click(screen.getByRole('button', { name: 'open' }));
await userEvent.click(within(await screen.findByRole('listbox', { name: 'wanderer' })).getAllByRole('option')[0]);
await userEvent.click(await screen.findByRole('button', { name: 'Import into trip' }));
await waitFor(() => expect(onImported).toHaveBeenCalledWith(imported));
expect(bodies).toEqual([{ trailId: 'abc', contentHash: '0123456789abcdef0123' }]);
expect(screen.getByRole('button', { name: 'open' })).toBeInTheDocument();
});
it('RS-06: a trail already in the trip opens its snapshot instead of importing twice', async () => {
server.use(
http.get('/api/trips/3/tours/wanderer/trails/:id', () =>
HttpResponse.json(
preview({
state: 'unchanged',
snapshot: { placeId: 7, name: 'Kramerplateauweg', importedAt: '2026-10-15 10:00:00', editedLocally: false },
})
)
)
);
const onOpenTour = vi.fn();
render(<Harness onOpenTour={onOpenTour} />);
await userEvent.click(screen.getByRole('button', { name: 'open' }));
await userEvent.click(within(await screen.findByRole('listbox', { name: 'wanderer' })).getAllByRole('option')[1]);
await userEvent.click(await screen.findByRole('button', { name: 'Open the tour' }));
expect(onOpenTour).toHaveBeenCalledWith(7);
});
it('RS-06: a changed trail can come in again as a new version beside the old one', async () => {
server.use(
http.get('/api/trips/3/tours/wanderer/trails/:id', () =>
HttpResponse.json(
preview({
state: 'changed',
changes: ['name', 'route'],
snapshot: { placeId: 7, name: 'Kramerplateauweg', importedAt: '2026-10-15 10:00:00', editedLocally: true },
})
)
)
);
render(<Harness />);
await userEvent.click(screen.getByRole('button', { name: 'open' }));
await userEvent.click(within(await screen.findByRole('listbox', { name: 'wanderer' })).getAllByRole('option')[1]);
expect(await screen.findByRole('button', { name: 'Import as new version' })).toBeInTheDocument();
});
it('RS-03: without a connection it points to the settings', async () => {
server.use(
http.post('/api/trips/3/tours/wanderer/search', () =>
HttpResponse.json({ error: 'x', code: 'not_connected' }, { status: 409 })
)
);
render(<Harness />);
await userEvent.click(screen.getByRole('button', { name: 'open' }));
expect(await screen.findByRole('link', { name: /Connect in settings/ })).toHaveAttribute(
'href',
'/settings?tab=integrations'
);
});
it('RS-05: a fingerprint gone stale is read again rather than imported', async () => {
server.use(
http.post('/api/trips/3/tours/wanderer/import', () =>
HttpResponse.json({ error: 'x', code: 'preview_stale' }, { status: 409 })
)
);
render(<Harness />);
await userEvent.click(screen.getByRole('button', { name: 'open' }));
await userEvent.click(within(await screen.findByRole('listbox', { name: 'wanderer' })).getAllByRole('option')[0]);
await userEvent.click(await screen.findByRole('button', { name: 'Import into trip' }));
await waitFor(() => expect(toast.info).toHaveBeenCalled());
expect(await screen.findByRole('button', { name: 'Import into trip' })).toBeInTheDocument();
});
it('RS-03: is offered only to someone who connected their own wanderer', async () => {
let browser: WandererBrowser | undefined;
const connection = {
url: '',
tokenMasked: '',
allowInsecureTls: false,
connected: false,
lastCheckedAt: null,
lastCheckOk: null,
lastCheckError: null,
};
server.use(http.get('/api/integrations/wanderer/settings', () => HttpResponse.json(connection)));
const view = render(
<Harness
expose={(value) => {
browser = value;
}}
/>
);
await waitFor(() => expect(browser?.enabled).toBe(false));
view.unmount();
server.use(
http.get('/api/integrations/wanderer/settings', () =>
HttpResponse.json({ ...connection, url: 'https://w.test', connected: true })
)
);
render(
<Harness
expose={(value) => {
browser = value;
}}
/>
);
await waitFor(() => expect(browser?.enabled).toBe(true));
});
});
@@ -0,0 +1,463 @@
import { wandererDifficultyToSac, type WandererImportPreview, type WandererTrailSummary } from '@trek/shared';
import {
AlertTriangle,
ArrowLeft,
ArrowUp,
Check,
Clock,
Download,
ExternalLink,
Globe,
Loader2,
Lock,
MapPinned,
Ruler,
Search,
User,
type LucideIcon,
} from 'lucide-react';
import type { ReactNode } from 'react';
import { Link } from 'react-router';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDistance, formatElevation } from '../../../utils/units';
import { BarButton, WhiteBadge } from '../../Planner/planParts';
import { DialogButton, NEUTRAL_TINT, fs } from '../../shared/DialogShell';
import EmptyState from '../../shared/EmptyState';
import { Tooltip } from '../../shared/Tooltip';
import { RAIL_HEAD, RailTitle } from '../planner/TourPlannerPanels';
import { TourDayRowAvatar } from '../TourDayRowFacts';
import { TourListCard, TourMetricFields, TourNotice, TourSection } from '../tourParts';
import { formatPlannedTourDuration } from '../tourPresentation';
import type { WandererBrowser } from './useWandererBrowser';
import WandererMark from './WandererMark';
/**
* The left rail of the tours workspace while the user browses their wanderer
* trails: the search and its results, or one trail's preview with the import.
* The trail itself shows on the map between the rails.
*/
export default function WandererBrowserRail({ browser }: { browser: WandererBrowser }) {
const { t } = useTranslation();
const inPreview = browser.selected !== null;
return (
<section className="flex h-full min-h-0 flex-col" aria-label={t('wanderer.browser.title')}>
<div className={RAIL_HEAD} style={{ background: NEUTRAL_TINT }}>
<BarButton
label={inPreview ? t('wanderer.browser.backToList') : t('tours.planner.backToTours')}
onClick={inPreview ? browser.backToList : browser.close}
className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<ArrowLeft size={16} strokeWidth={2} />
</BarButton>
<RailTitle
title={t('wanderer.browser.title')}
sub={inPreview ? browser.selected?.name : t('wanderer.browser.sub')}
/>
</div>
{inPreview ? <TrailPreview browser={browser} /> : <TrailSearch browser={browser} />}
</section>
);
}
function TrailSearch({ browser }: { browser: WandererBrowser }) {
const { t } = useTranslation();
const notConnected = browser.searchError === 'not_connected' || browser.searchError === 'unauthorized';
return (
<>
<div className="flex flex-none flex-col gap-2 border-b border-edge-faint px-3 py-2.5">
<label className="flex h-8 items-center gap-2 rounded-[10px] bg-surface-card px-2.5 shadow-sm ring-1 ring-inset ring-edge-faint focus-within:ring-accent">
<Search size={14} strokeWidth={2} className="flex-none text-content-faint" />
<input
value={browser.query}
onChange={(event) => browser.setQuery(event.target.value)}
autoFocus
placeholder={t('wanderer.browser.searchPlaceholder')}
aria-label={t('wanderer.browser.searchPlaceholder')}
className="min-w-0 flex-1 border-0 bg-transparent text-content outline-none placeholder:text-content-faint"
style={fs(12.5, 'body')}
/>
{browser.searching && (
<Loader2 size={13} strokeWidth={2.2} className="flex-none animate-spin text-content-faint" />
)}
</label>
<Tooltip label={browser.area ? t('wanderer.browser.areaOffHint') : t('wanderer.browser.areaOnHint')}>
<button
type="button"
onClick={browser.toggleArea}
aria-pressed={!!browser.area}
className={`flex h-8 items-center justify-center gap-1.5 rounded-[10px] px-3 font-semibold shadow-sm transition-colors ${browser.area ? 'bg-accent text-accent-text' : 'bg-surface-card text-content-secondary ring-1 ring-inset ring-edge-faint hover:text-content'}`}
style={fs(12, 'body')}
>
<MapPinned size={14} strokeWidth={2.2} className="flex-none" />
{browser.area ? t('wanderer.browser.areaOn') : t('wanderer.browser.areaOff')}
</button>
</Tooltip>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2.5 py-2.5">
{notConnected ? (
<EmptyState
scene="tours"
size={92}
fill
surface="var(--bg-secondary)"
title={t('wanderer.browser.connectTitle')}
action={
<div className="flex flex-col items-center gap-3">
<p className="m-0 max-w-[260px] text-content-muted" style={fs(12, 'body')}>
{browser.errorText(browser.searchError ?? 'not_connected')}
</p>
<Link
to="/settings?tab=integrations"
className="inline-flex h-8 items-center gap-1.5 rounded-[10px] bg-accent px-3 font-semibold text-accent-text shadow-sm hover:opacity-90"
style={fs(12.5, 'body')}
>
<WandererMark size={14} />
{t('wanderer.browser.connect')}
</Link>
</div>
}
/>
) : browser.searchError ? (
<div className="px-1.5 py-3">
<TourNotice tone="warning">
<p className="m-0">{browser.errorText(browser.searchError)}</p>
<button type="button" onClick={browser.retry} className="mt-1.5 font-semibold underline">
{t('tours.planner.retry')}
</button>
</TourNotice>
</div>
) : !browser.searching && browser.trails.length === 0 ? (
<EmptyState
scene="tours"
size={92}
fill
surface="var(--bg-secondary)"
title={browser.area ? t('wanderer.browser.emptyArea') : t('wanderer.browser.empty')}
action={
<p className="m-0 max-w-[260px] text-content-muted" style={fs(12, 'body')}>
{t('wanderer.browser.emptyHint')}
</p>
}
/>
) : (
<>
<TourListCard
icon={<WandererMark size={14} />}
title={t('wanderer.browser.count', { count: browser.total })}
aside={
browser.area && (
<WhiteBadge icon={<MapPinned size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
{t('wanderer.browser.areaBadge')}
</WhiteBadge>
)
}
label={t('wanderer.browser.title')}
>
{browser.trails.map((trail) => (
<TrailRow key={trail.id} trail={trail} onSelect={() => void browser.select(trail)} />
))}
</TourListCard>
{browser.hasMore && (
<div className="flex justify-center py-2">
<DialogButton onClick={browser.loadMore} disabled={browser.searching}>
{t('wanderer.browser.more')}
</DialogButton>
</div>
)}
</>
)}
</div>
</>
);
}
/** One fact of a trail as the white badge the plan's rows use, named in full by its tooltip. */
function TrailBadge({
icon: Icon,
label,
children,
className = '',
}: {
icon?: LucideIcon;
label: string;
children?: ReactNode;
className?: string;
}) {
return (
<Tooltip label={label}>
<span aria-label={label} className="inline-flex flex-none">
<WhiteBadge
className={className}
icon={Icon && <Icon size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}
>
{children}
</WhiteBadge>
</span>
</Tooltip>
);
}
/**
* The facts a trail carries in the index, as the white badges of a plan row.
* A row shows time, distance and ascent, its avatar the rest (grade, in the
* trip, private). The preview has no avatar, so it names those as badges, and
* leaves out distance and ascent: it measures both from the GPX itself, and two
* numbers for one thing only confuse.
*/
function TrailBadges({ trail, variant }: { trail: WandererTrailSummary; variant: 'row' | 'preview' }) {
const preview = variant === 'preview';
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const distance = trail.distanceM != null ? formatDistance(trail.distanceM / 1000, unit) : null;
const ascent = trail.elevationGainM != null ? formatElevation(trail.elevationGainM, unit) : null;
const duration = trail.durationS != null ? formatPlannedTourDuration(Math.round(trail.durationS / 60)) : null;
const level = wandererDifficultyToSac(trail.difficulty);
return (
<>
{preview && trail.importedAs.length > 0 && (
<TrailBadge label={t('wanderer.browser.inTripHint')} className="text-success">
<Check size={10} strokeWidth={2.6} className="flex-none" />
{t('wanderer.browser.inTrip')}
</TrailBadge>
)}
{duration && (
<TrailBadge icon={Clock} label={`${t('tours.planner.inspector.duration')}: ${duration}`}>
{duration}
</TrailBadge>
)}
{!preview && distance && (
<TrailBadge icon={Ruler} label={`${t('tours.detail.distance')}: ${distance}`}>
{distance}
</TrailBadge>
)}
{!preview && ascent && (
<TrailBadge icon={ArrowUp} label={`${t('tours.detail.ascent')}: ${ascent}`}>
{ascent}
</TrailBadge>
)}
{preview && level != null && (
<TrailBadge label={t(`tours.planner.difficulty.t${level}`)}>{`T${level}`}</TrailBadge>
)}
{preview && (
<TrailBadge
icon={trail.public ? Globe : Lock}
label={trail.public ? t('wanderer.browser.publicHint') : t('wanderer.browser.privateHint')}
>
{trail.public ? t('wanderer.browser.public') : t('wanderer.browser.private')}
</TrailBadge>
)}
</>
);
}
/** The row's round tile with the trail's grade, and a check on it once the trail is in the trip. */
function TrailAvatar({ trail }: { trail: WandererTrailSummary }) {
const { t } = useTranslation();
return (
<span className="relative flex-none">
<TourDayRowAvatar
tour={{ tour_type: 'hike', max_hiking_difficulty: wandererDifficultyToSac(trail.difficulty) }}
/>
{trail.importedAs.length > 0 && (
<Tooltip label={t('wanderer.browser.inTripHint')}>
<span
aria-label={t('wanderer.browser.inTrip')}
className="absolute -end-0.5 -top-0.5 grid h-[15px] w-[15px] place-items-center rounded-full bg-surface-card text-success shadow-sm ring-1 ring-edge-faint"
>
<Check size={9} strokeWidth={3} />
</span>
</Tooltip>
)}
</span>
);
}
/** A trail in the results, laid out like a stop in the day plan: avatar, name, one line of badges and where it is. */
function TrailRow({ trail, onSelect }: { trail: WandererTrailSummary; onSelect: () => void }) {
return (
<li
role="option"
aria-selected={false}
tabIndex={0}
onClick={onSelect}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onSelect();
}
}}
className="mx-1.5 flex cursor-pointer items-center gap-2.5 rounded-[10px] px-1.5 py-2 outline-none transition-colors hover:bg-surface-hover focus-visible:bg-surface-hover"
>
<TrailAvatar trail={trail} />
<div className="min-w-0 flex-1">
<span className="block min-w-0 truncate font-semibold leading-tight text-content" style={fs(13, 'body')}>
{trail.name}
</span>
<div className="mt-0.5 flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden">
<TrailBadges trail={trail} variant="row" />
</div>
</div>
</li>
);
}
function TrailPreview({ browser }: { browser: WandererBrowser }) {
const { t, locale } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const trail = browser.preview?.trail ?? browser.selected;
if (!trail) return null;
const preview = browser.preview;
const snapshot = preview?.state === 'unchanged' ? preview.snapshot : null;
return (
<>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-3 py-3.5">
<div className="min-w-0">
{trail.location && (
<p className="m-0 text-content-muted" style={fs(12, 'body')}>
{trail.location}
</p>
)}
{trail.author && (
<p className="m-0 mt-0.5 flex items-center gap-1 text-content-faint" style={fs(11.5, 'body')}>
<User size={11} strokeWidth={2} className="flex-none" />
{trail.author}
</p>
)}
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-1">
<TrailBadges trail={trail} variant="preview" />
</div>
</div>
{browser.previewing && (
<TourNotice
icon={<Loader2 size={13} strokeWidth={2} className="animate-spin text-content-faint" />}
role="status"
>
{t('wanderer.preview.loading')}
</TourNotice>
)}
{browser.previewError && <TourNotice tone="warning">{browser.errorText(browser.previewError)}</TourNotice>}
{preview && (
<>
<SnapshotNotice preview={preview} locale={locale} onOpen={browser.openSnapshot} />
<TourSection label={t('wanderer.preview.measured')}>
<TourMetricFields
columns={2}
unit={unit}
analysis={{
distanceKm: preview.distanceKm,
minEle: null,
maxEle: null,
gain: preview.elevationGainM,
loss: preview.elevationLossM,
}}
extra={[{ label: t('wanderer.preview.points'), value: preview.points.toLocaleString(locale) }]}
/>
{!preview.hasElevation && (
<p className="m-0 mt-2 text-content-faint" style={fs(11.5, 'body')}>
{t('wanderer.preview.noElevation')}
</p>
)}
</TourSection>
{trail.description && (
<TourSection label={t('places.formDescription')}>
<p
className="m-0 whitespace-pre-line rounded-[10px] bg-surface-card px-3 py-2 text-content-secondary shadow-sm ring-1 ring-inset ring-edge-faint"
style={fs(12, 'body')}
>
{trail.description}
</p>
</TourSection>
)}
</>
)}
<a
href={trail.externalUrl}
target="_blank"
rel="noopener noreferrer nofollow"
className="inline-flex items-center gap-1.5 text-content-secondary hover:text-content hover:underline"
style={fs(12, 'body')}
>
<ExternalLink size={13} className="flex-none" />
{t('wanderer.preview.openExternal')}
</a>
</div>
<div className="flex flex-none flex-col gap-2 border-t border-edge-faint px-3 py-3">
{snapshot ? (
<DialogButton
variant="primary"
onClick={() => browser.openSnapshot(snapshot.placeId)}
icon={<Check size={14} strokeWidth={2} />}
>
{t('wanderer.preview.openSnapshot')}
</DialogButton>
) : (
<DialogButton
variant="primary"
onClick={() => void browser.importSelected()}
disabled={!preview || browser.importing}
icon={
browser.importing ? (
<Loader2 size={14} strokeWidth={2} className="animate-spin" />
) : (
<Download size={14} strokeWidth={2} />
)
}
>
{preview?.state === 'changed' ? t('wanderer.preview.importNewVersion') : t('wanderer.preview.import')}
</DialogButton>
)}
</div>
</>
);
}
/** How the trail stands to a snapshot already in the trip, and what importing it again would do. */
function SnapshotNotice({
preview,
locale,
onOpen,
}: {
preview: WandererImportPreview;
locale: string;
onOpen: (placeId: number) => void;
}) {
const { t } = useTranslation();
const snapshot = preview.snapshot;
if (preview.state === 'new' || !snapshot) {
return <TourNotice>{t('wanderer.preview.newHint')}</TourNotice>;
}
const when = snapshot.importedAt
? new Date(`${snapshot.importedAt.replace(' ', 'T')}Z`).toLocaleDateString(locale)
: '';
const open = (
<button
type="button"
onClick={() => onOpen(snapshot.placeId)}
className="mt-1 inline-flex items-center gap-1 font-semibold underline"
>
{t('wanderer.preview.openSnapshotShort')}
</button>
);
if (preview.state === 'unchanged') {
return (
<TourNotice icon={<Check size={13} strokeWidth={2.2} className="text-success" />}>
<p className="m-0">{t('wanderer.preview.unchanged', { name: snapshot.name, date: when })}</p>
</TourNotice>
);
}
const parts = preview.changes.map((part) => t(`wanderer.preview.change.${part}`)).join(', ');
return (
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2.2} />}>
<p className="m-0">{t('wanderer.preview.changed', { name: snapshot.name, date: when, parts })}</p>
{snapshot.editedLocally && <p className="m-0 mt-1">{t('wanderer.preview.editedLocally')}</p>}
<p className="m-0 mt-1">{t('wanderer.preview.newVersionHint')}</p>
{open}
</TourNotice>
);
}
@@ -0,0 +1,49 @@
import { useId } from 'react';
/** The emblem (the ring, the path and the peaks), without the word mark. */
const EMBLEM =
'M78.5486 127.669C110.819 124.389 136 97.1354 136 64C136 28.6538 107.346 0 72 0C36.6538 0 8 28.6538 8 64C8 88.081 21.2997 109.056 40.9546 119.979C53.1847 116.013 64.7987 110.896 67.375 105.25C69.0751 101.524 66.3217 97.7504 63.864 94.382C62.2426 92.1598 60.75 90.114 60.75 88.375C60.75 80.1424 83.125 77.5 83.125 77.5H90.875C90.875 77.5 70.125 80.125 71.125 88.375C71.46 91.1392 73.6802 93.3515 76.2021 95.8646C78.7434 98.397 81.5911 101.235 83.125 105.25C86.8193 114.92 80.3133 125.157 78.5486 127.669Z';
/** The emblem's inner lines, cut out of it so the background shows through. */
const DETAILS = [
'M40.9546 119.979C53.1847 116.013 64.7988 110.896 67.375 105.25C69.0751 101.524 66.3217 97.7504 63.8641 94.382C62.2427 92.1598 60.75 90.114 60.75 88.375C60.75 80.1424 83.125 77.5 83.125 77.5H90.875C90.875 77.5 70.125 80.125 71.125 88.375C71.4601 91.1392 73.6802 93.3515 76.2021 95.8646C78.7434 98.397 81.5911 101.235 83.125 105.25C86.8193 114.92 80.3133 125.157 78.5486 127.669C76.3955 127.888 74.2109 128 72 128C60.7347 128 50.1493 125.089 40.9546 119.979Z',
'M95.053 51.5036C95.4679 50.8321 96.5052 50.8321 96.9201 51.5036L112.091 76.0518C112.505 76.7232 111.987 77.5625 111.157 77.5625H80.8162C79.9863 77.5625 79.4677 76.7232 79.8826 76.0518L95.053 51.5036Z',
'M49.1809 45.819C49.6531 45.0011 50.8335 45.0011 51.3057 45.819L68.5704 75.7223C69.0426 76.5402 68.4524 77.5625 67.508 77.5625H32.9786C32.0342 77.5625 31.444 76.5402 31.9162 75.7223L49.1809 45.819Z',
'M74.5626 38.75C75.1399 37.75 76.5833 37.75 77.1606 38.75L98.27 75.3125C98.8474 76.3125 98.1257 77.5625 96.971 77.5625H54.7522C53.5975 77.5625 52.8758 76.3125 53.4532 75.3125L74.5626 38.75Z',
'M45.1033 28.8496C42.9573 28.8496 41.2175 31.5199 41.2175 34.8138H48.9892C48.9892 31.5199 47.2494 28.8496 45.1033 28.8496Z',
'M38.7776 28.8496C35.0344 28.8496 32 31.5199 32 34.8138H45.5552C45.5552 31.5199 42.5207 28.8496 38.7776 28.8496Z',
'M41.579 26.5C39.383 26.5 37.6028 28.8466 37.6028 31.7413H45.5551C45.5551 28.8466 43.7749 26.5 41.579 26.5Z',
'M77 16.75C74.1005 16.75 71.75 19.9959 71.75 24H82.25C82.25 19.9959 79.8995 16.75 77 16.75Z',
'M70.5 19.75C67.4624 19.75 65 21.6528 65 24H76C76 21.6528 73.5376 19.75 70.5 19.75Z',
'M71.75 15.75C69.4028 15.75 67.5 18.1005 67.5 21H76C76 18.1005 74.0972 15.75 71.75 15.75Z',
'M107.125 34.75C104.985 34.75 103.25 36.9886 103.25 39.75H111C111 36.9886 109.265 34.75 107.125 34.75Z',
'M99.25 34.5C96.2124 34.5 93.75 36.8505 93.75 39.75H104.75C104.75 36.8505 102.288 34.5 99.25 34.5Z',
'M102.25 31.75C99.0744 31.75 96.5 34.1005 96.5 37H108C108 34.1005 105.426 31.75 102.25 31.75Z',
'M36.3917 105.29C36.3917 104.876 36.7275 104.54 37.1417 104.54H38.8212C39.2354 104.54 39.5712 104.876 39.5712 105.29V106.44C39.5712 106.854 39.2354 107.19 38.8212 107.19H37.1417C36.7275 107.19 36.3917 106.854 36.3917 106.44V105.29Z',
'M37.3446 98.5749C37.7143 98.2696 38.2486 98.2696 38.6183 98.5749L44.0977 103.101C44.8206 103.698 44.3984 104.872 43.4609 104.872H32.502C31.5645 104.872 31.1424 103.698 31.8652 103.101L37.3446 98.5749Z',
'M37.293 95.5231C37.6789 95.1568 38.2841 95.1568 38.67 95.5231L43.6299 100.232C44.2851 100.854 43.8449 101.957 42.9414 101.957H33.0215C32.1181 101.957 31.6778 100.854 32.333 100.232L37.293 95.5231Z',
'M37.214 92.609C37.6137 92.1303 38.3493 92.1303 38.749 92.609L42.7518 97.4015C43.2956 98.0527 42.8326 99.0426 41.9843 99.0426H33.9787C33.1304 99.0426 32.6674 98.0527 33.2112 97.4015L37.214 92.609Z',
'M94.8917 99.2904C94.8917 98.8762 95.2275 98.5404 95.6417 98.5404H97.3212C97.7354 98.5404 98.0712 98.8762 98.0712 99.2904V100.44C98.0712 100.854 97.7354 101.19 97.3212 101.19H95.6417C95.2275 101.19 94.8917 100.854 94.8917 100.44V99.2904Z',
'M95.8446 92.5749C96.2143 92.2696 96.7486 92.2696 97.1183 92.5749L102.598 97.1006C103.321 97.6976 102.898 98.8716 101.961 98.8716H91.002C90.0645 98.8716 89.6424 97.6976 90.3652 97.1006L95.8446 92.5749Z',
'M95.793 89.5231C96.1789 89.1568 96.7841 89.1568 97.17 89.5231L102.13 94.2319C102.785 94.8539 102.345 95.9571 101.441 95.9571H91.5215C90.6181 95.9571 90.1778 94.8539 90.833 94.2319L95.793 89.5231Z',
'M95.714 86.609C96.1137 86.1303 96.8493 86.1303 97.249 86.609L101.252 91.4015C101.796 92.0527 101.333 93.0426 100.484 93.0426H92.4787C91.6304 93.0426 91.1674 92.0527 91.7112 91.4015L95.714 86.609Z',
];
/**
* wanderer's logo mark, one colour (`currentColor`) like the lucide icons
* beside it, sized the same way. Drawn after the logo in open-wanderer/wanderer.
*/
export default function WandererMark({ size = 16, className }: { size?: number; className?: string }) {
const mask = useId();
return (
<svg width={size} height={size} viewBox="8 0 128 128" className={className} aria-hidden="true" focusable="false">
<mask id={mask}>
<rect x="8" y="0" width="128" height="128" fill="white" />
{DETAILS.map((d) => (
<path key={d} d={d} fill="black" />
))}
</mask>
<path d={EMBLEM} fill="currentColor" mask={`url(#${mask})`} />
</svg>
);
}
@@ -0,0 +1,265 @@
import type { TourListItem, WandererBbox, WandererImportPreview, WandererTrailSummary } from '@trek/shared';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from '../../../i18n';
import { wandererErrorCode, wandererRepo } from '../../../repo/wandererRepo';
import { useToast } from '../../shared/Toast';
import { wandererErrorText } from './wandererErrors';
/** Typing settles for this long before the search goes out. */
const SEARCH_DEBOUNCE_MS = 350;
export type WandererBrowserError = ReturnType<typeof wandererErrorCode>;
const isAbort = (error: unknown) => {
const e = error as { name?: string; code?: string } | null;
return e?.name === 'CanceledError' || e?.code === 'ERR_CANCELED' || e?.name === 'AbortError';
};
/**
* The search half: a text, or the part of the map in view, and the trails it
* found, a page at a time. Every answer that arrives after a newer question is
* dropped, so a slow search never paints over the one typed after it.
*/
function useWandererSearch(tripId: number, active: boolean) {
const [query, setQuery] = useState('');
const [area, setArea] = useState<WandererBbox | null>(null);
const [trails, setTrails] = useState<WandererTrailSummary[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [searching, setSearching] = useState(false);
const [searchError, setSearchError] = useState<WandererBrowserError | null>(null);
const viewport = useRef<WandererBbox | null>(null);
const searchAbort = useRef<AbortController | null>(null);
const runSearch = useCallback(
async (q: string, bbox: WandererBbox | null, nextPage: number) => {
searchAbort.current?.abort();
const controller = new AbortController();
searchAbort.current = controller;
setSearching(true);
setSearchError(null);
try {
const result = await wandererRepo.search(
tripId,
{ q, page: nextPage, ...(bbox ? { bbox } : {}) },
controller.signal
);
if (controller.signal.aborted) return;
setTrails((current) => (nextPage === 1 ? result.trails : [...current, ...result.trails]));
setTotal(result.total);
setPage(nextPage);
} catch (error: unknown) {
if (isAbort(error) || controller.signal.aborted) return;
setSearchError(wandererErrorCode(error));
if (nextPage === 1) setTrails([]);
} finally {
if (!controller.signal.aborted) setSearching(false);
}
},
[tripId]
);
// A new text or a new area starts over at the first page, once typing settles.
useEffect(() => {
if (!active) return;
const timer = window.setTimeout(() => {
void runSearch(query.trim(), area, 1);
}, SEARCH_DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [active, area, query, runSearch]);
useEffect(() => () => searchAbort.current?.abort(), []);
return {
query,
setQuery,
area,
/** Search the part of the map in view; a second press goes back to searching everywhere. */
toggleArea: useCallback(() => setArea((current) => (current ? null : viewport.current)), []),
onViewportChange: useCallback((bounds: WandererBbox) => {
viewport.current = bounds;
}, []),
trails,
total,
hasMore: trails.length < total,
loadMore: useCallback(() => {
if (!searching) void runSearch(query.trim(), area, page + 1);
}, [area, page, query, runSearch, searching]),
searching,
searchError,
retry: useCallback(() => {
void runSearch(query.trim(), area, 1);
}, [area, query, runSearch]),
abortSearch: useCallback(() => searchAbort.current?.abort(), []),
};
}
/**
* The preview half: picking a trail fetches its preview (the full GPX measured,
* and how it stands to a snapshot already in the trip), and the import writes it
* as a tour of the trip only after the user confirms.
*/
function useWandererPreview(tripId: number, onImported: (tour: TourListItem) => Promise<void> | void) {
const { t } = useTranslation();
const toast = useToast();
const [selected, setSelected] = useState<WandererTrailSummary | null>(null);
const [preview, setPreview] = useState<WandererImportPreview | null>(null);
const [previewing, setPreviewing] = useState(false);
const [previewError, setPreviewError] = useState<WandererBrowserError | null>(null);
const [importing, setImporting] = useState(false);
const previewAbort = useRef<AbortController | null>(null);
const select = useCallback(
async (trail: WandererTrailSummary) => {
previewAbort.current?.abort();
const controller = new AbortController();
previewAbort.current = controller;
setSelected(trail);
setPreview(null);
setPreviewError(null);
setPreviewing(true);
try {
const result = await wandererRepo.preview(tripId, trail.id, controller.signal);
if (!controller.signal.aborted) setPreview(result);
} catch (error: unknown) {
if (isAbort(error) || controller.signal.aborted) return;
setPreviewError(wandererErrorCode(error));
} finally {
if (!controller.signal.aborted) setPreviewing(false);
}
},
[tripId]
);
const backToList = useCallback(() => {
previewAbort.current?.abort();
setSelected(null);
setPreview(null);
setPreviewError(null);
setPreviewing(false);
}, []);
useEffect(() => () => previewAbort.current?.abort(), []);
/** Writes the previewed trail; true once it is in, so the caller can close. */
const importSelected = useCallback(async () => {
if (!preview || importing) return false;
setImporting(true);
try {
const { tour } = await wandererRepo.importTrail(tripId, {
trailId: preview.trail.id,
contentHash: preview.contentHash,
});
toast.success(t('wanderer.import.done', { name: tour.name }));
if (tour.caution) toast.info(t('tours.import.caution'));
await onImported(tour);
return true;
} catch (error: unknown) {
const code = wandererErrorCode(error);
if (code === 'preview_stale' && selected) {
toast.info(t('wanderer.import.stale'));
void select(selected);
} else {
toast.error(wandererErrorText(t, code));
}
return false;
} finally {
setImporting(false);
}
}, [importing, onImported, preview, select, selected, t, toast, tripId]);
// The trail on the map: the preview's full line once it is in, the list's sketch until then.
// Read as plain pairs, since a workspace without strict mode sees the contract's tuples as optional.
const sourceLine = preview?.line ?? selected?.line ?? null;
const line = useMemo(
() => sourceLine?.map((point) => [Number(point[0]), Number(point[1])] as [number, number]) ?? null,
[sourceLine]
);
return { selected, select, backToList, preview, previewing, previewError, importing, importSelected, line };
}
/**
* Whether the signed-in user has connected their own wanderer, asked each time
* the workspace offers it: the entry to the trails shows only for someone who
* has trails to bring in. A failed answer counts as not connected.
*/
function useWandererConnected(active: boolean): boolean {
const [connected, setConnected] = useState(false);
useEffect(() => {
if (!active) return undefined;
let cancelled = false;
wandererRepo.getSettings().then(
(connection) => {
if (!cancelled) setConnected(connection.connected);
},
() => {
if (!cancelled) setConnected(false);
}
);
return () => {
cancelled = true;
};
}, [active]);
return active && connected;
}
interface WandererBrowserOptions {
tripId: number;
/** The tours tab is on screen and the user may add tours. */
enabled: boolean;
/** The new tour, once the import wrote it, for the lists and the map to take it in. */
onImported: (tour: TourListItem) => Promise<void> | void;
/** Open a tour of the trip, for a snapshot that is already here. */
onOpenTour: (placeId: number) => void;
}
/**
* Browsing the user's wanderer trails from the tours workspace, in place of the
* editor rail: the search, one trail's preview on the map, and its import.
*/
export function useWandererBrowser({ tripId, enabled, onImported, onOpenTour }: WandererBrowserOptions) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const connected = useWandererConnected(enabled);
const search = useWandererSearch(tripId, open && enabled);
const preview = useWandererPreview(tripId, onImported);
const { abortSearch } = search;
const { backToList } = preview;
const close = useCallback(() => {
abortSearch();
backToList();
setOpen(false);
}, [abortSearch, backToList]);
// Leaving the tab closes the browser, so coming back starts from the workspace.
useEffect(() => {
if (!enabled) return;
return close;
}, [close, enabled]);
const { importSelected } = preview;
return {
...search,
...preview,
errorText: useCallback((code: WandererBrowserError) => wandererErrorText(t, code), [t]),
/** The browser may be offered here: wanderer is allowed and the user has connected their own. */
enabled: connected,
open,
openBrowser: useCallback(() => setOpen(true), []),
close,
importSelected: useCallback(async () => {
if (await importSelected()) close();
}, [close, importSelected]),
openSnapshot: useCallback(
(placeId: number) => {
close();
onOpenTour(placeId);
},
[close, onOpenTour]
),
};
}
export type WandererBrowser = ReturnType<typeof useWandererBrowser>;
@@ -0,0 +1,7 @@
type Translate = (key: string, params?: Record<string, unknown>) => string;
/** A wanderer reason code as a sentence in the reader's language; an unknown code still gets one. */
export function wandererErrorText(t: Translate, code: string): string {
const text = t(`wanderer.error.${code}`);
return text === `wanderer.error.${code}` ? t('wanderer.error.unknown') : text;
}
@@ -15,8 +15,8 @@ vi.mock('../../store/settingsStore', () => ({
selector({ settings: { language: 'en', distance_unit: 'metric' } }),
}))
vi.mock('../../store/tripStore', () => ({
useTripStore: (selector: (state: { loadTrip: () => Promise<void> }) => unknown) =>
selector({ loadTrip: vi.fn() }),
useTripStore: (selector: (state: { loadTrip: () => Promise<void>; places: never[]; assignments: Record<string, never> }) => unknown) =>
selector({ loadTrip: vi.fn(), places: [], assignments: {} }),
}))
const tour: TourListItem = {
@@ -95,16 +95,14 @@ describe('CustomSelect consumer contracts', () => {
onAssignToDay={vi.fn()} onSelectTour={vi.fn()}
/></TranslationProvider>)
expect(screen.getAllByRole('option')).toHaveLength(2)
const filter = screen.getByRole('button', { name: 'Show' })
await user.click(filter)
// The menu stays open while the filters are set, so each pick is one click.
await user.click(screen.getByRole('button', { name: 'Show' }))
await user.click(screen.getByRole('button', { name: /Unplanned\s*1/ }))
expect(screen.getByRole('option', { name: /Ridge walk/ })).toBeInTheDocument()
expect(screen.queryByRole('option', { name: /Planned walk/ })).toBeNull()
await user.click(filter)
await user.click(screen.getByRole('button', { name: /Planned\s*1/ }))
await user.click(screen.getByRole('button', { name: /^Planned\s*1/ }))
expect(screen.getByRole('option', { name: /Planned walk/ })).toBeInTheDocument()
expect(screen.queryByRole('option', { name: /Ridge walk/ })).toBeNull()
await user.click(filter)
await user.click(screen.getByRole('button', { name: /All\s*2/ }))
expect(screen.getAllByRole('option')).toHaveLength(2)
})
+9 -2
View File
@@ -1,4 +1,4 @@
import type { Ferrata, RoadtripPreferences, TourListItem, TourWaypoint } from '@trek/shared';
import type { Ferrata, RoadtripPreferences, TourListItem, TourProvenance, TourWaypoint } from '@trek/shared';
import Dexie, { type Table } from 'dexie';
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile, Accommodation, TripMember, Tag, Category } from '../types';
@@ -15,6 +15,8 @@ export interface CachedTripMember extends TripMember {
export interface CachedTour extends TourListItem {
trip_id: number;
waypoints?: TourWaypoint[];
/** Where the tour came from, as its detail last said; kept across list refreshes, since it does not change. */
provenance?: TourProvenance;
}
/**
@@ -464,7 +466,12 @@ export async function replaceTripTours(tripId: number, tours: TourListItem[]): P
&& previous.elevation_gain === tour.elevation_gain
&& previous.elevation_loss === tour.elevation_loss
&& previous.has_waypoints === tour.has_waypoints;
return { ...tour, trip_id: tripId, ...(sameRoute && previous.waypoints ? { waypoints: previous.waypoints } : {}) };
return {
...tour,
trip_id: tripId,
...(sameRoute && previous.waypoints ? { waypoints: previous.waypoints } : {}),
...(previous?.provenance ? { provenance: previous.provenance } : {}),
};
}));
});
}
@@ -3,7 +3,7 @@ import { useTranslation } from '../../../i18n'
import { useIsDark } from '../../../hooks/useIsDark'
import {
Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen,
Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Check, Loader2,
Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Check, Loader2, Mountain, CarFront,
} from 'lucide-react'
import DawarichIcon from '../../../components/shared/DawarichIcon'
import AirTrailIcon from '../../../components/shared/AirTrailIcon'
@@ -24,11 +24,15 @@ import { useAddonManager } from '../../../components/Admin/addons/useAddonManage
import { useLlmParsingConfig } from '../../../components/Admin/addons/useLlmParsingConfig'
import { ImmichIcon, SynologyIcon } from '../../../components/Admin/addons/PhotoProviderIcons'
import { MAdminButton, MAdminCard, MAdminField, MAdminInput, MAdminSecretInput } from './MAdminUi'
import WandererMark from '../../../components/Tours/wanderer/WandererMark'
// Keys are the `icon` column from the addons table; anything unknown falls back to
// Puzzle. Users/Sparkles cover collab and llm_parsing, as on the desktop.
const ICON_MAP = {
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark, Users, Sparkles,
Mountain,
// Road trip was seeded as 'Route'; the car says what it is better than a line does.
Route: CarFront,
Dawarich: DawarichIcon,
}
@@ -60,6 +64,8 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac
const dark = useIsDark()
const {
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions,
toursWanderer,
toggleToursWanderer,
} = useAddonManager()
if (loading) {
@@ -107,6 +113,15 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac
/>
)}
{addon.id === 'documents' && addon.enabled && <MProviderShelf options={documentProviderOptions} />}
{addon.id === 'tours' && addon.enabled && (
<MSubRow
providerIcon={WandererMark}
title={t('admin.toursWanderer.title')}
subtitle={t('admin.toursWanderer.subtitle')}
enabled={toursWanderer}
onToggle={() => void toggleToursWanderer()}
/>
)}
{addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && (
<>
{COLLAB_SUB_FEATURES.map(feat => (
@@ -20,7 +20,7 @@ import MPlacesBulkCategorySheet from './MPlacesBulkCategorySheet'
import MPlacesSaveToCollectionSheet from './MPlacesSaveToCollectionSheet'
import MPlacesToursModeSwitch from './MPlacesToursModeSwitch'
import MToursSelectionList from './MToursSelectionList'
import type { TourFilter } from '../../../../components/Tours/tourPresentation'
import { useTourListFilters } from '../../../../components/Tours/useTourListFilters'
import { filterPool, firstPlannedDayNumbers, plannedPlaceIds } from './placesBrowserModel'
import { MCategoryFilterList, MRatingFloorChips, SquareCheck } from './MPlacesFilterControls'
import { countActivePlacesFilters } from '../../../../utils/placesFilter'
@@ -47,7 +47,7 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
// Top level of the places browser, mirroring the desktop right add-panel's
// Places <-> Tours switch placement.
const [toursMode, setToursMode] = useState(false)
const [toursFilter, setToursFilter] = useState<TourFilter>('all')
const tourFilters = useTourListFilters(planner.tours)
const poolPlaces = useMemo(
// Places that are tours stay out of the Places pool while the addon is on.
@@ -124,11 +124,11 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
{/* All / Unplanned / Planned only — no Tracks chip, no Import/Add/Select
buttons: tours are attached here, never created or bulk-managed. */}
<div className="flex items-center gap-[6px]">
<FilterChip active={toursFilter === 'all'} label={t('places.all')} onClick={() => setToursFilter('all')} />
<FilterChip active={toursFilter === 'unplanned'} label={t('places.unplanned')} onClick={() => setToursFilter('unplanned')} />
<FilterChip active={toursFilter === 'planned'} label={t('places.planned')} onClick={() => setToursFilter('planned')} />
<FilterChip active={tourFilters.status === 'all'} label={t('places.all')} onClick={() => tourFilters.setStatus('all')} />
<FilterChip active={tourFilters.status === 'unplanned'} label={t('places.unplanned')} onClick={() => tourFilters.setStatus('unplanned')} />
<FilterChip active={tourFilters.status === 'planned'} label={t('places.planned')} onClick={() => tourFilters.setStatus('planned')} />
</div>
<MToursSelectionList planner={planner} shell={shell} filter={toursFilter} />
<MToursSelectionList planner={planner} shell={shell} filters={tourFilters} />
</>
) : (
<>
@@ -7,6 +7,14 @@ import { resetAllStores, seedStore } from '../../../../../tests/helpers/store'
import { useSettingsStore } from '../../../../store/settingsStore'
import type { TripPlanner, MTripShellApi } from '../MTripShell'
import MToursSelectionList from './MToursSelectionList'
import type { ComponentProps } from 'react'
import { useTourListFilters } from '../../../../components/Tours/useTourListFilters'
/** The list as the places browser mounts it: its filters from the shared hook (PR1-13). */
function List(props: Omit<ComponentProps<typeof MToursSelectionList>, 'filters'>) {
const filters = useTourListFilters(props.planner.tours)
return <MToursSelectionList {...props} filters={filters} />
}
const tour: TourListItem = {
place_id: 42,
@@ -70,19 +78,19 @@ describe('MToursSelectionList', () => {
const value = de[key] ?? en[key]
return typeof value === 'string' ? value : key
} }
const { rerender } = render(<MToursSelectionList planner={germanPlanner} shell={shell()} filter="all" />)
const { rerender } = render(<List planner={germanPlanner} shell={shell()} />)
expect(screen.getByRole('button', { name: 'Zum Tag hinzufügen' })).toBeInTheDocument()
expect(screen.getByTitle('T2 – Bergwandern')).toHaveAttribute('aria-label', 'T2 – Bergwandern')
expect(screen.getByText(germanPlanner.t('tours.planner.mobileHint'))).toBeInTheDocument()
rerender(<MToursSelectionList planner={{ ...germanPlanner, tours: [] }} shell={shell()} filter="all" />)
rerender(<List planner={{ ...germanPlanner, tours: [] }} shell={shell()} />)
expect(screen.getByText('Noch keine Touren')).toBeInTheDocument()
expect(screen.getByText(germanPlanner.t('tours.empty.body'))).toBeInTheDocument()
expect(document.body.textContent).not.toMatch(/\btours\.[a-zA-Z]/)
})
it('gives a tour name the full row and up to two lines', () => {
render(<MToursSelectionList planner={planner()} shell={shell()} filter="all" />)
render(<List planner={planner()} shell={shell()} />)
const name = screen.getByText(tour.name)
expect(name).toHaveClass('line-clamp-2', 'break-words')
@@ -91,7 +99,7 @@ describe('MToursSelectionList', () => {
})
it('keeps the mobile TRIP-PLAN Tours browse list limited to viewing and assignment', () => {
const { container } = render(<MToursSelectionList planner={{ ...planner(), selectedPlaceId: tour.place_id }} shell={shell()} filter="all" canEdit canAssign />)
const { container } = render(<List planner={{ ...planner(), selectedPlaceId: tour.place_id }} shell={shell()} canEdit canAssign />)
expect(screen.getByRole('option', { name: new RegExp(tour.name) })).toHaveAttribute('aria-selected', 'true')
const metrics = screen.getByTestId('mobile-tour-metrics')
expect(metrics).toHaveTextContent('12.5 km')
@@ -120,7 +128,7 @@ describe('MToursSelectionList', () => {
return typeof value === 'string' ? value : key
},
}
render(<MToursSelectionList planner={germanPlanner} shell={shell()} filter="all" />)
render(<List planner={germanPlanner} shell={shell()} />)
expect(screen.getByRole('option', { name: /Eine sehr lange deutsche Wanderung/ })).toBeInTheDocument()
expect(screen.getByText('GPX-Import')).toBeInTheDocument()
expect(screen.getByLabelText('T2 – Bergwandern')).toBeInTheDocument()
@@ -129,7 +137,7 @@ describe('MToursSelectionList', () => {
it('shows no T-badge for an imported tour that carries no difficulty', () => {
const imported = { ...tour, has_waypoints: false, max_hiking_difficulty: null }
render(<MToursSelectionList planner={{ ...planner(), tours: [imported] }} shell={shell()} filter="all" />)
render(<List planner={{ ...planner(), tours: [imported] }} shell={shell()} />)
const row = screen.getByRole('option', { name: new RegExp(tour.name) })
expect(row).not.toHaveTextContent(/T\d/)
@@ -139,9 +147,9 @@ describe('MToursSelectionList', () => {
})
it('does not refresh Tours merely because the mobile assignment sheet closes', () => {
const { rerender } = render(<MToursSelectionList planner={planner()} shell={shell({ id: 'bract' })} filter="all" />)
const { rerender } = render(<List planner={planner()} shell={shell({ id: 'bract' })} />)
rerender(<MToursSelectionList planner={planner()} shell={shell()} filter="all" />)
rerender(<List planner={planner()} shell={shell()} />)
expect(reloadTourPlaceIds).not.toHaveBeenCalled()
})
@@ -2,15 +2,33 @@ import { ArrowDown, ArrowUp, Mountain, AlertTriangle, CalendarDays } from 'lucid
import type { TourListItem } from '@trek/shared'
import { useSettingsStore } from '../../../../store/settingsStore'
import { formatDistance, formatElevation } from '../../../../utils/units'
import { filterTours, hikeSourceBadgeLabel, type TourFilter } from '../../../../components/Tours/tourPresentation'
import { hikeSourceBadgeLabel, ORIGIN_LABEL_KEY } from '../../../../components/Tours/tourPresentation'
import type { TourListFilters } from '../../../../components/Tours/useTourListFilters'
import type { TripPlanner, MTripShellApi } from '../MTripShell'
import { useTourPermissions, type TourPermissionProps } from '../../../../components/Tours/useTourPermissions'
interface MToursSelectionListProps extends TourPermissionProps {
planner: TripPlanner
shell: MTripShellApi
/** All / Unplanned / Planned — same three states as the Places pool. */
filter: TourFilter
/** The list's filters (PR1-13): the day status chips sit above, the other dimensions here. */
filters: TourListFilters
}
/** One value of a dimension as a chip of the row; tapping a chosen one clears it. */
function FacetChip({ active, label, count, onClick }: { active: boolean; label: string; count: number; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={`flex h-8 flex-none items-center gap-1.5 whitespace-nowrap rounded-full px-3 text-caption font-semibold ${
active ? 'bg-m-act text-m-actfg' : 'bg-[color:var(--m-ic)] text-m-ink'
}`}
>
{label}
<span className="font-geist text-caption opacity-60">{count}</span>
</button>
)
}
/**
@@ -22,13 +40,42 @@ interface MToursSelectionListProps extends TourPermissionProps {
* (the same one MPlacesBrowser's unplanned-place rows open) — a tour is a
* Place under the hood, so no new linking code is needed here either.
*/
export default function MToursSelectionList({ planner, shell, filter, canAssign: assignPermission }: MToursSelectionListProps) {
export default function MToursSelectionList({ planner, shell, filters, canAssign: assignPermission }: MToursSelectionListProps) {
const { canAssign } = useTourPermissions({ tripId: planner.tripId, canAssign: assignPermission })
const { t } = planner
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const tours: TourListItem[] = planner.tours
const filtered: TourListItem[] = filters.filtered
const { facets, selection } = filters
const facetRow = filters.hasFacets && (
<div className="-mx-4 mt-2 flex gap-[6px] overflow-x-auto px-4 pb-1">
{facets.origin.map(option => (
<FacetChip key={`o-${option.value}`} active={selection.origin === option.value} label={t(ORIGIN_LABEL_KEY[option.value])}
count={option.count} onClick={() => filters.select('origin', option.value)} />
))}
{facets.grade.map(option => (
<FacetChip key={`g-${option.value}`} active={selection.grade === option.value} label={`T${option.value}`}
count={option.count} onClick={() => filters.select('grade', option.value)} />
))}
{facets.type.map(option => (
<FacetChip key={`t-${option.value}`} active={selection.type === option.value} label={t(`tourTypes.${option.value}`)}
count={option.count} onClick={() => filters.select('type', option.value)} />
))}
</div>
)
const filtered = filterTours(tours, filter)
if (filters.emptyByFilter) {
return (
<>
{facetRow}
<div role="status" className="mt-3 rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-6 text-center">
<p className="font-geist text-caption text-m-muted">{t('tours.filter.empty')}</p>
<button type="button" onClick={filters.reset} className="mt-3 rounded-full bg-m-act px-4 py-2 text-caption font-semibold text-m-actfg">
{t('tours.filter.reset')}
</button>
</div>
</>
)
}
if (filtered.length === 0) {
return (
@@ -50,6 +97,7 @@ export default function MToursSelectionList({ planner, shell, filter, canAssign:
<p className="mt-3 rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 py-2 font-geist text-[0.75rem] text-m-muted">
{t('tours.planner.mobileHint')}
</p>
{facetRow}
<ul className="mt-3 space-y-2">
{filtered.map(tour => {
const isSelected = planner.selectedPlaceId === tour.place_id
@@ -6,6 +6,7 @@ import { useTranslation } from '../../../../i18n'
import { useAddonStore } from '../../../../store/addonStore'
import { useSaveToCollectionStore } from '../../../../store/saveToCollectionStore'
import { placeToSaveTarget } from '../../../../components/Collections/saveTarget'
import { useTourDayAssignment } from '../../../../components/Tours/useTourDayAssignment'
import type { Place } from '../../../../types'
interface BrowseActionsPayload {
@@ -31,7 +32,8 @@ export default function MBrowseActionsSheet({ planner, shell }: MTripSheetsProps
const openSavePicker = useSaveToCollectionStore(s => s.open)
const [daysOpen, setDaysOpen] = useState(false)
const pendingTourDayIds = useRef(new Set<string>())
// A tour goes onto a day by the rules every tour list shares (PR1-18); a place keeps its own way.
const dayAssignment = useTourDayAssignment({ canAssign: canEditDays, onAssignToDay: planner.handleAssignToDay })
useEffect(() => { setDaysOpen(open && Boolean(payload.dayPicker)) }, [open, payload.dayPicker])
// Hold the last place so the card content survives the exit animation.
@@ -58,20 +60,13 @@ export default function MBrowseActionsSheet({ planner, shell }: MTripSheetsProps
openSavePicker(placeToSaveTarget(place))
}
const tourAlreadyAssignedToDay = (dayId: number) => isTourPlace(place.id)
&& (planner.assignments[String(dayId)] ?? []).some(assignment => assignment.place_id === place.id)
const tourUnavailableOnDay = (dayId: number) => isTourPlace(place.id) && !dayAssignment.isAvailable(place.id, dayId)
const assignToDay = async (dayId: number) => {
const isTour = isTourPlace(place.id)
const pendingKey = `${place.id}:${dayId}`
if (isTour && (tourAlreadyAssignedToDay(dayId) || pendingTourDayIds.current.has(pendingKey))) return
if (isTour) pendingTourDayIds.current.add(pendingKey)
try {
const assigned = await planner.handleAssignToDay(place.id, dayId)
if (assigned !== false) shell.closeSheet()
} finally {
if (isTour) pendingTourDayIds.current.delete(pendingKey)
}
const assigned = isTourPlace(place.id)
? await dayAssignment.assign(place.id, dayId)
: await planner.handleAssignToDay(place.id, dayId)
if (assigned !== false) shell.closeSheet()
}
const deletePlace = () => {
@@ -124,7 +119,7 @@ export default function MBrowseActionsSheet({ planner, shell }: MTripSheetsProps
<button
key={d.id}
type="button"
disabled={tourAlreadyAssignedToDay(d.id) || (isTourPlace(place.id) && pendingTourDayIds.current.has(`${place.id}:${d.id}`))}
disabled={tourUnavailableOnDay(d.id)}
onClick={() => { void assignToDay(d.id) }}
className={`flex w-full items-center gap-2 px-3 py-[10px] text-start disabled:cursor-default disabled:opacity-40 ${i > 0 ? 'border-t border-[color:var(--m-rowbr)]' : ''}`}
>
@@ -98,13 +98,14 @@ describe('MSelectedTourDetail', () => {
it('renders the selected tour through the shared detail dialog and elevation profile', () => {
render(<MSelectedTourDetail planner={planner()} />)
expect(screen.getAllByText('Track Stats')).toHaveLength(1)
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(document.querySelector('#tour-elevation-42')).toBeInTheDocument()
})
it('renders nothing for an ordinary selected place', () => {
render(<MSelectedTourDetail planner={planner({ selectedTour: null })} />)
expect(screen.queryByText('Track Stats')).not.toBeInTheDocument()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(document.querySelector('#tour-elevation-42')).not.toBeInTheDocument()
})
})
+22 -25
View File
@@ -1,9 +1,7 @@
import React, { useState, Suspense } from 'react'
import { createPortal } from 'react-dom'
import { useParams, useNavigate, useSearchParams } from 'react-router'
import { useSettingsStore } from '../store/settingsStore'
import type { CompassMap } from '../components/Map/MapCompassPill'
import { getCached, fetchPhoto } from '../services/photoService'
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
import RoadtripModeSwitch from '../components/Roadtrip/RoadtripModeSwitch'
import PlacesToursModeSwitch from '../components/Tours/PlacesToursModeSwitch'
@@ -22,7 +20,6 @@ import TransitJourneyModal from '../components/Planner/TransitJourneyModal'
import { BookingDetailPopup } from '../components/Planner/bookings/BookingDetailHost'
import BookingImportModal from '../components/Planner/BookingImportModal'
import AirTrailImportModal from '../components/Planner/AirTrailImportModal'
// MemoriesPanel moved to Journey addon
import PluginFrame from '../components/Plugins/PluginFrame'
import ErrorBoundary from '../components/shared/ErrorBoundary'
import { getDayBookendHotels } from '../utils/dayOrder'
@@ -31,17 +28,10 @@ import TripWarningsBanner from '../components/Planner/TripWarningsBanner'
import Navbar from '../components/Layout/Navbar'
import HelpAnchor from '../components/Help/HelpAnchor'
import { getHelpContext } from '../help/registry'
import { useToast } from '../components/shared/Toast'
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Ticket, Wallet, FolderOpen, Users, Train } from 'lucide-react'
import { addonsApi, accommodationsApi, authApi, tripsApi, mapsApi } from '../api/client'
import { accommodationRepo } from '../repo/accommodationRepo'
import { X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react'
import ConfirmDialog from '../components/shared/ConfirmDialog'
import { Tooltip } from '../components/shared/Tooltip'
import { useTripWebSocket } from '../hooks/useTripWebSocket'
import { useRouteCalculation } from '../hooks/useRouteCalculation'
import { usePlaceSelection } from '../hooks/usePlaceSelection'
import { usePlannerHistory } from '../hooks/usePlannerHistory'
import type { Accommodation, TripMember, Day, Place, Reservation } from '../types'
import type { Reservation } from '../types'
import { useTripPlannerPage } from './tripPlanner/useTripPlannerPage'
import { useBookingExpenseEditor } from './tripPlanner/useBookingExpenseEditor'
import { applyTripCoverUpdate } from './tripPlanner/tripCover'
@@ -49,7 +39,7 @@ import { latestReservation } from './tripPlanner/transportEditorOpeners'
import {
ReservationsPanel, FileManager, CostsPanel, ExpenseModal, CollabPanel,
RoadtripSidebar, RoadtripCorridorPanel, RoadtripLimitsCard, RoadtripStopPopup, RoadtripStayModal, RoadtripTrackModal,
TourPlannerRail, TourPlannerToursRail, TransportModal,
TourWorkspaceRail, TourPlannerToursRail, TransportModal,
} from './tripPlanner/plannerLazy'
import { LazyPanel } from './tripPlanner/LazyPanel'
import { ListsContainer } from './tripPlanner/ListsContainer'
@@ -58,6 +48,7 @@ import { TourPlannerMapStage } from './tripPlanner/TourPlannerMapStage'
import { useMergedMapPois } from '../components/Map/useMergedMapPois'
import { useTouchDragBridge } from '../hooks/useTouchDragBridge'
import PanelResizeHandle from '../components/Planner/PanelResizeHandle'
import TourGpxDropZone from '../components/Tours/TourGpxDropZone'
/** The tab ids are historical; the help screens carry the names the tabs show. */
const TRIP_TAB_HELP: Record<string, string> = {
@@ -136,13 +127,19 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
selectedPlace, dayOrderMap, dayPlaces,
toursEnabled, toursMode, setToursMode, tours, toursLoading, tourDataReady, tourPlaceIds,
reloadTourPlaceIds, invalidateTourPlaceIds, selectedTour,
invalidateTourPlaceIds, selectedTour,
mapTileUrl, fontStyle, splashDone,
} = pageState.planner
const {
poi,
placeActions,
tourPlanner,
tourGpxImport,
wandererBrowser,
viewImportedTour,
tourConversion,
tourDetailActions,
recordedTrack,
permissions: { canPlaceEdit, canDayEdit },
tourDetails,
tourMap,
@@ -234,10 +231,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
onImageFromFile: async (placeId: number, fileId: number) => { await tripActions.setPlaceImageFromFile(tripId, placeId, fileId) },
onRate: placeActions.ratePlace,
}
const tourDetailProps = {
...selectedPlaceProps,
onUpdatePlace: async (placeId: number, data: Partial<Place>) => { await tripActions.updatePlace(tripId, placeId, data); await reloadTourPlaceIds() },
}
const tourDetailProps = { ...selectedPlaceProps, ...tourDetailActions }
// The trip is a family of help screens: the frame, then one per tab, and on
// the plan one per overlay that is open. A screen that has no help yet falls
@@ -637,11 +631,12 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
)}
{activeTab === 'tour-planner' && enabledAddons.tours && !isMobile && (
<div style={{ position: 'absolute', inset: 0 }}>
<TourGpxDropZone gpxImport={tourGpxImport} canEdit={canPlaceEdit} className="absolute inset-0">
<TourPlannerMapStage
tripId={tripId} mapTileUrl={mapTileUrl} leftHidden={leftHidden} rightHidden={rightHidden} t={t}
tourPlanner={tourPlanner} tourMap={tourMap} locationSearch={locationSearch} canPlaceEdit={canPlaceEdit}
leftPanelPx={leftPanelPx} rightPanelPx={rightPanelPx}
onViewportChange={wandererBrowser.onViewportChange}
/>
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
<PanelTab hidden={leftHidden} label={leftHidden ? t('trip.mobilePlan') : t('common.collapse')} onToggle={toggleLeft} />
@@ -653,7 +648,11 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
transition: 'width 0.25s ease', opacity: leftHidden ? 0 : 1,
}}>
{!leftHidden && <PanelResizeHandle side="left" width={leftWidth} min={resizeMin} max={resizeMax} onStart={startResizeLeft} onNudge={nudgeLeft} />}
<LazyPanel id="tour-planner-rail"><TourPlannerRail planner={tourPlanner} canEdit={canPlaceEdit} canAssign={canDayEdit} /></LazyPanel>
<LazyPanel id="tour-planner-rail"><TourWorkspaceRail
planner={tourPlanner}
wanderer={wandererBrowser}
conversion={tourConversion}
recordedTrack={recordedTrack} canEdit={canPlaceEdit} canAssign={canDayEdit} /></LazyPanel>
</div>
</div>
<div className="absolute bottom-[10px] right-[10px] top-[10px] z-20 hidden md:block">
@@ -692,16 +691,14 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
loading={toursLoading}
onAssignToDay={(placeId, dayId) => handleAssignToDay(placeId, dayId)}
onDeleteTour={handleDeleteTour}
onViewGpxTour={tour => {
const place = places.find(item => item.id === tour.place_id)
tourPlanner.viewGpxTour(tour, place?.route_geometry ?? null)
}}
gpxImport={tourGpxImport}
onViewGpxTour={viewImportedTour}
/>
</LazyPanel>
)}
</div>
</div>
</div>
</TourGpxDropZone>
)}
{activeTab === 'transports' && (
+4 -3
View File
@@ -17,7 +17,8 @@ interface AdminUsersTabProps {
locale: string
}
const TH = 'px-3.5 py-2.5 font-geist font-bold uppercase tracking-[.08em] text-content-faint'
const TH_BASE = 'px-3.5 py-2.5 font-geist font-bold uppercase tracking-[.08em] text-content-faint'
const TH = `${TH_BASE} text-start`
const TD = 'px-3.5 py-3 align-middle'
/** An icon action of a row, named by its tooltip and its aria-label. */
@@ -74,7 +75,7 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
<th className={TH}>{t('admin.table.role')}</th>
<th className={TH}>{t('admin.table.created')}</th>
<th className={TH}>{t('admin.table.lastActive')}</th>
<th className={`${TH} text-end`}>{t('admin.table.actions')}</th>
<th className={`${TH_BASE} text-center`}>{t('admin.table.actions')}</th>
</tr>
</thead>
<tbody className="trek-stagger divide-y divide-edge-faint">
@@ -118,7 +119,7 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
{formatLastActive(u.last_active ?? u.last_login, locale, hour12)}
</td>
<td className={TD}>
<div className="flex items-center justify-end gap-1.5">
<div className="flex items-center justify-center gap-1.5">
<RowIconButton label={t('admin.editUser')} onClick={() => handleEditUser(u)}>
<Edit2 size={14} strokeWidth={2} />
</RowIconButton>
@@ -1,4 +1,5 @@
import { lazy, Suspense, useEffect, useMemo, useState, type ReactElement } from 'react';
import type { FerrataBbox } from '@trek/shared';
import { lazy, Suspense, useCallback, useEffect, useMemo, useState, type ReactElement } from 'react';
import FerrataDetailOverlay from '../../components/Ferratas/FerrataDetailOverlay';
import { crossingLegs, waypointsAtFerrataEnds } from '../../components/Ferratas/ferrataCrossings';
import { ferrataLabel, type FerrataGroup } from '../../components/Ferratas/ferrataGroups';
@@ -55,6 +56,8 @@ export interface TourPlannerMapStageProps
canPlaceEdit: boolean;
leftPanelPx: number;
rightPanelPx: number;
/** Told the visible area too, besides the via ferrata layer (the wanderer area search). */
onViewportChange?: (bounds: FerrataBbox) => void;
}
/**
@@ -74,6 +77,7 @@ export function TourPlannerMapStage({
canPlaceEdit,
leftPanelPx,
rightPanelPx,
onViewportChange,
}: Readonly<TourPlannerMapStageProps>): ReactElement {
const [terrain3d, setTerrain3d] = useState(false);
// How high the 3D map's controls reach: one row, or two once the grade legend moved above the dock.
@@ -87,6 +91,14 @@ export function TourPlannerMapStage({
);
const analysis = tourPlanner.mode.type === 'view-gpx' ? tourPlanner.readOnlyGpxAnalysis : tourPlanner.routeAnalysis;
const ferrata = useFerrataMapLayer(tripId, ferrataArea, analysis?.routeCoordinates ?? null);
const ferrataViewportChange = ferrata.onViewportChange;
const viewportChanged = useCallback(
(bounds: FerrataBbox) => {
ferrataViewportChange(bounds);
onViewportChange?.(bounds);
},
[ferrataViewportChange, onViewportChange]
);
// The rails beside the map warn about the stretches that climb a ferrata, and band them on the profile.
const { t: translate, language } = useTranslation();
const tourWaypoints = tourPlanner.mode.type === 'view-gpx' ? NO_POINTS : tourPlanner.waypoints;
@@ -120,6 +132,7 @@ export function TourPlannerMapStage({
mode: terrain3d ? '3d' : '2d',
ferrataOpen: !!ferrata.selected,
guard: closingPress.guard,
previewPoints: tourMap.previewPoints,
});
const onAdd = waypoints.onAdd;
// What each panel covers of the map, a collapsed one its rail.
@@ -152,7 +165,7 @@ export function TourPlannerMapStage({
waypoints={waypoints}
framePoints={tourMap.focusPoints}
ferrataLayer={ferrata.layer}
onViewportChange={ferrata.onViewportChange}
onViewportChange={viewportChanged}
onControlsHeightChange={setTerrainControlsPx}
onLegendLayout={setTerrainLegend}
placeTool={placeTool}
@@ -163,6 +176,7 @@ export function TourPlannerMapStage({
tripId={tripId}
places={[]}
route={tourMap.route}
routeWalking={tourMap.routeWalking}
followSelection={false}
onMapClick={
onAdd
@@ -183,7 +197,7 @@ export function TourPlannerMapStage({
onViewBaseLayerChange={tourPlanner.setMapBaseLayer}
onToggleTerrain3d={() => setTerrain3d(true)}
ferrataLayer={ferrata.layer}
onViewportChange={ferrata.onViewportChange}
onViewportChange={viewportChanged}
placeTool={placeTool}
/>
)}
@@ -14,6 +14,8 @@ const pageControllerPath = 'client/src/pages/tripPlanner/useTripPlannerPage.ts'
const pageText = readFileSync(resolve(process.cwd(), 'src/pages/TripPlannerPage.tsx'), 'utf8')
const hookText = readFileSync(resolve(process.cwd(), 'src/pages/tripPlanner/useTripPlanner.ts'), 'utf8')
const pageControllerText = readFileSync(resolve(process.cwd(), 'src/pages/tripPlanner/useTripPlannerPage.ts'), 'utf8')
// The tours workspace (editor, GPX import, wanderer) is wired in its own hook the page hook calls.
const workspaceText = readFileSync(resolve(process.cwd(), 'src/pages/tripPlanner/useTourWorkspace.ts'), 'utf8')
// The Tours map and its edit gates live in the map stage the page renders.
const mapStageText = readFileSync(resolve(process.cwd(), 'src/pages/tripPlanner/TourPlannerMapStage.tsx'), 'utf8')
// Which clicks place a point is decided by the map stage's points hook.
@@ -22,6 +24,7 @@ const parse = (file: string, text: string) => ts.createSourceFile(file, text, ts
const page = parse(pagePath, pageText)
const hook = parse(hookPath, hookText)
const pageController = parse(pageControllerPath, pageControllerText)
const workspace = parse('client/src/pages/tripPlanner/useTourWorkspace.ts', workspaceText)
const mapStage = parse('client/src/pages/tripPlanner/TourPlannerMapStage.tsx', mapStageText)
function collect(source: ts.Node, predicate: (node: ts.Node) => boolean): ts.Node[] {
@@ -127,7 +130,8 @@ test('B4A desktop page delegates Tours state and permissions to its page hook',
&& ts.isIdentifier(node.expression) && node.expression.text === 'useTripPlannerPage')[0]
assert.ok(pageHookCall)
assert.ok(callNamed(pageController, 'useTourPlanner'))
assert.ok(callNamed(pageController, 'useTourWorkspace'))
assert.ok(callNamed(workspace, 'useTourPlanner'))
const mobileFiles = mobileSourceFiles(resolve(process.cwd(), 'src/mobile'))
for (const file of mobileFiles) {
const mobileSource = parse(file, readFileSync(file, 'utf8'))
@@ -153,11 +157,17 @@ test('B4A desktop page delegates Tours state and permissions to its page hook',
assert.ok(ts.isCallExpression(permissions) && ts.isIdentifier(permissions.expression)
&& permissions.expression.text === 'can')
const tourPlannerCall = callNamed(pageController, 'useTourPlanner')
// The page hook hands both rights to the tours workspace, which hands them to the planner.
const workspaceCall = callNamed(pageController, 'useTourWorkspace')
assert.deepEqual(
workspaceCall?.arguments.map((argument) => argument.getText(pageController)),
['planner', 'canPlaceEdit', 'canDayEdit']
)
const tourPlannerCall = callNamed(workspace, 'useTourPlanner')
assert.ok(tourPlannerCall && ts.isObjectLiteralExpression(tourPlannerCall.arguments[0]))
const tourPlannerOptions = tourPlannerCall.arguments[0]
assert.equal(propertyValue(tourPlannerOptions, 'canEdit')?.getText(pageController), 'canPlaceEdit')
assert.equal(propertyValue(tourPlannerOptions, 'canAssign')?.getText(pageController), 'canDayEdit')
assert.equal(propertyValue(tourPlannerOptions, 'canEdit')?.getText(workspace), 'canPlaceEdit')
assert.equal(propertyValue(tourPlannerOptions, 'canAssign')?.getText(workspace), 'canDayEdit')
const returnedViewModel = collect(pageController, node => ts.isReturnStatement(node)
&& !!node.expression && ts.isObjectLiteralExpression(node.expression))
@@ -170,7 +180,7 @@ test('B4A desktop page delegates Tours state and permissions to its page hook',
assert.ok(returnedPermissions.properties.some(property => ts.isShorthandPropertyAssignment(property)
&& property.name.text === 'canDayEdit'))
for (const name of ['TourPlannerRail', 'TourPlannerToursRail', 'ToursSidebar', 'TourDetailDialog']) {
for (const name of ['TourWorkspaceRail', 'TourPlannerToursRail', 'ToursSidebar', 'TourDetailDialog']) {
const tags = jsxTags(page, name)
assert.equal(tags.length, name === 'TourDetailDialog' ? 2 : 1)
for (const tag of tags) {
@@ -197,14 +207,16 @@ test('B4A desktop page delegates Tours state and permissions to its page hook',
})
test('B4A dedicated desktop controller retains GPX, draft, focus, and saved rail wiring', () => {
assert.ok(callNamed(pageController, 'useTourPlanner'))
assert.ok(callNamed(pageController, 'useTourWorkspace'))
assert.ok(callNamed(workspace, 'useTourPlanner'))
assert.ok(pageText.includes('const pageState = useTripPlannerPage()'))
assert.ok(pageControllerText.includes("tourPlanner.mode.type === 'view-gpx'"))
for (const mode of ['new-draft', 'edit-saved']) assert.ok(mapPointsText.includes(`tourPlanner.mode.type === '${mode}'`))
assert.match(pageControllerText, /readOnlyGpxAnalysis\?\.routeCoordinates/)
assert.match(mapStageText, /routeProfileFocus=\{tourPlanner\.routeProfileFocus\}/)
assert.match(pageText, /tourPlanner\.viewGpxTour\(tour, place\?\.route_geometry \?\? null\)/)
assert.match(pageControllerText, /onSaved:[\s\S]*?upsertTour\(result\.tour\)/)
assert.match(workspaceText, /viewGpxTour\(tour, place\?\.route_geometry \?\? null\)/)
assert.match(pageText, /onViewGpxTour=\{viewImportedTour\}/)
assert.match(workspaceText, /onSaved:[\s\S]*?upsertTour\(result\.tour\)/)
assert.doesNotMatch(pageText, /setSelectedPlaceId\(result\.tour/)
})
@@ -1,7 +1,3 @@
// FE-PAGE-PLANNER-LAZY-001 to FE-PAGE-PLANNER-LAZY-002
//
// Every planner panel loads as a chunk of its own. The targets are stubbed so
// each loader, and each hop to a named export, is checked by what it renders.
import { createElement, Suspense, type ComponentType } from 'react'
import { render, screen } from '@testing-library/react'
import * as lazy from './plannerLazy'
@@ -22,6 +18,7 @@ vi.mock('../../components/Roadtrip/RoadtripStopPopup', () => ({ default: label('
vi.mock('../../components/Roadtrip/RoadtripStayModal', () => ({ default: label('roadtrip stay modal') }))
vi.mock('../../components/Roadtrip/RoadtripTrackModal', () => ({ default: label('roadtrip track modal') }))
vi.mock('../../components/Roadtrip/RoadtripAlternativesBar', () => ({ default: label('roadtrip alternatives bar') }))
vi.mock('../../components/Tours/TourWorkspaceRail', () => ({ default: label('tour workspace rail') }))
vi.mock('../../components/Tours/planner/TourPlannerPanels', () => ({
TourPlannerRail: label('tour planner rail'),
TourPlannerToursRail: label('tour planner tours rail'),
@@ -43,7 +40,7 @@ const cases: Array<[keyof typeof lazy, string]> = [
['RoadtripStayModal', 'roadtrip stay modal'],
['RoadtripTrackModal', 'roadtrip track modal'],
['RoadtripAlternativesBar', 'roadtrip alternatives bar'],
['TourPlannerRail', 'tour planner rail'],
['TourWorkspaceRail', 'tour workspace rail'],
['TourPlannerToursRail', 'tour planner tours rail'],
['TransportModal', 'transport modal'],
]
+2 -2
View File
@@ -24,8 +24,8 @@ export const RoadtripStopPopup = lazyWithRetry(() => import('../../components/Ro
export const RoadtripStayModal = lazyWithRetry(() => import('../../components/Roadtrip/RoadtripStayModal'))
export const RoadtripTrackModal = lazyWithRetry(() => import('../../components/Roadtrip/RoadtripTrackModal'))
export const RoadtripAlternativesBar = lazyWithRetry(() => import('../../components/Roadtrip/RoadtripAlternativesBar'))
export const TourPlannerRail = lazyWithRetry(() =>
import('../../components/Tours/planner/TourPlannerPanels').then(module => ({ default: module.TourPlannerRail }))
export const TourWorkspaceRail = lazyWithRetry(() =>
import('../../components/Tours/TourWorkspaceRail')
)
export const TourPlannerToursRail = lazyWithRetry(() =>
import('../../components/Tours/planner/TourPlannerPanels').then(module => ({ default: module.TourPlannerToursRail }))
@@ -10,6 +10,7 @@ import { restoreVisit, visitOf } from './placeUndo'
import type { PlannerBase, PlannerHistory } from './plannerTypes'
import type { PlaceEdits } from './usePlaceEdits'
import type { RoadtripFeed } from './useRoadtripFeed'
import { isTourOnDay } from '../../components/Tours/useTourDayAssignment'
interface DayPlanEditsOptions
extends Pick<PlannerBase, 'tripId' | 'tripActions' | 'toast' | 't'>,
@@ -26,6 +27,9 @@ interface DayPlanEditsOptions
updateRouteForDay: ReturnType<typeof useRouteCalculation>['updateRouteForDay']
}
/** The day's number in the trip, as the plan shows it. */
const dayNumberOf = (dayId: number) => useTripStore.getState().days.findIndex(day => day.id === dayId) + 1
/**
* Edits to the day plan, each with its undo where one makes sense: putting a place on
* a day, moving a stop to another day, taking one off, reordering a day, renaming a
@@ -42,7 +46,8 @@ export function useDayPlanEdits(options: DayPlanEditsOptions) {
const handleAssignToDay = useCallback(async (placeId: number, dayId?: number, position?: number) => {
const target = dayId || selectedDayId
if (!target) { toast.error(t('trip.toast.selectDay')); return false }
if (isTourPlace(placeId) && (storedAssignments[String(target)] ?? []).some(assignment => assignment.place_id === placeId)) return false
// A tour is on a day at most once; a drop onto such a day still gets here, and says why nothing happens.
if (isTourPlace(placeId) && isTourOnDay(storedAssignments, placeId, target)) { toast.info(t('tours.alreadyOnDay')); return false }
const place = places.find(p => p.id === placeId)
// A place with a start of its own is drawn by it, so it is stored there too, the
// way a stop moved over from another day is. Without one it goes where it was put.
@@ -56,7 +61,7 @@ export function useDayPlanEdits(options: DayPlanEditsOptions) {
return false
}
if (isTourPlace(placeId)) await reloadTourPlaceIds()
toast.success(t('trip.toast.assignedToDay'))
toast.success(isTourPlace(placeId) ? t('tours.addedToDay', { n: dayNumberOf(target) }) : t('trip.toast.assignedToDay'))
if (assignment?.id) {
const capturedAssignmentId = assignment.id
const capturedTarget = target
@@ -7,6 +7,8 @@
import { act, renderHook } from '@testing-library/react'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useAuthStore } from '../../store/authStore'
import { tourVisibilityScope, useTourMapVisibilityStore } from '../../store/tourMapVisibilityStore'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { buildAssignment, buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories'
import type { Accommodation, AssignmentsMap, Day, Place, Reservation } from '../../types'
@@ -20,6 +22,9 @@ interface Props {
tripAccommodations: Accommodation[]
reservations: Reservation[]
toursEnabled: boolean
tripId: number
tourPlaceIds: ReadonlySet<number>
isMobile: boolean
}
// Four places: one on day 1, one on day 2 and two in the pool, one of which has no
@@ -47,6 +52,9 @@ function renderMapPlaces(over: Partial<Props> = {}) {
tripAccommodations: [],
reservations: [],
toursEnabled: false,
tripId: 7,
tourPlaceIds: new Set<number>(),
isMobile: false,
...over,
}
return renderHook((props: Props) => useMapPlaces(props), { initialProps })
@@ -101,7 +109,7 @@ describe('useMapPlaces', () => {
act(() => { useTripStore.setState({ placesFilter: 'unplanned' }) })
rerender({
places: [museum, harbour, bakery, nowhere], assignments, selectedDayId: 1, days,
tripAccommodations: [], reservations: [], toursEnabled: false,
tripAccommodations: [], reservations: [], toursEnabled: false, tripId: 7, tourPlaceIds: new Set<number>(), isMobile: false,
})
expect(ids(result.current.mapPlaces)).toEqual([3])
})
@@ -180,4 +188,16 @@ describe('useMapPlaces', () => {
expect(renderMapPlaces({ assignments: day, selectedDayId: 9 }).result.current.dayPlaces).toEqual([])
expect(renderMapPlaces({ assignments: day, selectedDayId: 3 }).result.current.dayPlaces).toEqual([bakery])
})
it('FE-TP-MAPPLACES-011: PR1-07 a tour the person hid stays off the desktop map, the phone map and a disabled addon keep it', () => {
seedStore(useAuthStore, { user: { id: 3 } as never })
useTourMapVisibilityStore.setState({ scope: tourVisibilityScope(3, 7), hidden: [3, 99] })
const tours = new Set([3])
expect(ids(renderMapPlaces({ toursEnabled: true, tourPlaceIds: tours }).result.current.mapPlaces)).toEqual([1, 2])
expect(ids(renderMapPlaces({ toursEnabled: true, tourPlaceIds: tours, isMobile: true }).result.current.mapPlaces)).toEqual([1, 2, 3])
expect(ids(renderMapPlaces({ toursEnabled: false, tourPlaceIds: tours }).result.current.mapPlaces)).toEqual([1, 2, 3])
// Another trip of the same person is not this choice.
expect(ids(renderMapPlaces({ toursEnabled: true, tourPlaceIds: tours, tripId: 8 }).result.current.mapPlaces)).toEqual([1, 2, 3])
})
})
+11 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { isServiceStopType } from '../../components/Roadtrip/roadtripModel'
import { useTourMapVisibility } from '../../components/Tours/useTourMapVisibility'
import { useSettingsStore } from '../../store/settingsStore'
import { useTripStore, type TripStoreState } from '../../store/tripStore'
import type { Accommodation, Place } from '../../types'
@@ -15,6 +16,11 @@ interface MapPlacesOptions {
tripAccommodations: Accommodation[]
reservations: TripStoreState['reservations']
toursEnabled: boolean
tripId: number
/** The trip's tours, to read the person's hidden ones against. */
tourPlaceIds: ReadonlySet<number>
/** The phone map keeps every tour: its list has no switch to show one again. */
isMobile: boolean
}
/**
@@ -25,6 +31,9 @@ interface MapPlacesOptions {
*/
export function useMapPlaces(options: MapPlacesOptions) {
const { places, assignments, selectedDayId, days, tripAccommodations, reservations, toursEnabled } = options
// PR1-07: the tours this person hid from the map stay off it, line and marker alike.
const { hiddenIds: hiddenTourIds } = useTourMapVisibility(options.tripId, options.tourPlaceIds)
const hideTours = toursEnabled && !options.isMobile && hiddenTourIds.size > 0
// Same filter the places sidebar renders, shared via the store so tab
// switches can't desync the marker set from the filter UI (#1541).
const placesFilter = useTripStore((s) => s.placesFilter)
@@ -71,6 +80,7 @@ export function useMapPlaces(options: MapPlacesOptions) {
const filterState = { filter: placesFilter, categoryFilters: placesCategoryFilter, ratingFilter: placesRatingFilter }
return places.filter(p => {
if (!p.lat || !p.lng) return false
if (hideTours && hiddenTourIds.has(p.id)) return false
// Collapsed-day declutter hides a day's stops on every filter EXCEPT 'planned':
// there the user asked to see the whole plan on the map, so a collapsed day
// must not drop its planned places.
@@ -83,7 +93,7 @@ export function useMapPlaces(options: MapPlacesOptions) {
const pending = pendingIds.has(p.id)
return compact || pending ? { ...p, _compact: compact, _pending: pending } : p
})
}, [places, placesCategoryFilter, placesFilter, placesRatingFilter, assignments, expandedDayIds, selectedDayId, days, tripAccommodations, reservations, compactUnplanned])
}, [places, placesCategoryFilter, placesFilter, placesRatingFilter, assignments, expandedDayIds, selectedDayId, days, tripAccommodations, reservations, compactUnplanned, hideTours, hiddenTourIds])
// The "Tracks" pool is only offered while a place carries a track. When the last
// one goes (deleted here or by a collaborator), fall back to "all". That happens here
@@ -0,0 +1,41 @@
import type { TourListItem } from '@trek/shared';
import { tourRepo } from '../../repo/tourRepo';
import { useTripStore } from '../../store/tripStore';
import type { Place } from '../../types';
import type { useTripPlanner } from './useTripPlanner';
/**
* What the tour's detail does beyond a place's: a rename reloads the tours,
* a reimport from its source check (MF-03) takes the new snapshot in and opens
* it, and "save as new tour" (P2C-07) makes the copy and opens it under a name
* that says it is one, so renaming it is the next step.
*/
export function useTourDetailActions(planner: ReturnType<typeof useTripPlanner>) {
const { invalidateTourPlaceIds, reloadTourPlaceIds, t, toast, tripActions, tripId, upsertTour } = planner;
/** A tour that just came in: in the lists and on the map, then open. */
const takeIn = async (tour: TourListItem) => {
upsertTour(tour);
await useTripStore.getState().refreshPlaces(tripId);
void invalidateTourPlaceIds({ placeIds: [tour.place_id] });
planner.handlePlaceClick(tour.place_id);
};
return {
onUpdatePlace: async (placeId: number, data: Partial<Place>) => {
await tripActions.updatePlace(tripId, placeId, data);
await reloadTourPlaceIds();
},
onSourceReimported: takeIn,
onSaveAsNew: async (tour: TourListItem) => {
try {
const name = t('tours.copy.name', { name: tour.name }).slice(0, 255);
const { tour: copy } = await tourRepo.copy(tripId, tour.place_id, name);
await takeIn(copy);
toast.success(t('tours.copy.saved'));
} catch {
toast.error(t('tours.copy.failed'));
}
},
};
}

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