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
This commit is contained in:
Maurice
2026-10-15 17:44:15 +02:00
parent 31ffcf1228
commit 782e613506
237 changed files with 13023 additions and 1049 deletions
+5 -5
View File
@@ -1,10 +1,10 @@
{
"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,
@@ -12,14 +12,14 @@
"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,
-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",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"e2e/help/fixtures.ts": 2291,
"src/api/client.ts": 1916,
"src/api/client.ts": 1881,
"src/components/Admin/AdminPluginsPanel.test.tsx": 2704,
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
"src/components/Budget/CostsPanel.test.tsx": 2764,
@@ -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": 1148,
"src/pages/atlas/useAtlas.ts": 1287,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3309,
+1 -1
View File
@@ -89,7 +89,7 @@
"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/Tours/tourParts.tsx": 5,
"src/components/Trips/TripFormModal.tsx": 3,
"src/components/Trips/TripMembersModal.tsx": 1,
"src/components/Vacay/VacayCalendar.tsx": 1,
+7 -1
View File
@@ -2,7 +2,7 @@ import { isEffectivelyOffline } from '../sync/networkMode'
import axios, { AxiosInstance } from 'axios'
import type { z } from 'zod'
import type { Day, Place, Trip } from '../types'
import type { MapsNearbyRequest, JourneyReorderEntryPhotosRequest, TransitProvider, GoogleQuotaUpdateRequest, NotificationDefaultsUpdateRequest, TourListItem, TourImportGpxResponse, TourCreateRequest, TourCreateResponse, TourDetailResponse } from '@trek/shared'
import type { AdminFeatureToggleResponse, MapsNearbyRequest, JourneyReorderEntryPhotosRequest, TransitProvider, GoogleQuotaUpdateRequest, NotificationDefaultsUpdateRequest, TourListItem, TourImportGpxResponse, TourCreateRequest, TourCreateResponse, TourDetailResponse } from '@trek/shared'
import { randomId } from '../utils/randomId'
import { postProviderPhotosInBatches } from './providerPhotoBatches'
import {
@@ -525,6 +525,8 @@ export const toursApi = {
apiClient.post(`/trips/${tripId}/tours`, body).then(r => r.data),
update: (tripId: number | string, placeId: number | string, body: TourCreateRequest): Promise<TourDetailResponse> =>
apiClient.put(`/trips/${tripId}/tours/${placeId}`, body).then(r => r.data),
restoreRecorded: (tripId: number | string, placeId: number | string): Promise<TourDetailResponse> =>
apiClient.post(`/trips/${tripId}/tours/${placeId}/restore-recorded`).then(r => r.data),
importGpx: (tripId: number | string, file: File): Promise<TourImportGpxResponse> => {
const fd = new FormData()
fd.append('file', file)
@@ -706,6 +708,10 @@ export const adminApi = {
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
getToursWanderer: (): Promise<AdminFeatureToggleResponse> =>
apiClient.get('/admin/tours-wanderer').then(r => r.data),
updateToursWanderer: (enabled: boolean): Promise<AdminFeatureToggleResponse> =>
apiClient.put('/admin/tours-wanderer', { enabled }).then(r => r.data),
getPlacesPhotos: () => apiClient.get('/admin/places-photos').then(r => r.data),
updatePlacesPhotos: (enabled: boolean) => apiClient.put('/admin/places-photos', { enabled }).then(r => r.data),
getPlaceShadow: () => apiClient.get('/admin/place-shadow').then(r => r.data),
+41
View File
@@ -0,0 +1,41 @@
import type {
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),
};
@@ -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>
@@ -18,6 +18,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 +31,10 @@ export function useAddonManager() {
const loadOnMount = useEffectEvent(() => {
void loadAddons().finally(() => setLoading(false));
adminApi
.getToursWanderer()
.then((flag) => setToursWanderer(flag.enabled))
.catch(() => toast.error(t('admin.addons.toast.error')));
});
useEffect(() => {
loadOnMount();
@@ -57,6 +62,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 adminApi.updateToursWanderer(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 +103,7 @@ export function useAddonManager() {
integrationAddons,
providerOptions,
documentProviderOptions,
toursWanderer,
toggleToursWanderer,
};
}
+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
@@ -5,7 +5,7 @@ import { useTranslation } from '../../i18n';
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 +65,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 +75,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 +93,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="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', () => {
@@ -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('Planned from 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(/Planned from/)).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>
);
}
+54 -26
View File
@@ -1,25 +1,42 @@
import { useId, type ReactNode } from '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'
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
}
/** 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) {
/**
* 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 }: 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 +44,10 @@ 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'}`}
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 +57,39 @@ 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>
<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. */}
<div
data-testid="tour-metrics"
className="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>
{action && (
<div data-testid="tour-action-row" role="presentation" className="flex flex-none items-center gap-1.5" onClick={event => event.stopPropagation()}>
// Out on hover, focus or selection, as a plan row's controls are: the name and badges have the room otherwise.
<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'}`}>
{action}
</div>
)}
</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}
/>
);
}
@@ -6,6 +6,7 @@ import { useSettingsStore } from '../../store/settingsStore'
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 +33,8 @@ const tour: TourListItem = {
}
beforeEach(() => {
vi.restoreAllMocks()
Object.values(toast).forEach((notify) => notify.mockClear())
resetAllStores()
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } })
})
@@ -56,15 +59,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,11 +103,9 @@ 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"]')!
@@ -205,7 +208,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 +220,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 +236,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 +246,62 @@ 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')
})
})
+10 -51
View File
@@ -2,10 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'
import { Check, ChevronDown, 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'
@@ -13,8 +10,7 @@ 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 { useTourGpxImport } from './useTourGpxImport'
interface ToursSidebarProps extends TourPermissionProps {
tripId: number
@@ -43,52 +39,14 @@ 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 gpxImport = useTourGpxImport({ tripId, canEdit, onToursChanged })
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 handleAssign = (tour: TourListItem, day: Day) => {
if (canAssign) void onAssignToDay(tour.place_id, day.id)
}
const counts = {
@@ -126,15 +84,14 @@ 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)}
@@ -173,9 +130,11 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
<TourListRow
key={tour.place_id}
tour={tour}
days={days}
draggableToDay={canAssign && days.length > 0}
selected={tour.place_id === selectedPlaceId}
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')} disabled={!canAssign} onPick={(day) => handleAssign(tour, day)} />}
/>
))}
</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,18 @@ 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 WandererMark from '../wanderer/WandererMark'
const STATUS_KEY: Partial<Record<TourPlannerStatus, string>> = {
dirty: 'tours.planner.status.dirty',
@@ -33,7 +39,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 +51,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 +158,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 +313,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 +340,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 +386,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,6 +482,29 @@ 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[]
@@ -361,9 +513,12 @@ interface TourPlannerToursRailProps extends TourPermissionProps {
onAssignToDay: (placeId: number, dayId: number) => void | boolean | Promise<void | boolean>
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()
@@ -390,7 +545,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 +582,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 +596,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 +609,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}
@@ -498,9 +665,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]);
}
+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;
}
+26
View File
@@ -0,0 +1,26 @@
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,
unit: DistanceUnit | undefined,
t: (key: string, params?: Record<string, unknown>) => string,
extra: readonly TourMetricField[] = []
): TourMetricField[] {
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;
}
+220 -85
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'
/**
* 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,57 +46,111 @@ 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>
)}
</>
)
}
/**
@@ -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>
@@ -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'
@@ -61,12 +63,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 +111,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' }))
@@ -181,17 +190,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,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,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;
}
@@ -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 => (
+19 -20
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 { Place, 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> = {
@@ -143,6 +134,11 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
poi,
placeActions,
tourPlanner,
tourGpxImport,
wandererBrowser,
viewImportedTour,
tourConversion,
recordedTrack,
permissions: { canPlaceEdit, canDayEdit },
tourDetails,
tourMap,
@@ -637,11 +633,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 +650,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 +693,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' && (
@@ -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 }))
@@ -26,6 +26,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 +45,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) && (storedAssignments[String(target)] ?? []).some(a => a.place_id === placeId)) { 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 +60,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
@@ -34,6 +34,8 @@ interface Options {
ferrataOpen: boolean;
/** Drops the click that closed a ferrata card. */
guard: ReturnType<typeof useOutsidePressGuard>['guard'];
/** Control points of an editable version previewed over an import: shown, never moved. */
previewPoints?: readonly { lat: number; lng: number }[] | null;
}
/**
@@ -42,7 +44,7 @@ interface Options {
* after a point went in. A click places a point only while the tool is on,
* only when it was a click and not a drag, and never when it closed a card.
*/
export function useTourMapPoints({ tourPlanner, canPlaceEdit, mode, ferrataOpen, guard }: Options) {
export function useTourMapPoints({ tourPlanner, canPlaceEdit, mode, ferrataOpen, guard, previewPoints }: Options) {
const { t } = useTranslation();
const editing = tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved';
// The same lookup as the waypoint list, so a pin is named on hover as its row is.
@@ -65,17 +67,28 @@ export function useTourMapPoints({ tourPlanner, canPlaceEdit, mode, ferrataOpen,
const onAdd = guard(gate(placeOn ? addPoint : undefined));
// Kept by identity while nothing about the points changed, so the map does not redraw its pins on every render.
const { waypoints } = tourPlanner;
const points = useMemo(
() =>
editing
? waypoints.map((point, index) => ({
...point,
name: placeNames[point.id] ?? t('tours.planner.waypointLabel', { n: index + 1 }),
detail: t(ROLE_KEY[point.role]),
}))
: NO_POINTS,
[editing, waypoints, placeNames, t]
);
const points = useMemo(() => {
if (editing) {
return waypoints.map((point, index) => ({
...point,
name: placeNames[point.id] ?? t('tours.planner.waypointLabel', { n: index + 1 }),
detail: t(ROLE_KEY[point.role]),
}));
}
if (!previewPoints?.length) return NO_POINTS;
const last = previewPoints.length - 1;
return previewPoints.map((point, index) => {
const role = index === 0 ? 'start' : index === last ? 'end' : 'via';
return {
id: `preview-${index}`,
lat: point.lat,
lng: point.lng,
role,
name: t('tours.planner.waypointLabel', { n: index + 1 }),
detail: t(ROLE_KEY[role]),
} as const;
});
}, [editing, waypoints, placeNames, previewPoints, t]);
return {
waypoints: {
@@ -0,0 +1,120 @@
import type { TourListItem } from '@trek/shared';
import { useCallback } from 'react';
import { useGpxConversion } from '../../components/Tours/conversion/useGpxConversion';
import { useTourPlanner } from '../../components/Tours/planner/useTourPlanner';
import { useRecordedTrack } from '../../components/Tours/useRecordedTrack';
import { useTourGpxImport } from '../../components/Tours/useTourGpxImport';
import { useWandererBrowser } from '../../components/Tours/wanderer/useWandererBrowser';
import { useAddonStore } from '../../store/addonStore';
import { useTripStore } from '../../store/tripStore';
import type { useTripPlanner } from './useTripPlanner';
/**
* The desktop tours workspace: the tour editor, its GPX import (button and file drop)
* and the wanderer trail browser, wired so that whatever comes in opens on the map.
*/
export function useTourWorkspace(
planner: ReturnType<typeof useTripPlanner>,
canPlaceEdit: boolean,
canDayEdit: boolean
) {
const {
activeTab,
enabledAddons,
invalidateTourPlaceIds,
isMobile,
places,
reloadTourPlaceIds,
t,
toast,
tours,
tripActions,
tripId,
upsertTour,
} = planner;
// wanderer only while the admin allows it under Tours (the store reads Tours being on into it too).
const wandererAllowed = useAddonStore((state) => state.toursWanderer);
const tourPlanner = useTourPlanner({
tripId,
canEdit: canPlaceEdit,
canAssign: canDayEdit,
active: activeTab === 'tour-planner' && enabledAddons.tours && !isMobile,
onSaved: async (result) => {
upsertTour(result.tour);
toast.success(t('tours.planner.saved'));
await tripActions.loadTrip(tripId);
void reloadTourPlaceIds();
},
});
// A tour of the trip opened in the planner: an import has no waypoints of its own and
// shows as it came in, a planned tour opens in the editor. Never over an edit it would
// replace.
const { hasUnsavedChanges, viewGpxTour, openTour } = tourPlanner;
/** An import shown as it came in, over whatever the planner shows; the caller asked before discarding. */
const viewImportedTour = useCallback(
(tour: TourListItem) => {
// The store first: right after an import the page's list may not have the new place yet.
const byId = (item: { id: number }) => item.id === tour.place_id;
const place = useTripStore.getState().places.find(byId) ?? places.find(byId);
viewGpxTour(tour, place?.route_geometry ?? null);
},
[places, viewGpxTour]
);
const openTourInPlanner = useCallback(
(placeId: number, known?: TourListItem) => {
const tour = known ?? tours.find((item) => item.place_id === placeId);
if (!tour || hasUnsavedChanges) return;
if (tour.has_waypoints === false) viewImportedTour(tour);
else void openTour(tour);
},
[hasUnsavedChanges, openTour, tours, viewImportedTour]
);
// The tours workspace's GPX import, shared by its button and its file drop. One new
// tour opens on the map at once.
const openImportedTours = useCallback(
(imported: TourListItem[]) => {
if (imported.length === 1) openTourInPlanner(imported[0].place_id, imported[0]);
},
[openTourInPlanner]
);
const tourGpxImport = useTourGpxImport({
tripId,
canEdit: canPlaceEdit,
onToursChanged: (placeIds) => invalidateTourPlaceIds(placeIds ? { placeIds } : undefined),
onImported: openImportedTours,
});
// Browsing the user's wanderer trails in place of the editor rail; the trail shows on the map.
const wandererBrowser = useWandererBrowser({
tripId,
enabled: activeTab === 'tour-planner' && wandererAllowed && !isMobile && canPlaceEdit,
onImported: async (tour) => {
upsertTour(tour);
await useTripStore.getState().refreshPlaces(tripId);
void invalidateTourPlaceIds({ placeIds: [tour.place_id] });
openTourInPlanner(tour.place_id, tour);
},
onOpenTour: openTourInPlanner,
});
// An import planned as an editable tour: analysed on request, adopted over itself or as a new draft.
const tourConversion = useGpxConversion(tourPlanner);
// The way back from an import planned over in place to its recorded track.
const recordedTrack = useRecordedTrack({
tripId,
planner: tourPlanner,
tours,
canEdit: canPlaceEdit,
onRestored: async (tour) => {
upsertTour(tour);
await useTripStore.getState().refreshPlaces(tripId);
void invalidateTourPlaceIds({ placeIds: [tour.place_id] });
viewImportedTour(tour);
},
});
return { tourPlanner, viewImportedTour, tourGpxImport, wandererBrowser, tourConversion, recordedTrack };
}
@@ -1,46 +1,29 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { TourListItem } from '@trek/shared'
import { usePoiExplore, type Bbox } from '../../components/Map/usePoiExplore'
import { placeActions } from '../../components/Planner/usePlaceActions'
import { useTourPlanner } from '../../components/Tours/planner/useTourPlanner'
import { tourPlannedTimes as projectTourPlannedTimes } from '../../components/Tours/tourPresentation'
import { usePlanMapViewport } from '../../hooks/usePlanMapViewport'
import { useMapLocationSearch } from './useMapLocationSearch'
import { useTripPlanner } from './useTripPlanner'
import { useTourWorkspace } from './useTourWorkspace'
export function useTripPlannerPage() {
const planner = useTripPlanner()
const {
activeTab,
can,
enabledAddons,
isMobile,
places,
reloadTourPlaceIds,
can, places,
setSelectedPlaceId,
t,
toast,
tours,
trip,
tripActions,
tripId,
upsertTour,
} = planner
tripId } = planner
const canPlaceEdit = can('place_edit', trip)
const canDayEdit = can('day_edit', trip)
const tourPlanner = useTourPlanner({
tripId,
canEdit: canPlaceEdit,
canAssign: canDayEdit,
active: activeTab === 'tour-planner' && enabledAddons.tours && !isMobile,
onSaved: async result => {
upsertTour(result.tour)
toast.success(t('tours.planner.saved'))
await tripActions.loadTrip(tripId)
void reloadTourPlaceIds()
},
})
const { tourPlanner, viewImportedTour, tourGpxImport, wandererBrowser, tourConversion, recordedTrack } =
useTourWorkspace(planner, canPlaceEdit, canDayEdit)
const tourDetailOpenerRef = useRef<{ placeId: number; element: HTMLElement } | null>(null)
const lastPlanMapViewportRef = useRef<Bbox | null>(null)
@@ -64,6 +47,7 @@ export function useTripPlannerPage() {
}, [places, plannerViewportSnapshot])
const focusPoints = useMemo<[number, number][]>(() => {
if (wandererBrowser.open) return wandererBrowser.line ?? plannerFallbackFocusPoints
if (tourPlanner.mode.type === 'view-gpx') return tourPlanner.readOnlyGpxAnalysis?.routeCoordinates ?? []
if (tourPlanner.mode.type === 'new-draft') {
return tourPlanner.waypoints.map(point => [point.lat, point.lng])
@@ -72,20 +56,31 @@ export function useTripPlannerPage() {
return tourPlanner.waypoints.map(point => [point.lat, point.lng])
}
return plannerFallbackFocusPoints
}, [tourPlanner.mode.type, tourPlanner.readOnlyGpxAnalysis?.routeCoordinates, tourPlanner.waypoints, plannerFallbackFocusPoints])
}, [
wandererBrowser.open,
wandererBrowser.line,
tourPlanner.mode.type, tourPlanner.readOnlyGpxAnalysis?.routeCoordinates, tourPlanner.waypoints, plannerFallbackFocusPoints])
const { mapFocusPoints, mapFocusKey, locationSearch } = useMapLocationSearch(tourPlanner.mapFocusKey, focusPoints)
const route = useMemo<[number, number][][] | null>(() => {
if (wandererBrowser.open) return wandererBrowser.line ? [wandererBrowser.line] : null
if (tourPlanner.mode.type === 'view-gpx') {
const coordinates = tourPlanner.readOnlyGpxAnalysis?.routeCoordinates
return coordinates?.length ? [coordinates] : null
if (!coordinates?.length) return null
// While an editable version is previewed, the import (dashed) and the suggestion side by side.
return tourConversion.line ? [coordinates, tourConversion.line] : [coordinates]
}
if (tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved') {
return tourPlanner.route ? [tourPlanner.route] : null
}
return null
}, [tourPlanner.mode.type, tourPlanner.readOnlyGpxAnalysis?.routeCoordinates, tourPlanner.route])
}, [
wandererBrowser.open,
wandererBrowser.line,
tourPlanner.mode.type, tourPlanner.readOnlyGpxAnalysis?.routeCoordinates, tourPlanner.route,
tourConversion.line,
])
const { closeGpxTour } = tourPlanner
useEffect(() => {
@@ -106,7 +101,7 @@ export function useTripPlannerPage() {
// Each tour's planned times keyed by its place, for the day plan's tour rows.
const plannedTourDurations = new Map(tours.map(tour => [tour.place_id, { ...projectTourPlannedTimes(tour), tour }] as const))
const onSelectTour = (tour: TourListItem | null, opener?: HTMLElement) => {
const onSelectTour = (tour: (typeof tours)[number] | null, opener?: HTMLElement) => {
tourDetailOpenerRef.current = tour && opener ? { placeId: tour.place_id, element: opener } : null
setSelectedPlaceId(tour?.place_id ?? null)
}
@@ -118,8 +113,20 @@ export function useTripPlannerPage() {
placeActions: placeActions({ tripId, tripActions, toast, t }),
permissions: { canPlaceEdit, canDayEdit },
tourPlanner,
tourGpxImport,
wandererBrowser,
viewImportedTour,
tourDetails: { openerRef: tourDetailOpenerRef, onSelectTour },
tourMap: { route, focusPoints: mapFocusPoints, focusKey: mapFocusKey, captureViewport },
tourConversion,
recordedTrack,
tourMap: { route,
routeWalking: route && route.length === 2 && tourConversion.line ? [true, false] : null,
previewPoints: tourConversion.status === 'idle' ? null : tourConversion.points,
focusPoints: mapFocusPoints,
// A picked wanderer trail frames itself; the rest follows the planner.
focusKey:
wandererBrowser.open && wandererBrowser.selected ? `wanderer:${wandererBrowser.selected.id}` : mapFocusKey, captureViewport,
},
locationSearch,
plannedTourDurations,
}
@@ -66,4 +66,51 @@ describe('reverse geocoded place names', () => {
resolvers[2]({ name: 'Three', address: null });
await expect(Promise.all(names)).resolves.toEqual(['One', 'Two', 'Three']);
});
it('FE-REVERSE-GEOCODE-007: an answered name outlives a reload and is not asked for again', async () => {
await reverseGeocodeRepo.placeName(47.42, 11.01, 'de');
vi.resetModules();
const reloaded = (await import('./reverseGeocodeRepo')).reverseGeocodeRepo;
const network = (await import('../api/client')).mapsApi;
vi.mocked(network.reverse).mockClear();
await expect(reloaded.placeName(47.42, 11.01, 'de')).resolves.toBe('Zugspitze');
expect(reloaded.knownName(47.42, 11.01, 'de')).toBe('Zugspitze');
expect(network.reverse).not.toHaveBeenCalled();
});
it('FE-REVERSE-GEOCODE-008: a name answered before is there offline too', async () => {
await reverseGeocodeRepo.placeName(47.43, 11.02, 'de');
vi.mocked(isEffectivelyOffline).mockReturnValue(true);
await expect(reverseGeocodeRepo.placeName(47.43, 11.02, 'de')).resolves.toBe('Zugspitze');
});
it('FE-REVERSE-GEOCODE-009: knows a name only once it was answered, and per language', async () => {
expect(reverseGeocodeRepo.knownName(47.44, 11.03, 'de')).toBeUndefined();
await reverseGeocodeRepo.placeName(47.44, 11.03, 'de');
expect(reverseGeocodeRepo.knownName(47.44, 11.03, 'de')).toBe('Zugspitze');
expect(reverseGeocodeRepo.knownName(47.44, 11.03, 'en')).toBeUndefined();
});
it('FE-REVERSE-GEOCODE-010: keeps no answer without a name, so it is asked again later', async () => {
vi.mocked(mapsApi.reverse).mockResolvedValueOnce({ name: null, address: null });
await expect(reverseGeocodeRepo.placeName(47.45, 11.04, 'de')).resolves.toBeNull();
expect(reverseGeocodeRepo.knownName(47.45, 11.04, 'de')).toBeUndefined();
});
it('FE-REVERSE-GEOCODE-011: drops the oldest kept name past the limit and survives broken storage', async () => {
const seeded = Array.from({ length: 2000 }, (_, n) => [`${n},0|de`, `Place ${n}`]);
localStorage.setItem('trek-place-names-v1', JSON.stringify(seeded));
vi.resetModules();
const reloaded = (await import('./reverseGeocodeRepo')).reverseGeocodeRepo;
await reloaded.placeName(47.46, 11.05, 'de');
const kept = JSON.parse(localStorage.getItem('trek-place-names-v1') ?? '[]') as [string, string][];
expect(kept).toHaveLength(2000);
expect(kept[0][0]).toBe('1,0|de');
expect(kept[kept.length - 1]).toEqual(['47.46000,11.05000|de', 'Zugspitze']);
localStorage.setItem('trek-place-names-v1', '{broken');
vi.resetModules();
const fresh = (await import('./reverseGeocodeRepo')).reverseGeocodeRepo;
expect(fresh.knownName(47.46, 11.05, 'de')).toBeUndefined();
});
});
+59 -3
View File
@@ -3,6 +3,10 @@ import { isEffectivelyOffline } from '../sync/networkMode';
/** How many coordinates the cache remembers before it starts over. */
const CACHE_LIMIT = 500;
/** How many answered names outlive a reload; the oldest give way first. */
const STORED_LIMIT = 2000;
/** Where the answered names are kept between visits: a convenience, rebuilt from the geocoder when gone. */
const STORAGE_KEY = 'trek-place-names-v1';
/** How many lookups may be in flight at once, so a long tour does not flood the geocoder. */
const CONCURRENCY = 2;
@@ -34,6 +38,39 @@ function runQueued<T>(task: () => Promise<T>): Promise<T> {
});
}
/** The names answered on earlier visits, read once; an unreadable store is an empty one. */
let stored: Map<string, string> | null = null;
function storedNames(): Map<string, string> {
if (stored) return stored;
stored = new Map();
try {
const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]') as unknown;
if (Array.isArray(raw)) {
for (const entry of raw) {
if (Array.isArray(entry) && typeof entry[0] === 'string' && typeof entry[1] === 'string')
stored.set(entry[0], entry[1]);
}
}
} catch {
// Blocked or broken storage: names are simply asked for again.
}
return stored;
}
/** Keeps an answered name for later visits, dropping the oldest past the limit. */
function storeName(key: string, name: string): void {
const names = storedNames();
names.delete(key);
names.set(key, name);
while (names.size > STORED_LIMIT) names.delete(names.keys().next().value as string);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...names]));
} catch {
// Full or blocked storage: the name still serves this visit from memory.
}
}
/** The name a reverse lookup gives, or the first part of its address when it has no name. */
function placeLabel(result: { name: string | null; address: string | null }): string | null {
const name = result.name?.trim();
@@ -49,20 +86,39 @@ export const reverseGeocodeRepo = {
* same point is asked again the next time it is shown.
*/
placeName(lat: number, lng: number, lang: string): Promise<string | null> {
if (isEffectivelyOffline()) return Promise.reject(new Error('Offline'));
const key = keyOf(lat, lng, lang);
const known = storedNames().get(key);
if (known !== undefined) return Promise.resolve(known);
if (isEffectivelyOffline()) return Promise.reject(new Error('Offline'));
const cached = cache.get(key);
if (cached !== undefined) return cached;
if (cache.size >= CACHE_LIMIT) cache.clear();
const value = runQueued(() => mapsApi.reverse(lat, lng, lang)).then(placeLabel);
const value = runQueued(() => mapsApi.reverse(lat, lng, lang)).then((result) => {
const label = placeLabel(result);
if (label) storeName(key, label);
return label;
});
cache.set(key, value);
void value.catch(() => {
if (cache.get(key) === value) cache.delete(key);
});
return value;
},
/** Forgets every remembered name; for tests. */
/**
* A name answered before, on this visit or an earlier one, without asking;
* undefined when the coordinate has not been answered with a name yet.
*/
knownName(lat: number, lng: number, lang: string): string | undefined {
return storedNames().get(keyOf(lat, lng, lang));
},
/** Forgets every remembered name, kept ones included; for tests. */
clear() {
cache.clear();
stored = new Map();
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// Nothing kept to forget.
}
},
};
+7
View File
@@ -176,6 +176,13 @@ export const tourRepo = {
return result
},
/** Online only, like the import: the recorded track is kept on the server, not in the cache. */
async restoreRecorded(tripId: number | string, placeId: number): Promise<TourDetailResponse> {
const result = await toursApi.restoreRecorded(tripId, placeId)
await offlineDb.tours.put({ ...result.tour, trip_id: Number(tripId), waypoints: result.waypoints })
return result
},
async importGpx(tripId: number | string, file: File): Promise<TourImportGpxResponse> {
const result = await toursApi.importGpx(tripId, file)
await offlineDb.tours.bulkPut(result.tours.map(tour => ({ ...tour, trip_id: Number(tripId) })))
+74
View File
@@ -0,0 +1,74 @@
import { http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { server } from '../../tests/helpers/msw/server';
import { offlineDb } from '../db/offlineDb';
import { isEffectivelyOffline } from '../sync/networkMode';
import { wandererErrorCode, WandererOfflineError, wandererRepo } from './wandererRepo';
vi.mock('../sync/networkMode', async (importOriginal) => ({
...(await importOriginal<typeof import('../sync/networkMode')>()),
isEffectivelyOffline: vi.fn(() => false),
}));
const tour = {
place_id: 70,
name: 'Ridge',
tour_type: 'hike',
distance: 5,
elevation_gain: 300,
elevation_loss: 300,
duration: null,
difficulty: 'easy',
wanderer_ref: 'https://w.test#abc',
match_confidence: 1,
max_hiking_difficulty: 1,
planned: false,
caution: false,
has_waypoints: false,
};
beforeEach(async () => {
vi.mocked(isEffectivelyOffline).mockReturnValue(false);
await offlineDb.tours.clear();
});
describe('wandererRepo', () => {
it('RS-04: searches and previews through the server, with the trail id kept safe in the path', async () => {
const seen: string[] = [];
server.use(
http.post('/api/trips/3/tours/wanderer/search', async ({ request }) => {
seen.push(JSON.stringify(await request.json()));
return HttpResponse.json({ instance: 'https://w.test', trails: [], total: 0, pageSize: 20 });
}),
http.get('/api/trips/3/tours/wanderer/trails/:id', ({ params }) => {
seen.push(String(params.id));
return HttpResponse.json({ ok: true });
})
);
await expect(wandererRepo.search(3, { q: 'ridge', page: 1 })).resolves.toMatchObject({ total: 0 });
await wandererRepo.preview(3, 'abc');
expect(seen).toEqual(['{"q":"ridge","page":1}', 'abc']);
});
it('RS-05: an import is cached like every other tour', async () => {
server.use(http.post('/api/trips/3/tours/wanderer/import', () => HttpResponse.json({ tour })));
await wandererRepo.importTrail(3, { trailId: 'abc', contentHash: '0123456789abcdef' });
expect(await offlineDb.tours.get(70)).toMatchObject({ place_id: 70, trip_id: 3 });
});
it('RS-04: is online only and says so instead of sending', async () => {
vi.mocked(isEffectivelyOffline).mockReturnValue(true);
await expect(wandererRepo.search(3, { q: '', page: 1 })).rejects.toBeInstanceOf(WandererOfflineError);
await expect(wandererRepo.preview(3, 'abc')).rejects.toBeInstanceOf(WandererOfflineError);
await expect(
wandererRepo.importTrail(3, { trailId: 'abc', contentHash: '0123456789abcdef' })
).rejects.toBeInstanceOf(WandererOfflineError);
});
it('RS-03: reads the reason of a failed call from the server, the offline case or nothing', () => {
expect(wandererErrorCode(new WandererOfflineError())).toBe('offline');
expect(wandererErrorCode({ response: { data: { code: 'not_own_trail' } } })).toBe('not_own_trail');
expect(wandererErrorCode({ response: { data: {} } })).toBe('unknown');
expect(wandererErrorCode(new Error('boom'))).toBe('unknown');
});
});
+51
View File
@@ -0,0 +1,51 @@
import type { WandererErrorCode, WandererImportRequest, WandererSearchRequest } from '@trek/shared';
import { wandererApi } from '../api/wanderer';
import { offlineDb } from '../db/offlineDb';
import { isEffectivelyOffline } from '../sync/networkMode';
/**
* wanderer, deliberately online-only: the connection is the user's own and
* every read goes to their instance through the server, so there is nothing a
* device could answer offline. What the import writes is an ordinary tour,
* cached like every other the moment it comes back.
*/
export const wandererRepo = {
getSettings: wandererApi.getSettings,
saveSettings: wandererApi.saveSettings,
disconnect: wandererApi.disconnect,
test: wandererApi.test,
async search(tripId: number, body: WandererSearchRequest, signal?: AbortSignal) {
if (isEffectivelyOffline()) throw new WandererOfflineError();
return await wandererApi.search(tripId, body, signal);
},
async preview(tripId: number, trailId: string, signal?: AbortSignal) {
if (isEffectivelyOffline()) throw new WandererOfflineError();
return await wandererApi.preview(tripId, trailId, signal);
},
async importTrail(tripId: number, body: WandererImportRequest) {
if (isEffectivelyOffline()) throw new WandererOfflineError();
const result = await wandererApi.importTrail(tripId, body);
await offlineDb.tours.put({ ...result.tour, trip_id: tripId });
return result;
},
};
/** Thrown instead of a request while the app is offline, so the caller can say so. */
export class WandererOfflineError extends Error {
constructor() {
super('offline');
this.name = 'WandererOfflineError';
}
}
/** The reason code of a failed wanderer call: the server's own, or what the failure itself says. */
export function wandererErrorCode(
error: unknown
): WandererErrorCode | 'offline' | 'preview_stale' | 'already_imported' | 'no_track' | 'unknown' {
if (error instanceof WandererOfflineError) return 'offline';
const code = (error as { response?: { data?: { code?: unknown } } })?.response?.data?.code;
return typeof code === 'string' ? (code as WandererErrorCode) : 'unknown';
}
+7 -1
View File
@@ -25,6 +25,8 @@ interface Addon {
interface AddonState {
addons: Addon[]
bagTracking: boolean
/** Tours is on and the admin allows wanderer under it. */
toursWanderer: boolean
loaded: boolean
loadAddons: () => Promise<void>
isEnabled: (id: string) => boolean
@@ -33,12 +35,16 @@ interface AddonState {
export const useAddonStore = create<AddonState>((set, get) => ({
addons: [],
bagTracking: false,
toursWanderer: false,
loaded: false,
loadAddons: async () => {
try {
const data = await addonsApi.enabled()
set({ addons: data.addons || [], bagTracking: !!data.bagTracking, loaded: true })
set({ addons: data.addons || [], bagTracking: !!data.bagTracking,
toursWanderer: !!data.toursWanderer,
loaded: true,
})
} catch {
set({ loaded: true })
}
+30
View File
@@ -0,0 +1,30 @@
import { fireEvent } from '@testing-library/react';
import { vi } from 'vitest';
/**
* Presses a control that opens the browser's file picker by building an
* `<input type="file">` and clicking it, and hands that picker `files` the way
* a user choosing them would. Returns the picker, or null when the press built none.
*/
export function pickFiles(control: HTMLElement, files: File[]): HTMLInputElement | null {
const createElement = document.createElement.bind(document);
let picker: HTMLInputElement | null = null;
const spy = vi.spyOn(document, 'createElement').mockImplementation(((
tagName: string,
options?: ElementCreationOptions
) => {
const element = createElement(tagName, options);
if (tagName.toLowerCase() === 'input') {
picker = element as HTMLInputElement;
vi.spyOn(picker, 'click').mockImplementation(() => undefined);
}
return element;
}) as typeof document.createElement);
fireEvent.click(control);
spy.mockRestore();
if (!picker) return null;
const input: HTMLInputElement = picker;
Object.defineProperty(input, 'files', { value: files, configurable: true });
fireEvent.change(input);
return input;
}
@@ -1,4 +1,3 @@
// FE-MOB-AADD-001 to FE-MOB-AADD-037
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import userEvent from '@testing-library/user-event';
import { delay, http, HttpResponse } from 'msw';
@@ -764,4 +763,22 @@ describe('MAdminAddonManager', () => {
act(() => document.documentElement.classList.add('dark'));
await waitFor(() => expect(screen.getByAltText('TREK')).toHaveAttribute('src', '/text-light.svg'));
});
it('FE-MOB-AADD-030: wanderer hangs off an enabled Tours addon and saves its switch', async () => {
const user = userEvent.setup();
const puts: unknown[] = [];
server.use(
addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]),
http.get('/api/admin/tours-wanderer', () => HttpResponse.json({ enabled: false })),
http.put('/api/admin/tours-wanderer', async ({ request }) => {
puts.push(await request.json());
return HttpResponse.json({ enabled: true });
})
);
render(<MAdminAddonManager />);
const toggle = await screen.findByRole('switch', { name: 'wanderer' });
expect(toggle).toHaveAttribute('aria-checked', 'false');
await user.click(toggle);
await waitFor(() => expect(puts).toEqual([{ enabled: true }]));
});
});
@@ -31,6 +31,16 @@ describe('addonStore', () => {
await useAddonStore.getState().loadAddons();
expect(useAddonStore.getState().bagTracking).toBe(true);
});
it('captures whether wanderer may be used under Tours', async () => {
server.use(http.get('/api/addons', () => HttpResponse.json({ toursWanderer: true, addons: [] })));
await useAddonStore.getState().loadAddons();
expect(useAddonStore.getState().toursWanderer).toBe(true);
server.use(http.get('/api/addons', () => HttpResponse.json({ addons: [] })));
await useAddonStore.getState().loadAddons();
expect(useAddonStore.getState().toursWanderer).toBe(false);
});
});
describe('FE-ADDON-002: isEnabled returns true for known addon', () => {
-1
View File
@@ -20,7 +20,6 @@
"src/nest/plugins/runtime/plugin-sdk.ts",
"src/nest/reservations/reservations.mcp.ts",
"src/nest/reservations/reservations.service.ts",
"src/nest/trips/trips.service.ts",
"src/nest/vacay/vacay.service.ts",
"tests/unit/db/repositories/Places.repository.test.ts",
"tests/unit/nest/atlas.service.test.ts",
+2
View File
@@ -104,6 +104,7 @@
"Tags": "tags",
"TodoCategoryAssignees": "todo",
"TodoItems": "todo",
"TourSources": "tours",
"TourTypes": "tours",
"TourWaypoints": "tours",
"Tours": "tours",
@@ -131,6 +132,7 @@
"VacayYears": "vacay",
"VisitedCountries": "atlas",
"VisitedRegions": "atlas",
"WandererConnections": "wanderer",
"WebauthnChallenges": "auth",
"WebauthnCredentials": "auth"
}
+2
View File
@@ -69,6 +69,7 @@ export class Places {
reservations_collection = new Collection<Reservations>(this);
roadtrip_day_tracks_collection = new Collection<RoadtripDayTracks>(this);
tours: Ref<Tours> | null = null;
tours_collection = new Collection<Tours>(this);
trip_files_collection = new Collection<TripFiles>(this);
}
@@ -125,6 +126,7 @@ export const PlacesSchema = defineEntity({
reservations_collection: () => p.oneToMany(Reservations).mappedBy('place').hidden(),
roadtrip_day_tracks_collection: () => p.oneToMany(RoadtripDayTracks).mappedBy('place').hidden(),
tours: () => p.oneToOne(Tours).ref().mappedBy('place').hidden(),
tours_collection: () => p.oneToMany(Tours).mappedBy('derivedFromPlace').hidden(),
trip_files_collection: () => p.oneToMany(TripFiles).mappedBy('place').hidden(),
},
});
@@ -0,0 +1,47 @@
import { EntityRepositoryType, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core';
import { TourSourcesRepository } from '../repositories/TourSources.repository';
import { DbTimestampType } from '../types';
import { Tours } from './Tours.entity';
import { Users } from './Users.entity';
export class TourSources {
[EntityRepositoryType]?: TourSourcesRepository;
[PrimaryKeyProp]?: 'place';
place?: Ref<Tours> | null;
place_id?: number | null;
provider!: string;
instance!: string;
external_id!: string;
external_url?: string | null;
external_updated?: string | null;
content_hash!: string;
local_hash!: string;
imported_at?: string | null;
imported_by?: number | null;
importedByRef?: Ref<Users> | null;
}
export const TourSourcesSchema = defineEntity({
class: TourSources,
repository: () => TourSourcesRepository,
indexes: [
{
name: 'idx_tour_sources_external',
properties: ['provider', 'instance', 'external_id'],
},
],
properties: {
place: () => p.oneToOne(Tours).primary().ref().name('place_id').deleteRule('cascade').nullable().hidden(),
place_id: p.integer().nullable().persist(false),
provider: p.text(),
instance: p.text(),
external_id: p.text(),
external_url: p.text().nullable(),
external_updated: p.text().nullable(),
content_hash: p.text(),
local_hash: p.text(),
imported_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`),
imported_by: p.integer().nullable().persist(false).index('idx_tour_sources_imported_by'),
importedByRef: () => p.manyToOne(Users).ref().joinColumn('imported_by').nullable().hidden().index('idx_tour_sources_imported_by'),
},
});
+16
View File
@@ -1,6 +1,7 @@
import { Collection, EntityRepositoryType, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core';
import { ToursRepository } from '../repositories/Tours.repository';
import { Places } from './Places.entity';
import { TourSources } from './TourSources.entity';
import { TourTypes } from './TourTypes.entity';
import { TourWaypoints } from './TourWaypoints.entity';
@@ -21,13 +22,24 @@ export class Tours {
max_hiking_difficulty?: number | null = 2;
planned_duration_minutes?: number | null;
break_additional_minutes?: number | null;
derivedFromPlace?: Ref<Places> | null;
derived_from_place_id?: number | null;
recorded_route_geometry?: string | null;
tourTypeRef!: Ref<TourTypes>;
tour_sources: Ref<TourSources> | null = null;
tour_waypoints_collection = new Collection<TourWaypoints>(this);
}
export const ToursSchema = defineEntity({
class: Tours,
repository: () => ToursRepository,
indexes: [
{
name: 'idx_tours_derived_from_place_id',
where: 'derived_from_place_id IS NOT NULL',
properties: ['derived_from_place_id'],
},
],
properties: {
place: () => p.oneToOne(Places).primary().ref().deleteRule('cascade').nullable().hidden(),
place_id: p.integer().nullable().persist(false),
@@ -43,7 +55,11 @@ export const ToursSchema = defineEntity({
max_hiking_difficulty: p.integer().nullable(),
planned_duration_minutes: p.integer().nullable(),
break_additional_minutes: p.integer().nullable(),
derivedFromPlace: () => p.manyToOne(Places).ref().nullable().hidden(),
derived_from_place_id: p.integer().nullable().persist(false),
recorded_route_geometry: p.text().nullable(),
tourTypeRef: () => p.manyToOne(TourTypes).ref().joinColumn('tour_type').hidden().index('idx_tours_tour_type'),
tour_sources: () => p.oneToOne(TourSources).ref().mappedBy('place').hidden(),
tour_waypoints_collection: () => p.oneToMany(TourWaypoints).mappedBy('place').hidden(),
},
});
+6
View File
@@ -51,6 +51,7 @@ import { ShareTokens } from './ShareTokens.entity';
import { Tags } from './Tags.entity';
import { TodoCategoryAssignees } from './TodoCategoryAssignees.entity';
import { TodoItems } from './TodoItems.entity';
import { TourSources } from './TourSources.entity';
import { TrekPhotos } from './TrekPhotos.entity';
import { TripAlbumLinks } from './TripAlbumLinks.entity';
import { TripDocumentLinks } from './TripDocumentLinks.entity';
@@ -70,6 +71,7 @@ import { VacayUserSettings } from './VacayUserSettings.entity';
import { VacayUserYears } from './VacayUserYears.entity';
import { VisitedCountries } from './VisitedCountries.entity';
import { VisitedRegions } from './VisitedRegions.entity';
import { WandererConnections } from './WandererConnections.entity';
import { WebauthnChallenges } from './WebauthnChallenges.entity';
import { WebauthnCredentials } from './WebauthnCredentials.entity';
@@ -174,6 +176,7 @@ export class Users {
tags_collection = new Collection<Tags>(this);
todo_category_assignees_collection = new Collection<TodoCategoryAssignees>(this);
todo_items_collection = new Collection<TodoItems>(this);
tour_sources_collection = new Collection<TourSources>(this);
trek_photos_collection = new Collection<TrekPhotos>(this);
trip_album_links_collection = new Collection<TripAlbumLinks>(this);
trip_document_links_collection = new Collection<TripDocumentLinks>(this);
@@ -195,6 +198,7 @@ export class Users {
vacay_user_years_collection = new Collection<VacayUserYears>(this);
visited_countries_collection = new Collection<VisitedCountries>(this);
visited_regions_collection = new Collection<VisitedRegions>(this);
wanderer_connections: Ref<WandererConnections> | null = null;
webauthn_challenges_collection = new Collection<WebauthnChallenges>(this);
webauthn_credentials_collection = new Collection<WebauthnCredentials>(this);
}
@@ -315,6 +319,7 @@ export const UsersSchema = defineEntity({
tags_collection: () => p.oneToMany(Tags).mappedBy('user').hidden(),
todo_category_assignees_collection: () => p.oneToMany(TodoCategoryAssignees).mappedBy('user').hidden(),
todo_items_collection: () => p.oneToMany(TodoItems).mappedBy('assignedUser').hidden(),
tour_sources_collection: () => p.oneToMany(TourSources).mappedBy('importedByRef').hidden(),
trek_photos_collection: () => p.oneToMany(TrekPhotos).mappedBy('owner').hidden(),
trip_album_links_collection: () => p.oneToMany(TripAlbumLinks).mappedBy('user').hidden(),
trip_document_links_collection: () => p.oneToMany(TripDocumentLinks).mappedBy('createdByRef').hidden(),
@@ -336,6 +341,7 @@ export const UsersSchema = defineEntity({
vacay_user_years_collection: () => p.oneToMany(VacayUserYears).mappedBy('user').hidden(),
visited_countries_collection: () => p.oneToMany(VisitedCountries).mappedBy('user').hidden(),
visited_regions_collection: () => p.oneToMany(VisitedRegions).mappedBy('user').hidden(),
wanderer_connections: () => p.oneToOne(WandererConnections).ref().mappedBy('user').hidden(),
webauthn_challenges_collection: () => p.oneToMany(WebauthnChallenges).mappedBy('user').hidden(),
webauthn_credentials_collection: () => p.oneToMany(WebauthnCredentials).mappedBy('user').hidden(),
},
@@ -0,0 +1,36 @@
import { EntityRepositoryType, type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core';
import { WandererConnectionsRepository } from '../repositories/WandererConnections.repository';
import { DbTimestampType } from '../types';
import { Users } from './Users.entity';
export class WandererConnections {
[EntityRepositoryType]?: WandererConnectionsRepository;
[PrimaryKeyProp]?: 'user';
user?: Ref<Users> | null;
user_id?: number | null;
url!: string;
token?: string | null;
allow_insecure_tls: number & Opt = 0;
last_checked_at?: string | null;
last_check_ok?: number | null;
last_check_error?: string | null;
created_at?: string | null;
updated_at?: string | null;
}
export const WandererConnectionsSchema = defineEntity({
class: WandererConnections,
repository: () => WandererConnectionsRepository,
properties: {
user: () => p.oneToOne(Users).primary().ref().deleteRule('cascade').nullable().hidden(),
user_id: p.integer().nullable().persist(false),
url: p.text(),
token: p.text().nullable(),
allow_insecure_tls: p.integer().default(0),
last_checked_at: p.text().nullable(),
last_check_ok: p.integer().nullable(),
last_check_error: p.text().nullable(),
created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`),
updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`),
},
});
+4
View File
@@ -103,6 +103,7 @@ import { ShareTokensSchema } from './ShareTokens.entity';
import { TagsSchema } from './Tags.entity';
import { TodoCategoryAssigneesSchema } from './TodoCategoryAssignees.entity';
import { TodoItemsSchema } from './TodoItems.entity';
import { TourSourcesSchema } from './TourSources.entity';
import { TourTypesSchema } from './TourTypes.entity';
import { TourWaypointsSchema } from './TourWaypoints.entity';
import { ToursSchema } from './Tours.entity';
@@ -130,6 +131,7 @@ import { VacayUserYearsSchema } from './VacayUserYears.entity';
import { VacayYearsSchema } from './VacayYears.entity';
import { VisitedCountriesSchema } from './VisitedCountries.entity';
import { VisitedRegionsSchema } from './VisitedRegions.entity';
import { WandererConnectionsSchema } from './WandererConnections.entity';
import { WebauthnChallengesSchema } from './WebauthnChallenges.entity';
import { WebauthnCredentialsSchema } from './WebauthnCredentials.entity';
@@ -238,6 +240,7 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [
TagsSchema,
TodoCategoryAssigneesSchema,
TodoItemsSchema,
TourSourcesSchema,
TourTypesSchema,
TourWaypointsSchema,
ToursSchema,
@@ -265,6 +268,7 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [
VacayYearsSchema,
VisitedCountriesSchema,
VisitedRegionsSchema,
WandererConnectionsSchema,
WebauthnChallengesSchema,
WebauthnCredentialsSchema,
];
+16
View File
@@ -0,0 +1,16 @@
// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:
// change the migration, then run `npm run gen:db-types`.
import type { InsertOptional } from './columns';
export interface TourSourcesTable {
place_id: InsertOptional<number>;
provider: string;
instance: string;
external_id: string;
external_url: InsertOptional<string | null>;
external_updated: InsertOptional<string | null>;
content_hash: string;
local_hash: string;
imported_at: InsertOptional<string | null>;
imported_by: InsertOptional<number | null>;
}
+2
View File
@@ -16,4 +16,6 @@ export interface ToursTable {
max_hiking_difficulty: InsertOptional<number | null>;
planned_duration_minutes: InsertOptional<number | null>;
break_additional_minutes: InsertOptional<number | null>;
derived_from_place_id: InsertOptional<number | null>;
recorded_route_geometry: InsertOptional<string | null>;
}
@@ -0,0 +1,15 @@
// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:
// change the migration, then run `npm run gen:db-types`.
import type { InsertOptional } from './columns';
export interface WandererConnectionsTable {
user_id: InsertOptional<number>;
url: string;
token: InsertOptional<string | null>;
allow_insecure_tls: InsertOptional<number>;
last_checked_at: InsertOptional<string | null>;
last_check_ok: InsertOptional<number | null>;
last_check_error: InsertOptional<string | null>;
created_at: InsertOptional<string | null>;
updated_at: InsertOptional<string | null>;
}
+4
View File
@@ -109,6 +109,7 @@ import type { ShareTokensTable } from './ShareTokens.table';
import type { TagsTable } from './Tags.table';
import type { TodoCategoryAssigneesTable } from './TodoCategoryAssignees.table';
import type { TodoItemsTable } from './TodoItems.table';
import type { TourSourcesTable } from './TourSources.table';
import type { TourTypesTable } from './TourTypes.table';
import type { TourWaypointsTable } from './TourWaypoints.table';
import type { ToursTable } from './Tours.table';
@@ -136,6 +137,7 @@ import type { VacayUserYearsTable } from './VacayUserYears.table';
import type { VacayYearsTable } from './VacayYears.table';
import type { VisitedCountriesTable } from './VisitedCountries.table';
import type { VisitedRegionsTable } from './VisitedRegions.table';
import type { WandererConnectionsTable } from './WandererConnections.table';
import type { WebauthnChallengesTable } from './WebauthnChallenges.table';
import type { WebauthnCredentialsTable } from './WebauthnCredentials.table';
@@ -250,6 +252,7 @@ export interface DB {
tags: TagsTable;
todo_category_assignees: TodoCategoryAssigneesTable;
todo_items: TodoItemsTable;
tour_sources: TourSourcesTable;
tour_types: TourTypesTable;
tour_waypoints: TourWaypointsTable;
tours: ToursTable;
@@ -277,6 +280,7 @@ export interface DB {
vacay_years: VacayYearsTable;
visited_countries: VisitedCountriesTable;
visited_regions: VisitedRegionsTable;
wanderer_connections: WandererConnectionsTable;
webauthn_challenges: WebauthnChallengesTable;
webauthn_credentials: WebauthnCredentialsTable;
}
@@ -0,0 +1,53 @@
import { Migration } from '@mikro-orm/migrations';
/**
* A personal wanderer connection, and where an imported tour came from.
*
* `wanderer_connections` holds one user's instance address and API token, the
* token encrypted like every other integration credential. It is the user's
* own and is never shared with a trip; what a trip gets is the snapshot.
*
* `tour_sources` records, for a tour copied in from an outside source, which
* source it was (the provider, the instance, the trail's id there), when it was
* copied and two fingerprints: the content as the source delivered it, to tell
* on a later import whether the source changed, and the tour as it was saved
* here, to tell whether it was changed since. It is not wanderer-specific, so
* other sources can record their provenance the same way. It goes with its tour.
*/
export class Migration20200101043400_wanderer_connection_and_tour_sources extends Migration {
override name = 'Migration20200101043400_wanderer_connection_and_tour_sources';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS wanderer_connections (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
url TEXT NOT NULL,
token TEXT,
allow_insecure_tls INTEGER NOT NULL DEFAULT 0,
last_checked_at TEXT,
last_check_ok INTEGER,
last_check_error TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS tour_sources (
place_id INTEGER PRIMARY KEY REFERENCES tours(place_id) ON DELETE CASCADE,
provider TEXT NOT NULL,
instance TEXT NOT NULL,
external_id TEXT NOT NULL,
external_url TEXT,
external_updated TEXT,
content_hash TEXT NOT NULL,
local_hash TEXT NOT NULL,
imported_at DATETIME DEFAULT CURRENT_TIMESTAMP,
imported_by INTEGER REFERENCES users(id) ON DELETE SET NULL
)
`);
this.addSql(
'CREATE INDEX IF NOT EXISTS idx_tour_sources_external ON tour_sources(provider, instance, external_id)',
);
this.addSql('CREATE INDEX IF NOT EXISTS idx_tour_sources_imported_by ON tour_sources(imported_by)');
}
}
@@ -0,0 +1,22 @@
import { Migration } from '@mikro-orm/migrations';
/**
* Tours imported from wanderer carry wanderer's own word for how hard they are
* (`difficulty`: easy, moderate, difficult), and tours show one scale only, the
* SAC grade in `max_hiking_difficulty`. An import now writes both; this gives
* the imports made before that their grade the same way: easy is T1, moderate
* T2, difficult T3. A grade someone already set is left as it is.
*/
export class Migration20200101043500_wanderer_difficulty_on_the_sac_scale extends Migration {
override name = 'Migration20200101043500_wanderer_difficulty_on_the_sac_scale';
override up(): void {
this.addSql(`
UPDATE tours
SET max_hiking_difficulty = CASE difficulty WHEN 'easy' THEN 1 WHEN 'moderate' THEN 2 WHEN 'difficult' THEN 3 END
WHERE wanderer_ref IS NOT NULL
AND max_hiking_difficulty IS NULL
AND difficulty IN ('easy', 'moderate', 'difficult')
`);
}
}
@@ -0,0 +1,18 @@
import { Migration } from '@mikro-orm/migrations';
/**
* A tour planned from an imported one (a GPX file or a wanderer snapshot,
* re-routed through control points taken from its track) remembers which import
* it came from, so the tour can say so. The import stays as it was; deleting it
* leaves the derived tour standing and only forgets where it came from.
*/
export class Migration20200101043600_a_tour_remembers_the_import_it_was_derived_from extends Migration {
override name = 'Migration20200101043600_a_tour_remembers_the_import_it_was_derived_from';
override up(): void {
this.addSql('ALTER TABLE tours ADD COLUMN derived_from_place_id INTEGER REFERENCES places(id) ON DELETE SET NULL');
this.addSql(
'CREATE INDEX IF NOT EXISTS idx_tours_derived_from_place_id ON tours(derived_from_place_id) WHERE derived_from_place_id IS NOT NULL',
);
}
}
@@ -0,0 +1,15 @@
import { Migration } from '@mikro-orm/migrations';
/**
* An import (a GPX file or a wanderer snapshot) can be planned over in place:
* the tour keeps its id, its days and its notes, and gets waypoints and a line
* routed through them. The track it was recorded with is kept here the first
* time that happens, so the tour can go back to it whenever its owner wants.
*/
export class Migration20200101043700_an_import_planned_over_keeps_its_recorded_track extends Migration {
override name = 'Migration20200101043700_an_import_planned_over_keeps_its_recorded_track';
override up(): void {
this.addSql('ALTER TABLE tours ADD COLUMN recorded_route_geometry TEXT');
}
}
@@ -0,0 +1,79 @@
import type { TourSources } from '../entities/TourSources.entity';
import type { DB } from '../kysely/db';
import { TrekRepository } from './_shared/trek-repository';
/** Where an imported tour came from, as it is written once at the import. */
export interface TourSourceInsert {
place_id: number;
provider: string;
instance: string;
external_id: string;
external_url: string | null;
external_updated: string | null;
content_hash: string;
local_hash: string;
imported_by: number;
}
/** A snapshot of an outside source in a trip, with the tour as it stands now. */
export interface TourSnapshotRow {
place_id: number;
external_id: string;
content_hash: string;
local_hash: string;
imported_at: string | null;
name: string;
description: string | null;
route_geometry: string | null;
}
type SourcesDB = Pick<DB, 'tour_sources' | 'places'>;
/**
* `tour_sources`: the provenance of tours copied in from outside, one row per
* tour, gone with the tour. The source's own id is not unique here on purpose:
* importing a changed trail again makes a second snapshot beside the first.
*/
export class TourSourcesRepository extends TrekRepository<TourSources> {
private db() {
return this.kysely<SourcesDB>();
}
/** Written in the same transaction as the tour it describes. */
async insertSource(row: TourSourceInsert): Promise<void> {
await this.db().insertInto('tour_sources').values(row).execute();
}
/**
* Every snapshot in the trip of the given trails of one instance, newest
* first, with the tour's current name and line for comparing.
*/
async listSnapshots(
tripId: number,
provider: string,
instance: string,
externalIds: readonly string[],
): Promise<TourSnapshotRow[]> {
if (externalIds.length === 0) return [];
return await this.db()
.selectFrom('tour_sources as s')
.innerJoin('places as p', 'p.id', 's.place_id')
.select([
's.place_id',
's.external_id',
's.content_hash',
's.local_hash',
's.imported_at',
'p.name',
'p.description',
'p.route_geometry',
])
.where('p.trip_id', '=', tripId)
.where('s.provider', '=', provider)
.where('s.instance', '=', instance)
.where('s.external_id', 'in', [...externalIds])
.orderBy('s.imported_at', 'desc')
.orderBy('s.place_id', 'desc')
.execute();
}
}
+75 -3
View File
@@ -25,6 +25,8 @@ export interface TourListRow {
wanderer_ref: string | null;
match_confidence: number | null;
max_hiking_difficulty: number | null;
derived_from_place_id: number | null;
has_recorded_track: number;
planned: number;
has_waypoints: number;
}
@@ -44,6 +46,8 @@ export interface TourRow {
match_confidence: number | null;
created_at: string | null;
max_hiking_difficulty: number | null;
derived_from_place_id: number | null;
recorded_route_geometry: string | null;
}
const _tourRowKeys: AssertRowKeys<TourRow, Tours> = true;
@@ -105,6 +109,8 @@ export class ToursRepository extends TrekRepository<Tours> {
't.wanderer_ref as wanderer_ref',
't.match_confidence as match_confidence',
't.max_hiking_difficulty as max_hiking_difficulty',
't.derived_from_place_id as derived_from_place_id',
eb('t.recorded_route_geometry', 'is not', null).as('has_recorded_track'),
eb
.exists(eb.selectFrom('day_assignments as da').select('da.id').whereRef('da.place_id', '=', 'p.id'))
.as('planned'),
@@ -184,8 +190,21 @@ export class ToursRepository extends TrekRepository<Tours> {
}
/** TO7 — `INSERT INTO tours (place_id, tour_type, distance, ...) VALUES (...)`. */
async insertTour(input: TourInsert): Promise<void> {
/**
* `wanderer_ref` and `difficulty` are set only by an import from a source that
* carries them, `derived_from_place_id` only by a tour planned from an import.
*/
async insertTour(
input: TourInsert & {
wanderer_ref?: string | null;
difficulty?: string | null;
derived_from_place_id?: number | null;
},
): Promise<void> {
await this.insert({
derivedFromPlace: input.derived_from_place_id ?? null,
wanderer_ref: input.wanderer_ref ?? null,
difficulty: input.difficulty ?? null,
place: input.place_id,
tourTypeRef: input.tour_type,
distance: input.distance,
@@ -238,10 +257,63 @@ export class ToursRepository extends TrekRepository<Tours> {
.executeTakeFirst();
return Number(result.numUpdatedRows ?? 0) > 0;
}
/**
* Keeps an import's recorded line before it is planned over for the first
* time: copies the place's line into the tour while the tour has no
* waypoints and nothing kept yet. One statement, so it cannot keep a line
* that a concurrent edit already replaced.
*/
async keepRecordedTrack(place_id: number): Promise<void> {
await this.db()
.updateTable('tours')
.set((eb) => ({
recorded_route_geometry: eb
.selectFrom('places')
.select('places.route_geometry')
.where('places.id', '=', place_id),
}))
.where('place_id', '=', place_id)
.where('recorded_route_geometry', 'is', null)
.where((eb) =>
eb.not(
eb.exists(
eb.selectFrom('tour_waypoints').select('tour_waypoints.id').where('tour_waypoints.place_id', '=', place_id),
),
),
)
.execute();
}
/** The recorded line kept for a tour of the trip, or null when it has none. */
async findRecordedTrack(trip_id: number, place_id: number): Promise<string | null> {
const row = await this.db()
.selectFrom('tours as t')
.innerJoin('places as p', 'p.id', 't.place_id')
.select('t.recorded_route_geometry')
.where('t.place_id', '=', place_id)
.where('p.trip_id', '=', trip_id)
.executeTakeFirst();
return row?.recorded_route_geometry ?? null;
}
/** Forgets the kept line once the tour has gone back to it. */
async clearRecordedTrack(place_id: number): Promise<void> {
await this.nativeUpdate({ place: place_id }, { recorded_route_geometry: null });
}
}
function toListRow<
R extends Omit<TourListRow, 'planned' | 'has_waypoints'> & { planned: unknown; has_waypoints: unknown },
R extends Omit<TourListRow, 'planned' | 'has_waypoints' | 'has_recorded_track'> & {
planned: unknown;
has_waypoints: unknown;
has_recorded_track: unknown;
},
>(r: R): TourListRow {
return { ...r, planned: Number(r.planned), has_waypoints: Number(r.has_waypoints) };
return {
...r,
planned: Number(r.planned),
has_waypoints: Number(r.has_waypoints),
has_recorded_track: Number(r.has_recorded_track),
};
}
@@ -0,0 +1,87 @@
import { currentTimestampKysely } from '../dialect/kysely-functions';
import { currentTimestamp } from '../dialect/sql-functions';
import type { WandererConnections } from '../entities/WandererConnections.entity';
import type { DB } from '../kysely/db';
import { TrekRepository } from './_shared/trek-repository';
/**
* One user's wanderer connection as stored. `token` is the still-encrypted
* value; the service decrypts it for a call and never hands it on.
*/
export interface WandererConnectionRow {
user_id: number;
url: string;
token: string | null;
allow_insecure_tls: number;
last_checked_at: string | null;
last_check_ok: number | null;
last_check_error: string | null;
}
/**
* `wanderer_connections`: one row per user who connected an instance, the
* user id its primary key. Only the owner's own requests read or write it.
*/
export class WandererConnectionsRepository extends TrekRepository<WandererConnections> {
private db() {
return this.kysely<Pick<DB, 'wanderer_connections'>>();
}
private platform() {
return this.getEntityManager().getPlatform();
}
async findRow(userId: number): Promise<WandererConnectionRow | null> {
const row = await this.db()
.selectFrom('wanderer_connections')
.select(['user_id', 'url', 'token', 'allow_insecure_tls', 'last_checked_at', 'last_check_ok', 'last_check_error'])
.where('user_id', '=', userId)
.executeTakeFirst();
return row ?? null;
}
/**
* Write the address and the TLS choice, and the token when one is given:
* `undefined` keeps the stored token, `null` drops it. A changed address
* clears what the last check said, since it said it about another server.
*/
async save(
userId: number,
data: { url: string; allowInsecureTls: boolean; token?: string | null; hostChanged: boolean },
): Promise<void> {
const platform = this.platform();
const tokenColumns = data.token !== undefined ? { token: data.token } : {};
const checkColumns = data.hostChanged ? { last_checked_at: null, last_check_ok: null, last_check_error: null } : {};
await this.db()
.insertInto('wanderer_connections')
.values({
user_id: userId,
url: data.url,
allow_insecure_tls: data.allowInsecureTls ? 1 : 0,
...tokenColumns,
updated_at: currentTimestampKysely(platform),
})
.onConflict((oc) =>
oc.column('user_id').doUpdateSet({
url: data.url,
allow_insecure_tls: data.allowInsecureTls ? 1 : 0,
...tokenColumns,
...checkColumns,
updated_at: () => currentTimestampKysely(platform),
}),
)
.execute();
}
/** What the last test or call against the instance said, for the settings card. */
async recordCheck(userId: number, ok: boolean, error: string | null): Promise<void> {
await this.nativeUpdate(
{ user: userId },
{ last_checked_at: currentTimestamp(this.platform()), last_check_ok: ok ? 1 : 0, last_check_error: error },
);
}
async remove(userId: number): Promise<void> {
await this.nativeDelete({ user: userId });
}
}
+17
View File
@@ -67,6 +67,22 @@ export class AddonsService {
return { enabled: !!enabled };
}
/** The admin's switch for wanderer under Tours, as stored; whether Tours is on is asked apart. */
async getToursWanderer() {
const value = await readAppSetting(this.appSettings, 'tours_wanderer_enabled');
return { enabled: value === 'true' };
}
async updateToursWanderer(enabled: boolean) {
await this.appSettings.setValue('tours_wanderer_enabled', enabled ? 'true' : 'false');
return { enabled: !!enabled };
}
/** wanderer may be used: Tours is on and the admin allowed it under Tours. */
async isWandererAvailable(): Promise<boolean> {
return (await this.isAddonEnabled(ADDON_IDS.TOURS)) && (await this.getToursWanderer()).enabled;
}
/**
* `AppSettingsRepository.getValues` drops a key from the returned `Map`
* both when no row exists AND when the row's `value` is `NULL` (Task 0
@@ -146,6 +162,7 @@ export class AddonsService {
return {
collabFeatures: await this.getCollabFeatures(),
bagTracking: (await this.getBagTracking()).enabled,
toursWanderer: await this.isWandererAvailable(),
addons: [
// The repository row carries every scalar column (description, config,
// sort_order included); the legacy statement selected only these five,
+21
View File
@@ -10,6 +10,7 @@ import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { RegistrationInvitesService } from '../auth/registration-invites.service';
import { KitineraryExtractorService } from '../booking-import/kitinerary-extractor.service';
import { ManagedForbidden } from '../common/managed';
import { ResponseContract } from '../common/response-contract';
import { NotificationsService } from '../notifications/notifications.service';
import { OauthService } from '../oauth/oauth.service';
import { PluginRuntimeService } from '../plugins/plugin-runtime.service';
@@ -41,6 +42,7 @@ import {
Req,
UseGuards,
} from '@nestjs/common';
import { adminFeatureToggleResponseSchema } from '@trek/shared';
import type { Request } from 'express';
@@ -283,6 +285,25 @@ export class AdminController {
return result;
}
@Get('tours-wanderer')
@ResponseContract(adminFeatureToggleResponseSchema)
getToursWanderer() {
return this.addons.getToursWanderer();
}
@Put('tours-wanderer')
@ResponseContract(adminFeatureToggleResponseSchema)
async updateToursWanderer(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
const result = await this.addons.updateToursWanderer(body.enabled);
await this.audit.writeAudit({
userId: user.id,
action: 'admin.tours_wanderer',
ip: getClientIp(req),
details: { enabled: result.enabled },
});
return result;
}
@Get('places-photos')
getPlacesPhotos() {
return this.addons.getPlacesPhotos();
+2
View File
@@ -92,6 +92,7 @@ import { TransitModule } from './transit/transit.module';
import { TripInviteModule } from './trip-invite/trip-invite.module';
import { TripsModule } from './trips/trips.module';
import { VacayModule } from './vacay/vacay.module';
import { WandererModule } from './wanderer/wanderer.module';
import { WeatherModule } from './weather/weather.module';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Module } from '@nestjs/common';
@@ -152,6 +153,7 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
PlacesModule,
RoadtripModule,
ToursModule,
WandererModule,
FerratasModule,
TripsModule,
CollabModule,
@@ -106,6 +106,8 @@ export const APP_SETTINGS = {
places_enrich_enabled: { schema: flag, default: 'true', publicConfig: true },
place_shadow_enabled: { schema: flag, default: 'false', publicConfig: true },
bag_tracking_enabled: { schema: flag, default: 'false' },
// Off until an admin allows it: every user's wanderer calls go out to an address of their choosing.
tours_wanderer_enabled: { schema: flag, default: 'false' },
route_usage_enabled: { schema: flag, default: 'false' },
airtrail_sync_enabled: { schema: flag, default: 'true' },
airtrail_poll_interval_minutes: { schema: intText, default: null },
@@ -0,0 +1,7 @@
import { wandererImportRequestSchema, wandererSearchRequestSchema } from '@trek/shared';
import { createZodDto } from 'nestjs-zod';
/** The trip-side wanderer routes' bodies, validated by the global Zod pipe against the shared contracts. */
export class WandererSearchDto extends createZodDto(wandererSearchRequestSchema) {}
export class WandererImportDto extends createZodDto(wandererImportRequestSchema) {}

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