diff --git a/client/src/components/Planner/stayFormModel.ts b/client/src/components/Planner/stayFormModel.ts
index 3e7399b3f..1b0057750 100644
--- a/client/src/components/Planner/stayFormModel.ts
+++ b/client/src/components/Planner/stayFormModel.ts
@@ -83,14 +83,12 @@ export function stayFormFrom
(
}
/**
- * The places a stay can be at (no GPX tracks, but always the one already picked),
+ * The places a stay can be at (no GPX tracks or tours, but always the one already picked),
* narrowed to a category when one is chosen.
*/
-export function stayPlaceChoices
(
- places: P[],
- placeId: number | string | null | undefined,
- categoryId: number | null
-): P[] {
+export function stayPlaceChoices<
+ P extends { id: number; route_geometry?: string | null; tour_place_id?: number | null; category_id?: number | null },
+>(places: P[], placeId: number | string | null | undefined, categoryId: number | null): P[] {
const offered = stayPlaces
(places, placeId);
return categoryId != null ? offered.filter((p) => p.category_id === categoryId) : offered;
}
diff --git a/client/src/components/Tours/TourDetailDialog.test.tsx b/client/src/components/Tours/TourDetailDialog.test.tsx
index c737c97d5..ab8b82876 100644
--- a/client/src/components/Tours/TourDetailDialog.test.tsx
+++ b/client/src/components/Tours/TourDetailDialog.test.tsx
@@ -209,6 +209,24 @@ describe('TourDetailDialog', () => {
expect(screen.getByText(/Open 10:00 to 18:00/)).toBeInTheDocument()
})
+ it('CW-11: a walk saved with its stops only says once that its way is planned in the planner, and shows no measures yet', async () => {
+ const pending = {
+ ...tour, tour_type: 'walk' as const, max_hiking_difficulty: null, distance: null, elevation_gain: null, elevation_loss: null,
+ station_count: 2, route_pending: true,
+ }
+ vi.spyOn(tourRepo, 'detail').mockResolvedValue({ tour: pending, waypoints: [] })
+ const view = render()
+
+ expect(screen.getByText(
+ 'Saved with its stops only. The way between them is planned when the walk is opened in the planner and saved.'
+ )).toBeInTheDocument()
+ expect(screen.queryByText('Distance')).not.toBeInTheDocument()
+ expect(await screen.findByTestId('walk-stations')).toBeInTheDocument()
+
+ view.rerender()
+ expect(screen.queryByText(/Saved with its stops only/)).not.toBeInTheDocument()
+ })
+
it('PAD-01: a paddle tour without heights shows distance, paddling and total side by side in one row', () => {
render()
diff --git a/client/src/components/Tours/TourDetailDialog.tsx b/client/src/components/Tours/TourDetailDialog.tsx
index 85480eb13..4bf7d6529 100644
--- a/client/src/components/Tours/TourDetailDialog.tsx
+++ b/client/src/components/Tours/TourDetailDialog.tsx
@@ -66,6 +66,7 @@ import {
} from './tourPresentation';
import { useTourDayAssignment } from './useTourDayAssignment';
import { useTourPermissions, type TourPermissionProps } from './useTourPermissions';
+import { WalkRoutePendingHint } from './walk/WalkRoutePending';
import { WalkStationsCard } from './walk/WalkStations';
import { walkTimeFields } from './walk/walkTimeFields';
@@ -675,6 +676,7 @@ export default function TourDetailDialog({
)}
+ {tour.route_pending && }
{analysis && (
+
diff --git a/client/src/components/Tours/planner/tourRouteOutcome.ts b/client/src/components/Tours/planner/tourRouteOutcome.ts
index 290aabe20..ddb17390a 100644
--- a/client/src/components/Tours/planner/tourRouteOutcome.ts
+++ b/client/src/components/Tours/planner/tourRouteOutcome.ts
@@ -1,3 +1,4 @@
+import { TOUR_MAX_SNAP_M } from '@trek/shared';
import type { TourRouteResult } from './tourRouting';
/**
@@ -17,4 +18,4 @@ export function isTourRoute(value: TourRouteResult | TourRouteUnreachable | null
}
/** How far the router may move a point onto its way before the point counts as unreachable for the tour's type. */
-export const MAX_TOUR_SNAP_M = 300;
+export const MAX_TOUR_SNAP_M = TOUR_MAX_SNAP_M;
diff --git a/client/src/components/Tours/planner/useTourPlanner.ts b/client/src/components/Tours/planner/useTourPlanner.ts
index 6192e7489..ad4e618f8 100644
--- a/client/src/components/Tours/planner/useTourPlanner.ts
+++ b/client/src/components/Tours/planner/useTourPlanner.ts
@@ -137,6 +137,8 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
const [editingPlaceId, setEditingPlaceId] = useState(restored?.editingPlaceId ?? null)
const [derivedFromPlaceId, setDerivedFromPlaceId] = useState(restored?.derivedFromPlaceId ?? null)
const [draftSource, setDraftSource] = useState(restored?.draftSource ?? null)
+ // CW-11: the open walk was saved with its stations only; its way is planned here and kept by the next save.
+ const [routePending, setRoutePending] = useState(false)
const [openingTourId, setOpeningTourId] = useState(null)
const [saveOutcome, setSaveOutcome] = useState(null)
const [retryToken, setRetryToken] = useState(0)
@@ -545,6 +547,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
editingPlaceIdRef.current = null
setDerivedFromPlaceId(null)
setDraftSource(null)
+ setRoutePending(false)
adoptRevision(null)
setOpeningTourId(null)
setSaveOutcome(null)
@@ -707,7 +710,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
clearHistory()
setStatus('dirty')
clearRoute()
- setHasUnsavedChanges(false)
+ // CW-11: a walk without its way yet is planned on opening and can be saved with it at once.
+ const pending = result.tour.route_pending === true
+ setRoutePending(pending)
+ setHasUnsavedChanges(pending && editPermissionRef.current)
setDraftRestored(false)
setEditingPlaceId(result.tour.place_id)
editingPlaceIdRef.current = result.tour.place_id
@@ -833,6 +839,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
adoptRevision(result.tour.revision)
if (options.name) setNameState(options.name.trim())
setHasUnsavedChanges(false)
+ setRoutePending(false)
setStatus('saved')
clearHistory()
setEditingPlaceId(result.tour.place_id)
@@ -886,7 +893,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
draftSource,
newTourConfirmationOpen, cancelNewTour, forgetDeletedTour,
elevationProfileExpanded, toggleElevationProfile, newDraftGeneration, draftRestored, mapFocusKey,
- editingPlaceId, openingTourId, openTour,
+ editingPlaceId, openingTourId, openTour, routePending,
saveOutcome, hasUnsavedChanges, canSave, save, discard, returnToNeutral, revision,
}
}
diff --git a/client/src/components/Tours/planner/useTourPlanner.walk.test.tsx b/client/src/components/Tours/planner/useTourPlanner.walk.test.tsx
index 6cf828686..6e91a2fbc 100644
--- a/client/src/components/Tours/planner/useTourPlanner.walk.test.tsx
+++ b/client/src/components/Tours/planner/useTourPlanner.walk.test.tsx
@@ -20,8 +20,9 @@ vi.mock('../useTourPermissions', () => ({ useTourPermissions: () => ({ canEdit:
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
import { useTourPlanner } from './useTourPlanner';
-// FE-TOUR-WALK-PLANNER-001 to FE-TOUR-WALK-PLANNER-007: a city walk's stays in the planner, edited without
-// planning its route again, and saved without breaks (CW-02).
+// FE-TOUR-WALK-PLANNER-001 to FE-TOUR-WALK-PLANNER-008: a city walk's stays in the planner, edited without
+// planning its route again, and saved without breaks (CW-02). FE-TOUR-WALK-PLANNER-009 and 010: a walk an
+// assistant saved with its stops only, planned when it is opened (CW-11).
/** 45 minutes on foot between the stops. */
const routed = {
@@ -277,4 +278,69 @@ describe('useTourPlanner, city walks (CW-02)', () => {
expect(result.current.waypoints[3]?.stayMinutes).toBeUndefined();
expect(result.current.stationStays).toMatchObject({ stayMinutes: 20, stations: 3, unknownStays: 2 });
});
+
+ /** A saved walk of two stops as the detail answers it, with or without its way. */
+ const savedWalk = (routePending: boolean) => ({
+ tour: {
+ place_id: 42,
+ name: 'Briefed walk',
+ tour_type: 'walk' as const,
+ distance: routePending ? null : 3.2,
+ elevation_gain: null,
+ elevation_loss: null,
+ duration: routePending ? null : 45,
+ difficulty: null,
+ wanderer_ref: null,
+ match_confidence: 1,
+ max_hiking_difficulty: null,
+ planned: false,
+ caution: false,
+ has_waypoints: true,
+ revision: 1,
+ route_pending: routePending,
+ },
+ waypoints: [
+ { lat: 48, lng: 11, role: 'start' as const, sequence: 0, key: 'stop-cathedral', name: 'Cathedral' },
+ { lat: 48.01, lng: 11.02, role: 'end' as const, sequence: 1, key: 'stop-park', name: 'Park' },
+ ],
+ });
+
+ it('FE-TOUR-WALK-PLANNER-009: a walk saved with its stops only is planned on opening and can be saved with its way at once', async () => {
+ detailTour.mockResolvedValue(savedWalk(true));
+ updateTour.mockImplementation(async (_trip: number, _place: number, body: TourCreateRequest) => answered(body));
+ const { result } = renderHook(() => useTourPlanner({ tripId: 31 }));
+ await act(async () => {
+ await result.current.openTour({ place_id: 42 });
+ });
+ expect(result.current.routePending).toBe(true);
+ expect(result.current.hasUnsavedChanges).toBe(true);
+
+ await settle();
+ expect(routeCityWalk).toHaveBeenCalledTimes(1);
+ expect(result.current.status).toBe('ready');
+ expect(result.current.canSave).toBe(true);
+ await act(async () => {
+ await result.current.save();
+ });
+ expect(updateTour).toHaveBeenCalledWith(
+ 31,
+ 42,
+ expect.objectContaining({ route_geometry: enriched, tour_type: 'walk', expected_revision: 1 })
+ );
+ expect(result.current.routePending).toBe(false);
+ expect(result.current.hasUnsavedChanges).toBe(false);
+ expect(result.current.status).toBe('saved');
+ });
+
+ it('FE-TOUR-WALK-PLANNER-010: a walk with its way opens with nothing to save', async () => {
+ detailTour.mockResolvedValue(savedWalk(false));
+ const { result } = renderHook(() => useTourPlanner({ tripId: 31 }));
+ await act(async () => {
+ await result.current.openTour({ place_id: 42 });
+ });
+ await settle();
+ expect(result.current.routePending).toBe(false);
+ expect(result.current.hasUnsavedChanges).toBe(false);
+ expect(result.current.canSave).toBe(false);
+ });
});
diff --git a/client/src/components/Tours/walk/WalkRoutePending.test.tsx b/client/src/components/Tours/walk/WalkRoutePending.test.tsx
new file mode 100644
index 000000000..55101da07
--- /dev/null
+++ b/client/src/components/Tours/walk/WalkRoutePending.test.tsx
@@ -0,0 +1,37 @@
+import { describe, expect, it } from 'vitest';
+import { render, screen } from '../../../../tests/helpers/render';
+import { WalkRoutePendingHint, WalkRoutePendingNotice } from './WalkRoutePending';
+
+// FE-WALK-ROUTE-PENDING-001 to FE-WALK-ROUTE-PENDING-002: a walk an assistant saved with its stops only (CW-11).
+
+describe('WalkRoutePending (CW-11)', () => {
+ it('FE-WALK-ROUTE-PENDING-001: the planner says the way was just planned until the walk is saved with it', () => {
+ const { rerender } = render(
+
+ );
+ expect(screen.getByRole('status')).toHaveTextContent(
+ 'Saved with its stops only. The way between them was planned just now; save the walk to keep it.'
+ );
+ // Saved, or open for someone who may not save it: nothing to say.
+ rerender();
+ expect(screen.queryByRole('status')).toBeNull();
+ rerender();
+ expect(screen.queryByRole('status')).toBeNull();
+ // Still being planned, or planning it failed: nothing was planned yet.
+ rerender();
+ expect(screen.queryByRole('status')).toBeNull();
+ rerender(
+
+ );
+ expect(screen.queryByRole('status')).toBeNull();
+ });
+
+ it('FE-WALK-ROUTE-PENDING-002: the detail says where the way gets planned', () => {
+ render();
+ expect(
+ screen.getByText(
+ 'Saved with its stops only. The way between them is planned when the walk is opened in the planner and saved.'
+ )
+ ).toBeInTheDocument();
+ });
+});
diff --git a/client/src/components/Tours/walk/WalkRoutePending.tsx b/client/src/components/Tours/walk/WalkRoutePending.tsx
new file mode 100644
index 000000000..5d8f32dac
--- /dev/null
+++ b/client/src/components/Tours/walk/WalkRoutePending.tsx
@@ -0,0 +1,29 @@
+import { Route } from 'lucide-react';
+import { useTranslation } from '../../../i18n';
+import type { TourPlannerController } from '../planner/useTourPlanner';
+import { TourNotice } from '../tourParts';
+
+/**
+ * CW-11: said in the planner once a walk an assistant saved with its stops
+ * only has its way planned here and not saved yet; not while the way is still
+ * being planned, nor when planning it failed.
+ */
+export function WalkRoutePendingNotice({
+ planner,
+}: {
+ planner: Pick;
+}) {
+ const { t } = useTranslation();
+ if (!planner.routePending || !planner.hasUnsavedChanges || planner.status !== 'ready') return null;
+ return (
+ }>
+ {t('tours.walk.routePendingPlanner')}
+
+ );
+}
+
+/** CW-11: the detail of a walk saved with its stops only, whose way is still to be planned. */
+export function WalkRoutePendingHint() {
+ const { t } = useTranslation();
+ return }>{t('tours.walk.routePending')};
+}
diff --git a/client/src/utils/stayPlaces.test.ts b/client/src/utils/stayPlaces.test.ts
index 22206e3cc..08b2aed3a 100644
--- a/client/src/utils/stayPlaces.test.ts
+++ b/client/src/utils/stayPlaces.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { stayPlaces } from './stayPlaces'
-// FE-UTIL-STAYPLACES-001 to FE-UTIL-STAYPLACES-003
+// FE-UTIL-STAYPLACES-001 to FE-UTIL-STAYPLACES-004
describe('stayPlaces', () => {
const hotel = { id: 1, route_geometry: null }
@@ -22,4 +22,11 @@ describe('stayPlaces', () => {
it('FE-UTIL-STAYPLACES-003: a stay at an ordinary place lets no track in', () => {
expect(stayPlaces([hotel, track], 1).map(p => p.id)).toEqual([1])
})
+
+ it('FE-UTIL-STAYPLACES-004: a city walk still waiting for its line is a tour, not somewhere to stay', () => {
+ const walk = { id: 4, route_geometry: null, tour_place_id: 4 }
+ const inn = { id: 5, route_geometry: null, tour_place_id: null }
+ expect(stayPlaces([hotel, walk, inn]).map(p => p.id)).toEqual([1, 5])
+ expect(stayPlaces([hotel, walk], 4).map(p => p.id)).toEqual([1, 4])
+ })
})
diff --git a/client/src/utils/stayPlaces.ts b/client/src/utils/stayPlaces.ts
index 8364d831a..c4e5cc28c 100644
--- a/client/src/utils/stayPlaces.ts
+++ b/client/src/utils/stayPlaces.ts
@@ -1,14 +1,15 @@
/**
- * The places a stay can be booked at: every place but an imported GPX or KML track,
- * which is a route to follow, not somewhere to sleep.
+ * The places a stay can be booked at: every place but an imported GPX or KML track
+ * or a tour, which is a route to follow, not somewhere to sleep. A city walk saved
+ * with its stops only has no line yet and is still a tour.
*
* The place a stay already names stays in the list, track or not, so a booking made
* before tracks were left out still shows what it points at and saves unchanged.
*/
-export function stayPlaces(
+export function stayPlaces
(
places: P[],
currentPlaceId?: number | string | null,
): P[] {
- return places.filter(place => !place.route_geometry
+ return places.filter(place => (!place.route_geometry && place.tour_place_id !== place.id)
|| (currentPlaceId != null && currentPlaceId !== '' && String(place.id) === String(currentPlaceId)))
}
diff --git a/server/src/db/repositories/Tours.repository.ts b/server/src/db/repositories/Tours.repository.ts
index b3b2408f3..4962b2ad6 100644
--- a/server/src/db/repositories/Tours.repository.ts
+++ b/server/src/db/repositories/Tours.repository.ts
@@ -40,6 +40,8 @@ export interface TourListRow {
has_recorded_track: number;
planned: number;
has_waypoints: number;
+ /** CW-11: 1 while the owning place has no line, a city walk saved with its stations only. */
+ route_missing: number;
/** P2C-03: the tour's technical revision. */
revision: number;
/** MF-08: a hike's routing preferences, still JSON text. */
@@ -249,6 +251,7 @@ export class ToursRepository extends TrekRepository {
't.revision as revision',
't.hiking_preferences as hiking_preferences',
eb('t.recorded_route_geometry', 'is not', null).as('has_recorded_track'),
+ eb('p.route_geometry', 'is', null).as('route_missing'),
eb
.exists(eb.selectFrom('day_assignments as da').select('da.id').whereRef('da.place_id', '=', 'p.id'))
.as('planned'),
@@ -510,6 +513,16 @@ export class ToursRepository extends TrekRepository {
.execute();
}
+ /** CW-11: a day of this trip with its date, for a city walk proposed on it; `undefined` for a day of another trip. */
+ async dayOfTrip(trip_id: number, day_id: number): Promise<{ id: number; date: string | null } | undefined> {
+ return await this.db()
+ .selectFrom('days')
+ .select(['id', 'date'])
+ .where('id', '=', day_id)
+ .where('trip_id', '=', trip_id)
+ .executeTakeFirst();
+ }
+
/** TO3 — `SELECT 1 FROM tours WHERE place_id = ?`: whether the place is a tour. */
async existsForPlace(place_id: number): Promise {
const row = await this.db()
@@ -780,10 +793,11 @@ function toLibraryRow & { has_wa
}
function toListRow<
- R extends Omit & {
+ R extends Omit & {
planned: unknown;
has_waypoints: unknown;
has_recorded_track: unknown;
+ route_missing: unknown;
},
>(r: R): TourListRow {
return {
@@ -791,5 +805,6 @@ function toListRow<
planned: Number(r.planned),
has_waypoints: Number(r.has_waypoints),
has_recorded_track: Number(r.has_recorded_track),
+ route_missing: Number(r.route_missing),
};
}
diff --git a/server/src/db/repositories/tour-place-write.ts b/server/src/db/repositories/tour-place-write.ts
index 21bba3a7f..ae65ad77e 100644
--- a/server/src/db/repositories/tour-place-write.ts
+++ b/server/src/db/repositories/tour-place-write.ts
@@ -1,9 +1,9 @@
-/** What a drawn tour writes onto its owning place. */
+/** What a drawn tour writes onto its owning place; a city walk saved with its stations only has no line yet (CW-11). */
export interface TourPlaceWrite {
name: string;
lat: number;
lng: number;
- route_geometry: string;
+ route_geometry: string | null;
description?: string | null;
website?: string | null;
}
diff --git a/server/src/nest/places/places.mcp.ts b/server/src/nest/places/places.mcp.ts
index 22700836c..c67c35124 100644
--- a/server/src/nest/places/places.mcp.ts
+++ b/server/src/nest/places/places.mcp.ts
@@ -276,7 +276,7 @@ export class PlacesMcp {
@Tool({
name: 'delete_place',
- description: 'Permanently delete a place from a trip. Removes its day assignments, its linked expenses and any nights booked at it, including each night\'s reservation and that reservation\'s expense. If the place is a Tour, its route and editable waypoints are also permanently deleted. Warn the user before calling: this cannot be undone.',
+ description: 'Permanently delete a place from a trip. Removes its day assignments, its linked expenses and any nights booked at it, including each night\'s reservation and that reservation\'s expense. If the place is a Tour, its route and editable waypoints are also permanently deleted. A city walk takes all its stations with it, protected ones included: name those to the traveller and ask first. Warn the user before calling: this cannot be undone.',
inputSchema: {
tripId: idSchema,
placeId: idSchema,
@@ -447,7 +447,7 @@ export class PlacesMcp {
@Tool({
name: 'bulk_delete_places',
- description: 'Permanently delete multiple places from a trip at once. Removes all day assignments for each place as well, plus each place\'s linked expenses and any nights booked at it, with their reservations and expenses. Any Tours in the selection also lose their routes and editable waypoints. Warn the user before calling: this cannot be undone.',
+ description: 'Permanently delete multiple places from a trip at once. Removes all day assignments for each place as well, plus each place\'s linked expenses and any nights booked at it, with their reservations and expenses. Any Tours in the selection also lose their routes and editable waypoints. A city walk in the selection takes all its stations with it, protected ones included: name those to the traveller and ask first. Warn the user before calling: this cannot be undone.',
inputSchema: {
tripId: idSchema,
placeIds: z.array(idSchema).min(1).max(200),
diff --git a/server/src/nest/roadtrip/roadtrip.module.ts b/server/src/nest/roadtrip/roadtrip.module.ts
index 03a58dc10..1cbbbff52 100644
--- a/server/src/nest/roadtrip/roadtrip.module.ts
+++ b/server/src/nest/roadtrip/roadtrip.module.ts
@@ -129,6 +129,7 @@ import { Module } from '@nestjs/common';
RoadtripPlanService,
RoadtripPlanningMcp,
],
- exports: [RoadtripService],
+ // The router also plans a city walk on foot that an assistant prepares (CW-11).
+ exports: [RoadtripService, RoadtripRouterService],
})
export class RoadtripModule {}
diff --git a/server/src/nest/tours/city-walk-briefing.helpers.ts b/server/src/nest/tours/city-walk-briefing.helpers.ts
new file mode 100644
index 000000000..853b701cc
--- /dev/null
+++ b/server/src/nest/tours/city-walk-briefing.helpers.ts
@@ -0,0 +1,593 @@
+import { badRequest } from '../common/domain-error';
+import {
+ clockOf,
+ haversineMetres,
+ minutesOf,
+ stationFit,
+ stationHoursOn,
+ TOUR_MAX_SNAP_M,
+ TOUR_POI_MAX_CATEGORIES,
+ TOUR_STATION_HOURS_MAX,
+ TOUR_STATION_NAME_MAX,
+ WALK_THEMES,
+ type CityWalkStation,
+ type HoursSource,
+ type HoursWindow,
+ type StationFit,
+ type TourPoi,
+ type TourPoiCategory,
+ type WalkTheme,
+} from '@trek/shared';
+
+/**
+ * CW-11: a city walk proposed from a briefing, worked out without guessing.
+ * The stations are ordered on estimates over the straight distance with an
+ * allowance for streets; the walk in that order is then planned on foot by the
+ * router, and its legs time the walk. Where the router gives no way, the
+ * estimates stand and say so. A stay is known only where the traveller gave
+ * it, and every other stay stays unknown, which makes the arrivals after it
+ * lower bounds. Opening hours are read with the helpers the planner and the
+ * tour detail use, and a lower bound is never called a conflict unless it
+ * already is one.
+ */
+
+/** The walking pace of the estimate, 4.5 km/h, in metres a minute. */
+const WALK_PACE_M_PER_MIN = 75;
+/** The allowance for streets over the straight distance. */
+const STREET_FACTOR = 1.3;
+/** Closer than this, a place found is a station the traveller already named. */
+const SAME_PLACE_M = 60;
+
+type BriefingSource = 'start' | 'end' | 'wished' | 'appointment' | 'trip_place' | 'theme';
+
+/** Why a place of a theme is in: its theme and category, the source that has it, how far from the line searched. */
+interface ThemeFind {
+ theme: WalkTheme;
+ category: TourPoiCategory;
+ kind: string;
+ found_in: string;
+ poi_id: string;
+ distance_to_search_m: number;
+}
+
+/** A station the walk may get, as the proposal weighs it. */
+export interface WalkCandidate {
+ source: BriefingSource;
+ name: string | null;
+ lat: number;
+ lng: number;
+ /** Minutes; null when nobody said. */
+ stay: number | null;
+ /** A trip place's own hours, seven days as JSON. */
+ placeHours: string | null;
+ /** Hours as OpenStreetMap writes them. */
+ osmHours: string | null;
+ /** The trip place it stands for. */
+ placeId: number | null;
+ /** An appointment's time, minutes after midnight. */
+ time: number | null;
+ find: ThemeFind | null;
+}
+
+/** The frame of a walk in minutes after midnight, and the date its hours are read for. */
+export interface WalkFrame {
+ start: number;
+ end: number;
+ date: string | null;
+}
+
+export type LeftOutReason =
+ 'closed_all_day' | 'closed_at_arrival' | 'closes_during_stay' | 'no_time' | 'no_position' | 'not_in_trip' | 'is_tour';
+
+export interface LeftOut {
+ name: string | null;
+ source: BriefingSource;
+ place_id: number | null;
+ poi_id: string | null;
+ reason: LeftOutReason;
+}
+
+type StationConflict = 'closed_all_day' | 'closed_at_arrival' | 'closes_during_stay' | 'late' | 'after_frame';
+type HoursConflict = Extract;
+
+type Clock = { from: string; to: string };
+
+/** What a station's hours say about the visit, as an assistant reads it. */
+export type HoursCheck =
+ | { kind: 'no_date' }
+ | { kind: 'unknown' }
+ | { kind: 'unread'; text: string }
+ | { kind: 'next_day' }
+ | { kind: 'uncertain'; source: HoursSource }
+ | { kind: 'open'; source: HoursSource; window: Clock }
+ | { kind: 'closed_all_day'; source: HoursSource }
+ | { kind: 'closed'; source: HoursSource; windows: Clock[] }
+ | { kind: 'closes'; source: HoursSource; window: Clock };
+
+interface Leg {
+ metres: number;
+ minutes: number;
+}
+
+/** The way on foot between the stations as the router planned it, or why the estimates stand in for it. */
+export type FootWay =
+ | { kind: 'routed'; line: Array<[number, number]>; legs: Leg[]; seconds: number }
+ | {
+ kind: 'unrouted';
+ why: 'nothing_to_route' | 'router_unavailable' | 'station_off_the_way';
+ /** The station no way reaches, counted from 1. */
+ position: number | null;
+ };
+
+/** What the router answers for the stations: its line, a part for each leg, and how far it moved each station onto a way. */
+export interface FootRouteAnswer {
+ leg: { line: Array<[number, number]> };
+ parts: ReadonlyArray<{ distance: number; duration: number }>;
+ snapped?: ReadonlyArray<{ meters: number }>;
+}
+
+interface Visit {
+ node: WalkCandidate;
+ leg: Leg | null;
+ arrival: number;
+ /** The arrival is only a lower bound: a stay before it is not known. */
+ earliest: boolean;
+ leave: number | null;
+ lateBy: number;
+ roomBefore: number;
+ fit: StationFit | 'no_date' | null;
+}
+
+interface Timeline {
+ visits: Visit[];
+ endsAt: number;
+ endsEarliest: boolean;
+}
+
+/** The frame a briefing gives; refused when it ends before it starts or an appointment lies outside it. */
+export function frameOf(
+ briefing: { start_time: string; end_time: string; appointments?: ReadonlyArray<{ time: string }> },
+ date: string | null,
+): WalkFrame {
+ const start = minutesOf(briefing.start_time) ?? Number.NaN;
+ const end = minutesOf(briefing.end_time) ?? Number.NaN;
+ if (!(end > start)) throw badRequest('end_time must be after start_time, both HH:MM on one day');
+ const outside = (briefing.appointments ?? []).some((appointment) => {
+ const time = minutesOf(appointment.time) ?? Number.NaN;
+ return !(time >= start && time <= end);
+ });
+ if (outside) throw badRequest('An appointment has to lie between start_time and end_time');
+ return { start, end, date };
+}
+
+/** The categories the themes ask for, each with the first theme that brought it, as many as one search takes. */
+export function briefingCategories(themes: readonly WalkTheme[]): {
+ searched: Array<{ category: TourPoiCategory; theme: WalkTheme }>;
+ notSearched: TourPoiCategory[];
+} {
+ const all = themes.flatMap((theme) => WALK_THEMES[theme].map((category) => ({ category, theme })));
+ const unique = all.filter((entry, index) => all.findIndex((other) => other.category === entry.category) === index);
+ return {
+ searched: unique.slice(0, TOUR_POI_MAX_CATEGORIES),
+ notSearched: unique.slice(TOUR_POI_MAX_CATEGORIES).map((entry) => entry.category),
+ };
+}
+
+function metresBetween(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
+ return haversineMetres(a.lat, a.lng, b.lat, b.lng);
+}
+
+/** The straight distance and the walking time it estimates. */
+export function legOf(from: { lat: number; lng: number }, to: { lat: number; lng: number }): Leg {
+ const metres = metresBetween(from, to);
+ return { metres: Math.round(metres), minutes: Math.ceil((metres * STREET_FACTOR) / WALK_PACE_M_PER_MIN) };
+}
+
+/**
+ * The router's way as the walk takes it: each leg's distance and walking time,
+ * and the line. A station the router had to move further onto a way than the
+ * planner allows is one no way on foot reaches, and the way is not taken.
+ */
+export function footWayOf(answer: FootRouteAnswer): FootWay {
+ const off = (answer.snapped ?? []).findIndex((point) => point.meters > TOUR_MAX_SNAP_M);
+ if (off >= 0) return { kind: 'unrouted', why: 'station_off_the_way', position: off + 1 };
+ return {
+ kind: 'routed',
+ line: answer.leg.line,
+ legs: answer.parts.map((part) => ({ metres: Math.round(part.distance), minutes: Math.ceil(part.duration / 60) })),
+ seconds: answer.parts.reduce((sum, part) => sum + part.duration, 0),
+ };
+}
+
+/**
+ * The places of the themes as candidates, the nearest to the line searched
+ * first, taking turns between the categories so each theme is heard. A place
+ * without a name, or one where the traveller already named a station, is not
+ * offered.
+ */
+export function themeCandidates(
+ pois: readonly TourPoi[],
+ searched: ReadonlyArray<{ category: TourPoiCategory; theme: WalkTheme }>,
+ taken: ReadonlyArray<{ lat: number; lng: number }>,
+): WalkCandidate[] {
+ const offered = pois.filter(
+ (poi) => poi.name.trim() !== '' && !taken.some((spot) => metresBetween(spot, poi) <= SAME_PLACE_M),
+ );
+ const queues = searched.map(({ category, theme }) =>
+ offered
+ .filter((poi) => poi.category === category)
+ .sort((a, b) => a.distance_m - b.distance_m)
+ .map((poi) => themeCandidate(poi, theme)),
+ );
+ const rounds = Math.max(0, ...queues.map((queue) => queue.length));
+ return Array.from({ length: rounds }, (_, round) => queues.flatMap((queue) => queue.slice(round, round + 1))).flat();
+}
+
+function themeCandidate(poi: TourPoi, theme: WalkTheme): WalkCandidate {
+ return {
+ source: 'theme',
+ name: poi.name,
+ lat: poi.lat,
+ lng: poi.lng,
+ stay: null,
+ placeHours: null,
+ osmHours: poi.opening_hours,
+ placeId: null,
+ time: null,
+ find: {
+ theme,
+ category: poi.category,
+ kind: poi.kind,
+ found_in: poi.source,
+ poi_id: poi.id,
+ distance_to_search_m: poi.distance_m,
+ },
+ };
+}
+
+function fitOf(node: WalkCandidate, at: { arrival: number; atLeast: boolean }, date: string | null): Visit['fit'] {
+ if (node.source === 'start' || node.source === 'end') return null;
+ if (date === null) return 'no_date';
+ return stationFit(stationHoursOn({ place: node.placeHours, osm: node.osmHours }, date), at, node.stay);
+}
+
+/**
+ * The arrivals along the walk: the start at its time, the walking time of each
+ * leg (the router's where it planned the way, else the estimate), the stays as
+ * far as known. An appointment's own time sets the clock again once the walk
+ * is there in time; a stay not known makes every later arrival a lower bound
+ * until then.
+ */
+function timelineOf(route: readonly WalkCandidate[], frame: WalkFrame, legs?: readonly Leg[]): Timeline {
+ let clock = frame.start;
+ let unknownBefore = false;
+ const visits = route.map((node, index): Visit => {
+ const leg = index > 0 ? (legs?.[index - 1] ?? legOf(route[index - 1], node)) : null;
+ clock += leg?.minutes ?? 0;
+ const lateBy = node.time === null ? 0 : Math.max(0, clock - node.time);
+ const roomBefore = node.time === null ? 0 : Math.max(0, node.time - clock);
+ if (node.time !== null && lateBy === 0) {
+ clock = node.time;
+ unknownBefore = false;
+ }
+ const at = { arrival: clock, atLeast: unknownBefore };
+ const fit = fitOf(node, at, frame.date);
+ if (node.stay === null) unknownBefore = true;
+ else clock += node.stay;
+ return {
+ node,
+ leg,
+ arrival: at.arrival,
+ earliest: at.atLeast,
+ leave: node.stay === null ? null : clock,
+ lateBy,
+ roomBefore,
+ fit,
+ };
+ });
+ return { visits, endsAt: clock, endsEarliest: unknownBefore };
+}
+
+const HOURS_CONFLICTS: Partial> = {
+ closedAllDay: 'closed_all_day',
+ closed: 'closed_at_arrival',
+ closes: 'closes_during_stay',
+};
+
+function hoursConflictOf(visit: Visit): HoursConflict | undefined {
+ return visit.fit && visit.fit !== 'no_date' ? HOURS_CONFLICTS[visit.fit.kind] : undefined;
+}
+
+/** How far the walk misses its times: appointments reached late, and the end past the frame. */
+function overrunOf(timeline: Timeline, frame: WalkFrame): number {
+ return timeline.visits.reduce((sum, visit) => sum + visit.lateBy, Math.max(0, timeline.endsAt - frame.end));
+}
+
+/** The places a station may take: after the start, and before the end when the walk has one. */
+function slotsOf(route: readonly WalkCandidate[], hasEnd: boolean): number[] {
+ return Array.from({ length: route.length - (hasEnd ? 1 : 0) }, (_, index) => index + 1);
+}
+
+/** The straight metres a station adds where it goes in. */
+function detourOf(route: readonly WalkCandidate[], at: number, station: WalkCandidate): number {
+ const before = route[at - 1];
+ const after = route[at];
+ const to = metresBetween(before, station);
+ return after ? to + metresBetween(station, after) - metresBetween(before, after) : to;
+}
+
+/**
+ * Where a station goes: the cheapest place it fits, open on arrival, without
+ * making an appointment late or the walk end later past its frame, and
+ * without bringing a station already on the walk there after it closed; the
+ * cheapest place and what stands in its way otherwise.
+ */
+function placed(
+ route: readonly WalkCandidate[],
+ station: WalkCandidate,
+ frame: WalkFrame,
+ hasEnd: boolean,
+): { route: WalkCandidate[]; trouble: Exclude | null } {
+ const before = timelineOf(route, frame);
+ const base = overrunOf(before, frame);
+ const closedBefore = new Set(
+ before.visits.filter((visit) => hoursConflictOf(visit) !== undefined).map((visit) => visit.node),
+ );
+ const options = slotsOf(route, hasEnd)
+ .map((at) => {
+ const next = [...route.slice(0, at), station, ...route.slice(at)];
+ const timeline = timelineOf(next, frame);
+ const visit = timeline.visits[at];
+ const late = visit.arrival > frame.end || overrunOf(timeline, frame) > base;
+ const closesOther = timeline.visits.some(
+ (other, index) => index !== at && hoursConflictOf(other) !== undefined && !closedBefore.has(other.node),
+ );
+ return {
+ route: next,
+ trouble: hoursConflictOf(visit) ?? (late || closesOther ? ('no_time' as const) : null),
+ detour: detourOf(route, at, station),
+ };
+ })
+ .sort((a, b) => a.detour - b.detour);
+ return options.find((option) => option.trouble === null) ?? options[0];
+}
+
+function leftOutOf(station: WalkCandidate, reason: LeftOutReason): LeftOut {
+ return {
+ name: station.name,
+ source: station.source,
+ place_id: station.placeId,
+ poi_id: station.find?.poi_id ?? null,
+ reason,
+ };
+}
+
+export interface WalkPlanInput {
+ frame: WalkFrame;
+ start: WalkCandidate;
+ end: WalkCandidate | null;
+ appointments: WalkCandidate[];
+ wished: WalkCandidate[];
+ tripPlaces: WalkCandidate[];
+ /** In the order they are offered. */
+ themes: WalkCandidate[];
+ themeLimit: number;
+}
+
+/**
+ * The stations in their order: the start, the appointments in time order and
+ * the end stay where they are; every wished station goes in, where it fits
+ * best, whatever stands in its way; a trip place and a place of a theme go in
+ * only where they fit, and a theme takes at most `themeLimit` places, looking
+ * at twice as many. What did not fit is left out with the reason.
+ */
+export function planWalk(input: WalkPlanInput): { route: WalkCandidate[]; leftOut: LeftOut[] } {
+ const hasEnd = input.end !== null;
+ const appointments = [...input.appointments].sort((a, b) => Number(a.time) - Number(b.time));
+ let route = [input.start, ...appointments, ...(input.end ? [input.end] : [])];
+ const leftOut: LeftOut[] = [];
+ for (const station of input.wished) route = placed(route, station, input.frame, hasEnd).route;
+ const offer = (station: WalkCandidate) => {
+ const option = placed(route, station, input.frame, hasEnd);
+ if (option.trouble === null) route = option.route;
+ else leftOut.push(leftOutOf(station, option.trouble));
+ return option.trouble === null;
+ };
+ input.tripPlaces.forEach(offer);
+ let taken = 0;
+ for (const station of input.themes.slice(0, input.themeLimit * 2)) {
+ if (taken < input.themeLimit && offer(station)) taken += 1;
+ }
+ return { route, leftOut };
+}
+
+function clockSpan([from, to]: HoursWindow): Clock {
+ return { from: clockOf(from), to: clockOf(to) };
+}
+
+/** A station's hours check as an assistant reads it. */
+export function hoursCheckOf(fit: StationFit | 'no_date'): HoursCheck {
+ if (fit === 'no_date') return { kind: 'no_date' };
+ switch (fit.kind) {
+ case 'unknown':
+ return { kind: 'unknown' };
+ case 'unread':
+ return { kind: 'unread', text: fit.text };
+ case 'nextDay':
+ return { kind: 'next_day' };
+ case 'uncertain':
+ return { kind: 'uncertain', source: fit.source };
+ case 'open':
+ return { kind: 'open', source: fit.source, window: clockSpan(fit.window) };
+ case 'closedAllDay':
+ return { kind: 'closed_all_day', source: fit.source };
+ case 'closed':
+ return { kind: 'closed', source: fit.source, windows: fit.windows.map(clockSpan) };
+ case 'closes':
+ return { kind: 'closes', source: fit.source, window: clockSpan(fit.window) };
+ }
+}
+
+function conflictsOf(visit: Visit, frame: WalkFrame): StationConflict[] {
+ const hours = hoursConflictOf(visit);
+ return [
+ ...(hours ? [hours] : []),
+ ...(visit.lateBy > 0 ? (['late'] as const) : []),
+ ...(visit.arrival > frame.end ? (['after_frame'] as const) : []),
+ ];
+}
+
+const IMPORTANCE: Record = {
+ start: null,
+ end: null,
+ wished: 'must',
+ appointment: 'must',
+ trip_place: null,
+ theme: 'optional',
+};
+
+/** A station as create_city_walk takes it: what the proposal knows, nothing more. */
+function stationForCreate(node: WalkCandidate): CityWalkStation {
+ const hours = node.osmHours?.trim() ?? '';
+ return {
+ lat: node.lat,
+ lng: node.lng,
+ name: node.name?.slice(0, TOUR_STATION_NAME_MAX) ?? null,
+ stay_minutes: node.stay,
+ importance: IMPORTANCE[node.source],
+ protected: node.source === 'appointment',
+ // Hours too long to keep are left out rather than cut into something else.
+ opening_hours: hours && hours.length <= TOUR_STATION_HOURS_MAX ? hours : null,
+ linked_place_id: node.placeId,
+ };
+}
+
+/** Where a known stay comes from: the traveller, or the frame, which leaves the start and ends at the end; null when not known. */
+function staySourceOf(node: WalkCandidate): 'given' | 'frame' | null {
+ if (node.stay === null) return null;
+ return node.source === 'start' || node.source === 'end' ? 'frame' : 'given';
+}
+
+function stationOf(visit: Visit, index: number, frame: WalkFrame) {
+ const { node } = visit;
+ return {
+ position: index + 1,
+ name: node.name,
+ lat: node.lat,
+ lng: node.lng,
+ source: node.source,
+ found: node.find,
+ place_id: node.placeId,
+ walk_from_previous: visit.leg && { metres: visit.leg.metres, minutes: visit.leg.minutes },
+ arrival: clockOf(visit.arrival),
+ arrival_is_earliest: visit.earliest,
+ appointment_time: node.time === null ? null : clockOf(node.time),
+ stay_minutes: node.stay,
+ stay_source: staySourceOf(node),
+ leave: visit.leave === null ? null : clockOf(visit.leave),
+ importance: IMPORTANCE[node.source],
+ protected: node.source === 'appointment',
+ opening_hours: node.osmHours,
+ hours: visit.fit && hoursCheckOf(visit.fit),
+ conflicts: conflictsOf(visit, frame),
+ };
+}
+
+interface Gap {
+ before_position: number | null;
+ from: string;
+ to: string;
+ minutes: number;
+ unknown_stays: number;
+ kind: 'before_appointment' | 'at_end';
+}
+
+/** Free time before an appointment the walk reaches early, and at the end of the frame, with the unknown stays it may take. */
+function gapsOf(timeline: Timeline, frame: WalkFrame): Gap[] {
+ const gaps: Gap[] = [];
+ let unknown = 0;
+ timeline.visits.forEach((visit, index) => {
+ if (visit.node.time !== null) {
+ if (visit.roomBefore > 0) {
+ const from = clockOf(visit.arrival - visit.roomBefore);
+ gaps.push({
+ before_position: index + 1,
+ from,
+ to: clockOf(visit.arrival),
+ minutes: visit.roomBefore,
+ unknown_stays: unknown,
+ kind: 'before_appointment',
+ });
+ }
+ unknown = 0;
+ }
+ if (visit.node.stay === null) unknown += 1;
+ });
+ const room = frame.end - timeline.endsAt;
+ if (room > 0) {
+ gaps.push({
+ before_position: null,
+ from: clockOf(timeline.endsAt),
+ to: clockOf(frame.end),
+ minutes: room,
+ unknown_stays: unknown,
+ kind: 'at_end',
+ });
+ }
+ return gaps;
+}
+
+function totalsOf(timeline: Timeline, gaps: readonly Gap[], frame: WalkFrame) {
+ const { visits } = timeline;
+ const walk = visits.reduce((sum, visit) => sum + (visit.leg?.minutes ?? 0), 0);
+ const stays = visits.reduce((sum, visit) => sum + (visit.node.stay ?? 0), 0);
+ const unknown = visits.filter((visit) => visit.node.stay === null).length;
+ return {
+ walk_minutes: walk,
+ walk_m: visits.reduce((sum, visit) => sum + (visit.leg?.metres ?? 0), 0),
+ stay_minutes_known: stays,
+ stays_unknown: unknown,
+ total_minutes: walk + stays,
+ total_complete: unknown === 0,
+ ends_at: clockOf(timeline.endsAt),
+ ends_at_is_earliest: timeline.endsEarliest,
+ over_frame_by_minutes: Math.max(0, timeline.endsAt - frame.end),
+ minutes_left_for_unknown_stays:
+ unknown === 0 ? null : gaps.filter((gap) => gap.unknown_stays > 0).reduce((sum, gap) => sum + gap.minutes, 0),
+ };
+}
+
+/** How the walking times were found: the router's way on foot, or the estimate over the straight distance and why. */
+function estimateOf(way: FootWay) {
+ if (way.kind === 'routed') return { kind: 'routed' as const, profile: 'walking' as const };
+ return {
+ kind: 'straight_line' as const,
+ street_factor: STREET_FACTOR,
+ pace_kmh: (WALK_PACE_M_PER_MIN * 60) / 1000,
+ why: way.why,
+ position: way.position,
+ };
+}
+
+/** The walk as the proposal says it: its stations with their times, the gaps, the totals and what create_city_walk takes. */
+export function describeWalk(route: readonly WalkCandidate[], frame: WalkFrame, way: FootWay) {
+ const timeline = timelineOf(route, frame, way.kind === 'routed' ? way.legs : undefined);
+ const stations = timeline.visits.map((visit, index) => stationOf(visit, index, frame));
+ const gaps = gapsOf(timeline, frame);
+ const stationsForCreate = route.map(stationForCreate);
+ return {
+ date: frame.date,
+ frame: { start_time: clockOf(frame.start), end_time: clockOf(frame.end) },
+ stations,
+ wishes_not_met: stations
+ .filter(
+ (station) => (station.source === 'wished' || station.source === 'appointment') && station.conflicts.length > 0,
+ )
+ .map(({ position, name, source, conflicts }) => ({ position, name, source, conflicts })),
+ gaps,
+ totals: totalsOf(timeline, gaps, frame),
+ estimate: estimateOf(way),
+ stations_for_create: stationsForCreate,
+ can_create: stationsForCreate.length >= 2,
+ };
+}
diff --git a/server/src/nest/tours/city-walk-briefing.service.ts b/server/src/nest/tours/city-walk-briefing.service.ts
new file mode 100644
index 000000000..1659bfe6e
--- /dev/null
+++ b/server/src/nest/tours/city-walk-briefing.service.ts
@@ -0,0 +1,241 @@
+import { Places } from '../../db/entities/Places.entity';
+import { TourWaypoints } from '../../db/entities/TourWaypoints.entity';
+import { Tours } from '../../db/entities/Tours.entity';
+import type { PlaceRow, PlacesRepository } from '../../db/repositories/Places.repository';
+import type { TourWaypointsRepository } from '../../db/repositories/TourWaypoints.repository';
+import type { ToursRepository } from '../../db/repositories/Tours.repository';
+import { logWarn } from '../audit/audit-log.logger';
+import { notFound } from '../common/domain-error';
+import { RoadtripRouterService } from '../roadtrip/roadtrip-router.service';
+import {
+ briefingCategories,
+ describeWalk,
+ footWayOf,
+ frameOf,
+ planWalk,
+ themeCandidates,
+ type FootWay,
+ type LeftOut,
+ type LeftOutReason,
+ type WalkCandidate,
+} from './city-walk-briefing.helpers';
+import { TourPoiSearchService } from './tour-poi-search.service';
+import { ToursService } from './tours.service';
+import { InjectRepository } from '@mikro-orm/nestjs';
+import { Injectable } from '@nestjs/common';
+import {
+ CITY_WALK_BRIEFING_LIMITS,
+ minutesOf,
+ type CityWalkBriefingRequest,
+ type CityWalkCreateRequest,
+ type TourPoiSearchResponse,
+} from '@trek/shared';
+
+/** How far from the start, the wishes and the appointments the themes are searched when the briefing does not say. */
+const DEFAULT_CORRIDOR_M = 1000;
+
+/**
+ * The day the router is asked for. A way on foot does not depend on the day:
+ * the router only keys its cache by one and hands it to a routing plugin,
+ * which a walk on foot never asks.
+ */
+const ANY_DAY = 0;
+
+/** The places of the trip a briefing names, and which of them a station may stand for (no tour). */
+interface NamedPlaces {
+ rows: Map;
+ linkable: Set;
+}
+
+type Spot = { name?: string; lat: number; lng: number };
+
+/** The traveller's own point, a start, an end, a wish or an appointment, as a candidate. */
+function given(
+ source: WalkCandidate['source'],
+ spot: Spot,
+ more: Partial>,
+): WalkCandidate {
+ return {
+ source,
+ name: spot.name ?? null,
+ lat: spot.lat,
+ lng: spot.lng,
+ stay: more.stay ?? null,
+ placeHours: more.placeHours ?? null,
+ osmHours: more.osmHours ?? null,
+ placeId: more.placeId ?? null,
+ time: more.time ?? null,
+ find: null,
+ };
+}
+
+/** A place of the trip the traveller allowed, as a candidate, or why it cannot be one. */
+function tripPlaceOf(
+ choice: { place_id: number; stay_minutes?: number | null },
+ known: NamedPlaces,
+): WalkCandidate | LeftOut {
+ const place = known.rows.get(choice.place_id);
+ const refused = (reason: LeftOutReason): LeftOut => ({
+ name: place?.name ?? null,
+ source: 'trip_place',
+ place_id: choice.place_id,
+ poi_id: null,
+ reason,
+ });
+ if (!place) return refused('not_in_trip');
+ if (!known.linkable.has(place.id)) return refused('is_tour');
+ if (place.lat == null || place.lng == null) return refused('no_position');
+ return given(
+ 'trip_place',
+ { name: place.name, lat: place.lat, lng: place.lng },
+ { stay: choice.stay_minutes, placeHours: place.opening_hours, placeId: place.id },
+ );
+}
+
+/**
+ * CW-11: a city walk proposed from a briefing, for an assistant to show the
+ * traveller before anything is saved. It reads the trip's day and places,
+ * searches the places of the themes with the search along a tour uses and
+ * plans the way on foot with the router the road trip uses (the traveller's
+ * own routing server where they set one); it writes nothing. The walk is
+ * created only through create_city_walk, once the traveller confirmed it,
+ * with its way planned by the same router.
+ */
+@Injectable()
+export class CityWalkBriefingService {
+ constructor(
+ private readonly poiSearch: TourPoiSearchService,
+ private readonly router: RoadtripRouterService,
+ private readonly tours: ToursService,
+ @InjectRepository(Tours) private readonly toursRepo: ToursRepository,
+ @InjectRepository(TourWaypoints) private readonly waypointsRepo: TourWaypointsRepository,
+ @InjectRepository(Places) private readonly placesRepo: PlacesRepository,
+ ) {}
+
+ async propose(tripId: number, userId: number, briefing: CityWalkBriefingRequest) {
+ const frame = frameOf(briefing, await this.dateOf(tripId, briefing.day_id));
+ const known = await this.tripPlacesOf(tripId, briefing);
+ const choices = (briefing.trip_places ?? []).map((choice) => tripPlaceOf(choice, known));
+ const leftOut = choices.filter((choice): choice is LeftOut => 'reason' in choice);
+ const tripPlaces = choices.filter((choice): choice is WalkCandidate => !('reason' in choice));
+ const wished = (briefing.wished ?? []).map((wish) =>
+ given('wished', wish, { stay: wish.stay_minutes, osmHours: wish.opening_hours }),
+ );
+ const appointments = (briefing.appointments ?? []).map((appointment) => {
+ const id = appointment.place_id;
+ const place = id !== undefined && known.linkable.has(id) ? known.rows.get(id) : undefined;
+ return given('appointment', appointment, {
+ stay: appointment.stay_minutes,
+ time: minutesOf(appointment.time),
+ placeId: place?.id,
+ placeHours: place?.opening_hours,
+ });
+ });
+ const start = given('start', briefing.start, { stay: 0 });
+ const end = briefing.end ? given('end', briefing.end, { stay: 0 }) : null;
+ const named = [start, ...wished, ...tripPlaces, ...appointments, ...(end ? [end] : [])];
+ const theme = await this.themePlaces(briefing, named);
+ const plan = planWalk({
+ frame,
+ start,
+ end,
+ appointments,
+ wished,
+ tripPlaces,
+ themes: theme.candidates,
+ themeLimit: theme.limit,
+ });
+ return {
+ ...describeWalk(plan.route, frame, await this.footWay(userId, tripId, plan.route)),
+ left_out: [...leftOut, ...plan.leftOut],
+ searched: theme.searched,
+ };
+ }
+
+ /**
+ * The walk the traveller confirmed, created through the creation the
+ * planner's save uses, with its way on foot planned by the router the
+ * proposal used. Where the router gives no way, the walk is saved with its
+ * stations only and says why; no straight line is stored as a route.
+ */
+ async create(tripId: number, userId: number, body: CityWalkCreateRequest) {
+ const way = await this.footWay(userId, tripId, body.stations);
+ const created = await this.tours.createCityWalk(String(tripId), body, way.kind === 'routed' ? way : null);
+ return {
+ ...created,
+ foot_way: way.kind === 'routed' ? { planned: true } : { planned: false, why: way.why, position: way.position },
+ };
+ }
+
+ /** The way on foot through the stations in their order, or why there is none. */
+ private async footWay(
+ userId: number,
+ tripId: number,
+ stations: ReadonlyArray<{ lat: number; lng: number }>,
+ ): Promise {
+ if (stations.length < 2) return { kind: 'unrouted', why: 'nothing_to_route', position: null };
+ const points = stations.map(({ lat, lng }) => ({ lat, lng }));
+ try {
+ return footWayOf(await this.router.route(userId, tripId, ANY_DAY, points, 'walking', []));
+ } catch (err: unknown) {
+ logWarn(`[CityWalk] no way on foot for trip ${tripId}: ${err instanceof Error ? err.message : String(err)}`);
+ return { kind: 'unrouted', why: 'router_unavailable', position: null };
+ }
+ }
+
+ /** The date of the day the walk is for, null without one; a day of another trip is not found. */
+ private async dateOf(tripId: number, dayId: number | undefined): Promise {
+ if (dayId === undefined) return null;
+ const day = await this.toursRepo.dayOfTrip(tripId, dayId);
+ if (!day) throw notFound('Day not found');
+ return day.date;
+ }
+
+ /** The places of this trip the briefing names, none of another trip, and those a station may stand for: no tour. */
+ private async tripPlacesOf(tripId: number, briefing: CityWalkBriefingRequest): Promise {
+ const ids = [
+ ...new Set([
+ ...(briefing.trip_places ?? []).map((choice) => choice.place_id),
+ ...(briefing.appointments ?? []).flatMap((appointment) => appointment.place_id ?? []),
+ ]),
+ ];
+ const [linkable, rows] = await Promise.all([
+ this.waypointsRepo.linkablePlaceIds(tripId, ids),
+ Promise.all(ids.map((id) => this.placesRepo.findInTrip(id, tripId))),
+ ]);
+ const found = rows.filter((row): row is PlaceRow => row !== undefined);
+ return { rows: new Map(found.map((row) => [row.id, row])), linkable };
+ }
+
+ /** The places of the themes along the traveller's own points, or none when the briefing asks for none. */
+ private async themePlaces(briefing: CityWalkBriefingRequest, named: readonly WalkCandidate[]) {
+ const limit = briefing.theme_places_max ?? CITY_WALK_BRIEFING_LIMITS.themePlaces;
+ if (limit === 0) return { limit, candidates: [], searched: null };
+ const categories = briefingCategories(briefing.themes ?? ['mixed']);
+ const corridor = briefing.corridor_m ?? DEFAULT_CORRIDOR_M;
+ const line = named.map((spot): [number, number] => [spot.lat, spot.lng]);
+ const found: TourPoiSearchResponse = await this.poiSearch.search({
+ tour_type: 'walk',
+ categories: categories.searched.map((entry) => entry.category),
+ corridor_m: corridor,
+ // One point alone is searched around: a line from it to itself.
+ line: line.length > 1 ? line : [...line, ...line],
+ lang: briefing.lang,
+ });
+ const candidates = themeCandidates(found.pois, categories.searched, named);
+ return {
+ limit,
+ candidates,
+ searched: {
+ categories: categories.searched.map((entry) => entry.category),
+ categories_not_searched: categories.notSearched,
+ corridor_m: corridor,
+ found: found.pois.length,
+ unavailable: found.unavailable,
+ partial: found.partial,
+ capped: found.capped,
+ truncated: found.truncated,
+ },
+ };
+ }
+}
diff --git a/server/src/nest/tours/city-walk.mcp.ts b/server/src/nest/tours/city-walk.mcp.ts
new file mode 100644
index 000000000..a045cb231
--- /dev/null
+++ b/server/src/nest/tours/city-walk.mcp.ts
@@ -0,0 +1,79 @@
+import { ADDON_IDS } from '../../addons';
+import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
+import {
+ McpController,
+ Tool,
+ TOOL_ANNOTATIONS_NON_IDEMPOTENT,
+ TOOL_ANNOTATIONS_READONLY,
+ ok,
+ type McpContext,
+} from '../../nest-mcp';
+import { addonGate } from '../addons/addon-gate';
+import { AddonsService } from '../addons/addons.service';
+import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
+import { TripAccessService } from '../trip-membership/trip-access.service';
+import { CityWalkBriefingService } from './city-walk-briefing.service';
+import {
+ cityWalkBriefingRequestSchema,
+ cityWalkCreateRequestSchema,
+ idSchema,
+ type CityWalkBriefingRequest,
+ type CityWalkCreateRequest,
+} from '@trek/shared';
+
+const create = cityWalkCreateRequestSchema.shape;
+
+/**
+ * CW-11: a city walk prepared from a traveller's briefing. The proposal reads
+ * only, so turning it down changes nothing; the walk is created once the
+ * traveller confirmed it, through the creation the planner's save uses (one
+ * write, a key for every station, the links to the trip's places checked, the
+ * trip told), with 'place_edit' on the trip, as REST asks. Both plan the way on
+ * foot with the router the road trip uses. A refusal of the service is a
+ * DomainError, which the registry answers as an error result with the text
+ * REST sends.
+ */
+@McpController()
+export class CityWalkMcp {
+ constructor(
+ private readonly briefing: CityWalkBriefingService,
+ private readonly access: TripAccessService,
+ private readonly guards: McpToolGuardsService,
+ // Read by the addon gate of every tool here.
+ readonly addons: AddonsService,
+ ) {}
+
+ @Tool({
+ name: 'propose_city_walk',
+ description:
+ "Propose a city walk for a trip from the traveller's briefing, without saving anything: where it starts (and ends), the time frame (start_time to end_time, on day_id for its date), their interests (themes: culture for museums and sights, parks for parks and viewpoints, food for cafes, places to eat and markets, mixed for a bit of each; mixed when left out), the stations they asked for (wished), the places of the trip it may use (trip_places) and their fixed appointments with their times. Places for the themes come from the same search as search_tour_pois, around the start, the wishes and the appointments, up to theme_places_max (8 when left out); nothing is made up. Answers the stations in order, each with its source and why it is in (found names the theme, the category and where it was found), the walking distance and time from the one before (walk_from_previous: metres, minutes), the arrival and leave times, the stay where the traveller gave it (stay_source given; null when not known: then every later arrival is only the earliest, arrival_is_earliest; the start and the end are passed, stay_source frame), and its opening hours checked against the arrival on that day where they can be read (hours; an earliest arrival only ever shows a conflict that is certain). The order is chosen on estimates; the walk in that order is then planned on foot by TREK's router (the traveller's own routing server where they set one), whose legs give the distances and times (estimate kind routed). Where the router gives no way, they are estimates over the straight distance with an allowance for streets (estimate kind straight_line with street_factor and pace_kmh, and why: router_unavailable, or station_off_the_way with the position of the station no way on foot reaches); tell the traveller so. totals keeps walking and stays apart, total_complete only when every stay is known, and minutes_left_for_unknown_stays is what the frame leaves for the stays not known. gaps lists free time before an appointment and at the end, wishes_not_met the wishes and appointments that cannot be kept as asked (closed that day, closed or closing at the arrival, late, after the frame), left_out what was not taken and why, searched what was searched. Wished stations and appointments are always in; an appointment is a must and protected. Show the proposal to the traveller; once they confirm, pass stations_for_create to create_city_walk.",
+ inputSchema: { tripId: idSchema, ...cityWalkBriefingRequestSchema.shape },
+ annotations: TOOL_ANNOTATIONS_READONLY,
+ when: addonGate(ADDON_IDS.TOURS),
+ access: { group: 'trips', mode: 'read' },
+ })
+ async proposeCityWalk({ tripId, ...briefing }: { tripId: number } & CityWalkBriefingRequest, ctx: McpContext) {
+ if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
+ return ok(await this.briefing.propose(tripId, ctx.userId, briefing));
+ }
+
+ @Tool({
+ name: 'create_city_walk',
+ description:
+ 'Create a city walk in a trip from stations the traveller confirmed (for instance stations_for_create of propose_city_walk), the way the planner saves a new walk: a new tour of type walk with its stations in their order, each with its name, stay_minutes (0 for a point passed through, null when not known), importance (optional, must, or null for normal), protected (a station any later proposal has to keep where it is), opening_hours as OpenStreetMap writes them and the place of the trip it stands for (linked_place_id; a place that is not of this trip leaves the station unlinked, a tour is refused). The way between the stations is planned on foot by the router the proposal uses, and the walk is saved with it, its distance and its walking time (foot_way planned true). Where the router gives no way (foot_way planned false, with why and position as in the estimate of the proposal), the walk is saved with its stations only (route_pending true, distance and walking time null), and TREK plans the way on foot when someone opens it in the planner and saves it; no straight line is stored as a route. Put it on a day with assign_place_to_day and give it a start time with update_assignment_time. Needs place editing permission on the trip. Create it only after the traveller said yes.',
+ inputSchema: {
+ tripId: idSchema.describe('The trip to create the walk in'),
+ name: create.name,
+ description: create.description,
+ stations: create.stations,
+ },
+ annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
+ when: addonGate(ADDON_IDS.TOURS),
+ access: { group: 'trips', mode: 'write' },
+ })
+ async createCityWalk({ tripId, ...body }: { tripId: number } & CityWalkCreateRequest, ctx: McpContext) {
+ if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
+ if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
+ return ok(await this.briefing.create(tripId, ctx.userId, body));
+ }
+}
diff --git a/server/src/nest/tours/tour-library.mcp.ts b/server/src/nest/tours/tour-library.mcp.ts
index 0bfa63819..2c2e821ea 100644
--- a/server/src/nest/tours/tour-library.mcp.ts
+++ b/server/src/nest/tours/tour-library.mcp.ts
@@ -57,7 +57,7 @@ export class TourLibraryMcp {
@Tool({
name: 'take_over_tour',
description:
- 'Copy a saved tour of another trip (as list_tour_library gives it) into a trip, as an independent tour of that trip: its line, measures, settings and control points, without its days, its links to places of the other trip or the decisions taken there about obstacles. The copy remembers which trip and tour it was taken from. Nothing changes in the source trip.',
+ 'Copy a saved tour of another trip (as list_tour_library gives it) into a trip, as an independent tour of that trip: its line, measures, settings and control points, without its days, its links to places of the other trip or the decisions taken there about obstacles. The copy remembers which trip and tour it was taken from. A city walk keeps its stations with their keys, names, stays, importance, protection and opening hours; what was checked off on the days of the other trip stays there, and a walk still waiting for its way (route_pending) is copied as it is. Nothing changes in the source trip.',
inputSchema: {
tripId: idSchema.describe('The trip to copy the tour into'),
source_trip_id: takeOver.source_trip_id,
diff --git a/server/src/nest/tours/tour-visits.service.ts b/server/src/nest/tours/tour-visits.service.ts
index 0957385d9..8167d9bd2 100644
--- a/server/src/nest/tours/tour-visits.service.ts
+++ b/server/src/nest/tours/tour-visits.service.ts
@@ -66,6 +66,7 @@ export class TourVisitsService {
const statusOf = new Map(walk.visits.map((visit) => [visit.station_key, visit.status]));
const stations: WalkProgressStation[] = walk.stations.map((station, index) => ({
position: index + 1,
+ key: station.key ?? null,
name: station.name ?? null,
lat: station.lat,
lng: station.lng,
@@ -73,6 +74,7 @@ export class TourVisitsService {
importance: station.importance ?? 'normal',
protected: station.protected === true,
stay_minutes: station.stay_minutes ?? null,
+ opening_hours: station.opening_hours ?? null,
linked_place_id: station.linked_place_id ?? null,
}));
const open = stations.filter((station) => station.status === 'planned');
@@ -113,6 +115,8 @@ export class TourVisitsService {
/** CW-09: one station of a walk on a day, as an assistant reads it. */
interface WalkProgressStation {
position: number;
+ /** The station's own key, the same across saves; null only for a point saved before keys existed. */
+ key: string | null;
name: string | null;
lat: number;
lng: number;
@@ -121,6 +125,8 @@ interface WalkProgressStation {
protected: boolean;
/** Null where the stay is not known; 0 is a station passed through. */
stay_minutes: number | null;
+ /** As OpenStreetMap writes them; a hint to check, null when none is known. */
+ opening_hours: string | null;
linked_place_id: number | null;
}
diff --git a/server/src/nest/tours/tours.mcp.ts b/server/src/nest/tours/tours.mcp.ts
index 8a10dd1fb..22826ffbc 100644
--- a/server/src/nest/tours/tours.mcp.ts
+++ b/server/src/nest/tours/tours.mcp.ts
@@ -10,12 +10,43 @@ import { TourPoiSearchService } from './tour-poi-search.service';
import { TourVisitsService } from './tour-visits.service';
import { ToursService } from './tours.service';
import { NotFoundException } from '@nestjs/common';
-import { idSchema, tourPoiSearchRequestSchema, type TourListItem, type TourPoiSearchRequest } from '@trek/shared';
+import {
+ idSchema,
+ tourPoiSearchRequestSchema,
+ walkStationsOf,
+ type TourListItem,
+ type TourPoiSearchRequest,
+ type TourWaypoint,
+} from '@trek/shared';
type TourDay = Pick;
const poiFields = tourPoiSearchRequestSchema.shape;
+/**
+ * CW-10: a station of a city walk with every field it has spelled out, so an
+ * assistant reads a stay nobody entered as null and never as missing: null
+ * for an unknown stay, a normal importance and no known hours, false for an
+ * unprotected station. REST answers the stored fields as they are.
+ */
+function spelledOut(station: T) {
+ return {
+ ...station,
+ key: station.key ?? null,
+ name: station.name ?? null,
+ stay_minutes: station.stay_minutes ?? null,
+ importance: station.importance ?? null,
+ protected: station.protected === true,
+ opening_hours: station.opening_hours ?? null,
+ };
+}
+
+/** The points of a tour as get_tour_plan answers them: a city walk's stations spelled out, every other point as stored. */
+function planPoints(tourType: string, points: T[]) {
+ const stations = new Set(tourType === 'walk' ? walkStationsOf(points) : []);
+ return points.map((point) => (stations.has(point) ? spelledOut(point) : point));
+}
+
/** A tour as a client reads it: its list facts and the days of the trip it is on. */
function withDays(tour: TourListItem, days: ReadonlyMap) {
return { ...tour, days: days.get(tour.place_id) ?? [] };
@@ -54,7 +85,7 @@ export class ToursMcp {
@Tool({
name: 'list_tours',
description:
- 'List the saved tours of a trip (hikes, bike tours and paddle tours planned or imported in the Tours addon): name, type (hike, bike, or kayak for a paddle tour), where it came from (origin: trek, gpx or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_climbs for gentler ways, avoid_steps, avoid_ferries; weightings, never a grade of the path), the profile a bike tour is routed for (bike_profile: touring for paved cycle paths and quiet roads, road for asphalt, gravel for gravel and farm tracks, mtb for rough tracks; a routing preference, never a statement that a way is rideable or safe), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), the revision (raised by every saved change of the tour), and the days of the trip each tour is on.',
+ 'List the saved tours of a trip (hikes, city walks, bike tours and paddle tours planned or imported in the Tours addon): name, type (hike, walk for a city walk, bike, or kayak for a paddle tour), where it came from (origin: trek, gpx or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_climbs for gentler ways, avoid_steps, avoid_ferries; weightings, never a grade of the path), the profile a bike tour is routed for (bike_profile: touring for paved cycle paths and quiet roads, road for asphalt, gravel for gravel and farm tracks, mtb for rough tracks; a routing preference, never a statement that a way is rideable or safe), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), for a city walk the number of its stations (station_count: its points without the shaping points and the way back of a loop), their stays as far as entered (station_stay_minutes) and how many have none (station_stays_unknown), whether a tour still waits for its way to be planned (route_pending: saved with its points only, so its distance and walking time stay null until someone opens it in the planner and saves it), the revision (raised by every saved change of the tour), and the days of the trip each tour is on.',
inputSchema: { tripId: idSchema },
annotations: TOOL_ANNOTATIONS_READONLY,
when: addonGate(ADDON_IDS.TOURS),
@@ -69,7 +100,7 @@ export class ToursMcp {
@Tool({
name: 'get_tour_plan',
description:
- 'Read one saved tour of a trip: its facts, its ordered control points (start, via and end; an import without its own shows the two ends of its track), where it came from (provenance) and the days it is on. A control point with linked_place_id stands for that place of the trip (linked_place_name names it); it keeps its own coordinates. A control point whose fixed_leg is set leads to the next one along a line the router did not plan: kind ferrata follows a via ferrata, kind free is drawn by hand and was checked by nothing. A paddle tour also carries its obstacle checklist (obstacle_check): the obstacles of the OpenStreetMap data along its line, the decisions the travellers made about them and their own portages and locks; a planning aid, never a safety or legal clearance. The full track geometry is not included.',
+ 'Read one saved tour of a trip: its facts, its ordered control points (start, via and end; an import without its own shows the two ends of its track), where it came from (provenance) and the days it is on. A control point with linked_place_id stands for that place of the trip (linked_place_name names it); it keeps its own coordinates. A control point whose fixed_leg is set leads to the next one along a line the router did not plan: kind ferrata follows a via ferrata, kind free is drawn by hand and was checked by nothing. A paddle tour also carries its obstacle checklist (obstacle_check): the obstacles of the OpenStreetMap data along its line, the decisions the travellers made about them and their own portages and locks; a planning aid, never a safety or legal clearance. A city walk (tour_type walk) has stations for control points, shaping points and the way back of a loop aside, each with every field spelled out: key (its own id, the same across saves; the visits of a day are kept by it), name, stay_minutes (the stay in minutes; 0 for a point only passed through, null when not known), importance (optional or must; null for a normal station), protected (true for a station any change you propose has to keep where it is: neither removed, replaced nor moved; the travellers can still edit it themselves in the app) and opening_hours (as OpenStreetMap writes them; a hint to check, never a promise; null when none is known). How far a walk got on a day is get_city_walk_progress. The full track geometry is not included.',
inputSchema: { tripId: idSchema, placeId: idSchema.describe('The place id of the tour, as list_tours gives it') },
annotations: TOOL_ANNOTATIONS_READONLY,
when: addonGate(ADDON_IDS.TOURS),
@@ -84,9 +115,10 @@ export class ToursMcp {
]);
// A point standing for a place of the trip (PR1-10) says which, by name.
const names = await this.tours.linkedPlaceNames(tripId, detail.waypoints);
- const waypoints = detail.waypoints.map((point) =>
+ const named = detail.waypoints.map((point) =>
point.linked_place_id ? { ...point, linked_place_name: names.get(point.linked_place_id) ?? null } : point,
);
+ const waypoints = planPoints(detail.tour.tour_type, named);
return ok({ ...detail, waypoints, tour: withDays(detail.tour, days) });
} catch (err: unknown) {
if (err instanceof NotFoundException) return errorResult('Tour not found.');
@@ -97,7 +129,7 @@ export class ToursMcp {
@Tool({
name: 'get_city_walk_progress',
description:
- 'Read how far a city walk (tour_type walk) got on one day it is on: every station in order with its status that day (planned, visited or skipped, as the travellers checked them off in the day plan), its importance (normal, optional, must), whether it is protected (a station a proposed change must leave where it is, neither removed, replaced nor moved), its stay in minutes (null when not known, 0 for a point passed through) and its linked place. "open" sums what is still planned: the stations, their known stays and how many stays are unknown; walking_minutes is the walking time of the whole walk. Visits belong to that day only: the same walk on another day, or a copy, has none. Anything the traveller tells you besides (for instance "continue from 2 pm") is theirs to say, not stored here. Reading changes nothing.',
+ 'Read how far a city walk (tour_type walk) got on one day it is on: every station in order with its key (its own id, as get_tour_plan gives it), its status that day (planned, visited or skipped, as the travellers checked them off in the day plan), its importance (normal, optional, must; get_tour_plan says null for normal), whether it is protected (a station a proposed change must leave where it is, neither removed, replaced nor moved), its stay in minutes (null when not known, 0 for a point passed through), its opening hours as OpenStreetMap writes them (null when none is known) and its linked place. "open" sums what is still planned: the stations, their known stays and how many stays are unknown; walking_minutes is the walking time of the whole walk. Visits belong to that day only: the same walk on another day, or a copy, has none. Anything the traveller tells you besides (for instance "continue from 2 pm") is theirs to say, not stored here. Reading changes nothing.',
inputSchema: {
tripId: idSchema,
placeId: idSchema.describe('The place id of the city walk, as list_tours gives it'),
@@ -158,7 +190,7 @@ export class ToursMcp {
@Tool({
name: 'search_tour_pois',
description:
- 'Search places along the saved line of a tour: what a hike, a bike tour, a paddle tour or a city walk stops for (viewpoint, peak, shelter, drinking_water, food, toilets, picnic, campsite, sights, slipway, boat_rental, bike_repair for repair stations and bike shops, and for a city walk museum for museums and galleries, park for parks and gardens, cafe, restaurant and market), within corridor_m metres of the line. Answers each place with its source (TREK places index or OpenStreetMap), kind, address, website and phone where known, opening hours as the source has them (a hint to check, never a promise), distance_m (straight across to the line, not a detour) and along_m (from the start along the line); up to 60, the nearest to the line when more match, listed from the start. A place OpenStreetMap has no name for (a tap, a bench) comes with an empty name. unavailable lists the categories no source could be searched for right now: their places are missing, not absent, so ask again later rather than reporting none. partial says some stretches could not be searched, capped that only the first part of a long line was, truncated that more places matched. Nothing is added to the tour or the trip.',
+ 'Search places along the saved line of a tour: what a hike, a bike tour, a paddle tour or a city walk stops for (viewpoint, peak, shelter, drinking_water, food, toilets, picnic, campsite, sights, slipway, boat_rental, bike_repair for repair stations and bike shops, and for a city walk museum for museums and galleries, park for parks and gardens, cafe, restaurant and market), within corridor_m metres of the line. Answers each place with its source (TREK places index or OpenStreetMap), kind, address, website and phone where known, opening hours as the source has them (a hint to check, never a promise), distance_m (straight across to the line, not a detour) and along_m (from the start along the line); up to 60, the nearest to the line when more match, listed from the start. A place OpenStreetMap has no name for (a tap, a bench) comes with an empty name. unavailable lists the categories no source could be searched for right now: their places are missing, not absent, so ask again later rather than reporting none. partial says some stretches could not be searched, capped that only the first part of a long line was, truncated that more places matched. A tour saved with its points only (route_pending) has no line to search along yet. Nothing is added to the tour or the trip.',
inputSchema: {
tripId: idSchema,
placeId: idSchema.describe('The place id of the tour, as list_tours gives it'),
diff --git a/server/src/nest/tours/tours.module.ts b/server/src/nest/tours/tours.module.ts
index e94fa34a1..13fcecfdf 100644
--- a/server/src/nest/tours/tours.module.ts
+++ b/server/src/nest/tours/tours.module.ts
@@ -13,8 +13,11 @@ import { PaddleObstaclesModule } from '../paddle-obstacles/paddle-obstacles.modu
import { PermissionsModule } from '../permissions/permissions.module';
import { PlaceImportModule } from '../place-import/place-import.module';
import { PlacesModule } from '../places/places.module';
+import { RoadtripModule } from '../roadtrip/roadtrip.module';
import { TripMembershipModule } from '../trip-membership/trip-membership.module';
import { WandererModule } from '../wanderer/wanderer.module';
+import { CityWalkBriefingService } from './city-walk-briefing.service';
+import { CityWalkMcp } from './city-walk.mcp';
import { TourGpxExportService } from './tour-gpx-export.service';
import { TourLibraryController, TourTakeOverController } from './tour-library.controller';
import { TourLibraryMcp } from './tour-library.mcp';
@@ -48,7 +51,10 @@ import { Module } from '@nestjs/common';
* through PlacesRepository, registered here because forFeature only reaches
* this module's own providers. A Google Maps walking route is read into the
* stops of a city walk through PlaceImportModule as well (CW-07), its short
- * link followed by the narrow resolver there (CW-06).
+ * link followed by the narrow resolver there (CW-06). A city walk an assistant
+ * prepares from a briefing reads the day and places of the trip through the
+ * tours' own repositories, is planned on foot by RoadtripModule's router and
+ * is created through ToursService (CW-11).
*/
@Module({
imports: [
@@ -67,6 +73,8 @@ import { Module } from '@nestjs/common';
WandererModule,
// The obstacle data a paddle tour's checklist reads (PAD-06, PAD-09).
PaddleObstaclesModule,
+ // A city walk prepared over MCP is planned on foot by the router the road trip uses (CW-11).
+ RoadtripModule,
MikroOrmModule.forFeature([
Tours,
TourTypes,
@@ -101,9 +109,11 @@ import { Module } from '@nestjs/common';
TourTrailGradeService,
TourVisitsService,
TourWalkImportService,
+ CityWalkBriefingService,
ToursMcp,
TourLibraryMcp,
TourWalkImportMcp,
+ CityWalkMcp,
],
})
export class ToursModule {}
diff --git a/server/src/nest/tours/tours.service.ts b/server/src/nest/tours/tours.service.ts
index 926aa0f5d..1e2738b1c 100644
--- a/server/src/nest/tours/tours.service.ts
+++ b/server/src/nest/tours/tours.service.ts
@@ -20,7 +20,7 @@ import type {
TourUpdate,
} from '../../db/repositories/Tours.repository';
import { decodeJson, encodeJson } from '../../utils/json-column';
-import { DomainError } from '../common/domain-error';
+import { badRequest, DomainError } from '../common/domain-error';
import { toRowId } from '../common/row-id';
import { UnitOfWork } from '../database/unit-of-work';
import { PlaceImportService } from '../place-import/place-import.service';
@@ -36,13 +36,14 @@ import {
type GeometryPoint,
} from './tours.helpers';
import { InjectRepository } from '@mikro-orm/nestjs';
-import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
+import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import {
hikingPreferencesOf,
MAX_PLANNED_TOUR_DURATION_MINUTES,
plannedTourDurationMinutesSchema,
tourWebsiteSchema,
type BikeProfile,
+ type CityWalkCreateRequest,
type PaddleBoatType,
type PaddleLakeStyle,
type PaddleWaterContext,
@@ -80,8 +81,24 @@ export interface ImportGpxAsTourResult {
/** A GPX file imported without a type is a hike. */
const HIKE_IMPORT: TourGpxImportFields = { tour_type: 'hike' };
+/**
+ * What a create writes: the request as the contract reads it, or for a city
+ * walk an assistant created from its stations (CW-11) the way the server's
+ * router planned on foot, which has no heights, or no line when it planned
+ * none, which the planner then plans when the walk is opened.
+ */
+export type TourCreateCommand = Omit & {
+ route_geometry: GeometryPoint[] | null;
+};
+
+/** The way on foot the server's router planned for a city walk (CW-11): its line, and how long it walks in seconds. */
+interface CityWalkFootRoute {
+ line: Array<[number, number]>;
+ seconds: number;
+}
+
/** MF-08: a save's hiking preferences as stored: a hike's switched-on ones as JSON, else NULL. */
-function hikingFields(input: TourCreateRequest): string | null {
+function hikingFields(input: TourCreateCommand): string | null {
const preferences = input.tour_type === 'hike' ? hikingPreferencesOf(input.hiking_preferences) : null;
return preferences ? encodeJson(TOUR_HIKING_PREFERENCES, preferences) : null;
}
@@ -151,20 +168,24 @@ export class ToursService {
revision: r.revision,
hiking_preferences: decodeJson(TOUR_HIKING_PREFERENCES, r.hiking_preferences, 'tour'),
...walkStationFacts(r.tour_type, points),
+ route_pending: Boolean(r.route_missing) && Boolean(r.has_waypoints),
};
}
- /** The route a create or an update writes: metrics derived from the geometry, duration stored in minutes. */
- private routeFields(input: TourCreateRequest): TourUpdate {
- const metrics = computeTourMetrics(input.route_geometry as GeometryPoint[]);
+ /**
+ * The route a create or an update writes: metrics derived from the geometry, duration stored in minutes.
+ * Without a line (CW-11) distance, heights and walking time stay unknown until the planner plans the way.
+ */
+ private routeFields(input: TourCreateCommand): TourUpdate {
+ const metrics = input.route_geometry ? computeTourMetrics(input.route_geometry) : null;
// A paddle tour drawn here has its heights read off the banks at the points set, not
// off the water: its ascent and descent stay unknown rather than wrong.
const heightsKnown = input.tour_type !== 'kayak';
return {
tour_type: input.tour_type,
- distance: metrics.distanceKm,
- elevation_gain: heightsKnown ? metrics.elevationGainM : null,
- elevation_loss: heightsKnown ? metrics.elevationLossM : null,
+ distance: metrics?.distanceKm ?? null,
+ elevation_gain: heightsKnown ? (metrics?.elevationGainM ?? null) : null,
+ elevation_loss: heightsKnown ? (metrics?.elevationLossM ?? null) : null,
duration: input.duration_seconds == null ? null : Math.round(input.duration_seconds / 60),
...(input.planned_duration_minutes !== undefined
? { planned_duration_minutes: input.planned_duration_minutes }
@@ -188,25 +209,26 @@ export class ToursService {
/**
* `tour_types` knows every key the contract accepts, most of them disabled.
* Checked before the write, so a disabled type is a 400 and not a tour the
- * planner cannot show.
+ * planner cannot show. The refusals here are DomainErrors, so REST answers
+ * `{ error }` and an MCP tool the same text as its error result.
*/
private async assertTourTypeEnabled(key: string): Promise {
- if (!(await this.tourTypesRepo.isEnabled(key))) throw new BadRequestException('Tour type is not available');
+ if (!(await this.tourTypesRepo.isEnabled(key))) throw badRequest('Tour type is not available');
}
- private validateInformationalMetadata(input: TourCreateRequest): void {
+ private validateInformationalMetadata(input: TourCreateCommand): void {
if (input.description != null && input.description.length > 2000) {
- throw new BadRequestException('description must be 2000 characters or less');
+ throw badRequest('description must be 2000 characters or less');
}
if (input.website != null && !tourWebsiteSchema.safeParse(input.website).success) {
- throw new BadRequestException('website must be a valid HTTPS URL without credentials');
+ throw badRequest('website must be a valid HTTPS URL without credentials');
}
}
- private validatePlannedTimes(input: TourCreateRequest): void {
+ private validatePlannedTimes(input: TourCreateCommand): void {
for (const value of [input.planned_duration_minutes, input.break_additional_minutes]) {
if (value !== undefined && !plannedTourDurationMinutesSchema.safeParse(value).success) {
- throw new BadRequestException('planned Tour times must be whole minutes from 0 to 1440');
+ throw badRequest('planned Tour times must be whole minutes from 0 to 1440');
}
}
if (
@@ -215,9 +237,7 @@ export class ToursService {
Math.round(input.duration_seconds / 60) + (input.break_additional_minutes ?? 0) >
MAX_PLANNED_TOUR_DURATION_MINUTES
) {
- throw new BadRequestException(
- 'walking time plus breaks must be 1440 minutes or less unless a total is overridden',
- );
+ throw badRequest('walking time plus breaks must be 1440 minutes or less unless a total is overridden');
}
}
@@ -323,14 +343,20 @@ export class ToursService {
};
}
- /** Create the owning Place, Tours facet, and ordered control points as one write. */
- async createTour(tripId: string, input: TourCreateRequest, socketId?: string): Promise {
+ /**
+ * Create the owning Place, Tours facet, and ordered control points as one write. A create
+ * without a line (CW-11) puts the place at its first point.
+ */
+ async createTour(tripId: string, input: TourCreateCommand, socketId?: string): Promise {
// The controller's TripAccessGuard already resolved this trip id.
const tid = toRowId(tripId)!;
await this.assertTourTypeEnabled(input.tour_type);
this.validateInformationalMetadata(input);
this.validatePlannedTimes(input);
- const start = input.route_geometry[0];
+ const start = input.route_geometry ? input.route_geometry[0] : firstPointOf(input.waypoints);
+ if (!start || (input.route_geometry === null && input.waypoints.length < 2)) {
+ throw badRequest('A tour needs at least two points');
+ }
const fields = this.routeFields(input);
const derivedFrom = input.derived_from_place_id ?? null;
@@ -348,7 +374,7 @@ export class ToursService {
name: input.name,
lat: start[0],
lng: start[1],
- route_geometry: JSON.stringify(input.route_geometry),
+ route_geometry: input.route_geometry ? JSON.stringify(input.route_geometry) : null,
description: input.description ?? null,
website: input.website ?? null,
});
@@ -389,6 +415,36 @@ export class ToursService {
};
}
+ /**
+ * CW-11: a city walk the traveller confirmed through an assistant, made the
+ * way the planner's save makes one, in the same write with the same checks:
+ * its stations in order, each with a key of its own, its links to the trip's
+ * places checked. Its distance and walking time come from the way the
+ * server's router planned on foot. Without one it is saved with its stations
+ * only: no line, no distance and no walking time, until the planner plans the
+ * way when it is opened.
+ */
+ async createCityWalk(
+ tripId: string,
+ input: CityWalkCreateRequest,
+ way: CityWalkFootRoute | null,
+ ): Promise {
+ const last = input.stations.length - 1;
+ return await this.createTour(tripId, {
+ name: input.name,
+ description: input.description,
+ tour_type: 'walk',
+ route_geometry: way?.line ?? null,
+ duration_seconds: way?.seconds ?? null,
+ max_hiking_difficulty: null,
+ waypoints: input.stations.map((station, sequence) => ({
+ ...station,
+ role: sequence === 0 ? 'start' : sequence === last ? 'end' : 'via',
+ sequence,
+ })),
+ });
+ }
+
/**
* Takes an import that was planned over in place back to the track it was
* recorded with: its line, the measures read from that line and the grade
@@ -513,21 +569,23 @@ export class ToursService {
target: { tid: number; name: string },
how: { derivedFrom: number | null; sameTrip: boolean; takenOverFrom?: { tripTitle: string; tourName: string } },
): Promise {
- const [row, provenance, place] = await Promise.all([
+ const [row, provenance, place, waypoints] = await Promise.all([
this.toursRepo.findInTrip(source.tid, source.pid),
this.toursRepo.findProvenance(source.tid, source.pid),
this.placesRepo.findInTrip(source.pid, source.tid),
+ this.waypointsRepo.listForPlace(source.pid),
]);
- const start = parseRouteGeometry(place?.route_geometry)[0];
- // A tour without a line has nothing to copy.
- if (!row || !provenance || !place?.route_geometry || !start) throw new NotFoundException('Tour not found');
+ // A city walk saved with its stations only (CW-11) is copied from its first one, its way still to plan;
+ // a line that holds no point has nothing to copy.
+ const start = place?.route_geometry == null ? firstPointOf(waypoints) : parseRouteGeometry(place.route_geometry)[0];
+ if (!row || !provenance || !place || !start) throw new NotFoundException('Tour not found');
if (!how.sameTrip) await this.assertTourTypeEnabled(row.tour_type);
const id = await this.placesRepo.insertTourPlace({
trip_id: target.tid,
name: target.name,
lat: start[0],
lng: start[1],
- route_geometry: place.route_geometry,
+ route_geometry: place.route_geometry ?? null,
description: place.description ?? null,
website: place.website ?? null,
});
@@ -559,7 +617,6 @@ export class ToursService {
taken_over_from_trip_title: how.takenOverFrom?.tripTitle ?? provenance.taken_over_from_trip_title,
taken_over_from_tour_name: how.takenOverFrom?.tourName ?? provenance.taken_over_from_tour_name,
});
- const waypoints = await this.waypointsRepo.listForPlace(source.pid);
// A stop standing for a place of the source trip stands for nothing in another trip.
await this.waypointsRepo.insertForPlace(
id,
@@ -783,6 +840,7 @@ export class ToursService {
has_recorded_track: 0,
planned: 0,
has_waypoints: 0,
+ route_missing: 0,
revision: 1,
hiking_preferences: null,
}),
@@ -844,6 +902,12 @@ export class ToursService {
}
}
+/** Where a tour without a line starts (CW-11): at its first point. */
+function firstPointOf(points: readonly { lat: number; lng: number }[]): [number, number] | undefined {
+ const point = points[0];
+ return point ? [point.lat, point.lng] : undefined;
+}
+
/** The file name a GPX import keeps: the name as uploaded, without a path, cut to what the column shows. */
function gpxFileName(name: string | undefined): string | null {
const base = name?.split('/').pop()?.trim();
diff --git a/server/tests/e2e/tours-walk.e2e.test.ts b/server/tests/e2e/tours-walk.e2e.test.ts
index cdaca631f..d402a7f9d 100644
--- a/server/tests/e2e/tours-walk.e2e.test.ts
+++ b/server/tests/e2e/tours-walk.e2e.test.ts
@@ -1,7 +1,8 @@
/**
- * Tours e2e for city walks (CW-01 to CW-03, CW-10): a walk saved and read with
- * its stations, the keys of its stations kept through an edit, and the
- * stations checked off on the days the walk is on, through the real
+ * Tours e2e for city walks (CW-01 to CW-03, CW-10, CW-11): a walk saved and read
+ * with its stations, one saved with its stations only waiting for its way, the
+ * keys of its stations kept through an edit, and the stations checked off on
+ * the days the walk is on, through the real
* JwtAuthGuard, AddonGuard and TripAccessGuard against a migrated and seeded
* temp SQLite db. The permission check is a container spy and the broadcast
* goes to a FakeRealtimeService, as in tours.e2e.test.ts.
@@ -25,8 +26,10 @@ import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
import { ToursModule } from '../../src/nest/tours/tours.module';
import { createSnapshotTestDb } from '../helpers/db-mock';
+import { makePlace } from '../helpers/factories/places';
import { countRows, deleteRows, insertRows } from '../helpers/factories/rows';
import { setAddonEnabled } from '../helpers/factories/settings';
+import { addTourWaypoints, makeTour } from '../helpers/factories/tours';
import { addTripMember } from '../helpers/factories/trips';
import { makeUser } from '../helpers/factories/users';
import { FakeRealtimeService } from '../helpers/fake-realtime';
@@ -215,6 +218,69 @@ describe('City walks e2e (real guards + temp SQLite)', () => {
return res.body.visits as Array<{ assignment_id: number; station_key: string; status: string }>;
};
+ describe('a walk saved with its stations only (CW-11)', () => {
+ /** A walk an assistant created from confirmed stations: no line, no distance, no walking time. */
+ const pendingWalk = async () => {
+ const place = await makePlace(orm, 5, { name: 'Briefed walk', lat: 48.1, lng: 11.5, route_geometry: null });
+ await makeTour(orm, place.id, { tourTypeRef: 'walk' });
+ await addTourWaypoints(orm, place.id, [
+ {
+ lat: 48.1,
+ lng: 11.5,
+ station_key: 'stop-cathedral',
+ name: 'Cathedral',
+ stay_minutes: 30,
+ importance: 'must',
+ protected: 1,
+ },
+ { lat: 48.13, lng: 11.53, station_key: 'stop-park', name: 'Park' },
+ ]);
+ return place.id;
+ };
+ const listed = async (placeId: number) =>
+ (await request(server).get('/api/trips/5/tours').set('Cookie', cookie(VIEWER))).body.tours.find(
+ (tour: { place_id: number }) => tour.place_id === placeId,
+ );
+
+ it('the list and the detail say it waits for its way, until a save with a line plans it', async () => {
+ const { placeId: planned } = await saveWalk();
+ const placeId = await pendingWalk();
+ expect(await listed(planned)).toMatchObject({ route_pending: false });
+ expect(await listed(placeId)).toMatchObject({
+ route_pending: true,
+ distance: null,
+ duration: null,
+ station_count: 2,
+ });
+ const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', cookie(VIEWER));
+ expect(detail.status).toBe(200);
+ expect(detail.body.tour.route_pending).toBe(true);
+ const [cathedral, park] = detail.body.waypoints as Point[];
+ expect(cathedral).toMatchObject({ name: 'Cathedral', protected: true, stay_minutes: 30 });
+
+ const saved = await request(server)
+ .put(`/api/trips/5/tours/${placeId}`)
+ .set('Cookie', cookie())
+ .send(walkBody({ waypoints: inOrder([cathedral, park]), expected_revision: 1 }));
+ expect(saved.status).toBe(200);
+ expect(saved.body.tour).toMatchObject({ route_pending: false, duration: 60 });
+ expect(await listed(placeId)).toMatchObject({ route_pending: false });
+ });
+
+ it('is copied as it is, and a create over REST still needs its line', async () => {
+ const placeId = await pendingWalk();
+ const copy = await request(server)
+ .post(`/api/trips/5/tours/${placeId}/copy`)
+ .set('Cookie', cookie())
+ .send({ name: 'Briefed walk again' });
+ expect(copy.status).toBe(201);
+ expect(copy.body.tour).toMatchObject({ name: 'Briefed walk again', route_pending: true, station_count: 2 });
+ const { route_geometry: _line, ...withoutLine } = walkBody();
+ const refused = await request(server).post('/api/trips/5/tours').set('Cookie', cookie()).send(withoutLine);
+ expect(refused.status).toBe(400);
+ });
+ });
+
describe('a walk and its stations (CW-01, CW-02, CW-10)', () => {
it('saves a walk with a key on every point and its station fields, and lists how many stations it has', async () => {
const { placeId, waypoints } = await saveWalk();
diff --git a/server/tests/unit/db/repositories/Tours.repository.test.ts b/server/tests/unit/db/repositories/Tours.repository.test.ts
index 5beac8bce..602ea9121 100644
--- a/server/tests/unit/db/repositories/Tours.repository.test.ts
+++ b/server/tests/unit/db/repositories/Tours.repository.test.ts
@@ -99,6 +99,7 @@ describe('ToursRepository reads', () => {
paddle_lake_style: null,
track_marks: null,
has_recorded_track: 0,
+ route_missing: 1,
revision: 1,
hiking_preferences: null,
bike_profile: null,
@@ -130,6 +131,7 @@ describe('ToursRepository reads', () => {
paddle_lake_style: null,
track_marks: null,
has_recorded_track: 0,
+ route_missing: 1,
revision: 1,
hiking_preferences: null,
bike_profile: null,
@@ -163,6 +165,31 @@ describe('ToursRepository reads', () => {
expect(await repo.findInTrip(trip.id, plain.id)).toBeUndefined();
});
+ it('TOURREPO-014: the list and the detail say whether the owning place still has no line (CW-11)', async () => {
+ const { trip } = fixture();
+ const pending = createPlace(testDb, trip.id, { name: 'Stations only' });
+ const drawn = createPlace(testDb, trip.id, { name: 'Drawn' });
+ createTour(testDb, pending.id);
+ createTour(testDb, drawn.id);
+ await updateRows(t, Places, { id: drawn.id }, { route_geometry: '[[47,11,500],[47.1,11.1,520]]' });
+
+ const listed = new Map((await repo.listForTrip(trip.id)).map((row) => [row.place_id, row.route_missing]));
+ expect([listed.get(pending.id), listed.get(drawn.id)]).toEqual([1, 0]);
+ expect((await repo.findInTrip(trip.id, pending.id))?.route_missing).toBe(1);
+ expect((await repo.findInTrip(trip.id, drawn.id))?.route_missing).toBe(0);
+ });
+
+ it('TOURREPO-015: dayOfTrip reads a day of the trip with its date, and nothing for a day of another trip', async () => {
+ const { trip, other } = fixture();
+ const dated = createDay(testDb, trip.id, { date: '2026-07-12' });
+ const undated = createDay(testDb, trip.id);
+ const elsewhere = createDay(testDb, other.id, { date: '2026-07-12' });
+
+ expect(await repo.dayOfTrip(trip.id, dated.id)).toEqual({ id: dated.id, date: '2026-07-12' });
+ expect(await repo.dayOfTrip(trip.id, undated.id)).toEqual({ id: undated.id, date: null });
+ expect(await repo.dayOfTrip(trip.id, elsewhere.id)).toBeUndefined();
+ });
+
it('TOURREPO-004: existsForPlace and listTourPlaceIds tell tours from plain places', async () => {
const { trip } = fixture();
const tour = createPlace(testDb, trip.id);
@@ -217,6 +244,7 @@ describe('ToursRepository writes', () => {
paddle_lake_style: null,
track_marks: null,
has_recorded_track: 0,
+ route_missing: 1,
revision: 1,
hiking_preferences: null,
bike_profile: null,
diff --git a/server/tests/unit/nest/city-walk-briefing.helpers.test.ts b/server/tests/unit/nest/city-walk-briefing.helpers.test.ts
new file mode 100644
index 000000000..e814e870f
--- /dev/null
+++ b/server/tests/unit/nest/city-walk-briefing.helpers.test.ts
@@ -0,0 +1,437 @@
+/**
+ * CW-11: the pure half of a city walk proposed from a briefing. Walking times
+ * are estimates over the straight distance, stays are only what was given,
+ * an arrival after an unknown stay is the earliest one, and the opening hours
+ * are read with the helpers the planner uses. The distances below run along
+ * one meridian at 48 degrees north: 0.009 degrees are about 1001 metres, an
+ * estimated 18 minutes on foot.
+ */
+import { DomainError } from '../../../src/nest/common/domain-error';
+import {
+ briefingCategories,
+ describeWalk,
+ footWayOf,
+ frameOf,
+ hoursCheckOf,
+ legOf,
+ planWalk,
+ themeCandidates,
+ type FootWay,
+ type WalkCandidate,
+ type WalkFrame,
+ type WalkPlanInput,
+} from '../../../src/nest/tours/city-walk-briefing.helpers';
+import { TOUR_STATION_HOURS_MAX, type TourPoi } from '@trek/shared';
+
+import { describe, expect, it } from 'vitest';
+
+const SUNDAY = '2026-07-12';
+const FRAME: WalkFrame = { start: 10 * 60, end: 13 * 60, date: null };
+/** No way from the router: the estimates time the walk. */
+const UNROUTED: FootWay = { kind: 'unrouted', why: 'router_unavailable', position: null };
+
+function candidate(source: WalkCandidate['source'], lat: number, over: Partial = {}): WalkCandidate {
+ return {
+ source,
+ name: null,
+ lat,
+ lng: 11,
+ stay: null,
+ placeHours: null,
+ osmHours: null,
+ placeId: null,
+ time: null,
+ find: null,
+ ...over,
+ };
+}
+
+const START = candidate('start', 48, { name: 'Hotel', stay: 0 });
+const END = candidate('end', 48.027, { stay: 0 });
+const CATHEDRAL = candidate('wished', 48.009, { name: 'Cathedral', stay: 30 });
+const LUNCH = candidate('appointment', 48.018, { name: 'Lunch', stay: 60, time: 11 * 60 + 30, placeId: 7 });
+
+function plan(over: Partial = {}): WalkPlanInput {
+ return {
+ frame: FRAME,
+ start: START,
+ end: END,
+ appointments: [LUNCH],
+ wished: [CATHEDRAL],
+ tripPlaces: [],
+ themes: [],
+ themeLimit: 8,
+ ...over,
+ };
+}
+
+function poi(id: string, lat: number, over: Partial = {}): TourPoi {
+ return {
+ id,
+ name: id,
+ lat,
+ lng: 11.0005,
+ category: 'museum',
+ kind: 'tourism=museum',
+ source: 'openstreetmap',
+ address: null,
+ website: null,
+ phone: null,
+ opening_hours: null,
+ distance_m: 40,
+ along_m: 0,
+ ...over,
+ };
+}
+
+/** The names of the stations in their order. */
+const order = (route: readonly WalkCandidate[]) => route.map((station) => station.name ?? station.source);
+
+describe('city walk briefing helpers (CW-11)', () => {
+ it('CWALK-HELP-001: the frame is read in minutes, and refused when it is backwards or an appointment lies outside', () => {
+ expect(frameOf({ start_time: '10:00', end_time: '13:00' }, SUNDAY)).toEqual({ start: 600, end: 780, date: SUNDAY });
+ const refusals = [
+ { start_time: '13:00', end_time: '10:00' },
+ { start_time: 'noon', end_time: '13:00' },
+ { start_time: '10:00', end_time: 'later' },
+ { start_time: '10:00', end_time: '13:00', appointments: [{ time: '09:30' }] },
+ { start_time: '10:00', end_time: '13:00', appointments: [{ time: 'later' }] },
+ ].map((briefing) => {
+ try {
+ frameOf(briefing, null);
+ return null;
+ } catch (err: unknown) {
+ return err instanceof DomainError ? [err.getStatus(), err.publicMessage] : err;
+ }
+ });
+ expect(refusals).toEqual([
+ [400, 'end_time must be after start_time, both HH:MM on one day'],
+ [400, 'end_time must be after start_time, both HH:MM on one day'],
+ [400, 'end_time must be after start_time, both HH:MM on one day'],
+ [400, 'An appointment has to lie between start_time and end_time'],
+ [400, 'An appointment has to lie between start_time and end_time'],
+ ]);
+ });
+
+ it('CWALK-HELP-002: the themes ask for their categories once each, as many as one search takes, and name the rest', () => {
+ expect(briefingCategories(['mixed'])).toEqual({
+ searched: ['sights', 'museum', 'park', 'viewpoint', 'cafe', 'market'].map((category) => ({
+ category,
+ theme: 'mixed',
+ })),
+ notSearched: [],
+ });
+ const three = briefingCategories(['culture', 'parks', 'food']);
+ expect(three.searched.map((entry) => `${entry.theme}:${entry.category}`)).toEqual([
+ 'culture:museum',
+ 'culture:sights',
+ 'parks:park',
+ 'parks:viewpoint',
+ 'food:cafe',
+ 'food:restaurant',
+ ]);
+ expect(three.notSearched).toEqual(['market']);
+ });
+
+ it('CWALK-HELP-003: a leg is its straight distance, its walking time an estimate with an allowance for streets', () => {
+ expect(legOf(START, CATHEDRAL)).toEqual({ metres: 1001, minutes: 18 });
+ expect(legOf(START, START)).toEqual({ metres: 0, minutes: 0 });
+ });
+
+ it('CWALK-HELP-004: places of the themes take turns by category, nearest first, without unnamed ones or a station already named', () => {
+ const searched = [
+ { category: 'museum' as const, theme: 'culture' as const },
+ { category: 'park' as const, theme: 'parks' as const },
+ ];
+ const found = [
+ poi('Far museum', 48.003, { distance_m: 400 }),
+ poi('Near museum', 48.004, { distance_m: 20, opening_hours: 'Mo-Su 09:00-18:00' }),
+ poi('', 48.005, { distance_m: 5 }),
+ poi('Garden', 48.006, { category: 'park', kind: 'leisure=park', distance_m: 90 }),
+ poi('Cathedral museum', 48.009, { distance_m: 1 }),
+ poi('Elsewhere', 48.007, { category: 'cafe' }),
+ ];
+ const offered = themeCandidates(found, searched, [START, CATHEDRAL]);
+ expect(order(offered)).toEqual(['Near museum', 'Garden', 'Far museum']);
+ expect(offered[0]).toMatchObject({
+ source: 'theme',
+ stay: null,
+ osmHours: 'Mo-Su 09:00-18:00',
+ find: {
+ theme: 'culture',
+ category: 'museum',
+ kind: 'tourism=museum',
+ found_in: 'openstreetmap',
+ poi_id: 'Near museum',
+ distance_to_search_m: 20,
+ },
+ });
+ expect(themeCandidates([], searched, [])).toEqual([]);
+ });
+
+ it('CWALK-HELP-005: with every stay known the times are complete, the appointment keeps its time and the free time is named', () => {
+ const { route, leftOut } = planWalk(plan());
+ expect(order(route)).toEqual(['Hotel', 'Cathedral', 'Lunch', 'end']);
+ expect(leftOut).toEqual([]);
+ const walk = describeWalk(route, FRAME, UNROUTED);
+ expect(
+ walk.stations.map((s) => [s.name, s.arrival, s.arrival_is_earliest, s.leave, s.walk_from_previous?.minutes]),
+ ).toEqual([
+ ['Hotel', '10:00', false, '10:00', undefined],
+ ['Cathedral', '10:18', false, '10:48', 18],
+ ['Lunch', '11:30', false, '12:30', 18],
+ [null, '12:48', false, '12:48', 18],
+ ]);
+ expect(walk.stations[2]).toMatchObject({
+ appointment_time: '11:30',
+ importance: 'must',
+ protected: true,
+ place_id: 7,
+ hours: { kind: 'no_date' },
+ conflicts: [],
+ });
+ expect(walk.stations[0]).toMatchObject({
+ hours: null,
+ walk_from_previous: null,
+ importance: null,
+ stay_source: 'frame',
+ });
+ expect(walk.stations[1]?.stay_source).toBe('given');
+ expect(walk.gaps).toEqual([
+ { before_position: 3, from: '11:06', to: '11:30', minutes: 24, unknown_stays: 0, kind: 'before_appointment' },
+ { before_position: null, from: '12:48', to: '13:00', minutes: 12, unknown_stays: 0, kind: 'at_end' },
+ ]);
+ expect(walk.totals).toEqual({
+ walk_minutes: 54,
+ walk_m: 3003,
+ stay_minutes_known: 90,
+ stays_unknown: 0,
+ total_minutes: 144,
+ total_complete: true,
+ ends_at: '12:48',
+ ends_at_is_earliest: false,
+ over_frame_by_minutes: 0,
+ minutes_left_for_unknown_stays: null,
+ });
+ expect(walk.estimate).toEqual({
+ kind: 'straight_line',
+ street_factor: 1.3,
+ pace_kmh: 4.5,
+ why: 'router_unavailable',
+ position: null,
+ });
+ expect(walk.wishes_not_met).toEqual([]);
+ expect(walk.can_create).toBe(true);
+ });
+
+ it('CWALK-HELP-006: a place of a theme has no stay: later arrivals are the earliest, and the total is at least', () => {
+ const museum = candidate('theme', 48.0045, { name: 'Museum', lng: 11.0005, osmHours: 'Mo-Su 09:00-18:00' });
+ const { route } = planWalk(plan({ themes: [museum] }));
+ expect(order(route)).toEqual(['Hotel', 'Museum', 'Cathedral', 'Lunch', 'end']);
+ const walk = describeWalk(route, { ...FRAME, date: SUNDAY }, UNROUTED);
+ expect(walk.stations.map((s) => [s.name, s.arrival, s.arrival_is_earliest, s.leave])).toEqual([
+ ['Hotel', '10:00', false, '10:00'],
+ ['Museum', '10:09', false, null],
+ ['Cathedral', '10:18', true, '10:48'],
+ ['Lunch', '11:30', false, '12:30'],
+ [null, '12:48', false, '12:48'],
+ ]);
+ expect(walk.stations[1]).toMatchObject({
+ stay_source: null,
+ importance: 'optional',
+ opening_hours: 'Mo-Su 09:00-18:00',
+ hours: { kind: 'open', source: 'osm', window: { from: '09:00', to: '18:00' } },
+ });
+ // The cathedral has no hours: unknown, never open.
+ expect(walk.stations[2]?.hours).toEqual({ kind: 'unknown' });
+ expect(walk.gaps[0]).toMatchObject({ before_position: 4, minutes: 24, unknown_stays: 1 });
+ expect(walk.totals).toMatchObject({
+ walk_minutes: 54,
+ stays_unknown: 1,
+ total_minutes: 144,
+ total_complete: false,
+ minutes_left_for_unknown_stays: 24,
+ });
+ expect(walk.stations_for_create[1]).toEqual({
+ lat: 48.0045,
+ lng: 11.0005,
+ name: 'Museum',
+ stay_minutes: null,
+ importance: 'optional',
+ protected: false,
+ opening_hours: 'Mo-Su 09:00-18:00',
+ linked_place_id: null,
+ });
+ });
+
+ it('CWALK-HELP-007: a place that cannot fit is left out with the reason; a wish is kept and said to be unmet', () => {
+ const castle = candidate('theme', 48.5, { name: 'Castle', find: null });
+ const gallery = candidate('theme', 48.0135, { name: 'Gallery', osmHours: 'Mo-Fr 09:00-18:00; Sa,Su off' });
+ const shop = candidate('trip_place', 48.012, { name: 'Shop', placeId: 9, osmHours: 'Su 08:00-10:00' });
+ const chapel = candidate('wished', 48.003, { name: 'Chapel', stay: 10, osmHours: 'Su off' });
+ const { route, leftOut } = planWalk(
+ plan({
+ frame: { ...FRAME, date: SUNDAY },
+ wished: [CATHEDRAL, chapel],
+ tripPlaces: [shop],
+ themes: [castle, gallery],
+ }),
+ );
+ expect(order(route)).toEqual(['Hotel', 'Chapel', 'Cathedral', 'Lunch', 'end']);
+ expect(leftOut).toEqual([
+ { name: 'Shop', source: 'trip_place', place_id: 9, poi_id: null, reason: 'closed_at_arrival' },
+ { name: 'Castle', source: 'theme', place_id: null, poi_id: null, reason: 'no_time' },
+ { name: 'Gallery', source: 'theme', place_id: null, poi_id: null, reason: 'closed_all_day' },
+ ]);
+ const walk = describeWalk(route, { ...FRAME, date: SUNDAY }, UNROUTED);
+ expect(walk.wishes_not_met).toEqual([
+ { position: 2, name: 'Chapel', source: 'wished', conflicts: ['closed_all_day'] },
+ ]);
+ });
+
+ it('CWALK-HELP-008: an appointment reached late, a wish after the frame and an end past it are said, never hidden', () => {
+ const early = candidate('appointment', 48.018, { name: 'Tickets', stay: null, time: 10 * 60 + 5 });
+ const far = candidate('wished', 48.2, { name: 'Lake', stay: 30 });
+ const { route } = planWalk(plan({ appointments: [early], wished: [far], end: null }));
+ expect(order(route)).toEqual(['Hotel', 'Tickets', 'Lake']);
+ const walk = describeWalk(route, FRAME, UNROUTED);
+ expect(walk.stations[1]).toMatchObject({ arrival: '10:35', appointment_time: '10:05', conflicts: ['late'] });
+ expect(walk.stations[2]).toMatchObject({ arrival_is_earliest: true, conflicts: ['after_frame'] });
+ expect(walk.wishes_not_met.map((wish) => wish.conflicts)).toEqual([['late'], ['after_frame']]);
+ expect(walk.gaps).toEqual([]);
+ expect(walk.totals.over_frame_by_minutes).toBeGreaterThan(0);
+ // The late appointment does not set the clock again: what follows its unknown stay is the earliest.
+ expect(walk.totals).toMatchObject({ ends_at_is_earliest: true, minutes_left_for_unknown_stays: 0 });
+ });
+
+ it('CWALK-HELP-009: a theme gives as many places as asked and looks at no more than twice as many', () => {
+ const near = [1, 2, 3, 4, 5].map((step) => candidate('theme', 48 + step * 0.0005, { name: `Sight ${step}` }));
+ const one = planWalk(plan({ themes: near, themeLimit: 1, wished: [], appointments: [], end: null }));
+ expect(order(one.route)).toEqual(['Hotel', 'Sight 1']);
+ expect(planWalk(plan({ themes: near, themeLimit: 0 })).route).toHaveLength(4);
+ const closed = near.map((station) => ({ ...station, osmHours: 'Su off' }));
+ const looked = planWalk(plan({ themes: closed, themeLimit: 2, frame: { ...FRAME, date: SUNDAY } }));
+ expect(looked.leftOut.map((station) => station.name)).toEqual(['Sight 1', 'Sight 2', 'Sight 3', 'Sight 4']);
+ });
+
+ it('CWALK-HELP-010: what create_city_walk takes is what the proposal knows: no hours too long to keep, no name made up', () => {
+ const long = 'Mo 09:00-10:00; '.repeat(40);
+ expect(long.length).toBeGreaterThan(TOUR_STATION_HOURS_MAX);
+ const shop = candidate('trip_place', 48.003, { name: 'S'.repeat(250), placeId: 9, osmHours: long, stay: 15 });
+ const lone = describeWalk([candidate('start', 48, { stay: 0 }), shop], FRAME, UNROUTED);
+ expect(lone.stations_for_create).toEqual([
+ {
+ lat: 48,
+ lng: 11,
+ name: null,
+ stay_minutes: 0,
+ importance: null,
+ protected: false,
+ opening_hours: null,
+ linked_place_id: null,
+ },
+ {
+ lat: 48.003,
+ lng: 11,
+ name: 'S'.repeat(200),
+ stay_minutes: 15,
+ importance: null,
+ protected: false,
+ opening_hours: null,
+ linked_place_id: 9,
+ },
+ ]);
+ expect(describeWalk([START], FRAME, UNROUTED)).toMatchObject({
+ can_create: false,
+ stations_for_create: [{ name: 'Hotel' }],
+ });
+ });
+
+ it('CWALK-HELP-011: every answer of the opening hours reads as an assistant reads it', () => {
+ const window = [540, 1080] as const;
+ expect(
+ [
+ 'no_date' as const,
+ { kind: 'unknown' as const },
+ { kind: 'unread' as const, text: 'by appointment' },
+ { kind: 'nextDay' as const },
+ { kind: 'uncertain' as const, source: 'osm' as const },
+ { kind: 'open' as const, source: 'place' as const, window },
+ { kind: 'closedAllDay' as const, source: 'osm' as const },
+ { kind: 'closed' as const, source: 'osm' as const, windows: [window] },
+ { kind: 'closes' as const, source: 'osm' as const, window },
+ ].map(hoursCheckOf),
+ ).toEqual([
+ { kind: 'no_date' },
+ { kind: 'unknown' },
+ { kind: 'unread', text: 'by appointment' },
+ { kind: 'next_day' },
+ { kind: 'uncertain', source: 'osm' },
+ { kind: 'open', source: 'place', window: { from: '09:00', to: '18:00' } },
+ { kind: 'closed_all_day', source: 'osm' },
+ { kind: 'closed', source: 'osm', windows: [{ from: '09:00', to: '18:00' }] },
+ { kind: 'closes', source: 'osm', window: { from: '09:00', to: '18:00' } },
+ ]);
+ });
+
+ it('CWALK-HELP-012: appointments are kept in the order of their times, however they were told', () => {
+ const tea = candidate('appointment', 48.009, { name: 'Tea', stay: 30, time: 12 * 60 });
+ const { route } = planWalk(plan({ appointments: [tea, LUNCH], wished: [] }));
+ expect(order(route)).toEqual(['Hotel', 'Lunch', 'Tea', 'end']);
+ });
+
+ it('CWALK-HELP-013: a place goes in only where every station already on the walk stays open', () => {
+ // The wish closes at 10:40 and is reached at 10:27. The garden lies a kilometre to the side of the start: before
+ // the wish is the shorter detour, but the wish would then be reached at 10:50 at the earliest, so it goes after.
+ const tower = candidate('wished', 48.0135, { name: 'Tower', osmHours: 'Mo-Su 09:00-10:40' });
+ const garden = candidate('theme', 48, { name: 'Garden', lng: 11.0134 });
+ const sunday = { ...FRAME, date: SUNDAY };
+ const { route, leftOut } = planWalk(
+ plan({ frame: sunday, end: null, appointments: [], wished: [tower], themes: [garden] }),
+ );
+ expect(order(route)).toEqual(['Hotel', 'Tower', 'Garden']);
+ expect(leftOut).toEqual([]);
+ const walk = describeWalk(route, sunday, UNROUTED);
+ expect(walk.stations[1]).toMatchObject({ arrival: '10:27', conflicts: [] });
+ expect(walk.wishes_not_met).toEqual([]);
+ });
+
+ it('CWALK-HELP-014: the way the router planned on foot times the walk; a station it had to move far onto a way leaves the estimates', () => {
+ const line: Array<[number, number]> = [
+ [48, 11],
+ [48.009, 11.001],
+ [48.018, 11],
+ ];
+ const parts = [
+ { distance: 1240.4, duration: 900 },
+ { distance: 1180.6, duration: 841 },
+ ];
+ const way = footWayOf({ leg: { line }, parts, snapped: [{ meters: 4 }, { meters: 300 }, { meters: 12 }] });
+ expect(way).toEqual({
+ kind: 'routed',
+ line,
+ legs: [
+ { metres: 1240, minutes: 15 },
+ { metres: 1181, minutes: 15 },
+ ],
+ seconds: 1741,
+ });
+ const { route } = planWalk(plan({ end: null }));
+ const walk = describeWalk(route, FRAME, way);
+ expect(walk.stations.map((s) => [s.name, s.arrival, s.walk_from_previous])).toEqual([
+ ['Hotel', '10:00', null],
+ ['Cathedral', '10:15', { metres: 1240, minutes: 15 }],
+ ['Lunch', '11:30', { metres: 1181, minutes: 15 }],
+ ]);
+ expect(walk.totals).toMatchObject({ walk_minutes: 30, walk_m: 2421 });
+ expect(walk.estimate).toEqual({ kind: 'routed', profile: 'walking' });
+
+ // Without the snapped points nothing says a station is off the way; with one further than the planner allows, nothing is taken.
+ expect(footWayOf({ leg: { line }, parts })).toMatchObject({ kind: 'routed', seconds: 1741 });
+ expect(footWayOf({ leg: { line }, parts, snapped: [{ meters: 4 }, { meters: 301 }, { meters: 12 }] })).toEqual({
+ kind: 'unrouted',
+ why: 'station_off_the_way',
+ position: 2,
+ });
+ });
+});
diff --git a/server/tests/unit/nest/city-walk.mcp.test.ts b/server/tests/unit/nest/city-walk.mcp.test.ts
new file mode 100644
index 000000000..1dca1b426
--- /dev/null
+++ b/server/tests/unit/nest/city-walk.mcp.test.ts
@@ -0,0 +1,510 @@
+/**
+ * CityWalkMcp (CW-11): a city walk prepared from a traveller's briefing. The
+ * proposal reads the trip's day and places, searches the places of the themes
+ * through the map search and plans the way on foot with the road trip's
+ * router, both stubs here, and writes nothing; the confirmed walk is created
+ * through ToursService.createTour, the creation the planner's save uses, with
+ * the way the router planned. The suite takes its subject from the container
+ * over a snapshot test db and seeds through the ORM factories.
+ */
+import { Places } from '../../../src/db/entities/Places.entity';
+import { TourTypes } from '../../../src/db/entities/TourTypes.entity';
+import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
+import { Tours } from '../../../src/db/entities/Tours.entity';
+import type { McpContext } from '../../../src/nest-mcp';
+import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata';
+import { MapsService } from '../../../src/nest/maps/maps.service';
+import { McpToolGuardsService } from '../../../src/nest/mcp-shared/mcp-tool-guards.service';
+import { RoadtripRouterService } from '../../../src/nest/roadtrip/roadtrip-router.service';
+import { CityWalkMcp } from '../../../src/nest/tours/city-walk.mcp';
+import { ToursMcp } from '../../../src/nest/tours/tours.mcp';
+import { ToursModule } from '../../../src/nest/tours/tours.module';
+import { createSnapshotTestDb } from '../../helpers/db-mock';
+import { makePlace } from '../../helpers/factories/places';
+import { countRows, findRow, findRows, updateRows } from '../../helpers/factories/rows';
+import { makeTour } from '../../helpers/factories/tours';
+import { makeDay, makeTrip } from '../../helpers/factories/trips';
+import { makeUser } from '../../helpers/factories/users';
+import { resetTestDb } from '../../helpers/test-db';
+import { createTestModule, type TestModule } from '../../helpers/test-module';
+import { MikroORM } from '@mikro-orm/core';
+import { stationKeySchema, type CityWalkBriefingRequest, type CityWalkStation } from '@trek/shared';
+
+import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const db = createSnapshotTestDb();
+const poisAlong = vi.fn();
+const route = vi.fn();
+let t: TestModule;
+let mcp: CityWalkMcp;
+const orm = () => t.get(MikroORM);
+
+beforeAll(async () => {
+ t = await createTestModule({
+ db,
+ imports: [ToursModule],
+ overrides: [
+ { provide: MapsService, useValue: { poisAlong } },
+ { provide: RoadtripRouterService, useValue: { route } },
+ ],
+ });
+ mcp = t.get(CityWalkMcp);
+});
+beforeEach(() => {
+ resetTestDb(db);
+ poisAlong.mockReset();
+ // Unless a test says otherwise, the router gives no way and the estimates stand.
+ route.mockReset().mockRejectedValue(new Error('Routing provider returned 503'));
+ t.realtime.broadcastMock.mockReset();
+});
+afterAll(async () => {
+ await t.close();
+ db.close();
+});
+
+const ctx = (userId: number) => ({ userId }) as McpContext;
+
+/** The JSON a tool answered, or the error text it refused with. */
+function read(result: { content: Array<{ type: string; text: string }>; isError?: boolean }) {
+ const text = result.content[0]?.text ?? '';
+ return result.isError ? { error: text } : JSON.parse(text);
+}
+
+/** Six days from Monday a place's own hours, closed on Sunday, or open on Sunday morning only. */
+const week = (sunday: { closed: boolean; open?: string; close?: string }) =>
+ JSON.stringify([...Array.from({ length: 6 }, () => ({ closed: false, open: '09:00', close: '17:00' })), sunday]);
+
+function mapsPoi(id: string, name: string, lat: number, category: string, opening_hours: string | null = null) {
+ return {
+ osm_id: id,
+ name,
+ lat,
+ lng: 11.0004,
+ category,
+ poi_type: `tourism=${category}`,
+ address: null,
+ website: null,
+ phone: null,
+ opening_hours,
+ cuisine: null,
+ brand: null,
+ brand_wikidata: null,
+ charging: null,
+ source: 'openstreetmap',
+ };
+}
+
+/**
+ * A trip with a Sunday and its places: a bakery open on Sunday morning, a
+ * museum closed on Sundays, a café for lunch, a place without a position and a
+ * tour; another trip with a day and a place of its own.
+ */
+async function fixture() {
+ const { user } = await makeUser(orm());
+ const { user: outsider } = await makeUser(orm());
+ const trip = await makeTrip(orm(), user.id);
+ const other = await makeTrip(orm(), user.id);
+ const sunday = await makeDay(orm(), trip.id, { day_number: 1, date: '2026-07-12' });
+ const otherDay = await makeDay(orm(), other.id, { day_number: 1, date: '2026-07-12' });
+ const bakery = await makePlace(orm(), trip.id, {
+ name: 'Bakery',
+ lat: 48.003,
+ lng: 11,
+ opening_hours: week({ closed: false, open: '08:00', close: '12:00' }),
+ });
+ const museum = await makePlace(orm(), trip.id, {
+ name: 'Museum',
+ lat: 48.0135,
+ lng: 11,
+ opening_hours: week({ closed: true }),
+ });
+ const cafe = await makePlace(orm(), trip.id, { name: 'Cafe', lat: 48.018, lng: 11 });
+ const nowhere = await makePlace(orm(), trip.id, { name: 'Somewhere', lat: null, lng: null });
+ const ridge = await makePlace(orm(), trip.id, { name: 'Ridge' });
+ await makeTour(orm(), ridge.id);
+ const foreign = await makePlace(orm(), other.id, { name: 'Foreign' });
+ return { user, outsider, trip, other, sunday, otherDay, bakery, museum, cafe, nowhere, ridge, foreign };
+}
+
+type Fixture = Awaited>;
+
+/** Sunday from 10:00 to 13:00 from the hotel: the cathedral wished for, two places of the trip and lunch at 11:30. */
+const briefingOf = (f: Fixture, over: Partial = {}): CityWalkBriefingRequest => ({
+ start: { name: 'Hotel', lat: 48, lng: 11 },
+ day_id: f.sunday.id,
+ start_time: '10:00',
+ end_time: '13:00',
+ themes: ['culture'],
+ wished: [{ name: 'Cathedral', lat: 48.009, lng: 11, stay_minutes: 30 }],
+ trip_places: [{ place_id: f.bakery.id, stay_minutes: 10 }, { place_id: f.museum.id }],
+ appointments: [{ name: 'Lunch', lat: 48.018, lng: 11, time: '11:30', stay_minutes: 60, place_id: f.cafe.id }],
+ ...over,
+});
+
+/** The rows a proposal must leave alone. */
+const written = async () => [
+ await countRows(orm(), Places, {}),
+ await countRows(orm(), Tours, {}),
+ await countRows(orm(), TourWaypoints, {}),
+];
+
+describe('CityWalkMcp (CW-11)', () => {
+ it('CWALK-MCP-001: propose_city_walk orders the stations with their sources, times and hours, and writes nothing', async () => {
+ const f = await fixture();
+ poisAlong.mockResolvedValue({
+ pois: [
+ mapsPoi('node:1', 'Old museum', 48.0045, 'museum_gallery', 'Mo-Su 09:00-18:00'),
+ mapsPoi('node:2', '', 48.006, 'sights'),
+ mapsPoi('node:3', 'Town gate', 48.0075, 'sights'),
+ ],
+ failed: [],
+ partial: false,
+ truncated: false,
+ capped: false,
+ });
+ const before = await written();
+ const proposal = read(await mcp.proposeCityWalk({ tripId: f.trip.id, ...briefingOf(f) }, ctx(f.user.id)));
+
+ expect(proposal.date).toBe('2026-07-12');
+ expect(proposal.stations.map((s: { name: string; source: string }) => [s.name, s.source])).toEqual([
+ ['Hotel', 'start'],
+ ['Bakery', 'trip_place'],
+ ['Old museum', 'theme'],
+ ['Town gate', 'theme'],
+ ['Cathedral', 'wished'],
+ ['Lunch', 'appointment'],
+ ]);
+ const [, bakery, oldMuseum, , cathedral, lunch] = proposal.stations;
+ expect(bakery).toMatchObject({
+ place_id: f.bakery.id,
+ stay_minutes: 10,
+ arrival_is_earliest: false,
+ hours: { kind: 'open', source: 'place', window: { from: '08:00', to: '12:00' } },
+ });
+ expect(oldMuseum).toMatchObject({
+ stay_minutes: null,
+ leave: null,
+ importance: 'optional',
+ found: { theme: 'culture', category: 'museum', found_in: 'openstreetmap', poi_id: 'node:1' },
+ hours: { kind: 'open', source: 'osm' },
+ });
+ expect(cathedral).toMatchObject({ arrival_is_earliest: true, importance: 'must', hours: { kind: 'unknown' } });
+ expect(lunch).toMatchObject({
+ appointment_time: '11:30',
+ arrival: '11:30',
+ protected: true,
+ place_id: f.cafe.id,
+ conflicts: [],
+ });
+ expect(proposal.left_out).toEqual([
+ { name: 'Museum', source: 'trip_place', place_id: f.museum.id, poi_id: null, reason: 'closed_all_day' },
+ ]);
+ expect(proposal.totals).toMatchObject({ stays_unknown: 2, total_complete: false, ends_at: '12:30' });
+ expect(proposal.searched).toEqual({
+ categories: ['museum', 'sights'],
+ categories_not_searched: [],
+ corridor_m: 1000,
+ found: 3,
+ unavailable: [],
+ partial: false,
+ capped: false,
+ truncated: false,
+ });
+ expect(proposal.estimate).toEqual({
+ kind: 'straight_line',
+ street_factor: 1.3,
+ pace_kmh: 4.5,
+ why: 'router_unavailable',
+ position: null,
+ });
+ expect(JSON.stringify(proposal)).not.toContain('route_geometry');
+ // Searched along the traveller's own points: the start, the wish, the trip places and the appointment.
+ expect(poisAlong).toHaveBeenCalledWith(
+ ['museum_gallery', 'sights'],
+ [
+ { lat: 48, lng: 11 },
+ { lat: 48.009, lng: 11 },
+ { lat: 48.003, lng: 11 },
+ { lat: 48.0135, lng: 11 },
+ { lat: 48.018, lng: 11 },
+ ],
+ 1000,
+ undefined,
+ 30,
+ );
+ expect(await written()).toEqual(before);
+ expect(t.realtime.broadcastMock).not.toHaveBeenCalled();
+ });
+
+ it('CWALK-MCP-002: a proposal is refused to an outsider, for another trip’s day and for a frame that runs backwards; a place it cannot use is named', async () => {
+ const f = await fixture();
+ expect(read(await mcp.proposeCityWalk({ tripId: f.trip.id, ...briefingOf(f) }, ctx(f.outsider.id)))).toEqual({
+ error: 'Trip not found or access denied.',
+ });
+ // The registry answers a DomainError with its text; called directly, the tool lets it through.
+ await expect(
+ mcp.proposeCityWalk({ tripId: f.trip.id, ...briefingOf(f, { day_id: f.otherDay.id }) }, ctx(f.user.id)),
+ ).rejects.toThrow('Day not found');
+ await expect(
+ mcp.proposeCityWalk(
+ { tripId: f.trip.id, ...briefingOf(f, { start_time: '14:00', end_time: '13:00' }) },
+ ctx(f.user.id),
+ ),
+ ).rejects.toThrow('end_time must be after start_time, both HH:MM on one day');
+
+ const briefing = briefingOf(f, {
+ day_id: undefined,
+ themes: undefined,
+ theme_places_max: 0,
+ wished: undefined,
+ appointments: undefined,
+ trip_places: [{ place_id: f.foreign.id }, { place_id: f.ridge.id }, { place_id: f.nowhere.id }],
+ end: { lat: 48.001, lng: 11 },
+ });
+ const proposal = read(await mcp.proposeCityWalk({ tripId: f.trip.id, ...briefing }, ctx(f.user.id)));
+ expect(poisAlong).not.toHaveBeenCalled();
+ expect(proposal).toMatchObject({ date: null, searched: null, can_create: true });
+ expect(proposal.left_out).toEqual([
+ { name: null, source: 'trip_place', place_id: f.foreign.id, poi_id: null, reason: 'not_in_trip' },
+ { name: 'Ridge', source: 'trip_place', place_id: f.ridge.id, poi_id: null, reason: 'is_tour' },
+ { name: 'Somewhere', source: 'trip_place', place_id: f.nowhere.id, poi_id: null, reason: 'no_position' },
+ ]);
+ expect(proposal.stations.map((s: { source: string; hours: unknown }) => [s.source, s.hours])).toEqual([
+ ['start', null],
+ ['end', null],
+ ]);
+ });
+
+ it('CWALK-MCP-006: a start and a frame alone search around the start for a bit of everything, and an appointment needs no place', async () => {
+ const f = await fixture();
+ poisAlong.mockResolvedValue({ pois: [], failed: ['market'], partial: true, truncated: false, capped: false });
+ const bare = { start: { lat: 48, lng: 11 }, start_time: '10:00', end_time: '12:00' };
+ const alone = read(await mcp.proposeCityWalk({ tripId: f.trip.id, ...bare }, ctx(f.user.id)));
+ expect(poisAlong).toHaveBeenCalledWith(
+ ['sights', 'museum_gallery', 'park', 'viewpoint', 'cafe', 'market'],
+ [
+ { lat: 48, lng: 11 },
+ { lat: 48, lng: 11 },
+ ],
+ 1000,
+ undefined,
+ 30,
+ );
+ expect(alone).toMatchObject({
+ can_create: false,
+ left_out: [],
+ searched: { categories_not_searched: [], unavailable: ['market'], partial: true },
+ totals: { walk_minutes: 0, total_complete: true, ends_at: '10:00' },
+ });
+
+ const withTickets = read(
+ await mcp.proposeCityWalk(
+ { tripId: f.trip.id, ...bare, appointments: [{ name: 'Tickets', lat: 48.009, lng: 11, time: '11:00' }] },
+ ctx(f.user.id),
+ ),
+ );
+ expect(withTickets.stations[1]).toMatchObject({
+ name: 'Tickets',
+ place_id: null,
+ stay_minutes: null,
+ protected: true,
+ });
+ expect(withTickets.stations_for_create[1]).toMatchObject({ linked_place_id: null, importance: 'must' });
+ });
+
+ it('CWALK-MCP-003: create_city_walk saves the confirmed stations the way the planner saves one, without a line when the router gives none', async () => {
+ const f = await fixture();
+ poisAlong.mockResolvedValue({ pois: [], failed: [], partial: false, truncated: false, capped: false });
+ const proposal = read(await mcp.proposeCityWalk({ tripId: f.trip.id, ...briefingOf(f) }, ctx(f.user.id)));
+ const stations: CityWalkStation[] = proposal.stations_for_create;
+
+ const created = read(
+ await mcp.createCityWalk(
+ { tripId: f.trip.id, name: 'Old town', description: 'Sunday walk', stations },
+ ctx(f.user.id),
+ ),
+ );
+ expect(created.tour).toMatchObject({
+ name: 'Old town',
+ description: 'Sunday walk',
+ tour_type: 'walk',
+ distance: null,
+ elevation_gain: null,
+ duration: null,
+ station_count: 4,
+ route_pending: true,
+ has_waypoints: true,
+ });
+ expect(created.foot_way).toEqual({ planned: false, why: 'router_unavailable', position: null });
+ expect(
+ created.waypoints.map((p: { role: string; sequence: number; name: string }) => [p.role, p.sequence, p.name]),
+ ).toEqual([
+ ['start', 0, 'Hotel'],
+ ['via', 1, 'Bakery'],
+ ['via', 2, 'Cathedral'],
+ ['end', 3, 'Lunch'],
+ ]);
+ expect(created.waypoints.every((p: { key: string }) => stationKeySchema.safeParse(p.key).success)).toBe(true);
+ expect(created.waypoints[1]).toMatchObject({ stay_minutes: 10, linked_place_id: f.bakery.id });
+ expect(created.waypoints[2]).toMatchObject({ stay_minutes: 30, importance: 'must' });
+ expect(created.waypoints[3]).toMatchObject({ importance: 'must', protected: true, linked_place_id: f.cafe.id });
+
+ const placeId = created.tour.place_id;
+ const place = await findRow(orm(), Places, { id: placeId });
+ expect([place?.route_geometry, place?.lat, place?.lng]).toEqual([null, 48, 11]);
+ const tour = await findRow(orm(), Tours, { place: placeId });
+ expect([tour?.distance, tour?.elevation_gain, tour?.elevation_loss, tour?.duration]).toEqual([
+ null,
+ null,
+ null,
+ null,
+ ]);
+ expect(await countRows(orm(), TourWaypoints, { place: placeId })).toBe(4);
+ const events = t.realtime.broadcastMock.mock.calls.map(([tripId, event]) => [tripId, event]);
+ expect(events).toEqual([
+ [String(f.trip.id), 'tours:changed'],
+ [String(f.trip.id), 'place:created'],
+ ]);
+
+ // The tours list says the walk waits for its way.
+ const listed = read(await t.get(ToursMcp).listTours({ tripId: f.trip.id }, ctx(f.user.id))).tours;
+ expect(listed.find((item: { place_id: number }) => item.place_id === placeId)).toMatchObject({
+ route_pending: true,
+ station_count: 4,
+ });
+ });
+
+ it('CWALK-MCP-004: create_city_walk refuses without place_edit, a link to a tour and a switched-off walk; a link of another trip is dropped', async () => {
+ const f = await fixture();
+ const stations: CityWalkStation[] = [
+ { lat: 48, lng: 11, name: 'Hotel' },
+ { lat: 48.009, lng: 11, name: 'Cathedral', linked_place_id: f.foreign.id },
+ ];
+ const before = await written();
+ expect(read(await mcp.createCityWalk({ tripId: f.trip.id, name: 'Walk', stations }, ctx(f.outsider.id)))).toEqual({
+ error: 'Trip not found or access denied.',
+ });
+ const denied = vi.spyOn(t.get(McpToolGuardsService), 'hasTripPermission').mockResolvedValue(false);
+ try {
+ const refused = await mcp.createCityWalk({ tripId: f.trip.id, name: 'Walk', stations }, ctx(f.user.id));
+ expect(refused.isError).toBe(true);
+ expect(denied).toHaveBeenCalledWith('place_edit', f.trip.id, f.user.id);
+ } finally {
+ denied.mockRestore();
+ }
+ const toTour = [stations[0], { ...stations[1], linked_place_id: f.ridge.id }] as CityWalkStation[];
+ await expect(
+ mcp.createCityWalk({ tripId: f.trip.id, name: 'Walk', stations: toTour }, ctx(f.user.id)),
+ ).rejects.toThrow('A waypoint cannot stand for a tour');
+ await updateRows(orm(), TourTypes, { key: 'walk' }, { enabled: 0 });
+ try {
+ await expect(mcp.createCityWalk({ tripId: f.trip.id, name: 'Walk', stations }, ctx(f.user.id))).rejects.toThrow(
+ 'Tour type is not available',
+ );
+ } finally {
+ await updateRows(orm(), TourTypes, { key: 'walk' }, { enabled: 1 });
+ }
+ expect(await written()).toEqual(before);
+
+ const created = read(await mcp.createCityWalk({ tripId: f.trip.id, name: 'Walk', stations }, ctx(f.user.id)));
+ const stored = await findRows(orm(), TourWaypoints, { place: created.tour.place_id }, { sequence: 'asc' });
+ expect(stored.map((point) => point.linked_place_id ?? null)).toEqual([null, null]);
+ });
+
+ it('CWALK-MCP-007: the proposal and the creation take the way the router planned on foot; a station off every way leaves it unplanned', async () => {
+ const f = await fixture();
+ const legs = [
+ { distance: 1120.4, duration: 840 },
+ { distance: 1060.2, duration: 790 },
+ ];
+ /** The router's way through the points it is asked for, each moved onto a way by `moved` metres. */
+ const answer =
+ (moved: number[]) =>
+ async (_userId: number, _tripId: number, _dayId: number, points: Array<{ lat: number; lng: number }>) => ({
+ leg: { line: points.map((point): [number, number] => [point.lat, point.lng]) },
+ parts: legs,
+ snapped: moved.map((meters) => ({ meters })),
+ avoidMissed: [],
+ });
+ route.mockImplementation(answer([3, 8, 5]));
+ const briefing = briefingOf(f, {
+ day_id: undefined,
+ theme_places_max: 0,
+ trip_places: undefined,
+ appointments: undefined,
+ end: { name: 'Station', lat: 48.018, lng: 11 },
+ });
+ const proposal = read(await mcp.proposeCityWalk({ tripId: f.trip.id, ...briefing }, ctx(f.user.id)));
+ const points = [
+ { lat: 48, lng: 11 },
+ { lat: 48.009, lng: 11 },
+ { lat: 48.018, lng: 11 },
+ ];
+ expect(route).toHaveBeenCalledWith(f.user.id, f.trip.id, 0, points, 'walking', []);
+ expect(proposal.estimate).toEqual({ kind: 'routed', profile: 'walking' });
+ expect(
+ proposal.stations.map((s: { name: string; arrival: string; walk_from_previous: unknown }) => [
+ s.name,
+ s.arrival,
+ s.walk_from_previous,
+ ]),
+ ).toEqual([
+ ['Hotel', '10:00', null],
+ ['Cathedral', '10:14', { metres: 1120, minutes: 14 }],
+ ['Station', '10:58', { metres: 1060, minutes: 14 }],
+ ]);
+ expect(proposal.totals).toMatchObject({ walk_minutes: 28, walk_m: 2180, total_complete: true });
+
+ const created = read(
+ await mcp.createCityWalk(
+ { tripId: f.trip.id, name: 'Cathedral walk', stations: proposal.stations_for_create },
+ ctx(f.user.id),
+ ),
+ );
+ expect(route).toHaveBeenLastCalledWith(f.user.id, f.trip.id, 0, points, 'walking', []);
+ expect(created.foot_way).toEqual({ planned: true });
+ expect(created.tour).toMatchObject({ route_pending: false, duration: 27, elevation_gain: null, station_count: 3 });
+ expect(created.tour.distance).toBeCloseTo(2.0, 1);
+ const place = await findRow(orm(), Places, { id: created.tour.place_id });
+ expect(JSON.parse(place?.route_geometry ?? 'null')).toEqual(points.map((point) => [point.lat, point.lng]));
+
+ // The router moved the cathedral 900 metres onto a way: no way on foot reaches it, and the walk waits for its way.
+ route.mockImplementation(answer([3, 900, 5]));
+ const offTheWay = read(
+ await mcp.createCityWalk(
+ { tripId: f.trip.id, name: 'Cathedral walk', stations: proposal.stations_for_create },
+ ctx(f.user.id),
+ ),
+ );
+ expect(offTheWay.foot_way).toEqual({ planned: false, why: 'station_off_the_way', position: 2 });
+ expect(offTheWay.tour).toMatchObject({ route_pending: true, distance: null, duration: null });
+ });
+
+ it('CWALK-MCP-005: the proposal reads only and the creation writes, both on the Tours addon', async () => {
+ const options = (method: string) => {
+ const entry = getEntry(CityWalkMcp as unknown as ClassRef, method);
+ if (!entry || entry.kind !== 'tool') throw new Error(`${method} is no MCP tool`);
+ return entry.options;
+ };
+ const propose = options('proposeCityWalk');
+ const create = options('createCityWalk');
+ expect([propose.name, propose.annotations?.readOnlyHint, propose.access]).toEqual([
+ 'propose_city_walk',
+ true,
+ { group: 'trips', mode: 'read' },
+ ]);
+ expect([create.name, create.annotations?.readOnlyHint, create.access]).toEqual([
+ 'create_city_walk',
+ false,
+ { group: 'trips', mode: 'write' },
+ ]);
+ expect(propose.description).toContain('stations_for_create');
+ expect(create.description).toContain('route_pending');
+ const isAddonEnabled = vi.fn(async (id: string) => id === 'tours');
+ const gated = await Promise.all(
+ [propose, create].map((tool) => tool.when?.(ctx(1), { addons: { isAddonEnabled } })),
+ );
+ expect(gated).toEqual([true, true]);
+ expect(isAddonEnabled).toHaveBeenCalledWith('tours');
+ });
+});
diff --git a/server/tests/unit/nest/tour-library.mcp.test.ts b/server/tests/unit/nest/tour-library.mcp.test.ts
index 7942d77ee..144d91330 100644
--- a/server/tests/unit/nest/tour-library.mcp.test.ts
+++ b/server/tests/unit/nest/tour-library.mcp.test.ts
@@ -4,13 +4,16 @@
* container over a snapshot test db; the addon gate is the registry's.
*/
import { Places } from '../../../src/db/entities/Places.entity';
+import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
import type { McpContext } from '../../../src/nest-mcp';
import { McpToolGuardsService } from '../../../src/nest/mcp-shared/mcp-tool-guards.service';
import { TourLibraryMcp } from '../../../src/nest/tours/tour-library.mcp';
import { ToursModule } from '../../../src/nest/tours/tours.module';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { createPlace, createTrip, createUser } from '../../helpers/factories';
-import { findRow, updateRows } from '../../helpers/factories/rows';
+import { makePlace } from '../../helpers/factories/places';
+import { findRow, findRows, updateRows } from '../../helpers/factories/rows';
+import { addTourWaypoints, makeTour } from '../../helpers/factories/tours';
import { resetTestDb } from '../../helpers/test-db';
import { createTestModule, type TestModule } from '../../helpers/test-module';
import { createTestOrm } from '../../helpers/test-orm';
@@ -127,4 +130,48 @@ describe('TourLibraryMcp', () => {
expect(read(await mcp.listTourLibrary({ trip_id: next.id }, ctx(user.id))).tours).toEqual([]);
denied.mockRestore();
});
+
+ it('TOUR-LIB-MCP-005: a city walk taken over keeps its stations, protected ones included, even before its way is planned', async () => {
+ const { user, alps, next } = await fixture();
+ const orm = await createTestOrm(db);
+ // Saved with its stations only (CW-11): no line yet.
+ const walk = await makePlace(orm, alps.id, { name: 'Old town', lat: 48.1, lng: 11.5 });
+ await makeTour(orm, walk.id, { tourTypeRef: 'walk' });
+ await addTourWaypoints(orm, walk.id, [
+ {
+ lat: 48.1,
+ lng: 11.5,
+ station_key: 'stop-gate',
+ name: 'Gate',
+ stay_minutes: 20,
+ importance: 'must',
+ protected: 1,
+ opening_hours: 'Mo-Su 09:00-18:00',
+ linkedPlace: createPlace(db, alps.id, { name: 'Museum' }).id,
+ },
+ { lat: 48.11, lng: 11.51, station_key: 'stop-park', importance: 'optional' },
+ ]);
+ const stationsOf = async (placeId: number) =>
+ (await findRows(orm, TourWaypoints, { place: placeId }, { sequence: 'asc' })).map((point) => ({
+ key: point.station_key,
+ name: point.name ?? null,
+ stay: point.stay_minutes ?? null,
+ importance: point.importance ?? null,
+ protected: point.protected,
+ hours: point.opening_hours ?? null,
+ linked: point.linked_place_id ?? null,
+ }));
+ const before = await stationsOf(walk.id);
+
+ const result = read(
+ await mcp.takeOverTour({ tripId: next.id, source_trip_id: alps.id, source_place_id: walk.id }, ctx(user.id)),
+ );
+ expect(result.tour).toMatchObject({ tour_type: 'walk', route_pending: true, station_count: 2 });
+ const copied = await findRow(orm, Places, { id: result.tour.place_id });
+ expect([copied?.trip_id, copied?.route_geometry, copied?.lat, copied?.lng]).toEqual([next.id, null, 48.1, 11.5]);
+ // Everything of a station but its link to a place of the other trip.
+ expect(await stationsOf(result.tour.place_id)).toEqual(before.map((station) => ({ ...station, linked: null })));
+ expect(await stationsOf(walk.id)).toEqual(before);
+ await orm.close();
+ });
});
diff --git a/server/tests/unit/nest/tour-visits.service.test.ts b/server/tests/unit/nest/tour-visits.service.test.ts
index a69e8d03c..1774069a2 100644
--- a/server/tests/unit/nest/tour-visits.service.test.ts
+++ b/server/tests/unit/nest/tour-visits.service.test.ts
@@ -222,6 +222,7 @@ describe('TourVisitsService', () => {
stations: [
{
position: 1,
+ key: 'stop-cathedral',
name: 'Cathedral',
lat: 48.1,
lng: 11.5,
@@ -229,10 +230,12 @@ describe('TourVisitsService', () => {
importance: 'must',
protected: true,
stay_minutes: 30,
+ opening_hours: null,
linked_place_id: museum.id,
},
{
position: 2,
+ key: 'stop-market',
name: null,
lat: 48.11,
lng: 11.51,
@@ -240,10 +243,12 @@ describe('TourVisitsService', () => {
importance: 'optional',
protected: false,
stay_minutes: 0,
+ opening_hours: null,
linked_place_id: null,
},
{
position: 3,
+ key: null,
name: null,
lat: 48.12,
lng: 11.52,
@@ -251,10 +256,12 @@ describe('TourVisitsService', () => {
importance: 'normal',
protected: false,
stay_minutes: null,
+ opening_hours: null,
linked_place_id: null,
},
{
position: 4,
+ key: 'stop-park',
name: 'Park',
lat: 48.13,
lng: 11.53,
@@ -262,6 +269,7 @@ describe('TourVisitsService', () => {
importance: 'normal',
protected: false,
stay_minutes: 45,
+ opening_hours: null,
linked_place_id: null,
},
],
diff --git a/server/tests/unit/nest/tours.mcp.test.ts b/server/tests/unit/nest/tours.mcp.test.ts
index ef1bb618f..0c5dcc3b9 100644
--- a/server/tests/unit/nest/tours.mcp.test.ts
+++ b/server/tests/unit/nest/tours.mcp.test.ts
@@ -8,6 +8,9 @@ import { Places } from '../../../src/db/entities/Places.entity';
import { TourStationVisits } from '../../../src/db/entities/TourStationVisits.entity';
import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
import type { McpContext } from '../../../src/nest-mcp';
+import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata';
+import { PlacesMcp } from '../../../src/nest/places/places.mcp';
+import { TourLibraryMcp } from '../../../src/nest/tours/tour-library.mcp';
import { TourVisitsService } from '../../../src/nest/tours/tour-visits.service';
import { ToursMcp } from '../../../src/nest/tours/tours.mcp';
import { ToursModule } from '../../../src/nest/tours/tours.module';
@@ -188,7 +191,7 @@ describe('ToursMcp', () => {
await addTourWaypoints(orm, walk.id, [
{ lat: 48.1, lng: 11.5, station_key: 'stop-cathedral', name: 'Cathedral', stay_minutes: 30 },
{ lat: 48.11, lng: 11.51, station_key: 'stop-market', stay_minutes: 20 },
- { lat: 48.12, lng: 11.52, station_key: 'stop-park', importance: 'must' },
+ { lat: 48.12, lng: 11.52, station_key: 'stop-park', importance: 'must', opening_hours: 'Mo-Su 09:00-18:00' },
]);
const onDay1 = await makeDayAssignment(orm, base.day1.id, walk.id);
await markStationVisit(orm, onDay1.id, 'stop-cathedral', 'visited');
@@ -254,4 +257,101 @@ describe('ToursMcp', () => {
progress.mockRestore();
}
});
+
+ it("TOURS-MCP-011: get_tour_plan spells out every field of a city walk's stations, and leaves its other points and a hike as stored", async () => {
+ const { user, trip, ridge } = fixture();
+ const orm = await createTestOrm(db);
+ const walk = await makePlace(orm, trip.id, { name: 'Loop' });
+ await makeTour(orm, walk.id, { tourTypeRef: 'walk' });
+ await addTourWaypoints(orm, walk.id, [
+ {
+ lat: 48.1,
+ lng: 11.5,
+ station_key: 'stop-gate',
+ name: 'Gate',
+ stay_minutes: 15,
+ importance: 'optional',
+ protected: 1,
+ opening_hours: 'Mo-Su 08:00-20:00',
+ },
+ { lat: 48.105, lng: 11.505, station_key: 'shape-1', shaping: 1 },
+ { lat: 48.11, lng: 11.51, station_key: 'stop-bare' },
+ { lat: 48.1, lng: 11.5, station_key: 'stop-back' },
+ ]);
+ await addTourWaypoints(orm, ridge.id, [
+ { lat: 47, lng: 11 },
+ { lat: 47.1, lng: 11.1 },
+ ]);
+ await orm.close();
+
+ const plan = read(await mcp.getTourPlan({ tripId: trip.id, placeId: walk.id }, ctx(user.id)));
+ const fields = ['key', 'name', 'stay_minutes', 'importance', 'protected', 'opening_hours'] as const;
+ const spelled = (point: Record) => fields.map((field) => point[field]);
+ expect(spelled(plan.waypoints[0])).toEqual(['stop-gate', 'Gate', 15, 'optional', true, 'Mo-Su 08:00-20:00']);
+ expect(spelled(plan.waypoints[2])).toEqual(['stop-bare', null, null, null, false, null]);
+ // A shaping point and the way back of a loop are no stations: as stored.
+ expect('stay_minutes' in plan.waypoints[1] || 'protected' in plan.waypoints[1]).toBe(false);
+ expect('stay_minutes' in plan.waypoints[3] || 'protected' in plan.waypoints[3]).toBe(false);
+ const hike = read(await mcp.getTourPlan({ tripId: trip.id, placeId: ridge.id }, ctx(user.id)));
+ expect(hike.waypoints.some((point: object) => 'stay_minutes' in point || 'protected' in point)).toBe(false);
+ });
+
+ it("TOURS-MCP-012: list_tours counts a walk's stations and says while its way waits to be planned", async () => {
+ const { user, trip, walk, gorge } = await walkFixture();
+ const listed = async () => {
+ const { tours } = read(await mcp.listTours({ tripId: trip.id }, ctx(user.id)));
+ return new Map(tours.map((tour: { place_id: number }) => [tour.place_id, tour]));
+ };
+ expect((await listed()).get(walk.id)).toMatchObject({ station_count: 3, route_pending: true });
+ // A tour without points has nothing to plan a way between.
+ expect((await listed()).get(gorge.id)).toMatchObject({ station_count: null, route_pending: false });
+ const orm = await createTestOrm(db);
+ await updateRows(
+ orm,
+ Places,
+ { id: walk.id },
+ {
+ route_geometry: JSON.stringify([
+ [48.1, 11.5, 520],
+ [48.12, 11.52, 530],
+ ]),
+ },
+ );
+ await orm.close();
+ expect((await listed()).get(walk.id)).toMatchObject({ station_count: 3, route_pending: false });
+ });
+
+ it('TOURS-MCP-013: get_city_walk_progress names each station by its key and gives its opening hours', async () => {
+ const { member, trip, day1, walk } = await walkFixture();
+ const { stations } = read(
+ await mcp.getCityWalkProgress({ tripId: trip.id, placeId: walk.id, dayId: day1.id }, ctx(member.id)),
+ );
+ expect(
+ stations.map((station: { key: string; opening_hours: string | null }) => [station.key, station.opening_hours]),
+ ).toEqual([
+ ['stop-cathedral', null],
+ ['stop-market', null],
+ ['stop-park', 'Mo-Su 09:00-18:00'],
+ ]);
+ });
+
+ it('TOURS-MCP-014: the tools name the station fields, and the deletes ask before a walk takes its protected stations along', () => {
+ const description = (ctor: unknown, method: string) => {
+ const entry = getEntry(ctor as ClassRef, method);
+ if (!entry || entry.kind !== 'tool') throw new Error(`${method} is no MCP tool`);
+ return entry.options.description ?? '';
+ };
+ expect(description(ToursMcp, 'listTours')).toMatch(/station_count.*route_pending/);
+ for (const field of ['key', 'stay_minutes', 'importance', 'protected', 'opening_hours']) {
+ expect(description(ToursMcp, 'getTourPlan')).toContain(field);
+ }
+ expect(description(ToursMcp, 'getCityWalkProgress')).toMatch(/key .*opening hours/);
+ expect(description(ToursMcp, 'searchTourPois')).toContain('route_pending');
+ expect(description(TourLibraryMcp, 'takeOverTour')).toContain('protection');
+ for (const method of ['deletePlace', 'bulkDeletePlaces']) {
+ expect(description(PlacesMcp, method)).toContain(
+ 'protected ones included: name those to the traveller and ask first',
+ );
+ }
+ });
});
diff --git a/server/tests/unit/nest/tours.service.test.ts b/server/tests/unit/nest/tours.service.test.ts
index 63194c4fc..38b6b73c0 100644
--- a/server/tests/unit/nest/tours.service.test.ts
+++ b/server/tests/unit/nest/tours.service.test.ts
@@ -11,6 +11,7 @@ import { db as testDb } from '../../../src/db/database';
import { Places } from '../../../src/db/entities/Places.entity';
import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
import { Tours } from '../../../src/db/entities/Tours.entity';
+import { catchDomainError, DomainError } from '../../../src/nest/common/domain-error';
import type { PlacesService } from '../../../src/nest/places/places.service';
import { ToursService } from '../../../src/nest/tours/tours.service';
import { createPlace, createTrip, createUser } from '../../helpers/factories';
@@ -28,7 +29,7 @@ import {
createTour,
} from '../../helpers/tours-repos';
import type { EntityClass } from '@mikro-orm/core';
-import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
+import { Logger, NotFoundException } from '@nestjs/common';
import { tourCreateRequestSchema, type TourCreateRequest } from '@trek/shared';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -255,7 +256,12 @@ describe('ToursService planner creation', () => {
// day it lets more keys in before the planner offers them.
const bike = { ...request, tour_type: 'run' } as unknown as typeof request;
- await expect(service.createTour(tripId, bike)).rejects.toBeInstanceOf(BadRequestException);
+ // A DomainError: REST answers its text as { error } at 400, an MCP tool as its error result.
+ const refusal = await catchDomainError(() => service.createTour(tripId, bike));
+ expect(refusal instanceof DomainError && [refusal.getStatus(), refusal.publicMessage]).toEqual([
+ 400,
+ 'Tour type is not available',
+ ]);
expect(await count(Places)).toBe(0);
expect(await count(Tours)).toBe(0);
@@ -267,9 +273,11 @@ describe('ToursService planner creation', () => {
broadcast.mockReset();
const bike = { ...request, name: 'Renamed', tour_type: 'run' } as unknown as typeof request;
- await expect(service.updateTour(tripId, String(created.tour.place_id), bike)).rejects.toBeInstanceOf(
- BadRequestException,
- );
+ const refusal = await catchDomainError(() => service.updateTour(tripId, String(created.tour.place_id), bike));
+ expect(refusal instanceof DomainError && [refusal.getStatus(), refusal.publicMessage]).toEqual([
+ 400,
+ 'Tour type is not available',
+ ]);
const storedTour = await firstTour();
expect({ tour_type: storedTour.tour_type, name: (await placeById(Number(storedTour.place_id)))?.name }).toEqual({
diff --git a/server/tests/unit/nest/tours.walk.service.test.ts b/server/tests/unit/nest/tours.walk.service.test.ts
index ff407d106..fea755ece 100644
--- a/server/tests/unit/nest/tours.walk.service.test.ts
+++ b/server/tests/unit/nest/tours.walk.service.test.ts
@@ -7,6 +7,7 @@
*/
import { TourStationVisits } from '../../../src/db/entities/TourStationVisits.entity';
import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
+import { catchDomainError, DomainError } from '../../../src/nest/common/domain-error';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { TourLibraryService } from '../../../src/nest/tours/tour-library.service';
import { TourVisitsService } from '../../../src/nest/tours/tour-visits.service';
@@ -315,6 +316,93 @@ describe('ToursService and city walks', () => {
]);
expect((await tours.listTours(tripId))[0]).toMatchObject({ tour_type: 'walk', station_count: 0 });
});
+
+ it("TOURS-WALK-008: a walk made from its stations alone waits for its way; a save with a line plans it, and a protected stop stays the traveller's to move", async () => {
+ const { tripId } = await tripWithDays();
+ const created = await tours.createCityWalk(
+ tripId,
+ {
+ name: 'Old town',
+ stations: [
+ { lat: 48.1, lng: 11.5, name: 'Cathedral', stay_minutes: 30, importance: 'must', protected: true },
+ { lat: 48.11, lng: 11.51, name: 'Market' },
+ { lat: 48.13, lng: 11.53, name: 'Park', opening_hours: 'Mo-Su 08:00-20:00' },
+ ],
+ },
+ null,
+ );
+ expect(created.tour).toMatchObject({ route_pending: true, distance: null, duration: null, station_count: 3 });
+ expect(created.waypoints.map((point) => [point.role, point.sequence])).toEqual([
+ ['start', 0],
+ ['via', 1],
+ ['end', 2],
+ ]);
+
+ // The planner's save: the protected cathedral moved a little and the market left out, by the traveller.
+ const [cathedral, , park] = created.waypoints;
+ const saved = await tours.updateTour(
+ tripId,
+ String(created.tour.place_id),
+ walkBody({
+ waypoints: inOrder([{ ...cathedral, lat: 48.1005 }, park]),
+ route_geometry: [
+ [48.1005, 11.5, 520],
+ [48.13, 11.53, 528],
+ ],
+ expected_revision: created.tour.revision,
+ }),
+ );
+ expect(saved.tour).toMatchObject({ route_pending: false, station_count: 2, duration: 60 });
+ expect(saved.tour.distance).toBeGreaterThan(0);
+ expect(saved.waypoints[0]).toMatchObject({ key: cathedral.key, lat: 48.1005, protected: true });
+ });
+
+ it('TOURS-WALK-009: a walk still waiting for its way is copied from its first stop; a tour without a line or points is refused', async () => {
+ const { tripId } = await tripWithDays();
+ const created = await tours.createCityWalk(
+ tripId,
+ {
+ name: 'Old town',
+ stations: [
+ { lat: 48.1, lng: 11.5, name: 'Cathedral' },
+ { lat: 48.11, lng: 11.51, name: 'Market' },
+ ],
+ },
+ null,
+ );
+ const copy = await tours.copyTour(tripId, String(created.tour.place_id), 'Old town again');
+ expect(copy.tour).toMatchObject({ name: 'Old town again', route_pending: true, station_count: 2 });
+ expect(copy.waypoints.map((point) => point.key)).toEqual(created.waypoints.map((point) => point.key));
+
+ // Without a line a tour needs two points of its own: none, or one alone, is no tour.
+ const refusals = await Promise.all(
+ [[], walkBody().waypoints.slice(0, 1)].map((waypoints) =>
+ catchDomainError(() => tours.createTour(tripId, { ...walkBody(), route_geometry: null, waypoints })),
+ ),
+ );
+ expect(
+ refusals.map((refusal) => refusal instanceof DomainError && [refusal.getStatus(), refusal.publicMessage]),
+ ).toEqual([
+ [400, 'A tour needs at least two points'],
+ [400, 'A tour needs at least two points'],
+ ]);
+ // The bounds REST's contract holds are the service's too, so an assistant's call meets them as well.
+ const tooLong = await catchDomainError(() =>
+ tours.createCityWalk(
+ tripId,
+ {
+ name: 'Old town',
+ description: 'x'.repeat(2001),
+ stations: [
+ { lat: 48.1, lng: 11.5 },
+ { lat: 48.11, lng: 11.51 },
+ ],
+ },
+ null,
+ ),
+ );
+ expect(tooLong instanceof DomainError && tooLong.publicMessage).toBe('description must be 2000 characters or less');
+ });
});
/** A point without a station's stay, importance or protection, as a hike takes it. */
diff --git a/shared/src/i18n/ar/tours.ts b/shared/src/i18n/ar/tours.ts
index e94a290d9..d076c58fa 100644
--- a/shared/src/i18n/ar/tours.ts
+++ b/shared/src/i18n/ar/tours.ts
@@ -542,6 +542,8 @@ const tours: TranslationStrings = {
'tours.walk.timeEstimatedShort': 'المشي',
'tours.walk.timeEstimatedInfoLabel': 'كيف يُحسب وقت المشي',
'tours.walk.timeEstimatedInfo': 'وقت المسار سيرًا بين المحطات، دون مدد البقاء.',
+ 'tours.walk.routePending': 'حُفظت الجولة بمحطاتها فقط. يُخطَّط الطريق بينها عند فتح الجولة في المخطِّط وحفظها.',
+ 'tours.walk.routePendingPlanner': 'حُفظت الجولة بمحطاتها فقط. خُطِّط الطريق بينها للتو؛ احفظ الجولة للاحتفاظ به.',
'tours.walk.stays': 'مدد البقاء',
'tours.walk.totalAtLeast': '{time} على الأقل',
'tours.walk.totalComputed': 'محسوب',
diff --git a/shared/src/i18n/az/tours.ts b/shared/src/i18n/az/tours.ts
index 39f669a16..9815c730e 100644
--- a/shared/src/i18n/az/tours.ts
+++ b/shared/src/i18n/az/tours.ts
@@ -535,6 +535,10 @@ const tours: TranslationStrings = {
'tours.walk.timeEstimatedShort': 'Yürüş',
'tours.walk.timeEstimatedInfoLabel': 'Yürüş vaxtı necə hesablanır',
'tours.walk.timeEstimatedInfo': 'Dayanacaqlar arasında marşrutlaşdırıcının piyada vaxtı, qalma müddətləri olmadan.',
+ 'tours.walk.routePending':
+ 'Yalnız dayanacaqları ilə yadda saxlanılıb. Aralarındakı yol gəzinti planlayıcıda açılıb yadda saxlananda planlaşdırılır.',
+ 'tours.walk.routePendingPlanner':
+ 'Yalnız dayanacaqları ilə yadda saxlanılıb. Aralarındakı yol indicə planlaşdırıldı; onu saxlamaq üçün gəzintini yadda saxla.',
'tours.walk.stays': 'Qalmalar',
'tours.walk.totalAtLeast': 'ən azı {time}',
'tours.walk.totalComputed': 'Hesablanmış',
diff --git a/shared/src/i18n/br/tours.ts b/shared/src/i18n/br/tours.ts
index e495a32d7..9d2c9c42d 100644
--- a/shared/src/i18n/br/tours.ts
+++ b/shared/src/i18n/br/tours.ts
@@ -542,6 +542,10 @@ const tours: TranslationStrings = {
'tours.walk.timeEstimatedShort': 'Caminhada',
'tours.walk.timeEstimatedInfoLabel': 'Como o tempo de caminhada é calculado',
'tours.walk.timeEstimatedInfo': 'O tempo a pé do roteador entre as paradas, sem as permanências.',
+ 'tours.walk.routePending':
+ 'Salvo só com as paradas. O caminho entre elas é traçado quando o passeio é aberto no planejador e salvo.',
+ 'tours.walk.routePendingPlanner':
+ 'Salvo só com as paradas. O caminho entre elas acabou de ser traçado; salve o passeio para mantê-lo.',
'tours.walk.stays': 'Permanências',
'tours.walk.totalAtLeast': 'pelo menos {time}',
'tours.walk.totalComputed': 'Calculado',
diff --git a/shared/src/i18n/ca/tours.ts b/shared/src/i18n/ca/tours.ts
index de0dbd59c..9ca4e5ca0 100644
--- a/shared/src/i18n/ca/tours.ts
+++ b/shared/src/i18n/ca/tours.ts
@@ -543,6 +543,10 @@ const tours: TranslationStrings = {
'tours.walk.timeEstimatedShort': 'A peu',
'tours.walk.timeEstimatedInfoLabel': 'Com es calcula el temps a peu',
'tours.walk.timeEstimatedInfo': 'El temps a peu del càlcul de ruta entre parades, sense les estades.',
+ 'tours.walk.routePending':
+ "Desada només amb les parades. El camí entre elles es traça quan la ruta s'obre al planificador i es desa.",
+ 'tours.walk.routePendingPlanner':
+ "Desada només amb les parades. El camí entre elles s'acaba de traçar; desa la ruta per conservar-lo.",
'tours.walk.stays': 'Estades',
'tours.walk.totalAtLeast': 'com a mínim {time}',
'tours.walk.totalComputed': 'Calculat',
diff --git a/shared/src/i18n/cs/tours.ts b/shared/src/i18n/cs/tours.ts
index 16f54b6cb..3f450c854 100644
--- a/shared/src/i18n/cs/tours.ts
+++ b/shared/src/i18n/cs/tours.ts
@@ -532,6 +532,10 @@ const tours: TranslationStrings = {
'tours.walk.timeEstimatedShort': 'Chůze',
'tours.walk.timeEstimatedInfoLabel': 'Jak se počítá doba chůze',
'tours.walk.timeEstimatedInfo': 'Doba chůze mezi zastávkami podle plánovače trasy, bez pobytů.',
+ 'tours.walk.routePending':
+ 'Uloženo jen se zastávkami. Cesta mezi nimi se naplánuje, až se procházka otevře v plánovači a uloží.',
+ 'tours.walk.routePendingPlanner':
+ 'Uloženo jen se zastávkami. Cesta mezi nimi se právě naplánovala; ulož procházku, aby zůstala.',
'tours.walk.stays': 'Pobyty',
'tours.walk.totalAtLeast': 'nejméně {time}',
'tours.walk.totalComputed': 'Vypočteno',
diff --git a/shared/src/i18n/de/tours.ts b/shared/src/i18n/de/tours.ts
index c5dbd2931..b4b9253e6 100644
--- a/shared/src/i18n/de/tours.ts
+++ b/shared/src/i18n/de/tours.ts
@@ -546,6 +546,10 @@ const tours: TranslationStrings = {
'tours.walk.timeEstimatedShort': 'Gehzeit',
'tours.walk.timeEstimatedInfoLabel': 'Wie die Gehzeit entsteht',
'tours.walk.timeEstimatedInfo': 'Die Zeit des Routers zu Fuß zwischen den Stationen, ohne die Aufenthalte.',
+ 'tours.walk.routePending':
+ 'Nur mit seinen Stationen gespeichert. Der Weg dazwischen wird geplant, sobald der Walk im Planer geöffnet und gespeichert wird.',
+ 'tours.walk.routePendingPlanner':
+ 'Nur mit seinen Stationen gespeichert. Der Weg dazwischen wurde eben geplant; speichere den Walk, um ihn zu behalten.',
'tours.walk.stays': 'Aufenthalte',
'tours.walk.totalAtLeast': 'mindestens {time}',
'tours.walk.totalComputed': 'Berechnet',
diff --git a/shared/src/i18n/en/tours.ts b/shared/src/i18n/en/tours.ts
index c128ebbb9..962ffd4af 100644
--- a/shared/src/i18n/en/tours.ts
+++ b/shared/src/i18n/en/tours.ts
@@ -533,6 +533,10 @@ const tours: TranslationStrings = {
'tours.walk.timeEstimatedShort': 'Walking',
'tours.walk.timeEstimatedInfoLabel': 'How the walking time is worked out',
'tours.walk.timeEstimatedInfo': "The router's time on foot between the stops, without the stays.",
+ 'tours.walk.routePending':
+ 'Saved with its stops only. The way between them is planned when the walk is opened in the planner and saved.',
+ 'tours.walk.routePendingPlanner':
+ 'Saved with its stops only. The way between them was planned just now; save the walk to keep it.',
'tours.walk.stays': 'Stays',
'tours.walk.totalAtLeast': 'at least {time}',
'tours.walk.totalComputed': 'Computed',
diff --git a/shared/src/i18n/es/tours.ts b/shared/src/i18n/es/tours.ts
index 4bdf3a043..89cbc8f76 100644
--- a/shared/src/i18n/es/tours.ts
+++ b/shared/src/i18n/es/tours.ts
@@ -541,6 +541,10 @@ const tours: TranslationStrings = {
'tours.walk.timeEstimatedShort': 'A pie',
'tours.walk.timeEstimatedInfoLabel': 'Cómo se calcula el tiempo a pie',
'tours.walk.timeEstimatedInfo': 'El tiempo a pie del planificador entre paradas, sin las estancias.',
+ 'tours.walk.routePending':
+ 'Guardado solo con sus paradas. El camino entre ellas se traza al abrir el paseo en el planificador y guardarlo.',
+ 'tours.walk.routePendingPlanner':
+ 'Guardado solo con sus paradas. El camino entre ellas se acaba de trazar; guarda el paseo para conservarlo.',
'tours.walk.stays': 'Estancias',
'tours.walk.totalAtLeast': 'al menos {time}',
'tours.walk.totalComputed': 'Calculado',
diff --git a/shared/src/i18n/et/tours.ts b/shared/src/i18n/et/tours.ts
index fa1fe2039..2a9a3dc8c 100644
--- a/shared/src/i18n/et/tours.ts
+++ b/shared/src/i18n/et/tours.ts
@@ -534,6 +534,10 @@ const tours: TranslationStrings = {
'tours.walk.timeEstimatedShort': 'Kõnd',
'tours.walk.timeEstimatedInfoLabel': 'Kuidas kõndimisaeg arvutatakse',
'tours.walk.timeEstimatedInfo': 'Marsruutija kõndimisaeg peatuste vahel, ilma peatumisteta.',
+ 'tours.walk.routePending':
+ 'Salvestatud ainult peatustega. Tee nende vahel planeeritakse, kui jalutus planeerijas avatakse ja salvestatakse.',
+ 'tours.walk.routePendingPlanner':
+ 'Salvestatud ainult peatustega. Tee nende vahel planeeriti just praegu; salvesta jalutus, et see alles jääks.',
'tours.walk.stays': 'Peatumised',
'tours.walk.totalAtLeast': 'vähemalt {time}',
'tours.walk.totalComputed': 'Arvutatud',
diff --git a/shared/src/i18n/fr/tours.ts b/shared/src/i18n/fr/tours.ts
index b4910b481..c7fd825dd 100644
--- a/shared/src/i18n/fr/tours.ts
+++ b/shared/src/i18n/fr/tours.ts
@@ -550,6 +550,10 @@ const tours: TranslationStrings = {
'tours.walk.timeEstimatedShort': 'Marche',
'tours.walk.timeEstimatedInfoLabel': 'Comment le temps de marche est calculé',
'tours.walk.timeEstimatedInfo': "Le temps à pied du calcul d'itinéraire entre les étapes, sans les arrêts.",
+ 'tours.walk.routePending':
+ 'Enregistrée avec ses étapes seulement. Le chemin entre elles est tracé quand la balade est ouverte dans le planificateur et enregistrée.',
+ 'tours.walk.routePendingPlanner':
+ "Enregistrée avec ses étapes seulement. Le chemin entre elles vient d'être tracé ; enregistre la balade pour le garder.",
'tours.walk.stays': 'Arrêts',
'tours.walk.totalAtLeast': 'au moins {time}',
'tours.walk.totalComputed': 'Calculé',
diff --git a/shared/src/i18n/gr/tours.ts b/shared/src/i18n/gr/tours.ts
index 3bcf57e54..914597ea1 100644
--- a/shared/src/i18n/gr/tours.ts
+++ b/shared/src/i18n/gr/tours.ts
@@ -552,6 +552,10 @@ const tours: TranslationStrings = {
'tours.walk.timeEstimatedShort': 'Περπάτημα',
'tours.walk.timeEstimatedInfoLabel': 'Πώς υπολογίζεται ο χρόνος περπατήματος',
'tours.walk.timeEstimatedInfo': 'Ο χρόνος με τα πόδια του δρομολογητή ανάμεσα στις στάσεις, χωρίς τις παραμονές.',
+ 'tours.walk.routePending':
+ 'Αποθηκεύτηκε μόνο με τις στάσεις της. Η διαδρομή ανάμεσά τους σχεδιάζεται όταν η βόλτα ανοίξει στον σχεδιαστή και αποθηκευτεί.',
+ 'tours.walk.routePendingPlanner':
+ 'Αποθηκεύτηκε μόνο με τις στάσεις της. Η διαδρομή ανάμεσά τους μόλις σχεδιάστηκε· αποθήκευσε τη βόλτα για να την κρατήσεις.',
'tours.walk.stays': 'Παραμονές',
'tours.walk.totalAtLeast': 'τουλάχιστον {time}',
'tours.walk.totalComputed': 'Υπολογισμένο',
diff --git a/shared/src/i18n/hu/tours.ts b/shared/src/i18n/hu/tours.ts
index 295d6a172..faa6d162a 100644
--- a/shared/src/i18n/hu/tours.ts
+++ b/shared/src/i18n/hu/tours.ts
@@ -542,6 +542,10 @@ const tours: TranslationStrings = {
'tours.walk.timeEstimatedShort': 'Gyaloglás',
'tours.walk.timeEstimatedInfoLabel': 'Hogyan számoljuk a gyaloglási időt',
'tours.walk.timeEstimatedInfo': 'Az útvonaltervező gyalogos ideje a megállók között, a tartózkodások nélkül.',
+ 'tours.walk.routePending':
+ 'Csak a megállóival mentve. A köztük lévő utat a TREK akkor tervezi meg, amikor a sétát megnyitják a tervezőben és mentik.',
+ 'tours.walk.routePendingPlanner':
+ 'Csak a megállóival mentve. A köztük lévő utat épp most tervezte meg a TREK; mentsd a sétát, hogy megmaradjon.',
'tours.walk.stays': 'Tartózkodások',
'tours.walk.totalAtLeast': 'legalább {time}',
'tours.walk.totalComputed': 'Számított',
diff --git a/shared/src/i18n/id/tours.ts b/shared/src/i18n/id/tours.ts
index 03e5c6e2a..81c5cc981 100644
--- a/shared/src/i18n/id/tours.ts
+++ b/shared/src/i18n/id/tours.ts
@@ -536,6 +536,10 @@ const tours: TranslationStrings = {
'tours.walk.timeEstimatedShort': 'Berjalan',
'tours.walk.timeEstimatedInfoLabel': 'Cara waktu jalan dihitung',
'tours.walk.timeEstimatedInfo': 'Waktu jalan kaki dari perute di antara titik singgah, tanpa lama singgah.',
+ 'tours.walk.routePending':
+ 'Disimpan hanya dengan titik singgahnya. Jalan di antaranya direncanakan saat rute dibuka di perencana lalu disimpan.',
+ 'tours.walk.routePendingPlanner':
+ 'Disimpan hanya dengan titik singgahnya. Jalan di antaranya baru saja direncanakan; simpan rute ini untuk mempertahankannya.',
'tours.walk.stays': 'Lama singgah',
'tours.walk.totalAtLeast': 'setidaknya {time}',
'tours.walk.totalComputed': 'Dihitung',
diff --git a/shared/src/i18n/it/tours.ts b/shared/src/i18n/it/tours.ts
index 28f057d42..c8c69d103 100644
--- a/shared/src/i18n/it/tours.ts
+++ b/shared/src/i18n/it/tours.ts
@@ -543,6 +543,10 @@ const tours: TranslationStrings = {
'tours.walk.timeEstimatedShort': 'A piedi',
'tours.walk.timeEstimatedInfoLabel': 'Come si calcola il tempo a piedi',
'tours.walk.timeEstimatedInfo': 'Il tempo a piedi del calcolo percorso tra le tappe, senza le soste.',
+ 'tours.walk.routePending':
+ 'Salvato solo con le tappe. Il cammino tra di esse viene tracciato quando il percorso viene aperto nel pianificatore e salvato.',
+ 'tours.walk.routePendingPlanner':
+ 'Salvato solo con le tappe. Il cammino tra di esse è stato appena tracciato; salva il percorso per tenerlo.',
'tours.walk.stays': 'Soste',
'tours.walk.totalAtLeast': 'almeno {time}',
'tours.walk.totalComputed': 'Calcolato',
diff --git a/shared/src/i18n/ja/tours.ts b/shared/src/i18n/ja/tours.ts
index 4300fd36b..0188c2815 100644
--- a/shared/src/i18n/ja/tours.ts
+++ b/shared/src/i18n/ja/tours.ts
@@ -524,6 +524,10 @@ const tours: TranslationStrings = {
'tours.walk.timeEstimatedShort': '歩行',
'tours.walk.timeEstimatedInfoLabel': '歩行時間の求め方',
'tours.walk.timeEstimatedInfo': 'ルート計算による立ち寄り先間の徒歩時間。滞在時間は含みません。',
+ 'tours.walk.routePending':
+ '立ち寄り先だけで保存されています。間の道は、街歩きをプランナーで開いて保存すると計画されます。',
+ 'tours.walk.routePendingPlanner':
+ '立ち寄り先だけで保存されていました。間の道をいま計画しました。残すには街歩きを保存してください。',
'tours.walk.stays': '滞在',
'tours.walk.totalAtLeast': '{time} 以上',
'tours.walk.totalComputed': '計算値',
diff --git a/shared/src/i18n/ko/tours.ts b/shared/src/i18n/ko/tours.ts
index 625f50401..12dda37b2 100644
--- a/shared/src/i18n/ko/tours.ts
+++ b/shared/src/i18n/ko/tours.ts
@@ -521,6 +521,9 @@ const tours: TranslationStrings = {
'tours.walk.timeEstimatedShort': '도보',
'tours.walk.timeEstimatedInfoLabel': '도보 시간 계산 방법',
'tours.walk.timeEstimatedInfo': '경로 계산기가 낸 정류지 사이 도보 시간이며, 머무는 시간은 빠져 있습니다.',
+ 'tours.walk.routePending': '정류지만 저장되어 있습니다. 그 사이의 길은 산책을 플래너에서 열고 저장하면 계획됩니다.',
+ 'tours.walk.routePendingPlanner':
+ '정류지만 저장되어 있었습니다. 그 사이의 길을 방금 계획했습니다. 유지하려면 산책을 저장하세요.',
'tours.walk.stays': '머무는 시간',
'tours.walk.totalAtLeast': '최소 {time}',
'tours.walk.totalComputed': '계산값',
diff --git a/shared/src/i18n/nl/tours.ts b/shared/src/i18n/nl/tours.ts
index fa68f110b..242d67308 100644
--- a/shared/src/i18n/nl/tours.ts
+++ b/shared/src/i18n/nl/tours.ts
@@ -544,6 +544,10 @@ const tours: TranslationStrings = {
'tours.walk.timeEstimatedShort': 'Lopen',
'tours.walk.timeEstimatedInfoLabel': 'Hoe de looptijd wordt berekend',
'tours.walk.timeEstimatedInfo': 'De looptijd van de routeplanner tussen de haltes, zonder het verblijf.',
+ 'tours.walk.routePending':
+ 'Alleen met de haltes opgeslagen. De weg ertussen wordt gepland zodra de wandeling in de planner wordt geopend en opgeslagen.',
+ 'tours.walk.routePendingPlanner':
+ 'Alleen met de haltes opgeslagen. De weg ertussen is net gepland; sla de wandeling op om hem te bewaren.',
'tours.walk.stays': 'Verblijven',
'tours.walk.totalAtLeast': 'minstens {time}',
'tours.walk.totalComputed': 'Berekend',
diff --git a/shared/src/i18n/pl/tours.ts b/shared/src/i18n/pl/tours.ts
index e84cf3acf..0141f349b 100644
--- a/shared/src/i18n/pl/tours.ts
+++ b/shared/src/i18n/pl/tours.ts
@@ -551,6 +551,10 @@ const tours: TranslationStrings = {
'tours.walk.timeEstimatedShort': 'Marsz',
'tours.walk.timeEstimatedInfoLabel': 'Jak liczony jest czas marszu',
'tours.walk.timeEstimatedInfo': 'Czas marszu między przystankami według planera trasy, bez postojów.',
+ 'tours.walk.routePending':
+ 'Zapisano tylko z przystankami. Droga między nimi zostanie wyznaczona, gdy trasa zostanie otwarta w planerze i zapisana.',
+ 'tours.walk.routePendingPlanner':
+ 'Zapisano tylko z przystankami. Droga między nimi została właśnie wyznaczona; zapisz trasę, aby ją zachować.',
'tours.walk.stays': 'Postoje',
'tours.walk.totalAtLeast': 'co najmniej {time}',
'tours.walk.totalComputed': 'Obliczono',
diff --git a/shared/src/i18n/ru/tours.ts b/shared/src/i18n/ru/tours.ts
index 74efca2d1..5c4e0c68b 100644
--- a/shared/src/i18n/ru/tours.ts
+++ b/shared/src/i18n/ru/tours.ts
@@ -553,6 +553,10 @@ const tours: TranslationStrings = {
'tours.walk.timeEstimatedShort': 'Пешком',
'tours.walk.timeEstimatedInfoLabel': 'Как считается время в пути',
'tours.walk.timeEstimatedInfo': 'Время пешком между остановками по расчёту маршрута, без остановок на месте.',
+ 'tours.walk.routePending':
+ 'Сохранена только с остановками. Путь между ними прокладывается, когда прогулку открывают в планировщике и сохраняют.',
+ 'tours.walk.routePendingPlanner':
+ 'Сохранена только с остановками. Путь между ними только что проложен; сохрани прогулку, чтобы его оставить.',
'tours.walk.stays': 'Остановки',
'tours.walk.totalAtLeast': 'не меньше {time}',
'tours.walk.totalComputed': 'Расчёт',
diff --git a/shared/src/i18n/sk/tours.ts b/shared/src/i18n/sk/tours.ts
index 0573c1291..5d5ab8c66 100644
--- a/shared/src/i18n/sk/tours.ts
+++ b/shared/src/i18n/sk/tours.ts
@@ -534,6 +534,10 @@ const tours: TranslationStrings = {
'tours.walk.timeEstimatedShort': 'Chôdza',
'tours.walk.timeEstimatedInfoLabel': 'Ako sa počíta čas chôdze',
'tours.walk.timeEstimatedInfo': 'Čas chôdze medzi zastávkami podľa plánovača trasy, bez pobytov.',
+ 'tours.walk.routePending':
+ 'Uložené len so zastávkami. Cesta medzi nimi sa naplánuje, keď sa prechádzka otvorí v plánovači a uloží.',
+ 'tours.walk.routePendingPlanner':
+ 'Uložené len so zastávkami. Cesta medzi nimi sa práve naplánovala; ulož prechádzku, aby zostala.',
'tours.walk.stays': 'Pobyty',
'tours.walk.totalAtLeast': 'najmenej {time}',
'tours.walk.totalComputed': 'Vypočítané',
diff --git a/shared/src/i18n/sv/tours.ts b/shared/src/i18n/sv/tours.ts
index 05083b8f2..7b8d81087 100644
--- a/shared/src/i18n/sv/tours.ts
+++ b/shared/src/i18n/sv/tours.ts
@@ -533,6 +533,10 @@ const tours: TranslationStrings = {
'tours.walk.timeEstimatedShort': 'Gång',
'tours.walk.timeEstimatedInfoLabel': 'Hur gångtiden räknas fram',
'tours.walk.timeEstimatedInfo': 'Ruttplanerarens gångtid mellan stoppen, utan vistelserna.',
+ 'tours.walk.routePending':
+ 'Sparad med bara sina stopp. Vägen mellan dem planeras när promenaden öppnas i planeraren och sparas.',
+ 'tours.walk.routePendingPlanner':
+ 'Sparad med bara sina stopp. Vägen mellan dem har just planerats; spara promenaden för att behålla den.',
'tours.walk.stays': 'Vistelser',
'tours.walk.totalAtLeast': 'minst {time}',
'tours.walk.totalComputed': 'Beräknad',
diff --git a/shared/src/i18n/th/tours.ts b/shared/src/i18n/th/tours.ts
index e84c0c784..b89535c5e 100644
--- a/shared/src/i18n/th/tours.ts
+++ b/shared/src/i18n/th/tours.ts
@@ -512,6 +512,8 @@ const tours: TranslationStrings = {
'tours.walk.timeEstimatedShort': 'เดิน',
'tours.walk.timeEstimatedInfoLabel': 'วิธีคำนวณเวลาเดิน',
'tours.walk.timeEstimatedInfo': 'เวลาเดินระหว่างจุดแวะจากตัวคำนวณเส้นทาง ไม่รวมเวลาแวะพัก',
+ 'tours.walk.routePending': 'บันทึกไว้เพียงจุดแวะ ทางระหว่างจุดแวะจะถูกวางแผนเมื่อเปิดทริปในตัววางแผนและบันทึก',
+ 'tours.walk.routePendingPlanner': 'บันทึกไว้เพียงจุดแวะ ทางระหว่างจุดแวะเพิ่งถูกวางแผน บันทึกทริปเพื่อเก็บไว้',
'tours.walk.stays': 'เวลาแวะพัก',
'tours.walk.totalAtLeast': 'อย่างน้อย {time}',
'tours.walk.totalComputed': 'ค่าคำนวณ',
diff --git a/shared/src/i18n/tours-locales.spec.ts b/shared/src/i18n/tours-locales.spec.ts
index bd1176f76..7185ec951 100644
--- a/shared/src/i18n/tours-locales.spec.ts
+++ b/shared/src/i18n/tours-locales.spec.ts
@@ -20,7 +20,7 @@ const tokens = (value: string) => [...value.matchAll(/\{[^{}]+\}/g)].map((match)
// placeholder spec; here only the general keys are.
const groups = pluralGroups(Object.keys(english));
const generalKeys = (keys: string[]) => keys.filter((key) => !pluralFormOf(key, groups));
-const GENERAL_KEY_COUNT = 825;
+const GENERAL_KEY_COUNT = 827;
function literalKeys(source: string): string[] {
const tree = ts.createSourceFile('locale.ts', source, ts.ScriptTarget.Latest, true);
diff --git a/shared/src/i18n/tr/tours.ts b/shared/src/i18n/tr/tours.ts
index 3c30fa0ec..dae962abd 100644
--- a/shared/src/i18n/tr/tours.ts
+++ b/shared/src/i18n/tr/tours.ts
@@ -536,6 +536,10 @@ const tours: TranslationStrings = {
'tours.walk.timeEstimatedShort': 'Yürüme',
'tours.walk.timeEstimatedInfoLabel': 'Yürüme süresi nasıl hesaplanır',
'tours.walk.timeEstimatedInfo': 'Rota hesaplayıcının duraklar arasındaki yürüme süresi, kalışlar hariç.',
+ 'tours.walk.routePending':
+ 'Yalnızca duraklarıyla kaydedildi. Aralarındaki yol, yürüyüş planlayıcıda açılıp kaydedildiğinde planlanır.',
+ 'tours.walk.routePendingPlanner':
+ 'Yalnızca duraklarıyla kaydedildi. Aralarındaki yol az önce planlandı; korumak için yürüyüşü kaydet.',
'tours.walk.stays': 'Kalışlar',
'tours.walk.totalAtLeast': 'en az {time}',
'tours.walk.totalComputed': 'Hesaplanan',
diff --git a/shared/src/i18n/uk/tours.ts b/shared/src/i18n/uk/tours.ts
index 24d3511ea..d687ad1e4 100644
--- a/shared/src/i18n/uk/tours.ts
+++ b/shared/src/i18n/uk/tours.ts
@@ -549,6 +549,10 @@ const tours: TranslationStrings = {
'tours.walk.timeEstimatedShort': 'Ходьба',
'tours.walk.timeEstimatedInfoLabel': 'Як рахується час ходьби',
'tours.walk.timeEstimatedInfo': 'Час пішки між зупинками за розрахунком маршруту, без перебування.',
+ 'tours.walk.routePending':
+ 'Збережено лише із зупинками. Дорогу між ними прокладають, коли прогулянку відкривають у планувальнику й зберігають.',
+ 'tours.walk.routePendingPlanner':
+ 'Збережено лише із зупинками. Дорогу між ними щойно прокладено; збережи прогулянку, щоб її залишити.',
'tours.walk.stays': 'Перебування',
'tours.walk.totalAtLeast': 'щонайменше {time}',
'tours.walk.totalComputed': 'Розрахунок',
diff --git a/shared/src/i18n/vi/tours.ts b/shared/src/i18n/vi/tours.ts
index 9ff4301e8..1c1474bb9 100644
--- a/shared/src/i18n/vi/tours.ts
+++ b/shared/src/i18n/vi/tours.ts
@@ -532,6 +532,10 @@ const tours: TranslationStrings = {
'tours.walk.timeEstimatedShort': 'Đi bộ',
'tours.walk.timeEstimatedInfoLabel': 'Cách tính thời gian đi bộ',
'tours.walk.timeEstimatedInfo': 'Thời gian đi bộ giữa các điểm dừng do bộ định tuyến tính, không gồm thời gian dừng.',
+ 'tours.walk.routePending':
+ 'Chỉ được lưu cùng các điểm dừng. Đường đi giữa chúng sẽ được lên khi lộ trình được mở trong trình lập kế hoạch và lưu lại.',
+ 'tours.walk.routePendingPlanner':
+ 'Chỉ được lưu cùng các điểm dừng. Đường đi giữa chúng vừa được lên; hãy lưu lộ trình để giữ lại.',
'tours.walk.stays': 'Thời gian dừng',
'tours.walk.totalAtLeast': 'ít nhất {time}',
'tours.walk.totalComputed': 'Đã tính',
diff --git a/shared/src/i18n/zh-TW/tours.ts b/shared/src/i18n/zh-TW/tours.ts
index 6648b3e97..523f99bc2 100644
--- a/shared/src/i18n/zh-TW/tours.ts
+++ b/shared/src/i18n/zh-TW/tours.ts
@@ -492,6 +492,8 @@ const tours: TranslationStrings = {
'tours.walk.timeEstimatedShort': '步行',
'tours.walk.timeEstimatedInfoLabel': '步行時間如何計算',
'tours.walk.timeEstimatedInfo': '路線規劃給出的站點間步行時間,不含停留時間。',
+ 'tours.walk.routePending': '僅儲存了站點。站點之間的道路會在規劃器中開啟並儲存漫步時規劃。',
+ 'tours.walk.routePendingPlanner': '先前僅儲存了站點。站點之間的道路剛剛規劃好;儲存漫步即可保留。',
'tours.walk.stays': '停留',
'tours.walk.totalAtLeast': '至少 {time}',
'tours.walk.totalComputed': '計算值',
diff --git a/shared/src/i18n/zh/tours.ts b/shared/src/i18n/zh/tours.ts
index de7d792ea..8331787d5 100644
--- a/shared/src/i18n/zh/tours.ts
+++ b/shared/src/i18n/zh/tours.ts
@@ -493,6 +493,8 @@ const tours: TranslationStrings = {
'tours.walk.timeEstimatedShort': '步行',
'tours.walk.timeEstimatedInfoLabel': '步行时间如何计算',
'tours.walk.timeEstimatedInfo': '路线规划给出的站点间步行时间,不含停留时间。',
+ 'tours.walk.routePending': '仅保存了站点。站点之间的道路会在规划器中打开并保存漫步时规划。',
+ 'tours.walk.routePendingPlanner': '此前仅保存了站点。站点之间的道路刚刚规划好;保存漫步即可保留。',
'tours.walk.stays': '停留',
'tours.walk.totalAtLeast': '至少 {time}',
'tours.walk.totalComputed': '计算值',
diff --git a/shared/src/index.ts b/shared/src/index.ts
index c359662ae..acb3767af 100644
--- a/shared/src/index.ts
+++ b/shared/src/index.ts
@@ -61,6 +61,7 @@ export * from './tours/tour-poi-search.schema';
export * from './tours/tour-trail-grade.schema';
export * from './tours/tour-walk.schema';
export * from './tours/tour-walk';
+export * from './tours/tour-walk-briefing.schema';
export * from './tours/walk-hours';
export * from './tours/tours.metrics';
export * from './tours/tour-gpx-import.schema';
diff --git a/shared/src/place/place-hours.spec.ts b/shared/src/place/place-hours.spec.ts
index af7cadb35..bc5ea36d0 100644
--- a/shared/src/place/place-hours.spec.ts
+++ b/shared/src/place/place-hours.spec.ts
@@ -1,4 +1,10 @@
-import { hasPlaceHours, parsePlaceHours, placeEmailField, placeOpeningHoursField } from './place-hours';
+import {
+ clockTimeSchema,
+ hasPlaceHours,
+ parsePlaceHours,
+ placeEmailField,
+ placeOpeningHoursField,
+} from './place-hours';
import { placeCreateRequestSchema, placeUpdateRequestSchema } from './place.schema';
import { describe, expect, it } from 'vitest';
@@ -36,4 +42,15 @@ describe('place hours and e-mail (#2472)', () => {
expect(placeCreateRequestSchema.safeParse({ name: 'X', email: 'nope' }).success).toBe(false);
expect(placeUpdateRequestSchema.safeParse({ opening_hours: WEEK, email: null }).success).toBe(true);
});
+
+ it('reads a time of day on the 24-hour clock, HH:MM, and nothing else', () => {
+ expect(['00:00', '09:30', '23:59'].map((time) => clockTimeSchema.safeParse(time).success)).toEqual([
+ true,
+ true,
+ true,
+ ]);
+ expect(
+ ['9:30', '24:00', '12:60', '09:30:00', 'noon'].map((time) => clockTimeSchema.safeParse(time).success),
+ ).toEqual([false, false, false, false, false]);
+ });
});
diff --git a/shared/src/place/place-hours.ts b/shared/src/place/place-hours.ts
index 196f397da..746488851 100644
--- a/shared/src/place/place-hours.ts
+++ b/shared/src/place/place-hours.ts
@@ -11,12 +11,13 @@ import { z } from 'zod';
*/
export const PLACE_HOURS_DAYS = 7;
-const TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
+/** A time of day on the 24-hour clock, `HH:MM`. */
+export const clockTimeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/);
export const placeHoursDaySchema = z.object({
closed: z.boolean(),
- open: z.string().regex(TIME).optional(),
- close: z.string().regex(TIME).optional(),
+ open: clockTimeSchema.optional(),
+ close: clockTimeSchema.optional(),
});
export type PlaceHoursDay = z.infer;
diff --git a/shared/src/tours/tour-poi-search.schema.ts b/shared/src/tours/tour-poi-search.schema.ts
index aaa7f4196..2781dc4ce 100644
--- a/shared/src/tours/tour-poi-search.schema.ts
+++ b/shared/src/tours/tour-poi-search.schema.ts
@@ -44,14 +44,15 @@ export const TOUR_POI_CATEGORIES_BY_TYPE: Record;
export const WALK_THEMES = {
culture: ['museum', 'sights'],
parks: ['park', 'viewpoint'],
food: ['cafe', 'restaurant', 'market'],
mixed: ['sights', 'museum', 'park', 'viewpoint', 'cafe', 'market'],
-} as const satisfies Record;
-export type WalkTheme = keyof typeof WALK_THEMES;
-export const WALK_THEME_KEYS = Object.keys(WALK_THEMES) as WalkTheme[];
+} as const satisfies Record;
/** How far from the line a place may lie, the choices offered, in metres. */
export const TOUR_POI_CORRIDORS_M = [250, 500, 1000, 2000] as const;
diff --git a/shared/src/tours/tour-walk-briefing.schema.spec.ts b/shared/src/tours/tour-walk-briefing.schema.spec.ts
new file mode 100644
index 000000000..1c96e5fb8
--- /dev/null
+++ b/shared/src/tours/tour-walk-briefing.schema.spec.ts
@@ -0,0 +1,97 @@
+import { walkThemeSchema, WALK_THEME_KEYS, WALK_THEMES } from './tour-poi-search.schema';
+import {
+ CITY_WALK_BRIEFING_LIMITS,
+ CITY_WALK_MAX_STATIONS,
+ cityWalkBriefingRequestSchema,
+ cityWalkCreateRequestSchema,
+} from './tour-walk-briefing.schema';
+
+import { describe, expect, it } from 'vitest';
+
+const briefing = {
+ start: { lat: 48.1, lng: 11.5 },
+ start_time: '10:00',
+ end_time: '13:00',
+};
+
+const spot = (index: number) => ({ name: `Stop ${index}`, lat: 48.1, lng: 11.5 });
+
+describe('a city walk prepared from a briefing (CW-11)', () => {
+ it('takes the four themes of a city walk, by their keys', () => {
+ expect(walkThemeSchema.options).toEqual([...WALK_THEME_KEYS]);
+ expect(Object.keys(WALK_THEMES)).toEqual([...WALK_THEME_KEYS]);
+ expect(walkThemeSchema.safeParse('nightlife').success).toBe(false);
+ });
+
+ it('reads a start, a frame and nothing else as a briefing, and every part the traveller can add', () => {
+ expect(cityWalkBriefingRequestSchema.safeParse(briefing).success).toBe(true);
+ const full = {
+ ...briefing,
+ start: { name: 'Hotel', lat: 48.1, lng: 11.5 },
+ end: { lat: 48.12, lng: 11.52 },
+ day_id: 3,
+ themes: ['culture', 'food'],
+ wished: [{ ...spot(1), stay_minutes: 30, opening_hours: 'Mo-Su 09:00-18:00' }],
+ trip_places: [{ place_id: 4, stay_minutes: null }],
+ appointments: [{ ...spot(2), time: '11:30', stay_minutes: 60, place_id: 5 }],
+ theme_places_max: 0,
+ corridor_m: 500,
+ lang: 'de',
+ };
+ expect(cityWalkBriefingRequestSchema.parse(full)).toEqual(full);
+ });
+
+ it('refuses a time that is no time of day, a field it does not know and more than its limits', () => {
+ const refused = [
+ { ...briefing, start_time: '9:00' },
+ { ...briefing, end_time: '24:00' },
+ { ...briefing, appointments: [{ ...spot(1), time: 'noon' }] },
+ { ...briefing, start: { ...briefing.start, address: 'Main square' } },
+ { ...briefing, route: [] },
+ { ...briefing, themes: [] },
+ { ...briefing, themes: ['culture', 'parks', 'food', 'mixed'] },
+ { ...briefing, wished: Array.from({ length: CITY_WALK_BRIEFING_LIMITS.wished + 1 }, (_, i) => spot(i)) },
+ {
+ ...briefing,
+ trip_places: Array.from({ length: CITY_WALK_BRIEFING_LIMITS.tripPlaces + 1 }, (_, i) => ({ place_id: i + 1 })),
+ },
+ {
+ ...briefing,
+ appointments: Array.from({ length: CITY_WALK_BRIEFING_LIMITS.appointments + 1 }, (_, i) => ({
+ ...spot(i),
+ time: '11:00',
+ })),
+ },
+ { ...briefing, theme_places_max: CITY_WALK_BRIEFING_LIMITS.themePlaces + 1 },
+ { ...briefing, corridor_m: 5000 },
+ ];
+ expect(refused.map((body) => cityWalkBriefingRequestSchema.safeParse(body).success)).toEqual(
+ refused.map(() => false),
+ );
+ });
+
+ it('creates a walk with room for every station a proposal can hold', () => {
+ const { wished, tripPlaces, appointments, themePlaces } = CITY_WALK_BRIEFING_LIMITS;
+ // The start and the end besides.
+ expect(wished + tripPlaces + appointments + themePlaces + 2).toBeLessThanOrEqual(CITY_WALK_MAX_STATIONS);
+ });
+
+ it('creates from two stations up, each with its own fields and no key, which the server gives', () => {
+ const station = { lat: 48.1, lng: 11.5, name: 'Gate', stay_minutes: 15, importance: 'must', protected: true };
+ expect(cityWalkCreateRequestSchema.safeParse({ name: ' Old town ', stations: [station, station] })).toMatchObject({
+ success: true,
+ data: { name: 'Old town' },
+ });
+ const refused = [
+ { name: 'Old town', stations: [station] },
+ { name: '', stations: [station, station] },
+ { name: 'Old town', stations: [{ ...station, key: 'stop-gate' }, station] },
+ { name: 'Old town', stations: [{ ...station, role: 'start' }, station] },
+ { name: 'Old town', stations: Array.from({ length: CITY_WALK_MAX_STATIONS + 1 }, () => station) },
+ { name: 'Old town', stations: [station, station], route_geometry: [] },
+ ];
+ expect(refused.map((body) => cityWalkCreateRequestSchema.safeParse(body).success)).toEqual(
+ refused.map(() => false),
+ );
+ });
+});
diff --git a/shared/src/tours/tour-walk-briefing.schema.ts b/shared/src/tours/tour-walk-briefing.schema.ts
new file mode 100644
index 000000000..be0df0229
--- /dev/null
+++ b/shared/src/tours/tour-walk-briefing.schema.ts
@@ -0,0 +1,132 @@
+import { idSchema } from '../common/primitives.schema';
+import { clockTimeSchema } from '../place/place-hours';
+import { tourPoiSearchRequestSchema, walkThemeSchema } from './tour-poi-search.schema';
+import { TOUR_STATION_NAME_MAX, tourDescriptionSchema, tourNameSchema, tourWaypointSchema } from './tours.schema';
+
+import { z } from 'zod';
+
+/**
+ * CW-11: a city walk an assistant prepares from a traveller's briefing. The
+ * proposal reads only; the walk is created once the traveller confirmed it,
+ * through the same creation the planner's save uses. Nothing here carries a
+ * route: the server plans the way on foot through the stations, for the
+ * proposal and again for the creation.
+ */
+
+/** How much one briefing may name, and how many places of its themes a proposal offers at most. */
+export const CITY_WALK_BRIEFING_LIMITS = {
+ wished: 10,
+ tripPlaces: 12,
+ appointments: 5,
+ themes: 3,
+ themePlaces: 8,
+} as const;
+
+/** The most stations a city walk created from a briefing has: everything a proposal can hold, start and end included. */
+export const CITY_WALK_MAX_STATIONS = 40;
+
+const stationName = z.string().trim().min(1).max(TOUR_STATION_NAME_MAX);
+const { lat, lng, stay_minutes, opening_hours } = tourWaypointSchema.shape;
+
+const walkEndSchema = z.object({ name: stationName.optional(), lat, lng }).strict();
+
+const wishedStationSchema = z
+ .object({
+ name: stationName,
+ lat,
+ lng,
+ stay_minutes: stay_minutes.describe(
+ 'How long the traveller wants to stay, in minutes; left out when they did not say',
+ ),
+ opening_hours: opening_hours.describe('Its opening hours as OpenStreetMap writes them, where known'),
+ })
+ .strict();
+
+const tripPlaceChoiceSchema = z
+ .object({
+ place_id: idSchema.describe('A place of this trip, as list_places gives it'),
+ stay_minutes: stay_minutes.describe(
+ 'How long the traveller wants to stay there, in minutes; left out when they did not say',
+ ),
+ })
+ .strict();
+
+const appointmentSchema = z
+ .object({
+ name: stationName,
+ lat,
+ lng,
+ time: clockTimeSchema.describe('When the traveller has to be there, HH:MM'),
+ stay_minutes: stay_minutes.describe('How long it takes, in minutes; left out when they did not say'),
+ place_id: idSchema.optional().describe('The place of this trip it is at, if it is one'),
+ })
+ .strict();
+
+export const cityWalkBriefingRequestSchema = z
+ .object({
+ start: walkEndSchema.describe('Where the walk starts'),
+ end: walkEndSchema.optional().describe('Where it has to end; left out, it ends at its last station'),
+ day_id: idSchema
+ .optional()
+ .describe('The day of the trip it is for, as get_trip_summary gives it: its date decides the opening hours'),
+ start_time: clockTimeSchema.describe('When it starts, HH:MM'),
+ end_time: clockTimeSchema.describe('When it has to be over, HH:MM, later the same day'),
+ themes: z
+ .array(walkThemeSchema)
+ .min(1)
+ .max(CITY_WALK_BRIEFING_LIMITS.themes)
+ .optional()
+ .describe('What the traveller is interested in: culture, parks, food or mixed; mixed when left out'),
+ wished: z
+ .array(wishedStationSchema)
+ .max(CITY_WALK_BRIEFING_LIMITS.wished)
+ .optional()
+ .describe('The stations the traveller asked for by name and position'),
+ trip_places: z
+ .array(tripPlaceChoiceSchema)
+ .max(CITY_WALK_BRIEFING_LIMITS.tripPlaces)
+ .optional()
+ .describe('The places of this trip the walk may use; none when left out'),
+ appointments: z
+ .array(appointmentSchema)
+ .max(CITY_WALK_BRIEFING_LIMITS.appointments)
+ .optional()
+ .describe('Fixed appointments the traveller told you, with their time'),
+ theme_places_max: z
+ .number()
+ .int()
+ .min(0)
+ .max(CITY_WALK_BRIEFING_LIMITS.themePlaces)
+ .optional()
+ .describe('How many places of the themes to offer at most; 0 searches none'),
+ corridor_m: tourPoiSearchRequestSchema.shape.corridor_m
+ .optional()
+ .describe('How far from the start, the wishes and the appointments to search, in metres; 1000 when left out'),
+ lang: tourPoiSearchRequestSchema.shape.lang,
+ })
+ .strict();
+export type CityWalkBriefingRequest = z.infer;
+
+/** One station of a city walk created from a briefing, in its order: no key, the server gives each one. */
+export const cityWalkStationSchema = tourWaypointSchema
+ .pick({
+ lat: true,
+ lng: true,
+ name: true,
+ stay_minutes: true,
+ importance: true,
+ protected: true,
+ opening_hours: true,
+ linked_place_id: true,
+ })
+ .strict();
+export type CityWalkStation = z.infer;
+
+export const cityWalkCreateRequestSchema = z
+ .object({
+ name: tourNameSchema,
+ description: tourDescriptionSchema,
+ stations: z.array(cityWalkStationSchema).min(2).max(CITY_WALK_MAX_STATIONS),
+ })
+ .strict();
+export type CityWalkCreateRequest = z.infer;
diff --git a/shared/src/tours/tours.schema.test.ts b/shared/src/tours/tours.schema.test.ts
index c2c5cd149..59189185b 100644
--- a/shared/src/tours/tours.schema.test.ts
+++ b/shared/src/tours/tours.schema.test.ts
@@ -9,9 +9,12 @@ import {
recommendedPaddleSpeedKmh,
plannedTourDurationMinutesSchema,
stationKeySchema,
+ tourCopyRequestSchema,
tourCreateRequestSchema,
+ tourDescriptionSchema,
tourListItemSchema,
tourMaxHikingDifficultySchema,
+ tourNameSchema,
tourWebsiteSchema,
} from './tours.schema';
@@ -487,27 +490,47 @@ describe('city walks (CW-01 to CW-03, CW-10)', () => {
}
});
+ const listed = {
+ place_id: 1,
+ name: 'Old town',
+ tour_type: 'walk',
+ distance: null,
+ elevation_gain: null,
+ elevation_loss: null,
+ duration: null,
+ difficulty: null,
+ wanderer_ref: null,
+ match_confidence: null,
+ max_hiking_difficulty: null,
+ planned: false,
+ caution: false,
+ has_waypoints: true,
+ station_count: 3,
+ };
+
it("lists a walk's station count, and reads an item cached before it existed", () => {
- const item = tourListItemSchema.parse({
- place_id: 1,
- name: 'Old town',
- tour_type: 'walk',
- distance: null,
- elevation_gain: null,
- elevation_loss: null,
- duration: null,
- difficulty: null,
- wanderer_ref: null,
- match_confidence: null,
- max_hiking_difficulty: null,
- planned: false,
- caution: false,
- has_waypoints: true,
- station_count: 3,
- });
+ const item = tourListItemSchema.parse(listed);
expect(item.station_count).toBe(3);
const { station_count: _count, ...cached } = item;
expect(tourListItemSchema.parse(cached).station_count).toBeUndefined();
expect(tourListItemSchema.safeParse({ ...item, station_count: -1 }).success).toBe(false);
});
+
+ it('lists whether a walk still waits for its way, and reads a list cached before it was said (CW-11)', () => {
+ expect(tourListItemSchema.parse({ ...listed, route_pending: true }).route_pending).toBe(true);
+ expect(tourListItemSchema.parse(listed).route_pending).toBeUndefined();
+ expect(tourListItemSchema.safeParse({ ...listed, route_pending: 1 }).success).toBe(false);
+ });
+
+ it('holds a create, an edit and a copy to one name and one description', () => {
+ expect(tourNameSchema.parse(' Old town ')).toBe('Old town');
+ expect(['', ' ', 'x'.repeat(256)].map((name) => tourNameSchema.safeParse(name).success)).toEqual([
+ false,
+ false,
+ false,
+ ]);
+ expect(tourCopyRequestSchema.safeParse({ name: 'x'.repeat(256) }).success).toBe(false);
+ expect(tourDescriptionSchema.parse(null)).toBeNull();
+ expect(tourDescriptionSchema.safeParse('x'.repeat(2001)).success).toBe(false);
+ });
});
diff --git a/shared/src/tours/tours.schema.ts b/shared/src/tours/tours.schema.ts
index 4853ec153..6133835ac 100644
--- a/shared/src/tours/tours.schema.ts
+++ b/shared/src/tours/tours.schema.ts
@@ -241,6 +241,12 @@ export const tourListItemSchema = tourSchema.extend({
station_stay_minutes: z.number().int().nonnegative().nullable().optional(),
/** CW-02: how many of a city walk's stations have no stay entered yet; null for any other type. */
station_stays_unknown: z.number().int().nonnegative().nullable().optional(),
+ /**
+ * CW-11: a tour saved with its points only, its way not planned yet (a city
+ * walk an assistant created). The planner plans the way when the tour is
+ * opened, and a save keeps it. Absent in a list cached before it existed.
+ */
+ route_pending: z.boolean().optional(),
});
export type TourListItem = z.infer;
@@ -300,6 +306,12 @@ export const MAX_STATION_STAY_MINUTES = 24 * 60;
export const TOUR_STATION_NAME_MAX = 200;
/** The longest opening hours a station keeps, as OpenStreetMap writes them. */
export const TOUR_STATION_HOURS_MAX = 500;
+/**
+ * How far a router may move a point of a tour onto its way before the point
+ * counts as one no way of the tour's type reaches: the planner's limit, and
+ * the server's for a city walk it plans on foot (CW-11).
+ */
+export const TOUR_MAX_SNAP_M = 300;
/** CW-03: a station's key, the same across saves. */
export const stationKeySchema = z.string().regex(/^[A-Za-z0-9_-]{6,64}$/);
/** CW-03: how much a station matters, besides normal (null). */
@@ -307,6 +319,11 @@ export const STATION_IMPORTANCES = ['optional', 'must'] as const;
export const stationImportanceSchema = z.enum(STATION_IMPORTANCES);
export type StationImportance = z.infer;
+/** A tour's name, as a create, an edit and a copy take it. */
+export const tourNameSchema = z.string().trim().min(1).max(255);
+/** A tour's description, as a create and an edit take it. */
+export const tourDescriptionSchema = z.string().trim().max(2000).nullable().optional();
+
export const tourWaypointSchema = z.object({
lat: z.number().finite().min(-90).max(90),
lng: z.number().finite().min(-180).max(180),
@@ -367,7 +384,7 @@ const enrichedRoutePointSchema = z.tuple([
export const tourCreateRequestSchema = z
.object({
- name: z.string().trim().min(1).max(255),
+ name: tourNameSchema,
tour_type: plannableTourTypeSchema.default('hike'),
route_geometry: z.array(enrichedRoutePointSchema).min(2),
waypoints: z.array(tourWaypointSchema).min(2),
@@ -385,7 +402,7 @@ export const tourCreateRequestSchema = z
duration_seconds: z.number().finite().nonnegative().nullable().optional(),
planned_duration_minutes: plannedTourDurationMinutesSchema.optional(),
break_additional_minutes: plannedTourDurationMinutesSchema.optional(),
- description: z.string().trim().max(2000).nullable().optional(),
+ description: tourDescriptionSchema,
website: tourWebsiteSchema.nullable().optional(),
/**
* Creating only: the import of this trip the tour was planned from. It is
@@ -538,7 +555,7 @@ export type TourCreateResponse = z.infer;
* points, and no days.
*/
export const tourCopyRequestSchema = z.object({
- name: z.string().trim().min(1).max(255),
+ name: tourNameSchema,
});
export type TourCopyRequest = z.infer;
diff --git a/wiki/MCP-Addon-Tools.md b/wiki/MCP-Addon-Tools.md
index a5f70d177..4b622466d 100644
--- a/wiki/MCP-Addon-Tools.md
+++ b/wiki/MCP-Addon-Tools.md
@@ -233,18 +233,21 @@ Each tool asks for the scope of what it writes, so the scopes differ per tool. T
### Tours _(Tours addon required)_
-Read only: the saved tours of a trip, as the Tours list and the tour detail show them, and paddle legs planned on the water. Tours someone is still drawing in the planner are not saved yet and do not show here.
+The saved tours of a trip, as the Tours list and the tour detail show them, how far a city walk got on a day, paddle legs planned on the water, and a city walk prepared from a briefing. `take_over_tour` and `create_city_walk` write; every other tool here only reads. Tours someone is still drawing in the planner are not saved yet and do not show here.
| Tool | Description | Scope |
|---|---|---|
-| `list_tours` | The saved tours of a trip: name, type (`hike`, or `kayak` for a paddle tour), origin (`trek` for planned in TREK, `gpx` for a GPX import, `wanderer` for a wanderer snapshot), distance, ascent and descent (unknown for a paddle tour drawn in the planner), the stored routing limit of a hike on the SAC scale and its route choices (`hiking_preferences`), the boat, water and chosen speed of a paddle tour, the check bench of a paddle tour (`paddle_check`: how far the OpenStreetMap obstacle data covers its route and how many obstacles are open, to check again or decided; a planning status, never a safety or legal clearance), the `revision` (raised by every saved change of the tour), and the days of the trip each tour is on | `trips:read` |
-| `get_tour_plan` | One tour: its facts, its ordered control points, its provenance (where it came from, when, and whether it was changed in TREK since) and its days. A control point can carry a fixed leg to the next one: a via ferrata (`ferrata`) or a stretch drawn by hand (`free`), which no router planned or checked. A shaping point (`shaping: true`) only steers the route and is no stop. A paddle tour also carries its obstacle checklist (`obstacle_check`): the obstacles of the OpenStreetMap data along its route, what the travellers decided about each, and their own portages and locks. The full track geometry is not included | `trips:read` |
+| `list_tours` | The saved tours of a trip: name, type (`hike`, `walk` for a city walk, `bike`, or `kayak` for a paddle tour), origin (`trek` for planned in TREK, `gpx` for a GPX import, `wanderer` for a wanderer snapshot), distance, ascent and descent (unknown for a paddle tour drawn in the planner), the stored routing limit of a hike on the SAC scale and its route choices (`hiking_preferences`), the boat, water and chosen speed of a paddle tour, the check bench of a paddle tour (`paddle_check`: how far the OpenStreetMap obstacle data covers its route and how many obstacles are open, to check again or decided; a planning status, never a safety or legal clearance), for a city walk how many stations it has (`station_count`), their stays as far as entered and how many have none, whether a tour still waits for its way to be planned (`route_pending`: saved with its points only, its distance and walking time unknown until someone opens it in the planner and saves it), the `revision` (raised by every saved change of the tour), and the days of the trip each tour is on | `trips:read` |
+| `get_tour_plan` | One tour: its facts, its ordered control points, its provenance (where it came from, when, and whether it was changed in TREK since) and its days. A control point can carry a fixed leg to the next one: a via ferrata (`ferrata`) or a stretch drawn by hand (`free`), which no router planned or checked. A shaping point (`shaping: true`) only steers the route and is no stop. A paddle tour also carries its obstacle checklist (`obstacle_check`): the obstacles of the OpenStreetMap data along its route, what the travellers decided about each, and their own portages and locks. The stations of a city walk carry every field spelled out: `key` (the same across saves), `name`, `stay_minutes` (`0` for a point only passed, `null` when not known), `importance` (`optional`, `must`, or `null` for normal), `protected` (a station a proposed change has to keep where it is; the travellers can still edit it) and `opening_hours` (as OpenStreetMap writes them, `null` when none is known). The full track geometry is not included | `trips:read` |
| `find_tours_for_day` | The tours put on one day of a trip, with the same facts as `list_tours` | `trips:read` |
+| `get_city_walk_progress` | How far a city walk got on one day it is on: every station in order with its key, its status that day (planned, visited or skipped, as checked off in the day plan), importance, protection, stay and opening hours, what is still open, and the walking time of the whole walk. Visits belong to that day only. Reading changes nothing | `trips:read` |
| `export_tour_gpx` | One saved tour as a GPX file: its whole saved line as one track, with the heights it carries and none it lacks, and a file name. A tour without a line cannot be exported | `trips:read` |
-| `search_tour_pois` | Places along the saved line of a tour within a corridor (viewpoints, peaks, shelters, drinking water, food, toilets, picnic, campsites, sights, slipways, boat rental, bike repair, and for a city walk museums and galleries, parks and gardens, coffee shops, places to eat and markets): source (TREK places index or OpenStreetMap), kind, address, website, phone, opening hours as the source has them (a hint, never a promise), the distance straight across to the line (not a detour) and how far along the line; up to 60, the nearest to the line when more match, listed from the start. A place OpenStreetMap has no name for comes with an empty name. `unavailable` names the categories no source could be searched for right now, so their places are missing rather than absent. Says when stretches could not be searched, only the first part of a long line was, or more places matched. Nothing is added | `trips:read` |
+| `search_tour_pois` | Places along the saved line of a tour within a corridor (viewpoints, peaks, shelters, drinking water, food, toilets, picnic, campsites, sights, slipways, boat rental, bike repair, and for a city walk museums and galleries, parks and gardens, coffee shops, places to eat and markets): source (TREK places index or OpenStreetMap), kind, address, website, phone, opening hours as the source has them (a hint, never a promise), the distance straight across to the line (not a detour) and how far along the line; up to 60, the nearest to the line when more match, listed from the start. A place OpenStreetMap has no name for comes with an empty name. `unavailable` names the categories no source could be searched for right now, so their places are missing rather than absent. Says when stretches could not be searched, only the first part of a long line was, or more places matched. A tour saved with its points only (`route_pending`) has no line to search along yet. Nothing is added | `trips:read` |
| `list_tour_library` | The saved tours of every trip the user can open, to choose one to take over into another trip: name, type, distance, ascent, descent, planned times and the trip each is in. Narrowed by a part of the name, a type or a trip; the trip being planned can be left out. Up to 100 tours, `truncated` says there were more | `trips:read` |
-| `take_over_tour` | Copies a saved tour of another trip into a trip as an independent tour: its line, measures, settings and control points, without its days, its links to places of the other trip or the obstacle decisions taken there. The copy remembers which trip and tour it came from; the source trip is not changed. Needs `place_edit` on the trip it writes into | `trips:write` |
+| `take_over_tour` | Copies a saved tour of another trip into a trip as an independent tour: its line, measures, settings and control points, without its days, its links to places of the other trip or the obstacle decisions taken there. The copy remembers which trip and tour it came from; the source trip is not changed. A city walk keeps its stations with their keys, names, stays, importance, protection and opening hours, and none of the visits of the other trip's days. Needs `place_edit` on the trip it writes into | `trips:write` |
| `preview_google_maps_walk` | Reads the stops of a Google Maps walking route shared as a link (a `maps.app.goo.gl` short link or a `google.com/maps/dir` link), in order, for a city walk: each stop's name and position, or no position when the link gives none (nothing is looked up or guessed), and the long link that was read. Only a route Google Maps marks as on foot is read; a car, bike or transit route, or a link that does not say, is refused. A short link is followed only to Google Maps itself. Nothing is saved, and the line drawn in Google Maps is not taken over: TREK plans the way on foot between the stops again. Needs `place_edit` on the trip | `trips:read` |
+| `propose_city_walk` | Proposes a city walk from a briefing without saving anything: start (and end), the time frame on a day, the themes (`culture`, `parks`, `food`, `mixed`), the stations the traveller wished for, the places of the trip it may use and fixed appointments. Places for the themes come from the same search as `search_tour_pois`, up to 8, each with its source, theme and category; nothing is made up. Answers the stations in order with the walking distance and time from the one before, the arrival and leave times, the stay where the traveller gave it (unknown otherwise, which makes later arrivals the earliest possible), and the opening hours checked against the arrival on that day. The order is chosen on estimates; the walk in that order is then planned on foot by the router TREK uses for road trips (the traveller's own routing server where they set one), whose legs give the distances and times. Where it gives no way, because the router cannot be reached or a station lies more than 300 m off every way, the times are estimates over the straight distance with an allowance for streets at 4.5 km/h, and the answer says so and why. Walking and stays are kept apart, a complete total only when every stay is known, with the minutes the frame leaves for the stays not known. Lists free time, the wishes and appointments that cannot be kept as asked, what was left out and why, and `stations_for_create` for `create_city_walk` | `trips:read` |
+| `create_city_walk` | Creates a city walk from stations the traveller confirmed (for instance `stations_for_create` of `propose_city_walk`), through the same creation the planner's save uses: a key for every station, its name, stay, importance, protection, opening hours and the place of the trip it stands for. The way on foot is planned by the same router as for the proposal, and the walk is saved with it, its distance and its walking time. Where the router gives no way, the walk is saved with its stations only (`route_pending`), says why, and has no line, distance or walking time until someone opens it in the planner, where TREK plans the way on foot and the walk can be saved with it. Needs `place_edit` on the trip | `trips:write` |
| `plan_paddle_route` | Plans the legs of a paddle tour on the water network TREK builds from the paddle regions an admin added: each leg (`from`, `to`) along rivers and canals and across lakes, carried round weirs, dams and waterfalls, through or round locks (`lock_policy`), with `boat` and `lake_style` (`direct` or `shore`). Answers per leg its length, how far its ends were moved onto the water and its stretches (water, lake, whitewater, boat pass, portage with its source, lock, gap); a leg that cannot be planned says why (`not_on_water`, `no_region`, `cross_region`, `no_water_path`, `busy`, `timeout`). Nothing is saved; a planning aid, never a safety or legal clearance | `trips:read` |
### Road trip _(Road trip addon required)_