feat(tours): the assistant edits stations, reverses and closes tours, sets breaks, colour and copies, and writes ski and paddle plans

City walk changes take edit, reverse and close_loop; edits alone keep the
saved way and never ask the router. update_tour turns a hike round or closes
its loop with reshape. New MCP tools mirror the Tours tab's REST writes and
reads: update_tour_details, set_tour_route_color, copy_tour,
classify_ski_track, set_paddle_plan, decide_paddle_obstacles,
restore_recorded_track, get_tour_trail_details and find_horse_routes, with
change cards, undo where the answer keeps the values before, and broadcast
parity cases.
This commit is contained in:
Maurice
2026-10-23 21:07:41 +02:00
parent 338548bf7f
commit c418eef7ce
68 changed files with 3574 additions and 39 deletions
@@ -455,4 +455,5 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
difficulty: { labelKey: 'assistant.change.label.difficulty' },
track: { labelKey: 'assistant.change.label.track' },
packing: { labelKey: 'assistant.change.label.packing' },
breaks: { labelKey: 'assistant.change.label.breaks' },
};
@@ -1,5 +1,21 @@
import { type AssistantApplied, cumulativeMetres, type TourRouteCard, type TrekCard } from '@trek/shared';
import { ChartNoAxesColumn, Circle, CircleCheck, CircleSlash, Footprints, Luggage, Route } from 'lucide-react';
import {
ArrowLeftRight,
ChartNoAxesColumn,
Circle,
CircleCheck,
CircleSlash,
Copy,
Footprints,
History,
Luggage,
Palette,
Repeat,
Route,
Snowflake,
TriangleAlert,
Waves,
} from 'lucide-react';
import type { RouteLineSpec, RouteUse } from '../../../../types/assistantMap';
import type { EntityTarget, PackLook } from '../cardTypes';
@@ -95,6 +111,14 @@ export const TOUR_LOOK = {
countTour: { Icon: ChartNoAxesColumn, tone: 'success', labelKey: 'assistant.change.verb.countTour' },
linkPacking: { Icon: Luggage, tone: 'success', labelKey: 'assistant.change.verb.linkPacking' },
unlinkPacking: { Icon: Luggage, tone: 'info', labelKey: 'assistant.change.verb.unlinkPacking' },
reverseTour: { Icon: ArrowLeftRight, tone: 'info', labelKey: 'assistant.change.verb.reverseTour' },
closeLoop: { Icon: Repeat, tone: 'info', labelKey: 'assistant.change.verb.closeLoop' },
copyTour: { Icon: Copy, tone: 'success', labelKey: 'assistant.change.verb.copyTour' },
setRouteColor: { Icon: Palette, tone: 'info', labelKey: 'assistant.change.verb.setRouteColor' },
classifySki: { Icon: Snowflake, tone: 'info', labelKey: 'assistant.change.verb.classifySki' },
setPaddlePlan: { Icon: Waves, tone: 'info', labelKey: 'assistant.change.verb.setPaddlePlan' },
decideObstacles: { Icon: TriangleAlert, tone: 'info', labelKey: 'assistant.change.verb.decideObstacles' },
restoreTrack: { Icon: History, tone: 'info', labelKey: 'assistant.change.verb.restoreTrack' },
},
tools: {},
steps: {
@@ -106,6 +130,7 @@ export const TOUR_LOOK = {
find_tours_for_day: { stepKey: 'assistant.step.readingTours' },
search_tour_pois: { stepKey: 'assistant.step.tourPois' },
get_city_walk_progress: { stepKey: 'assistant.step.readingTour' },
get_tour_trail_details: { stepKey: 'assistant.step.readingTour' },
},
entities: {
tour: {
@@ -162,6 +162,12 @@ export interface TourInsert {
}
/** What a route edit replaces on an existing tour: the type and everything that belongs to it. */
/** The planned times of a tour as P8 writes them, null where there is none. */
interface TourTimesUpdate {
planned_duration_minutes: number | null;
break_additional_minutes: number | null;
}
export type TourUpdate = Omit<
TourInsert,
| 'place_id'
@@ -883,6 +889,23 @@ export class ToursRepository extends TrekRepository<Tours> {
return Number(result.numUpdatedRows ?? 0) > 0;
}
/**
* P8: a tour's planned times alone, as update_tour_details changes them;
* nothing else of the tour is touched. Returns whether a row of that trip was updated.
*/
async updateTimesInTrip(trip_id: number, place_id: number, input: TourTimesUpdate): Promise<boolean> {
const result = await this.db()
.updateTable('tours')
.set({
planned_duration_minutes: input.planned_duration_minutes,
break_additional_minutes: input.break_additional_minutes,
})
.where('place_id', '=', place_id)
.where('place_id', 'in', (eb) => eb.selectFrom('places').select('id').where('trip_id', '=', trip_id))
.executeTakeFirst();
return Number(result.numUpdatedRows ?? 0) > 0;
}
/**
* The paddle plan of a recorded track of the trip: its speeds, the marks
* laid onto it and the moving time they make. The track itself is not
+11 -1
View File
@@ -11,6 +11,7 @@ import {
type TourLoaded,
} from './tour-change.cards';
import { TOUR_DESCRIBERS } from './tour-describe';
import { TOUR_EDIT_CARDS } from './tour-edit.cards';
import { tourListCard } from './tour-lists.cards';
import { proposalCard, savedTourCard } from './tour-route.cards';
import { cityWalkCard, paddleCard, skiCard } from './tour-sport.cards';
@@ -76,6 +77,13 @@ export const TOUR_CARDS: DomainCards = {
},
update_tour: updateTour,
...TOUR_WRITE_CARDS,
...TOUR_EDIT_CARDS,
// A copy is a tour of its own, taken back by deleting it while it is as it was made.
copy_tour: {
write: { verbKey: 'copyTour', entity: 'tour' },
describe: TOUR_DESCRIBERS.copy_tour,
undo: DELETE_CREATED_TOUR,
},
set_city_walk_visit: {
write: { verbKey: 'markVisited', entity: 'tour' },
load: loadVisit,
@@ -95,6 +103,8 @@ export const TOUR_CARDS: DomainCards = {
},
prompt: [
'Hikes: call propose_hike with a name, and day_id and start_time when known; to compare ways, call it up to three times in one step, each with a variant_label and along false, the way you recommend first (the card shows the first one chosen). Call create_hike only once the user chose a way. A hike over several days is one hike: stage_end on each stop a day ends at (no day_id), then each stage on its day with assign_place_to_day and tourStageKey.',
'The app draws a proposed, planned or saved tour (route, profile, stops, times, weather, warnings) and lists of tours as cards: do not repeat their figures or list them again, say in one sentence what matters. Once a tour is planned, offer what goes with it: places along it (search_tour_pois), what to pack (get_tour_packing, link_tour_packing), the GPX file (export_tour_gpx).',
"The app draws a proposed, planned or saved tour (route, profile, stops, times, weather, warnings) and lists of tours as cards: do not repeat their figures or list them again, say in one sentence what matters. Once a tour is planned, offer what goes with it: places along it (search_tour_pois), what to pack (get_tour_packing, link_tour_packing), the GPX file (export_tour_gpx). To change a saved tour: a city walk's stations (name, stay, importance, protection, opening hours), its direction or its loop through prepare_tour_change and save_tour_change (edit, reverse, close_loop); a hike's direction or loop with update_tour and reshape; breaks, website, words and planned time of any tour with update_tour_details; its colour with set_tour_route_color; a variant with copy_tour first.",
],
// P8, P9: edits besides the route, and the writes and reads a ski, paddle or horse tour has of its own.
toolControllers: { tours: ['TourEditMcp', 'TourSportWritesMcp'] },
};
@@ -81,12 +81,21 @@ export function replans(args: ToolArgs, saved: unknown): boolean {
: hikingPreferencesOf(recordOf(args.hiking_preferences) as HikingPreferences);
return (
args.waypoints !== undefined ||
args.reshape !== undefined ||
plan.line === null ||
limit !== kept.limit ||
canonicalJson(preferences) !== canonicalJson(kept.preferences)
);
}
/** The points update_tour plans a reshape through: the saved ones turned round, or with the way back to the start. */
export function reshapedPoints(reshape: unknown, points: readonly Row[]): Row[] | null {
const first = points[0];
if (reshape === 'reverse') return [...points].reverse();
if (reshape === 'close_loop' && first) return [...points, { lat: first.lat, lng: first.lng }];
return null;
}
/**
* update_tour: the saved tour, and where the change plans the way anew the
* way it will plan, through the new points or the saved ones under the saved
@@ -100,7 +109,8 @@ export async function loadUpdateTour(args: ToolArgs, read: CardReader): Promise<
if (!saved || !replans(args, saved)) return { plan: null, saved };
const tour = recordOf(recordOf(saved).tour);
const kept = keptSettings(tour);
const points = args.waypoints ?? rowsIn(recordOf(saved).waypoints).map(({ lat, lng }) => ({ lat, lng }));
const savedPoints = rowsIn(recordOf(saved).waypoints).map(({ lat, lng }) => ({ lat, lng }));
const points = args.waypoints ?? reshapedPoints(args.reshape, savedPoints) ?? savedPoints;
const plan = await read('propose_hike', {
name: args.name ?? tour.name,
waypoints: points,
@@ -198,7 +208,8 @@ export function updateTourCard(ctx: PreviewContext, args: ToolArgs, loaded: Tour
const previous = previousOf(loaded.saved);
const planned = hikeCard(loaded.plan, args, ctx.card, 'pending');
if (planned?.routed) return fitted({ ...planned, placeId, name, label: null, previous });
const points = args.waypoints === undefined ? rowsIn(recordOf(loaded.saved).waypoints) : rowsIn(args.waypoints);
const saved = rowsIn(recordOf(loaded.saved).waypoints);
const points = args.waypoints === undefined ? (reshapedPoints(args.reshape, saved) ?? saved) : rowsIn(args.waypoints);
const straight = straightCard(points, 'hike', { placeId, name, previous, problem: heldProblem(planned) });
return straight ? fitted(straight) : null;
}
@@ -89,6 +89,20 @@ export const TOUR_DESCRIBERS = {
rows,
dayId: null,
placeIds: placeId === null ? [] : [placeId],
// PR1-01, PR1-02: a hike turned round or taken back to its start says so as its verb.
...(args.reshape === 'reverse' ? { verbKey: 'reverseTour' as const } : {}),
...(args.reshape === 'close_loop' ? { verbKey: 'closeLoop' as const } : {}),
};
},
// A tour of the trip saved again as a new one: the copy's name, the tour it is made from.
copy_tour: (ctx, args) => {
const source = id(args, 'placeId');
const from = source === null ? '' : (ctx.card.places.get(source)?.name ?? '');
return {
subject: text(args.name),
rows: new Rows().add('route', from),
dayId: null,
placeIds: source !== null && ctx.card.places.has(source) ? [source] : [],
};
},
// A tour of another trip copied in; its own name stays unless the copy is given one.
@@ -0,0 +1,187 @@
import type { Described, PreviewContext, ToolArgs, ToolCards, UndoCall, UndoSnapshot } from '../card-types';
import { argId, readKey, recordOf, type Row } from '../card-values';
import { Rows, text, UNCHECKED } from '../preview-format';
import { byId, snapshotPlaces } from '../undo-snapshot';
import type { AssistantChangeVerbKey } from '@trek/shared';
/**
* P8 and P9 in the chat: the edits of a saved tour besides its route (its
* words and times, the colour of its line, a copy), and the writes a ski or
* paddle tour has of its own. A card names the tour by the trip's place.
* Where the answer keeps what was there before, the way back writes that
* again, while the tour is still at the revision the change raised it to
* (the colour, which raises none, while the place still has it).
*/
/** A place of the trip by its id, '' where the trip card does not hold it. */
function placeName(ctx: PreviewContext, id: number | null): string {
return id === null ? '' : (ctx.card.places.get(id)?.name ?? '');
}
function described(ctx: PreviewContext, args: ToolArgs, rows: Rows, verbKey: AssistantChangeVerbKey): Described {
const placeId = argId(args, 'placeId');
return {
subject: placeName(ctx, placeId),
rows,
dayId: null,
placeIds: placeId === null || !ctx.card.places.has(placeId) ? [] : [placeId],
verbKey,
};
}
/** Minutes as hours and minutes, "1:30 h"; '' for none. */
function hours(value: unknown): string {
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) return '';
return `${Math.floor(value / 60)}:${String(value % 60).padStart(2, '0')} h`;
}
/** The plan of the tour read for an undo, by the call's own arguments. */
function planRead(snapshot: UndoSnapshot, call: UndoCall | undefined): Row {
return recordOf(snapshot.reads.get(readKey('get_tour_plan', { placeId: call?.args.placeId })));
}
/** The tour is still at the revision the change raised it to: nothing was saved on top of it since. */
function stillAtRevision(result: unknown, snapshot: UndoSnapshot, call?: UndoCall): boolean {
if (!call) return false;
const revision = recordOf(recordOf(result).tour).revision;
return typeof revision === 'number' && recordOf(planRead(snapshot, call).tour).revision === revision;
}
const READ_PLAN = (args: ToolArgs) => [{ tool: 'get_tour_plan', args: { placeId: args.placeId } }];
/** The revision the change raised the tour to, the one its way back names. */
function raisedRevision(result: unknown): number | null {
const revision = recordOf(recordOf(result).tour).revision;
return typeof revision === 'number' && Number.isInteger(revision) && revision > 0 ? revision : null;
}
// update_tour_details
const DETAIL_FIELDS = [
'name',
'description',
'website',
'break_additional_minutes',
'planned_duration_minutes',
] as const;
const updateTourDetails: ToolCards = {
write: { verbKey: 'updateTour', entity: 'tour' },
describe: (ctx, args) => {
const current = ctx.card.places.get(argId(args, 'placeId') ?? 0);
const rows = new Rows();
if (args.name !== undefined) rows.change('name', current?.name ?? null, text(args.name));
if (args.description !== undefined)
rows.change('description', current?.description ?? null, text(args.description));
// A field cleared reads as cleared, not as left out.
const cleared = (value: string) => value || UNCHECKED;
if (args.website !== undefined) rows.add('website', cleared(text(args.website)));
if (args.break_additional_minutes !== undefined) rows.add('breaks', cleared(hours(args.break_additional_minutes)));
if (args.planned_duration_minutes !== undefined) rows.add('time', cleared(hours(args.planned_duration_minutes)));
return described(ctx, args, rows, 'updateTour');
},
undo: {
tool: 'update_tour_details',
args: (args, result) => {
const revision = raisedRevision(result);
const previous = recordOf(recordOf(result).previous);
const changed = DETAIL_FIELDS.filter((field) => args[field] !== undefined && field in previous);
if (revision === null || changed.length === 0) return null;
return {
placeId: args.placeId,
expected_revision: revision,
...Object.fromEntries(changed.map((field) => [field, previous[field]])),
};
},
reads: READ_PLAN,
unchanged: stillAtRevision,
},
};
// set_tour_route_color
const setRouteColor: ToolCards = {
write: { verbKey: 'setRouteColor', entity: 'tour' },
describe: (ctx, args) => described(ctx, args, new Rows().add('color', text(args.route_color)), 'setRouteColor'),
undo: {
tool: 'set_tour_route_color',
args: (args, result) => {
const answer = recordOf(result);
const previous = answer.previous_route_color;
return previous === undefined ? null : { placeId: args.placeId, route_color: previous };
},
unchanged: (result, snapshot) => {
const answer = recordOf(result);
const place = byId(snapshotPlaces(snapshot), answer.place_id);
return place !== undefined && (place.route_color ?? null) === (answer.route_color ?? null);
},
},
};
// classify_ski_track
const classifySki: ToolCards = {
write: { verbKey: 'classifySki', entity: 'tour' },
describe: (ctx, args) => {
const technique = text(args.ski_technique);
const type = technique ? `${text(args.ski_discipline)} (${technique})` : text(args.ski_discipline);
return described(ctx, args, new Rows().add('type', type), 'classifySki');
},
undo: {
tool: 'classify_ski_track',
args: (args, result) => {
const revision = raisedRevision(result);
const previous = recordOf(recordOf(result).previous);
if (revision === null || typeof previous.ski_discipline !== 'string') return null;
return {
placeId: args.placeId,
ski_discipline: previous.ski_discipline,
ski_technique: previous.ski_technique ?? null,
expected_revision: revision,
};
},
reads: READ_PLAN,
unchanged: stillAtRevision,
},
};
// set_paddle_plan
const setPaddlePlan: ToolCards = {
write: { verbKey: 'setPaddlePlan', entity: 'tour' },
// The portages and locks are drawn on the track's own plan card; the change names the tour.
describe: (ctx, args) => described(ctx, args, new Rows(), 'setPaddlePlan'),
undo: {
tool: 'set_paddle_plan',
args: (args, result) => {
const revision = raisedRevision(result);
const previous = recordOf(recordOf(result).previous);
if (revision === null || !previous.track_marks) return null;
return {
placeId: args.placeId,
paddle_speed_kmh: previous.paddle_speed_kmh ?? null,
portage_speed_kmh: previous.portage_speed_kmh ?? null,
track_marks: previous.track_marks,
expected_revision: revision,
};
},
reads: READ_PLAN,
unchanged: stillAtRevision,
},
};
/** A write on one tour that the card names and nothing takes back on its own. */
function plainWrite(verbKey: AssistantChangeVerbKey): ToolCards {
return { write: { verbKey, entity: 'tour' }, describe: (ctx, args) => described(ctx, args, new Rows(), verbKey) };
}
export const TOUR_EDIT_CARDS: Readonly<Record<string, ToolCards>> = {
update_tour_details: updateTourDetails,
set_tour_route_color: setRouteColor,
classify_ski_track: classifySki,
set_paddle_plan: setPaddlePlan,
// The decisions are bound to the data the travellers saw; taking them back is a decision of its own.
decide_paddle_obstacles: plainWrite('decideObstacles'),
// The planning it drops is gone; copy_tour first keeps it.
restore_recorded_track: plainWrite('restoreTrack'),
};
+1 -1
View File
@@ -88,7 +88,7 @@ export class HikePlanMcp {
@Tool({
name: 'update_tour',
description:
"Change a saved hike (tour_type hike) against the revision it was read at: expected_revision is tour.revision from get_tour_plan or list_tours. A new name, description or planned_duration_minutes keeps the hike's way. New waypoints (start to end; a round trip ends on its start), a new max_hiking_difficulty or new hiking_preferences plan the way anew as propose_hike plans it, and so does a hike still waiting for its way (route_pending); propose the new way to the traveller first. A hike changed since that revision is refused as a conflict: read it again and show the traveller what changed. Where the new way cannot be planned, or not with its heights, nothing is saved and the hike keeps its way; the error says why. A new point standing where a saved one stood (within 25 m) keeps that point's link to a place of the trip and its name. A hike with a section drawn by hand (a via ferrata, a free line, a ferry) takes new points, a new limit or new preferences only in the planner: those changes are refused here. A hike split into stages takes new points only with stage_end given again on each stop a day ends at, and its stages keep their days where a stop stays where it was. A recorded track without points of its own (an import) takes only new waypoints, which plan it over and keep the recording. Any other type is refused: change a city walk with prepare_tour_change and save_tour_change. The hike stays on its days. The answer is the tour as saved, with its new revision, route and replanned. Needs place editing permission on the trip. Change it only after the traveller said yes.",
"Change a saved hike (tour_type hike) against the revision it was read at: expected_revision is tour.revision from get_tour_plan or list_tours. A new name, description or planned_duration_minutes keeps the hike's way. New waypoints (start to end; a round trip ends on its start), a new max_hiking_difficulty or new hiking_preferences plan the way anew as propose_hike plans it, and so does a hike still waiting for its way (route_pending); propose the new way to the traveller first. reshape turns the saved points instead of new ones: reverse runs the hike the other way round (its last point starts it), close_loop adds the way back from its last point to its start; either plans the way anew and keeps each point's name and link (a hike split into stages is not reversed here, nor one that already ends at its start). A hike changed since that revision is refused as a conflict: read it again and show the traveller what changed. Where the new way cannot be planned, or not with its heights, nothing is saved and the hike keeps its way; the error says why. A new point standing where a saved one stood (within 25 m) keeps that point's link to a place of the trip and its name. A hike with a section drawn by hand (a via ferrata, a free line, a ferry) takes new points, a new limit or new preferences only in the planner: those changes are refused here. A hike split into stages takes new points only with stage_end given again on each stop a day ends at, and its stages keep their days where a stop stays where it was. A recorded track without points of its own (an import) takes only new waypoints, which plan it over and keep the recording. Any other type is refused: change a city walk with prepare_tour_change and save_tour_change (which reverse it and close its loop too), its words, breaks and website with update_tour_details. The hike stays on its days. The answer is the tour as saved, with its new revision, route and replanned. Needs place editing permission on the trip. Change it only after the traveller said yes.",
inputSchema: { tripId: idSchema, ...tourUpdateRequestSchema.shape },
annotations: TOOL_ANNOTATIONS_WRITE,
when: addonGate(ADDON_IDS.TOURS),
+14 -2
View File
@@ -20,6 +20,7 @@ import {
import { HikeRoutingService, type PlannedHike } from './hike-routing.service';
import { noTrackFound } from './tour-file-import.helpers';
import { TourPoiSearchService } from './tour-poi-search.service';
import { reshapedHikePoints } from './tour-reshape.helpers';
import { tourRevisionConflict } from './tour-revision';
import { thinnedLine } from './tours.helpers';
import { ToursService, type ImportGpxAsTourResult, type TourCreateCommand } from './tours.service';
@@ -120,6 +121,15 @@ function repointingRefusal(saved: readonly TourWaypoint[], points: readonly Hike
return null;
}
/** The change with the saved points reshaped as its new points, or refused with why (a hike only). */
function withReshape(request: TourUpdateRequest, saved: readonly TourWaypoint[], type: string): TourUpdateRequest {
const { reshape, ...rest } = request;
if (!reshape || type !== 'hike') return rest;
const reshaped = reshapedHikePoints(saved, reshape);
if ('refused' in reshaped) throw badRequest(reshaped.refused);
return { ...rest, waypoints: reshaped.points };
}
/** Stage ends that do not hold (at the start or the end, too many stages): refused with their reason. */
function refuseBadStages(points: readonly HikeStop[], roundTrip: boolean | undefined): void {
const issues = hikeStageIssues(points, roundTrip);
@@ -296,9 +306,11 @@ export class HikePlanService {
* changed in the planner (a city walk through prepare_tour_change and
* save_tour_change).
*/
async update(userId: number, tripId: number, request: TourUpdateRequest): Promise<HikeChanged> {
const { placeId } = request;
async update(userId: number, tripId: number, asked: TourUpdateRequest): Promise<HikeChanged> {
const { placeId } = asked;
const saved = await this.tours.getTour(String(tripId), String(placeId));
// PR1-01, PR1-02: a reshape is the saved points turned or closed, then planned as new points are.
const request = asked.reshape ? withReshape(asked, saved.waypoints, saved.tour.tour_type) : asked;
const { tour } = saved;
if (tour.tour_type !== 'hike') {
throw badRequest(
+163 -9
View File
@@ -22,6 +22,7 @@
import {
CITY_WALK_MAX_STATIONS,
isClosedLoop,
TOUR_STATION_EDIT_FIELDS,
type CityWalkStation,
type TourChange,
type TourChangeOp,
@@ -36,7 +37,7 @@ import {
const CHANGEABLE_TYPES: ReadonlySet<string> = new Set(['walk']);
/** How a point of the changed plan came to be: kept from the saved tour, or brought in by a change. */
type DraftOrigin = 'saved' | 'added' | 'replacing' | 'moved';
type DraftOrigin = 'saved' | 'added' | 'replacing' | 'moved' | 'edited' | 'loop';
/** A point of the plan as the changes leave it. */
export type DraftPoint = TourWaypoint & {
@@ -73,7 +74,10 @@ type RefusalReason =
| 'fixed_leg'
| 'place_not_linkable'
| 'too_few_stations'
| 'too_many_stations';
| 'too_many_stations'
| 'reshape_not_alone'
| 'walk_under_way'
| 'already_loop';
/** Why a change, or the whole proposal (change null), cannot be prepared. */
interface ChangeRefusal {
@@ -130,26 +134,32 @@ function newStation(station: CityWalkStation, change: number): DraftPoint {
/** The work in progress: the points without the way back of a loop, and what the changes did so far. */
class ChangeRun {
readonly body: DraftPoint[];
private body: DraftPoint[];
readonly removed: DraftPoint[] = [];
readonly replaced: DraftPoint[] = [];
readonly shapingDropped: DraftPoint[] = [];
readonly refused: ChangeRefusal[] = [];
/** The saved stations a change removed or replaced, which no later change can name. */
private readonly gone = new Set<string>();
/** The way back of a loop as the walk ends: the saved one, turned by a reverse, or the one a close_loop adds. */
private back: DraftPoint | null;
constructor(
private readonly basis: ChangeBasis,
private readonly loop: boolean,
private loop: boolean,
) {
const points = basis.points.map(savedPoint);
this.body = loop ? points.slice(0, -1) : points;
this.back = loop ? (points[points.length - 1] ?? null) : null;
}
apply(change: TourChange, index: number): void {
const number = index + 1;
const refuse = (reason: RefusalReason, key: string | null, days?: StationDone[]) =>
this.refused.push({ change: number, op: change.op, key, reason, ...(days ? { days } : {}) });
if (change.op === 'edit') return this.edit(change, number, refuse);
if (change.op === 'reverse') return this.reverse(number, refuse);
if (change.op === 'close_loop') return this.closeLoop(number, refuse);
const before = this.refused.length;
if ('station' in change) this.checkLink(change.station, (reason) => refuse(reason, null));
if (change.op === 'add') {
@@ -173,6 +183,72 @@ class ChangeRun {
(change.op === 'remove' ? this.removed : this.replaced).push({ ...point, change: number });
}
/**
* The fields an edit names written onto its station, which stays where it
* is: an edit moves nothing, so a protected station or one already done
* takes it too.
*/
private edit(
change: Extract<TourChange, { op: 'edit' }>,
number: number,
refuse: (reason: RefusalReason, key: string) => void,
): void {
const index = this.indexOfStation(change.key, refuse);
if (index === null) return;
const point = this.body[index] as DraftPoint;
const edited: DraftPoint = { ...point, origin: point.origin === 'saved' ? 'edited' : point.origin, change: number };
for (const field of TOUR_STATION_EDIT_FIELDS) {
const value = change[field];
if (value !== undefined) Object.assign(edited, { [field]: value });
}
this.body[index] = edited;
}
/**
* PR1-02 for a city walk: the walk the other way round, as the planner
* turns it. A loop stays a loop from the same start, its start the station
* there; a leg drawn by hand stays on the stretch it covers, its line turned
* with it. Refused once the travellers checked a station off on a day.
*/
private reverse(number: number, refuse: (reason: RefusalReason, key: string | null) => void): void {
if ([...this.basis.done.values()].some((days) => days.length > 0)) {
refuse('walk_under_way', null);
return;
}
// A protected station keeps its place; turned round, every station but the start of a loop moves.
const kept = this.body.filter((point, at) => point.protected === true && !(this.back && at === 0));
for (const point of kept) refuse('protected', point.key ?? null);
if (kept.length > 0) return;
const whole = this.back ? [...this.body, this.back] : this.body;
const turned = reversedPoints(whole).map((point, at): DraftPoint => {
const was = whole.findIndex((one) => one.ref === point.ref);
return was !== at && point.origin === 'saved' ? { ...point, origin: 'moved', change: number } : point;
});
this.body = this.back ? turned.slice(0, -1) : turned;
this.back = this.back ? (turned[turned.length - 1] ?? null) : null;
}
/** PR1-01 for a city walk: a way back from its last station to its start, no station of its own. */
private closeLoop(number: number, refuse: (reason: RefusalReason, key: string | null) => void): void {
const first = this.body[0];
if (this.loop || !first) {
refuse('already_loop', null);
return;
}
this.loop = true;
this.back = {
lat: first.lat,
lng: first.lng,
role: 'end',
sequence: 0,
ref: `new:${number}`,
origin: 'loop',
change: number,
after: null,
replaces: null,
};
}
private replacing(station: CityWalkStation, change: number, key: string): DraftPoint {
return { ...newStation(station, change), origin: 'replacing', replaces: key };
}
@@ -294,8 +370,7 @@ class ChangeRun {
/** The changed points in order with their roles and sequence, the way back of a loop at its start again. */
finished(): DraftPoint[] {
const first = this.body[0];
const last = this.basis.points[this.basis.points.length - 1];
const back = this.loop && first && last ? [{ ...savedPoint(last), lat: first.lat, lng: first.lng }] : [];
const back = this.loop && first && this.back ? [{ ...this.back, lat: first.lat, lng: first.lng }] : [];
const points = [...this.body, ...back];
const end = points.length - 1;
return points.map((point, sequence): DraftPoint => ({
@@ -306,6 +381,74 @@ class ChangeRun {
}
}
type FixedLeg = NonNullable<TourWaypoint['fixed_leg']>;
/** A fixed leg run the other way: its line turned, a crossing by boat between the same points of it. */
export function turnedLeg(leg: FixedLeg): FixedLeg {
const geometry = [...leg.geometry].reverse();
if (leg.kind !== 'ferry') return { ...leg, geometry };
const last = geometry.length - 1;
const crossings = leg.crossings.map(([from, to]): [number, number] => [last - to, last - from]).reverse();
return { ...leg, geometry, crossings };
}
/** What makes a point a station, kept apart so the start of a loop stays the station there. */
const STATION_PARTS = [
'key',
'name',
'stay_minutes',
'importance',
'protected',
'opening_hours',
'poi_category',
] as const;
function stationPartsOf(point: DraftPoint): Partial<DraftPoint> {
return Object.fromEntries(STATION_PARTS.flatMap((part) => (point[part] === undefined ? [] : [[part, point[part]]])));
}
function withoutStationParts(point: DraftPoint): DraftPoint {
const rest = { ...point };
for (const part of STATION_PARTS) delete rest[part];
return rest;
}
/**
* The points the other way round: the leg after a point is the one that led
* into it before, turned. In a loop the way back and the start trade what
* makes each a station (and the ref the changes know it by), so the start
* keeps its key, name and stay (CW-03).
*/
export function reversedPoints(points: readonly DraftPoint[]): DraftPoint[] {
const reversed = points.map((_, index): DraftPoint => {
const point = points[points.length - 1 - index] as DraftPoint;
const before = points[points.length - 2 - index];
const { fixed_leg: _old, ...rest } = point;
const leg = before?.fixed_leg;
return leg && index < points.length - 1 ? { ...rest, fixed_leg: turnedLeg(leg) } : rest;
});
if (!isClosedLoop(points) || reversed.length < 2) return reversed;
const start = reversed[0] as DraftPoint;
const back = reversed[reversed.length - 1] as DraftPoint;
return [
{ ...withoutStationParts(start), ...stationPartsOf(back), ref: back.ref, origin: back.origin },
...reversed.slice(1, -1),
{ ...withoutStationParts(back), ...stationPartsOf(start), ref: start.ref, origin: start.origin },
];
}
/** A reverse or a close_loop goes with edits only: anything else would be laid onto points it turned. */
function reshapeNotAlone(changes: readonly TourChange[]): boolean {
const reshapes = changes.filter((change) => change.op === 'reverse' || change.op === 'close_loop').length;
const others = changes.filter((change) => change.op !== 'edit').length - reshapes;
return reshapes > 1 || (reshapes === 1 && others > 0);
}
/** Whether every change only edits stations, so the walk keeps its way as saved. */
export function editsOnly(changes: readonly TourChange[]): boolean {
return changes.every((change) => change.op === 'edit');
}
/** The whole numbers from `from` up to, not including, `to`. */
function range(from: number, to: number): number[] {
return Array.from({ length: Math.max(0, to - from) }, (_, offset) => from + offset);
@@ -323,12 +466,13 @@ export function applyChanges(basis: ChangeBasis, changes: readonly TourChange[])
});
if (!CHANGEABLE_TYPES.has(basis.tourType)) return whole('type_not_supported');
if (basis.recorded) return whole('recorded_track');
if (reshapeNotAlone(changes)) return whole('reshape_not_alone');
const loop = isClosedLoop(basis.points);
const run = new ChangeRun(basis, loop);
changes.forEach((change, index) => run.apply(change, index));
if (run.refused.length > 0) return { kind: 'refused', refused: run.refused };
const points = run.finished();
const count = stationCount(points, loop);
const count = stationCount(points, isClosedLoop(points));
if (count < 2) return whole('too_few_stations');
if (count > CITY_WALK_MAX_STATIONS && count > stationCount(basis.points.map(savedPoint), loop)) {
return whole('too_many_stations');
@@ -359,12 +503,22 @@ const REFUSAL_TEXT: Record<RefusalReason, string> = {
place_not_linkable: 'the new station names a place that is not a place of this trip, or is a tour',
too_few_stations: 'a city walk keeps at least two stations',
too_many_stations: `a city walk holds at most ${CITY_WALK_MAX_STATIONS} stations`,
reshape_not_alone:
'a reverse or a close_loop is the only change of its proposal besides edits; prepare the other changes apart',
walk_under_way:
'the travellers already visited or skipped stations of this walk; it is not turned round while it is under way',
already_loop: 'this walk already ends where it starts',
};
/** How a refusal names the change it is about: its op and the station it names, where it names one. */
function changeLabel(refusal: ChangeRefusal): string {
if (refusal.key) return `${refusal.op} ${refusal.key}`;
return refusal.op === 'reverse' || refusal.op === 'close_loop' ? refusal.op : `${refusal.op} station`;
}
/** One refusal as a sentence the assistant can pass on. */
function refusalLine(refusal: ChangeRefusal): string {
const about =
refusal.change === null ? 'The proposal' : `Change ${refusal.change} (${refusal.op} ${refusal.key ?? 'station'})`;
const about = refusal.change === null ? 'The proposal' : `Change ${refusal.change} (${changeLabel(refusal)})`;
const days = refusal.days ? `, day ids ${refusal.days.map((day) => `${day.day_id} ${day.status}`).join(', ')}` : '';
return `${about}: ${REFUSAL_TEXT[refusal.reason]}${days}.`;
}
+4 -1
View File
@@ -24,13 +24,16 @@ interface ChangeEffectsInput {
stored: { distance_km: number | null; walking_minutes: number | null };
}
type StationChange = 'kept' | 'added' | 'replaced' | 'moved';
type StationChange = 'kept' | 'added' | 'replaced' | 'moved' | 'edited';
const CHANGE_OF: Record<DraftPoint['origin'], StationChange> = {
saved: 'kept',
added: 'added',
replacing: 'replaced',
moved: 'moved',
edited: 'edited',
// The way back a close_loop adds is no station, so no station row ever names it.
loop: 'added',
};
/** The key a point is saved under; null for a station a change brings in, which gets one when saved. */
@@ -4,7 +4,7 @@ import type { TourStationVisitsRepository } from '../../db/repositories/TourStat
import type { TourWaypointsRepository } from '../../db/repositories/TourWaypoints.repository';
import { DomainError } from '../common/domain-error';
import { CityWalkFootWayService } from './city-walk-foot-way.service';
import { applyChanges, refusalText, type RefusedAction, type StationDone } from './tour-change-apply';
import { applyChanges, editsOnly, refusalText, type RefusedAction, type StationDone } from './tour-change-apply';
import { describeChange } from './tour-change-effects';
import { proposalIdOf, staleProposal } from './tour-change-proposal';
import { ToursService } from './tours.service';
@@ -96,10 +96,13 @@ export class TourChangePreviewService {
async prepare(tripId: number, placeId: number, userId: number, { changes }: TourChangeRequest) {
const { tour, waypoints, outcome } = await this.lay(tripId, placeId, changes, { action: 'prepared' });
const [wayBefore, wayAfter] = await Promise.all([
this.footWays.plan(userId, tripId, waypoints),
this.footWays.plan(userId, tripId, outcome.points),
]);
// Edits alone move no station, so the way after is the way before and the router is asked once.
const [wayBefore, wayAfter] = editsOnly(changes)
? await this.footWays.plan(userId, tripId, waypoints).then((way) => [way, way] as const)
: await Promise.all([
this.footWays.plan(userId, tripId, waypoints),
this.footWays.plan(userId, tripId, outcome.points),
]);
const revision = tour.revision ?? null;
return {
prepared_only: true,
@@ -6,7 +6,7 @@ import { DomainError } from '../common/domain-error';
import { UnitOfWork } from '../database/unit-of-work';
import type { FootWay } from './city-walk-briefing.helpers';
import { CityWalkFootWayService } from './city-walk-foot-way.service';
import type { DraftPoint } from './tour-change-apply';
import { editsOnly, type DraftPoint } from './tour-change-apply';
import { asWaypoint } from './tour-change-effects';
import { TourChangePreviewService } from './tour-change-preview.service';
import { proposalIdOf } from './tour-change-proposal';
@@ -14,7 +14,13 @@ import { tourWriteOf, writtenStations, writtenTour } from './tour-change-write';
import { ToursService, type TourCreateCommand, type TourRouteDrops } from './tours.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import type { TourChangeSaveRequest, TourDetailResponse, TourListItem, TourWaypoint } from '@trek/shared';
import type {
GeometryPoint,
TourChangeSaveRequest,
TourDetailResponse,
TourListItem,
TourWaypoint,
} from '@trek/shared';
/** The refusal of a save naming a proposal other than the one these changes were prepared as. */
export const TOUR_CHANGE_NOT_PREPARED = 'tour_change_not_prepared';
@@ -25,6 +31,12 @@ interface ChangedTour {
points: readonly DraftPoint[];
way: FootWay;
preparedOn: number;
/**
* Edits alone move no station: the walk keeps its saved line (heights and
* all) and its walking time, and the router is not asked, so a router that
* is down never takes the way of a walk whose stays were changed.
*/
kept?: { line: GeometryPoint[] | null; seconds: number | null };
}
/** The step a save recorded: its id and the revision it raised the tour to. */
@@ -42,11 +54,11 @@ type SaveOutcome = { earlier: TourChangeStepRow } | ({ step: SavedStep } & TourR
* only, as a created one does (CW-11): no line, no distance and no walking
* time until the planner plans the way.
*/
function writeOf({ tour, points, way, preparedOn }: ChangedTour): TourCreateCommand {
function writeOf({ tour, points, way, preparedOn, kept }: ChangedTour): TourCreateCommand {
const routed = way.kind === 'routed' ? way : null;
return tourWriteOf(tour, {
route_geometry: routed?.line ?? null,
duration_seconds: routed?.seconds ?? null,
route_geometry: kept ? kept.line : (routed?.line ?? null),
duration_seconds: kept ? kept.seconds : (routed?.seconds ?? null),
waypoints: points.map(asWaypoint),
expected_revision: preparedOn,
});
@@ -129,8 +141,14 @@ export class TourChangeSaveService {
const earlier = await this.stepsRepo.findSavedProposal(placeId, proposalId, preparedOn);
if (earlier) return alreadySavedAnswer(earlier);
const laid = await this.preview.lay(tripId, placeId, changes, { action: 'saved', preparedOn });
const way = await this.footWays.plan(userId, tripId, laid.outcome.points);
const changed: ChangedTour = { tour: laid.tour, points: laid.outcome.points, way, preparedOn };
const changed: ChangedTour = editsOnly(changes)
? await this.keptWay(tripId, placeId, laid.tour, laid.outcome.points, preparedOn)
: {
tour: laid.tour,
points: laid.outcome.points,
way: await this.footWays.plan(userId, tripId, laid.outcome.points),
preparedOn,
};
const write = await this.tours.routeWrite(writeOf(changed));
const saved = await this.uow.transactional(async (): Promise<SaveOutcome> => {
// The same proposal saved by a save sent alongside this one while the way was planned.
@@ -159,6 +177,23 @@ export class TourChangeSaveService {
return savedAnswer(detail, saved.step, changed);
}
/** A change of edits only, written with the walk's saved line and walking time. */
private async keptWay(
tripId: number,
placeId: number,
tour: TourListItem,
points: readonly DraftPoint[],
preparedOn: number,
): Promise<ChangedTour> {
const line = await this.tours.savedLine(tripId, placeId);
const way: FootWay =
line.length >= 2
? { kind: 'routed', line: line.map(([lat, lng]): [number, number] => [lat, lng]), legs: [], seconds: 0 }
: { kind: 'unrouted', why: 'nothing_to_route', position: null };
const seconds = tour.duration == null ? null : tour.duration * 60;
return { tour, points, way, preparedOn, kept: { line: line.length >= 2 ? line : null, seconds } };
}
/** The tour as it is before the change, as the step keeps it: its line, its points and its measures. */
private async beforeState(
tripId: number,
+10 -1
View File
@@ -24,7 +24,9 @@ export interface TourLineWrite {
/**
* The write of a tour's line and points, everything else of the tour as it
* is: a change of stations, or taking one back, changes nothing else.
* is: a change of stations, or taking one back, changes nothing else. Every
* setting of every type goes along, so a type that is no city walk keeps its
* profile, pace, discipline or boat through such a write as well.
*/
export function tourWriteOf(tour: TourListItem, line: TourLineWrite): TourCreateCommand {
return {
@@ -35,6 +37,13 @@ export function tourWriteOf(tour: TourListItem, line: TourLineWrite): TourCreate
max_hiking_difficulty: tour.max_hiking_difficulty,
hiking_preferences: tour.hiking_preferences,
bike_profile: tour.bike_profile,
run_profile: tour.run_profile,
run_pace_seconds_per_km: tour.run_pace_seconds_per_km,
ski_discipline: tour.ski_discipline,
ski_technique: tour.ski_technique,
ski_options: tour.ski_options,
ski_route_build: tour.ski_route_build,
ski_planned_legs: tour.ski_planned_legs,
boat_type: tour.boat_type,
water_context: tour.water_context,
paddle_speed_kmh: tour.paddle_speed_kmh,
+1 -1
View File
@@ -54,7 +54,7 @@ export class TourChangeMcp {
@Tool({
name: 'prepare_tour_change',
description:
"Prepare a change to a saved tour for the traveller to review, without saving anything: no tour, station, visit or day plan changes, and turning it down keeps the plan as it is. Read the tour with get_tour_plan first. A city walk (tour_type walk) takes changes so far; every other type, and a walk that is a recorded track without stations of its own, is refused with the reason. changes apply in order, each naming saved stations by their key: add (a new station after after_key; a spontaneous stop is an add after the station the traveller is at), remove, replace (a new station in the place of key) and move (key to after after_key). Stops put after the same station keep the order given. A station protected, or visited or skipped on any day of the walk, is never removed, replaced or moved, nothing is put before the last station already done, and a leg drawn by hand stays as drawn (the closing leg of a loop included); such a change is refused, and so is the whole proposal, with every reason in the error. The answer: prepared_only (always true), base (the tour and the revision it was prepared on: a save has to name it, and a tour changed since is refused rather than overwritten, so prepare again), stations_after (each with its key, null for a new one, change kept, added, replaced or moved, by_change, was_position and what it replaces), removed, effects (walking: the whole way on foot before and after planned by TREK's router and the change in metres and minutes, the distance and walking time stored with the walk apart; stays: known minutes and unknown stays before and after; legs: each leg after, new ones marked), data_gaps (what could not be worked out and why: no way from the router, stays not known, no arrival times, shaping points dropped because the leg they steered changed), warnings (a must station removed or replaced), for_later (each new station not yet a place of the trip as create_place takes it, to keep it as a place instead; a station with linked_place_id already is one and is left out) and waypoints_after. proposal_id names this very proposal for save_tour_change. Show every change and its effects to the traveller and save nothing without their yes. can_save says whether they may change the trip's places.",
"Prepare a change to a saved tour for the traveller to review, without saving anything: no tour, station, visit or day plan changes, and turning it down keeps the plan as it is. Read the tour with get_tour_plan first. A city walk (tour_type walk) takes changes so far; every other type, and a walk that is a recorded track without stations of its own, is refused with the reason. changes apply in order, each naming saved stations by their key: add (a new station after after_key; a spontaneous stop is an add after the station the traveller is at), remove, replace (a new station in the place of key), move (key to after after_key) and edit (what station key says of itself: name, stay_minutes with 0 for a point passed through and null for not known, importance optional or must with null for normal, protected, opening_hours; only the fields given change, the station stays where it is, and a protected or already visited station takes an edit too). reverse turns the whole walk round (a loop stays a loop from the same start) and close_loop adds the way back from its last station to its start; each of them is the only change of its proposal besides edits, a walk under way (a station visited or skipped) is not reversed, nor one with a protected station off its start. Stops put after the same station keep the order given. Edits alone keep the walk's saved way and walking time. A station protected, or visited or skipped on any day of the walk, is never removed, replaced or moved, nothing is put before the last station already done, and a leg drawn by hand stays as drawn (the closing leg of a loop included); such a change is refused, and so is the whole proposal, with every reason in the error. The answer: prepared_only (always true), base (the tour and the revision it was prepared on: a save has to name it, and a tour changed since is refused rather than overwritten, so prepare again), stations_after (each with its key, null for a new one, change kept, added, replaced or moved, by_change, was_position and what it replaces), removed, effects (walking: the whole way on foot before and after planned by TREK's router and the change in metres and minutes, the distance and walking time stored with the walk apart; stays: known minutes and unknown stays before and after; legs: each leg after, new ones marked), data_gaps (what could not be worked out and why: no way from the router, stays not known, no arrival times, shaping points dropped because the leg they steered changed), warnings (a must station removed or replaced), for_later (each new station not yet a place of the trip as create_place takes it, to keep it as a place instead; a station with linked_place_id already is one and is left out) and waypoints_after. proposal_id names this very proposal for save_tour_change. Show every change and its effects to the traveller and save nothing without their yes. can_save says whether they may change the trip's places.",
inputSchema: {
tripId: idSchema,
placeId: idSchema.describe('The place id of the tour, as list_tours gives it'),
@@ -0,0 +1,134 @@
import { Tours } from '../../db/entities/Tours.entity';
import type { ToursRepository } from '../../db/repositories/Tours.repository';
import { isUpdateConflict } from '../common/conflictResult';
import { badRequest } from '../common/domain-error';
import { UnitOfWork } from '../database/unit-of-work';
import { PlacesService } from '../places/places.service';
import { advanceTourRevision } from './tour-revision';
import { ToursService } from './tours.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable, NotFoundException } from '@nestjs/common';
import {
maxTourMinutesFor,
plannedTimeLimitMessage,
tourStageCountOf,
type TourDetailsUpdateRequest,
type TourListItem,
} from '@trek/shared';
/** A tour's words and planned times, as update_tour_details reads and writes them. */
interface TourDetails {
name: string;
description: string | null;
website: string | null;
break_additional_minutes: number | null;
planned_duration_minutes: number | null;
}
function detailsOf(tour: TourListItem): TourDetails {
return {
name: tour.name,
description: tour.description ?? null,
website: tour.website ?? null,
break_additional_minutes: tour.break_additional_minutes ?? null,
planned_duration_minutes: tour.planned_duration_minutes ?? null,
};
}
/** The value a change gives a field, or the one it keeps. */
function next<K extends keyof TourDetails>(request: TourDetailsUpdateRequest, before: TourDetails, key: K) {
const value = request[key];
return (value === undefined ? before[key] : value) as TourDetails[K];
}
/**
* P8: the edits of a saved tour besides its route, for every type, a
* recorded track included. Its words (name, description, website) are the
* place's, written through the places' own update; its planned times are
* the tour's, held to the limit the planner's save holds them to (the moving
* time and the breaks within a day, a day a stage for a tour over several
* days, unless a planned total is set). Both go in one transaction that
* raises the tour's revision against the one it was read at, and the trip
* hears tours:changed and place:updated as after the planner's save. The
* route, the points and the days stay as they are.
*
* Also the two writes the tour detail makes on the place: the colour of its
* line, and the copy of the tour as a new one of the trip.
*/
@Injectable()
export class TourDetailsService {
constructor(
private readonly uow: UnitOfWork,
private readonly tours: ToursService,
private readonly places: PlacesService,
@InjectRepository(Tours) private readonly toursRepo: ToursRepository,
) {}
async update(tripId: number, request: TourDetailsUpdateRequest) {
const { placeId } = request;
const detail = await this.tours.getTour(String(tripId), String(placeId));
const before = detailsOf(detail.tour);
const after: TourDetails = {
name: next(request, before, 'name'),
description: next(request, before, 'description'),
website: next(request, before, 'website'),
break_additional_minutes: next(request, before, 'break_additional_minutes'),
planned_duration_minutes: next(request, before, 'planned_duration_minutes'),
};
const stages = tourStageCountOf(detail.waypoints);
const moving = detail.tour.duration;
if (
after.planned_duration_minutes == null &&
moving != null &&
after.break_additional_minutes != null &&
moving + after.break_additional_minutes > maxTourMinutesFor(stages)
) {
throw badRequest(plannedTimeLimitMessage(stages));
}
const words = request.name !== undefined || request.description !== undefined || request.website !== undefined;
const times = request.break_additional_minutes !== undefined || request.planned_duration_minutes !== undefined;
const revision = await this.uow.transactional(async () => {
const raised = await advanceTourRevision(this.toursRepo, placeId, request.expected_revision);
if (words) {
// The places' update keeps a field it is not handed; website and description clear with null.
const place = await this.places.update(String(tripId), String(placeId), {
name: after.name,
description: after.description,
website: after.website,
} as Parameters<PlacesService['update']>[2]);
if (!place) throw new NotFoundException('Tour not found');
}
if (times) {
const { planned_duration_minutes, break_additional_minutes } = after;
await this.toursRepo.updateTimesInTrip(tripId, placeId, { planned_duration_minutes, break_additional_minutes });
}
return raised;
});
const saved = await this.tours.announceRouteUpdate(String(tripId), placeId, {
visitsDropped: [],
stagesDropped: [],
});
return {
tour: { place_id: placeId, tour_type: saved.tour.tour_type, revision, ...detailsOf(saved.tour) },
previous: { revision: detail.tour.revision ?? null, ...before },
};
}
/**
* The colour the tour's line is drawn in, as the tour detail sets it on the
* place: through the places' update and its place:updated, without raising
* the tour's revision (the route is not touched). Null draws it in the
* colour of its type again.
*/
async setRouteColor(tripId: number, placeId: number, color: string | null) {
if (!(await this.toursRepo.findInTrip(tripId, placeId))) throw new NotFoundException('Tour not found');
const current = await this.places.get(String(tripId), String(placeId));
if (!current) throw new NotFoundException('Tour not found');
const place = await this.places.update(String(tripId), String(placeId), { route_color: color });
// No base version is sent, so the update never answers a conflict; the check only narrows the type.
if (!place || isUpdateConflict(place)) throw new NotFoundException('Tour not found');
this.places.broadcast(String(tripId), 'place:updated', { place }, undefined);
await this.places.onUpdated(place.id);
return { place_id: placeId, route_color: color, previous_route_color: current.route_color ?? null };
}
}
+115
View File
@@ -0,0 +1,115 @@
import { ADDON_IDS } from '../../addons';
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
import {
McpController,
Tool,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_WRITE,
errorResult,
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 { TourDetailsService } from './tour-details.service';
import { ToursService } from './tours.service';
import { NotFoundException } from '@nestjs/common';
import {
idSchema,
tourCopyToolRequestSchema,
tourDetailsUpdateRequestSchema,
tourRouteColorRequestSchema,
type TourCopyToolRequest,
type TourDetailsUpdateRequest,
type TourRouteColorRequest,
} from '@trek/shared';
/** A tour the trip does not hold answers as not found, as REST answers its 404. */
async function orNotFound<T>(write: () => Promise<T>) {
try {
return ok(await write());
} catch (err: unknown) {
if (err instanceof NotFoundException) return errorResult('Tour not found.');
throw err;
}
}
/**
* P8: the edits of a saved tour besides its route, for every type: its words
* and planned times (the planner's editor fields, saved over REST with the
* tour's PUT), the colour of its line (the tour detail's colour picker, the
* places' PUT) and a copy of it as a new tour of the trip (POST .../copy).
* Each asks 'place_edit' on the trip as those routes do, and a refusal of the
* service (a time past the day's limit, a tour changed since it was read) is
* a DomainError the registry answers with the text REST sends.
*/
@McpController()
export class TourEditMcp {
constructor(
private readonly details: TourDetailsService,
private readonly tours: ToursService,
private readonly access: TripAccessService,
private readonly guards: McpToolGuardsService,
// Read by the addon gate of every tool here.
readonly addons: AddonsService,
) {}
/** Trip access, then 'place_edit', as the REST routes check them; null when both hold. */
private async refusal(tripId: number, userId: number) {
if (!(await this.access.findAccessible(tripId, userId))) return noAccess();
if (!(await this.guards.hasTripPermission('place_edit', tripId, userId))) return permissionDenied();
return null;
}
@Tool({
name: 'update_tour_details',
description:
'Change the words and planned times of a saved tour of any type (a hike, a city walk, a bike tour, a run, a paddle, ski or horse tour, a recorded track too) without touching its route, its points or its days: name, description, website (an https URL), break_additional_minutes (the breaks on top of the moving time) and planned_duration_minutes (the time out in all the travellers set themselves, breaks included); only the fields given change, null clears one. expected_revision is tour.revision from get_tour_plan or list_tours: a tour changed since is refused as a conflict, read it again. The moving time and the breaks stay within a day (a day a stage for a tour over several days) unless a planned total is set, as in the planner. The answer gives the tour as now saved with its new revision, and previous, the values before. Needs place editing permission on the trip. Change it only after the traveller said yes.',
inputSchema: { tripId: idSchema, ...tourDetailsUpdateRequestSchema.shape },
annotations: TOOL_ANNOTATIONS_WRITE,
when: addonGate(ADDON_IDS.TOURS),
access: { group: 'trips', mode: 'write' },
})
async updateTourDetails({ tripId, ...request }: { tripId: number } & TourDetailsUpdateRequest, ctx: McpContext) {
const refused = await this.refusal(tripId, ctx.userId);
if (refused) return refused;
const checked = tourDetailsUpdateRequestSchema.safeParse(request);
if (!checked.success) return errorResult(checked.error.issues.map((issue) => issue.message).join('; '));
return await orNotFound(() => this.details.update(tripId, checked.data));
}
@Tool({
name: 'set_tour_route_color',
description:
'Draw the line of a saved tour in another colour on the map, as the colour picker of the tour detail does: route_color is a hex colour such as "#e11d48", null draws it in the colour of its tour type again. Only the colour changes: the route, the revision and the days stay as they are. The answer gives route_color and previous_route_color. Needs place editing permission on the trip.',
inputSchema: { tripId: idSchema, ...tourRouteColorRequestSchema.shape },
annotations: TOOL_ANNOTATIONS_WRITE,
when: addonGate(ADDON_IDS.TOURS),
access: { group: 'trips', mode: 'write' },
})
async setTourRouteColor(
{ tripId, placeId, route_color }: { tripId: number } & TourRouteColorRequest,
ctx: McpContext,
) {
const refused = await this.refusal(tripId, ctx.userId);
if (refused) return refused;
return await orNotFound(() => this.details.setRouteColor(tripId, placeId, route_color));
}
@Tool({
name: 'copy_tour',
description:
'Save a tour of the trip again as a new, independent tour of the same trip under its own name, as the planner\'s "save as new tour" does: the copy has its own id, line and control points, the same type, measures and settings, its stops keep their links to the trip\'s places, and it is on no day yet (put it on one with assign_place_to_day). Use it to try a variant without losing the original: copy first, then change the copy. The answer is the copy as list_tours reads it (tour.place_id is its id, revision 1). Needs place editing permission on the trip. Copy only when the traveller asked for it.',
inputSchema: { tripId: idSchema, ...tourCopyToolRequestSchema.shape },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
when: addonGate(ADDON_IDS.TOURS),
access: { group: 'trips', mode: 'write' },
})
async copyTour({ tripId, placeId, name }: { tripId: number } & TourCopyToolRequest, ctx: McpContext) {
const refused = await this.refusal(tripId, ctx.userId);
if (refused) return refused;
return await orNotFound(() => this.tours.copyTour(String(tripId), String(placeId), name));
}
}
@@ -0,0 +1,43 @@
/**
* PR1-01 and PR1-02 for a saved hike changed over MCP: its points the other
* way round, or taken back to its start, as the planner's route tools turn
* them. Only the points are worked out here; update_tour plans the way
* through them anew, under the hike's limit and preferences, and keeps each
* point's name, key and link where it stands where a saved one stood.
*/
import { isClosedLoop, type HikeStop, type TourReshape, type TourWaypoint } from '@trek/shared';
/** The points a reshaped hike is planned through, or why it cannot be reshaped. */
type Reshaped = { points: HikeStop[] } | { refused: string };
/** A saved point as update_tour takes a new one: where it is, what it is, and whether a stage ends there. */
function stopOf(point: TourWaypoint): HikeStop {
return {
lat: point.lat,
lng: point.lng,
...(point.name ? { name: point.name } : {}),
...(point.poi_category ? { poi_category: point.poi_category } : {}),
...(point.stage_end ? { stage_end: true } : {}),
};
}
export function reshapedHikePoints(saved: readonly TourWaypoint[], reshape: TourReshape): Reshaped {
if (saved.length < 2) {
return {
refused:
'This hike is a recorded track without points of its own, so there is nothing to turn: give waypoints to plan it over first.',
};
}
if (reshape === 'reverse') {
if (saved.some((point) => point.stage_end)) {
return {
refused:
'This hike is split into stages over several days, which keep their days by the stop each starts at: turn it round in the planner, where its stages are put on their days again.',
};
}
return { points: [...saved].reverse().map(stopOf) };
}
if (isClosedLoop(saved)) return { refused: 'This hike already ends where it starts.' };
const first = saved[0] as TourWaypoint;
return { points: [...saved.map(stopOf), { lat: first.lat, lng: first.lng }] };
}
@@ -0,0 +1,255 @@
import { ADDON_IDS } from '../../addons';
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
import {
McpController,
Tool,
TOOL_ANNOTATIONS_READONLY,
TOOL_ANNOTATIONS_WRITE,
errorResult,
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 { parseHorseRoutesQuery } from './tour-horse-routes.controller';
import { TourHorseRoutesService } from './tour-horse-routes.service';
import { TourObstacleReviewsService } from './tour-obstacle-reviews.service';
import { TourPaddlePlanService } from './tour-paddle-plan.service';
import { TourSkiService } from './tour-ski.service';
import { TourTrailDetailsService } from './tour-trail-details.service';
import { ToursService } from './tours.service';
import { NotFoundException } from '@nestjs/common';
import {
horseRoutesToolRequestSchema,
idSchema,
obstacleDecisionsToolRequestSchema,
skiClassifyToolRequestSchema,
tourObstacleReviewsRequestSchema,
tourPaddlePlanRequestSchema,
tourRefToolRequestSchema,
tourSkiClassificationRequestSchema,
type HorseRoute,
type HorseRoutesToolRequest,
type ObstacleDecisionsToolRequest,
type SkiClassifyToolRequest,
type TourDetailResponse,
type TourPaddlePlanRequest,
type TourRefToolRequest,
} from '@trek/shared';
import type { ZodError } from 'zod';
const plan = tourPaddlePlanRequestSchema.shape;
/** A request the shared contract refuses, answered with every reason as REST's pipe gives them. */
function refusedBy(error: ZodError) {
return errorResult(error.issues.map((issue) => issue.message).join('; '));
}
/** The tour of a write's answer without what only the planner draws: its line, its points, its checklist. */
function tourOf(detail: TourDetailResponse) {
return detail.tour;
}
/** A horse route as the assistant reads it: each section's length, access and signposting, and where it starts and ends. */
function routeInBrief(route: HorseRoute) {
return {
id: route.id,
name: route.name,
ref: route.ref,
osm_url: route.osmUrl,
continues: route.continues,
sections: route.sections.map(({ geometry, ...section }) => ({
...section,
from: geometry[0] ?? null,
to: geometry[geometry.length - 1] ?? null,
})),
};
}
/**
* P9: the writes and reads of the Tours tab that are a type's own, over MCP
* with the services and checks of their REST routes: a ski track classified
* again (PUT .../ski), the paddle plan of a recorded paddle track (PUT
* .../paddle-plan) and the decisions about a paddle tour's obstacles (PUT
* .../obstacle-reviews), an import planned over taken back to its recording
* (POST .../restore-recorded), each with 'place_edit' on the trip; and two
* reads anyone who sees the trip may make, the trail details along a saved
* tour (the planner's trail grades) and the signposted horse routes in a
* map view (GET .../horse-routes). A refusal of a service is a DomainError
* the registry answers with the text REST sends.
*/
@McpController()
export class TourSportWritesMcp {
constructor(
private readonly tours: ToursService,
private readonly ski: TourSkiService,
private readonly paddlePlans: TourPaddlePlanService,
private readonly obstacleReviews: TourObstacleReviewsService,
private readonly trailDetails: TourTrailDetailsService,
private readonly horseRoutes: TourHorseRoutesService,
private readonly access: TripAccessService,
private readonly guards: McpToolGuardsService,
// Read by the addon gate of every tool here.
readonly addons: AddonsService,
) {}
/** Trip access, then 'place_edit', as the REST routes check them; null when both hold. */
private async editRefusal(tripId: number, userId: number) {
if (!(await this.access.findAccessible(tripId, userId))) return noAccess();
if (!(await this.guards.hasTripPermission('place_edit', tripId, userId))) return permissionDenied();
return null;
}
/** The write, answered with a tour the trip does not hold as not found, as REST answers its 404. */
private async orNotFound<T>(write: () => Promise<T>) {
try {
return ok(await write());
} catch (err: unknown) {
if (err instanceof NotFoundException) return errorResult('Tour not found.');
throw err;
}
}
@Tool({
name: 'classify_ski_track',
description:
'Classify a ski and winter tour (tour_type ski, usually an imported track) again when its discipline is not the right one: ski_discipline alpine, snowboard, cross_country, touring for ski touring, snowshoe or winter_hike, and for cross_country only ski_technique (classic, skating, or unknown when the travellers do not know). Only the classification changes: the track, its measures, its days and where it came from stay as they are, and nothing is planned. Any other type is refused. expected_revision (tour.revision of get_tour_plan) refuses it once the tour changed since. The answer gives the tour as now saved and previous, the discipline and technique before. Needs place editing permission on the trip. Change it only after the traveller said yes.',
inputSchema: { tripId: idSchema, ...skiClassifyToolRequestSchema.shape },
annotations: TOOL_ANNOTATIONS_WRITE,
when: addonGate(ADDON_IDS.TOURS),
access: { group: 'trips', mode: 'write' },
})
async classifySkiTrack(
{ tripId, placeId, ...request }: { tripId: number } & SkiClassifyToolRequest,
ctx: McpContext,
) {
const refused = await this.editRefusal(tripId, ctx.userId);
if (refused) return refused;
const checked = tourSkiClassificationRequestSchema.safeParse(request);
if (!checked.success) return refusedBy(checked.error);
return await this.orNotFound(async () => {
const before = await this.tours.getTour(String(tripId), String(placeId));
const after = await this.ski.classify(String(tripId), String(placeId), checked.data);
return {
tour: tourOf(after),
previous: {
revision: before.tour.revision ?? null,
ski_discipline: before.tour.ski_discipline ?? null,
ski_technique: before.tour.ski_technique ?? null,
},
};
});
}
@Tool({
name: 'set_paddle_plan',
description:
"Lay the paddle plan onto a recorded paddle track (tour_type kayak without control points of its own, an import), as the track's plan card does: the portages (from_m and to_m, metres from the start of the track, and extra_minutes for getting out and back in, null for TREK's starting value) and locks (at_m with wait_minutes and transit_minutes, null where not known) in track_marks, the paddle_speed_kmh (null for the boat's suggested speed) and the portage_speed_kmh (null for the starting value). The track is never redrawn; its moving time is reckoned anew from the plan. The plan given replaces the whole plan: send every portage and lock to keep. A planned paddle tour saves its portages and locks with its route in the planner and is refused here, and so is a mark past the end of the track. The answer gives the tour as now saved and previous, the plan before. Needs place editing permission on the trip. Change it only after the traveller said yes.",
inputSchema: {
tripId: idSchema,
placeId: tourRefToolRequestSchema.shape.placeId,
paddle_speed_kmh: plan.paddle_speed_kmh,
portage_speed_kmh: plan.portage_speed_kmh,
track_marks: plan.track_marks,
expected_revision: plan.expected_revision,
},
annotations: TOOL_ANNOTATIONS_WRITE,
when: addonGate(ADDON_IDS.TOURS),
access: { group: 'trips', mode: 'write' },
})
async setPaddlePlan(
{ tripId, placeId, ...request }: { tripId: number; placeId: number } & TourPaddlePlanRequest,
ctx: McpContext,
) {
const refused = await this.editRefusal(tripId, ctx.userId);
if (refused) return refused;
const checked = tourPaddlePlanRequestSchema.safeParse(request);
if (!checked.success) return refusedBy(checked.error);
return await this.orNotFound(async () => {
const before = await this.tours.getTour(String(tripId), String(placeId));
const after = await this.paddlePlans.update(String(tripId), String(placeId), checked.data);
return {
tour: tourOf(after),
previous: {
revision: before.tour.revision ?? null,
paddle_speed_kmh: before.tour.paddle_speed_kmh ?? null,
portage_speed_kmh: before.tour.portage_speed_kmh ?? null,
track_marks: before.tour.track_marks ?? { portages: [], locks: [] },
},
};
});
}
@Tool({
name: 'decide_paddle_obstacles',
description:
"Record what the paddlers decided about the obstacles of a paddle tour (tour_type kayak), as its obstacle checklist does: read get_tour_plan first and take each entry's key and basis from its obstacle_check, since a decision is bound to the route and the data the travellers saw. op decide stores decision pass, portage, reroute or not_affected with that basis (and a note), note changes the note only, clear takes the decision back, keep keeps a decision about an object the data no longer holds; each obstacle at most once. A decision is the paddlers' own planning, never a safety or legal clearance, and a ban in the data counts whatever was decided. The answer is the checklist as it now stands (obstacle_check) with the tour. Refused while the obstacle data cannot be read. Needs place editing permission on the trip. Decide only what the traveller decided.",
inputSchema: { tripId: idSchema, ...obstacleDecisionsToolRequestSchema.shape },
annotations: TOOL_ANNOTATIONS_WRITE,
when: addonGate(ADDON_IDS.TOURS),
access: { group: 'trips', mode: 'write' },
})
async decidePaddleObstacles(
{ tripId, placeId, ops }: { tripId: number } & ObstacleDecisionsToolRequest,
ctx: McpContext,
) {
const refused = await this.editRefusal(tripId, ctx.userId);
if (refused) return refused;
const checked = tourObstacleReviewsRequestSchema.safeParse({ ops });
if (!checked.success) return refusedBy(checked.error);
return await this.orNotFound(async () => {
const after = await this.obstacleReviews.save(String(tripId), String(placeId), checked.data, ctx.userId);
return { tour: tourOf(after), obstacle_check: after.obstacle_check ?? null };
});
}
@Tool({
name: 'restore_recorded_track',
description:
'Take an import that was planned over in place (tour.has_recorded_track true) back to the track it was recorded with: its line, the distance and heights read from it and the grade its source gave, without control points; the planning is dropped and cannot be brought back here (copy_tour it first to keep it). The tour keeps its id, days and notes; a split into stages ends, so a day that held a stage no longer does. A tour without a recorded track to go back to is refused. The answer is the tour as now saved. Needs place editing permission on the trip. Do it only after the traveller said yes.',
inputSchema: { tripId: idSchema, ...tourRefToolRequestSchema.shape },
annotations: TOOL_ANNOTATIONS_WRITE,
when: addonGate(ADDON_IDS.TOURS),
access: { group: 'trips', mode: 'write' },
})
async restoreRecordedTrack({ tripId, placeId }: { tripId: number } & TourRefToolRequest, ctx: McpContext) {
const refused = await this.editRefusal(tripId, ctx.userId);
if (refused) return refused;
return await this.orNotFound(async () => ({
tour: tourOf(await this.tours.restoreRecordedTrack(String(tripId), String(placeId))),
}));
}
@Tool({
name: 'get_tour_trail_details',
description:
"Read what OpenStreetMap records for the ways a saved tour runs on, along its saved line, as the planner's trail details show it: sac (the SAC hiking grades: highest from 1 for T1 to 6 for T6, the graded stretches in metres along the line, unknown_share), mtb (the mountain bike scale mtb:scale, its hardest value and the metres of each), surface and highway (the metres and share of each value as OpenStreetMap writes it, such as asphalt, gravel, path or track), unknown_share (the part whose way carries none of these or could not be asked about) and length_m. A way without a value is unknown, never easy, paved or rideable; ways_unanswered counts ways the data could not be asked about this time, truncated says a very long line was only read in part. A tour saved with its points only has no line yet and is refused. Reading changes nothing.",
inputSchema: { tripId: idSchema, ...tourRefToolRequestSchema.shape },
annotations: TOOL_ANNOTATIONS_READONLY,
when: addonGate(ADDON_IDS.TOURS),
access: { group: 'trips', mode: 'read' },
})
async getTourTrailDetails({ tripId, placeId }: { tripId: number } & TourRefToolRequest, ctx: McpContext) {
// A read, like the REST route: seeing the trip is enough.
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
return await this.orNotFound(() => this.trailDetails.details(ctx.userId, tripId, placeId));
}
@Tool({
name: 'find_horse_routes',
description:
'Find the signposted horse routes OpenStreetMap knows inside a map view, as the horse tour planner shows them: each route (id relation/<n>, name, ref, osm_url, continues when it runs on past the view) with its sections, the stretches a ride can take whole (number, length_m, from and to as [lat, lng], access: the metres of each horse=* value its ways carry, unknown where untagged, and signposted: with or against the direction it is listed in, null both ways). bbox is the view as south,west,north,east; a view too large is refused with the advice to zoom in. Being on a horse route is no permission to ride it. truncated says the view held more than one answer carries. Reading changes nothing; nothing about the trip goes out.',
inputSchema: { tripId: idSchema, ...horseRoutesToolRequestSchema.shape },
annotations: TOOL_ANNOTATIONS_READONLY,
when: addonGate(ADDON_IDS.TOURS),
access: { group: 'trips', mode: 'read' },
})
async findHorseRoutes({ tripId, bbox }: { tripId: number } & HorseRoutesToolRequest, ctx: McpContext) {
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
const found = await this.horseRoutes.list(parseHorseRoutesQuery({ bbox }).bbox);
return ok({ ...found, routes: found.routes.map(routeInBrief) });
}
}
@@ -0,0 +1,83 @@
/**
* BIK-05, MAP-03 and MAP-04 for a saved tour read over MCP: what
* OpenStreetMap records for the ways under its line, summed along it. The
* ways come from the router's description of the saved line and their tags
* from the trail grades the planner asks for; this half only lays them onto
* the line. A way without a value is unknown, never easy or paved.
*/
import type { TracedWay } from '../roadtrip/roadtrip-router.service';
import { gradesAlong } from './hike-routing.helpers';
import { sacStepOf, type HikeTrailGrades, type SacStep, type TourTrailGrade } from '@trek/shared';
/** How many metres of the line one written value covers, and its share of the whole line. */
interface ValueShare {
value: string;
metres: number;
share: number;
}
/** Everything the trail details answer, worked out from the ways and their tags. */
export interface TrailDetails {
length_m: number;
/** The SAC grades along the line, as a proposed hike carries them. */
sac: HikeTrailGrades;
/** The mountain bike scale (mtb:scale), the hardest step found and the metres of each; unknown where untagged. */
mtb: { highest: string | null; values: ValueShare[] };
surface: ValueShare[];
highway: ValueShare[];
/** The part of the line whose way has none of these tags or could not be asked about, 0 to 1. */
unknown_share: number;
}
const roundShare = (value: number) => Math.min(1, Math.round(value * 1000) / 1000);
/** The metres each value covers, the most first, scaled onto the saved length like the SAC stretches. */
function sharesOf(
ways: readonly TracedWay[],
valueOf: (grade: TourTrailGrade) => string | null,
grades: ReadonlyMap<number, TourTrailGrade>,
scale: number,
total: number,
): ValueShare[] {
const metres = new Map<string, number>();
for (const way of ways) {
const grade = way.wayId === null ? undefined : grades.get(way.wayId);
const value = grade ? valueOf(grade) : null;
if (value) metres.set(value, (metres.get(value) ?? 0) + way.metres * scale);
}
return [...metres]
.map(([value, along]) => ({ value, metres: Math.round(along), share: total > 0 ? roundShare(along / total) : 0 }))
.sort((a, b) => b.metres - a.metres || a.value.localeCompare(b.value));
}
/** The harder of two mtb:scale values, by their step (a + or - only between equal steps). */
function harderMtb(a: string | null, b: string): string {
if (a === null) return b;
const step = (value: string) => Number(value[0]) * 3 + (value.endsWith('+') ? 2 : value.endsWith('-') ? 0 : 1);
return step(b) > step(a) ? b : a;
}
/** The ways under the saved line with their tags, laid onto its length. */
export function trailDetailsOf(
ways: readonly TracedWay[],
grades: ReadonlyMap<number, TourTrailGrade>,
lengthM: number,
): TrailDetails {
const traced = ways.reduce((sum, way) => sum + way.metres, 0);
const scale = traced > 0 && lengthM > 0 ? lengthM / traced : 1;
const total = traced * scale;
const steps = new Map<number, SacStep>();
for (const [id, grade] of grades) if (grade.sac_scale) steps.set(id, sacStepOf(grade.sac_scale));
const mtb = sharesOf(ways, (grade) => grade.mtb_scale, grades, scale, total);
const known = ways
.filter((way) => way.wayId !== null && grades.has(way.wayId))
.reduce((sum, way) => sum + way.metres * scale, 0);
return {
length_m: Math.round(total),
sac: gradesAlong(ways, steps, lengthM),
mtb: { highest: mtb.reduce<string | null>((hardest, { value }) => harderMtb(hardest, value), null), values: mtb },
surface: sharesOf(ways, (grade) => grade.surface, grades, scale, total),
highway: sharesOf(ways, (grade) => grade.highway, grades, scale, total),
unknown_share: total > 0 ? roundShare(1 - known / total) : 1,
};
}
@@ -0,0 +1,77 @@
import { logWarn } from '../audit/audit-log.logger';
import { DomainError } from '../common/domain-error';
import { RoadtripRouterService, type TracedWay } from '../roadtrip/roadtrip-router.service';
import { gradableWayIds } from './hike-routing.helpers';
import { trailDetailsOf, type TrailDetails } from './tour-trail-details.helpers';
import { TourTrailGradeService } from './tour-trail-grade.service';
import { computeTourMetrics } from './tours.helpers';
import { ToursService } from './tours.service';
import { Injectable } from '@nestjs/common';
import { TOUR_TRAIL_GRADE_MAX_WAYS, type TourTrailGrade } from '@trek/shared';
/** The refusal of a tour without a saved line to read the ways under. */
const TOUR_TRAIL_NO_LINE = 'tour_trail_no_line';
/** The refusal while the router or the OpenStreetMap data cannot be asked. */
const TOUR_TRAIL_UNAVAILABLE = 'tour_trail_unavailable';
/** What get_tour_trail_details answers: the tour it read, and what its ways carry. */
interface TourTrailDetailsAnswer extends TrailDetails {
place_id: number;
tour_type: string;
/** Ways the data could not be asked about this time: their values are unknown, not absent. */
ways_unanswered: number;
/** The line ran over more ways than one question names; the rest were not read. */
truncated: boolean;
}
function reasonOf(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/**
* BIK-05, MAP-03 and MAP-04 for a saved tour: the ways under its saved line,
* as the router describes it (the planner's own description of a draft), and
* what OpenStreetMap records for them, through the trail grades the planner's
* POST /api/trips/:tripId/tours/trail-grades reads. A read: nothing about
* the tour changes. A tour saved with its points only has no line to read.
*/
@Injectable()
export class TourTrailDetailsService {
constructor(
private readonly tours: ToursService,
private readonly router: RoadtripRouterService,
private readonly trailGrades: TourTrailGradeService,
) {}
async details(userId: number, tripId: number, placeId: number): Promise<TourTrailDetailsAnswer> {
const { tour } = await this.tours.getTour(String(tripId), String(placeId));
const line = await this.tours.savedLine(tripId, placeId);
if (line.length < 2) {
throw new DomainError(422, 'This tour has no saved line yet: open it in the planner to plan its way first.', {
code: TOUR_TRAIL_NO_LINE,
});
}
const flat = line.map(([lat, lng]): [number, number] => [lat, lng]);
let ways: TracedWay[];
try {
ways = await this.router.wayIds(userId, flat);
} catch (err: unknown) {
logWarn(`[Tours] no ways under tour ${placeId}: ${reasonOf(err)}`);
throw new DomainError(503, 'The ways under this tour cannot be read right now: ask again later.', {
code: TOUR_TRAIL_UNAVAILABLE,
});
}
const ids = gradableWayIds(ways, TOUR_TRAIL_GRADE_MAX_WAYS);
const answer = ids.length > 0 ? await this.trailGrades.grades({ way_ids: ids }) : { ways: [], unanswered: [] };
const grades = new Map<number, TourTrailGrade>(answer.ways.map((way) => [way.id, way]));
const lengthM = computeTourMetrics(line).distanceKm * 1000;
const distinct = new Set(ways.flatMap((way) => (way.wayId === null ? [] : [way.wayId])));
return {
place_id: tour.place_id,
tour_type: tour.tour_type,
...trailDetailsOf(ways, grades, lengthM),
ways_unanswered: answer.unanswered.length,
truncated: distinct.size > ids.length,
};
}
}
+9
View File
@@ -49,8 +49,10 @@ import { TourChangeSaveService } from './tour-change-save.service';
import { TourChangeUndoMcp } from './tour-change-undo.mcp';
import { TourChangeUndoService } from './tour-change-undo.service';
import { TourChangeMcp } from './tour-change.mcp';
import { TourDetailsService } from './tour-details.service';
import { TourDraftsController } from './tour-drafts.controller';
import { TourDraftsService } from './tour-drafts.service';
import { TourEditMcp } from './tour-edit.mcp';
import { TourGpxExportService } from './tour-gpx-export.service';
import { TourHorseRoutesController } from './tour-horse-routes.controller';
import { TourHorseRoutesService } from './tour-horse-routes.service';
@@ -68,6 +70,8 @@ import { TourPoiSearchController } from './tour-poi-search.controller';
import { TourPoiSearchService } from './tour-poi-search.service';
import { TourSkiController } from './tour-ski.controller';
import { TourSkiService } from './tour-ski.service';
import { TourSportWritesMcp } from './tour-sport-writes.mcp';
import { TourTrailDetailsService } from './tour-trail-details.service';
import { TourTrailGradeController } from './tour-trail-grade.controller';
import { TourTrailGradeService } from './tour-trail-grade.service';
import { TourVisitsController } from './tour-visits.controller';
@@ -206,6 +210,9 @@ import { Module } from '@nestjs/common';
TourPoiSearchService,
TourSkiService,
TourTrailGradeService,
// P8, P9: a tour's words, times and colour, and the trail details along a saved one.
TourDetailsService,
TourTrailDetailsService,
TourVisitsService,
TourWalkImportService,
CityWalkFootWayService,
@@ -233,6 +240,8 @@ import { Module } from '@nestjs/common';
TourActivityStatsMcp,
CollectionToursMcp,
TourPackingMcp,
TourEditMcp,
TourSportWritesMcp,
],
})
export class ToursModule {}
+274
View File
@@ -0,0 +1,274 @@
/**
* Tours e2e for the assistant's edits of a saved tour (P8, P9): a hike saved
* over REST, its words and breaks, its colour and a copy changed over MCP and
* read back over REST; a city walk's station edited through a confirmed
* change without the router; the writes refused without 'place_edit'. Through
* the real JwtAuthGuard, AddonGuard and TripAccessGuard against a migrated and
* seeded temp SQLite db; the MCP tools run in a request context as the
* transport runs them, and the broadcast goes to a FakeRealtimeService.
*
* Fixtures: user 1 owns trip 5, user 2 is a member of it whom the permission
* spy denies 'place_edit'.
*/
import { Places } from '../../src/db/entities/Places.entity';
import { Trips } from '../../src/db/entities/Trips.entity';
import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../src/mcp/nest-mcp-policy';
import { createTestRegistry, type McpContext } from '../../src/nest-mcp';
import { AssignmentsModule } from '../../src/nest/assignments/assignments.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { withRequestContext } from '../../src/nest/database/request-context';
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
import { PlacesModule } from '../../src/nest/places/places.module';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
import { RoadtripRouterService } from '../../src/nest/roadtrip/roadtrip-router.service';
import { proposalIdOf } from '../../src/nest/tours/tour-change-proposal';
import { TourChangeMcp } from '../../src/nest/tours/tour-change.mcp';
import { TourEditMcp } from '../../src/nest/tours/tour-edit.mcp';
import { TourSportWritesMcp } from '../../src/nest/tours/tour-sport-writes.mcp';
import { ToursModule } from '../../src/nest/tours/tours.module';
import { createSnapshotTestDb } from '../helpers/db-mock';
import { deleteRows, insertRows } from '../helpers/factories/rows';
import { setAddonEnabled } from '../helpers/factories/settings';
import { addTripMember } from '../helpers/factories/trips';
import { makeUser } from '../helpers/factories/users';
import { FakeRealtimeService } from '../helpers/fake-realtime';
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { sessionCookie } from './harness';
import { MikroORM } from '@mikro-orm/core';
import { Test } from '@nestjs/testing';
import type { TourChange } from '@trek/shared';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import request from 'supertest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest';
vi.mock('../../src/nest/audit/audit-log.logger', () => ({
LOG_LEVEL: 'error',
logInfo: vi.fn(),
logDebug: vi.fn(),
logError: vi.fn(),
logWarn: vi.fn(),
}));
const { journeyHooks } = vi.hoisted(() => ({
journeyHooks: {
reconcileTripSkeletons: vi.fn().mockResolvedValue(undefined),
onPlaceCreated: vi.fn().mockResolvedValue(undefined),
onPlaceUpdated: vi.fn().mockResolvedValue(undefined),
onPlaceDeleted: vi.fn().mockResolvedValue(undefined),
},
}));
const db = createSnapshotTestDb();
const realtime = new FakeRealtimeService();
let orm: TestOrm;
let checkPermission: MockInstance;
let routeOnFoot: MockInstance;
const OWNER = 1;
const VIEWER = 2;
const LINE = [
[47, 11, 600],
[47.01, 11.01, 700],
[47.02, 11.02, 650],
];
const hikeBody = () => ({
name: 'Ridge',
route_geometry: LINE,
waypoints: [
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 1 },
],
max_hiking_difficulty: 2,
duration_seconds: 3600,
});
const walkBody = () => ({
name: 'Old town',
tour_type: 'walk',
route_geometry: [
[48.1, 11.5, 520],
[48.108, 11.5, 525],
],
waypoints: [
{ lat: 48.1, lng: 11.5, role: 'start', sequence: 0, key: 'walk-gate', name: 'Gate' },
{ lat: 48.108, lng: 11.5, role: 'end', sequence: 1, key: 'walk-park', name: 'Park' },
],
duration_seconds: 900,
});
type ToolResult = { content: Array<{ type: string; text: string }>; isError?: boolean };
describe('Tours e2e: the assistant edits a saved tour (P8, P9)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({
imports: [
await TestUnitOfWorkModule.forRoot(db),
await createTestMikroOrmModule(db),
RealtimeModule,
ToursModule,
PlacesModule,
AssignmentsModule,
],
})
.overrideProvider(RealtimeService)
.useValue(realtime)
.overrideProvider(JourneyDomainService)
.useValue(journeyHooks)
.compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalPipes(new ZodValidationPipe());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
orm = await createTestOrm(db);
await makeUser(orm, { id: 1, username: 'owner', email: 'owner@example.test', role: 'user', password_version: 0 });
await makeUser(orm, { id: 2, username: 'viewer', email: 'viewer@example.test', role: 'user', password_version: 0 });
await insertRows(orm, Trips, [{ id: 5, user: 1, title: 'Alps' }]);
await addTripMember(orm, 5, 2);
app = await build();
checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
routeOnFoot = vi.spyOn(app.get(RoadtripRouterService), 'route');
server = app.getHttpServer();
});
beforeEach(async () => {
checkPermission.mockImplementation(
(action: string, _role: string, _owner: number, userId: number) =>
!(action === 'place_edit' && userId === VIEWER),
);
routeOnFoot.mockReset().mockRejectedValue(new Error('no routing host in this test'));
await setAddonEnabled(orm, 'tours', true);
realtime.broadcastMock.mockClear();
await deleteRows(orm, Places);
});
afterAll(async () => {
await app.close();
await orm.close();
});
const cookie = (userId = OWNER) => sessionCookie(userId);
/** A tool run as the transport runs it: in a request context of its own. */
const run = (call: () => Promise<ToolResult>) =>
withRequestContext(app.get(MikroORM), async () => {
const result = await call();
const text = result.content[0]?.text ?? '';
return result.isError ? { error: text } : JSON.parse(text);
});
const as = (userId: number) => ({ userId }) as McpContext;
const saveTour = async (body: Record<string, unknown>) => {
const res = await request(server).post('/api/trips/5/tours').set('Cookie', cookie()).send(body);
expect(res.status).toBe(201);
return res.body.tour.place_id as number;
};
const tourOf = async (placeId: number) => {
const res = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', cookie());
expect(res.status).toBe(200);
return res.body;
};
it('registers both controllers under the trip access policy', () => {
expect(() =>
createTestRegistry([app.get(TourEditMcp), app.get(TourSportWritesMcp)], {
accessPolicy: trekMcpAccessPolicy,
validateAccess: trekMcpValidateAccess,
}),
).not.toThrow();
});
it('the words and breaks changed over MCP are what the planner reads, and its stale save is refused', async () => {
const placeId = await saveTour(hikeBody());
const changed = await run(() =>
app.get(TourEditMcp).updateTourDetails(
{
tripId: 5,
placeId,
expected_revision: 1,
website: 'https://ridge.example/',
break_additional_minutes: 40,
},
as(OWNER),
),
);
expect(changed.tour).toMatchObject({ revision: 2, website: 'https://ridge.example/' });
const detail = await tourOf(placeId);
expect(detail.tour).toMatchObject({ website: 'https://ridge.example/', break_additional_minutes: 40, revision: 2 });
expect(detail.waypoints).toHaveLength(2);
const stale = await request(server)
.put(`/api/trips/5/tours/${placeId}`)
.set('Cookie', cookie())
.send({ ...hikeBody(), expected_revision: 1 });
expect(stale.status).toBe(409);
});
it('a colour set and a copy made over MCP show over REST', async () => {
const placeId = await saveTour(hikeBody());
await run(() => app.get(TourEditMcp).setTourRouteColor({ tripId: 5, placeId, route_color: '#e11d48' }, as(OWNER)));
const place = await request(server).get(`/api/trips/5/places/${placeId}`).set('Cookie', cookie());
expect(place.body.place.route_color).toBe('#e11d48');
const copy = await run(() =>
app.get(TourEditMcp).copyTour({ tripId: 5, placeId, name: 'Ridge (short)' }, as(OWNER)),
);
const list = await request(server).get('/api/trips/5/tours').set('Cookie', cookie());
expect(list.body.tours.map((tour: { name: string }) => tour.name).sort()).toEqual(['Ridge', 'Ridge (short)']);
expect(copy.tour.derived_from_place_id).toBe(placeId);
});
it("a station's stay edited through a confirmed change keeps the walk's way without the router", async () => {
const placeId = await saveTour(walkBody());
const changes: TourChange[] = [{ op: 'edit', key: 'walk-park', stay_minutes: 30, importance: 'must' }];
const saved = await run(() =>
app.get(TourChangeMcp).saveTourChange(
{
tripId: 5,
placeId,
changes,
expected_revision: 1,
proposal_id: proposalIdOf({ tripId: 5, placeId, revision: 1, changes }),
confirmed: true,
},
as(OWNER),
),
);
expect(saved).toMatchObject({ saved: true, tour: { route_pending: false, walking_minutes: 15 } });
expect(routeOnFoot).not.toHaveBeenCalled();
const detail = await tourOf(placeId);
expect(detail.waypoints[1]).toMatchObject({ key: 'walk-park', stay_minutes: 30, importance: 'must' });
});
it('a member without place_edit changes nothing, over MCP as over REST', async () => {
const placeId = await saveTour(hikeBody());
const refused = await run(() =>
app.get(TourEditMcp).updateTourDetails({ tripId: 5, placeId, expected_revision: 1, name: 'Mine' }, as(VIEWER)),
);
expect(refused).toHaveProperty('error');
const ski = await run(() =>
app.get(TourSportWritesMcp).classifySkiTrack({ tripId: 5, placeId, ski_discipline: 'snowshoe' }, as(VIEWER)),
);
expect(ski).toHaveProperty('error');
const rest = await request(server)
.put(`/api/trips/5/tours/${placeId}/ski`)
.set('Cookie', cookie(VIEWER))
.send({ ski_discipline: 'snowshoe' });
expect(rest.status).toBe(403);
expect((await tourOf(placeId)).tour).toMatchObject({ name: 'Ridge', revision: 1 });
});
});
@@ -2,9 +2,10 @@ import { bookings } from './bookings.cases';
import { collabTrip } from './collab-trip.cases';
import { money } from './money.cases';
import { plan } from './plan.cases';
import { toursEdit } from './tours-edit.cases';
import { toursRoadtrip } from './tours-roadtrip.cases';
import type { CaseGroup } from './types';
import { user } from './user.cases';
/** Every group, imported statically, so the registry test sees each row without running a case. */
export const ALL_GROUPS: readonly CaseGroup[] = [plan, bookings, toursRoadtrip, money, collabTrip, user];
export const ALL_GROUPS: readonly CaseGroup[] = [plan, bookings, toursRoadtrip, toursEdit, money, collabTrip, user];
@@ -0,0 +1,166 @@
import { Places } from '../../../src/db/entities/Places.entity';
import { Tours } from '../../../src/db/entities/Tours.entity';
import { updateRows } from '../../helpers/factories/rows';
import { addTourWaypoints } from '../../helpers/factories/tours';
import type { CaseGroup, Scene } from './types';
import { MikroORM } from '@mikro-orm/core';
import type { INestApplication } from '@nestjs/common';
const ormOf = (app: INestApplication) => app.get(MikroORM);
/** A hike's line with its heights, as the planner saves one. */
const LINE: [number, number, number][] = [
[47, 11, 600],
[47.01, 11.01, 700],
[47.02, 11.02, 650],
];
/** The planner's save of the scene's hike, for the edit route. */
const hikeBody = (extra: Record<string, unknown>) => ({
name: 'Ridge',
route_geometry: LINE,
waypoints: [
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 1 },
],
max_hiking_difficulty: 2,
duration_seconds: 3600,
expected_revision: 1,
...extra,
});
/** The scene's hike with its line and its start and end, as the planner saves one. */
async function planHike(s: Scene, app: INestApplication): Promise<void> {
const orm = ormOf(app);
await updateRows(orm, Places, { id: s.tourPlace.id }, { route_geometry: JSON.stringify(LINE) });
await addTourWaypoints(orm, s.tourPlace.id, [
{ lat: 47, lng: 11 },
{ lat: 47.02, lng: 11.02 },
]);
}
/** The scene's tour as an imported ski track, classified alpine. */
async function skiTrack(s: Scene, app: INestApplication): Promise<void> {
const orm = ormOf(app);
await updateRows(orm, Places, { id: s.tourPlace.id }, { route_geometry: JSON.stringify(LINE) });
await updateRows(
orm,
Tours,
{ place: s.tourPlace.id },
{ tourTypeRef: 'ski', origin: 'gpx', ski_discipline: 'alpine' },
);
}
/** The scene's tour as a recorded paddle track; with `line` false it has no line yet. */
function paddleTrack(line: boolean) {
return async (s: Scene, app: INestApplication): Promise<void> => {
const orm = ormOf(app);
if (line) await updateRows(orm, Places, { id: s.tourPlace.id }, { route_geometry: JSON.stringify(LINE) });
await updateRows(
orm,
Tours,
{ place: s.tourPlace.id },
{ tourTypeRef: 'kayak', origin: 'gpx', boat_type: 'kayak', water_context: 'river', max_hiking_difficulty: null },
);
};
}
/** The scene's hike planned over an import: it still keeps the track it was recorded with. */
async function plannedImport(s: Scene, app: INestApplication): Promise<void> {
await planHike(s, app);
await updateRows(ormOf(app), Tours, { place: s.tourPlace.id }, { recorded_route_geometry: JSON.stringify(LINE) });
}
const tourPath = (s: Scene) => `/api/trips/${s.trip.id}/tours/${s.tourPlace.id}`;
const MARKS = { portages: [{ from_m: 100, to_m: 300, extra_minutes: null }], locks: [] };
const CLEAR = [{ key: 'node/12', op: 'clear', decision: null, note: null, basis: null }];
/**
* P8 and P9: the edits of a saved tour besides its route (its words and
* times, the colour of its line, a copy) and the writes a ski or paddle tour
* has of its own, each with the REST route the Tours tab saves it through.
*/
export const toursEdit: CaseGroup = {
name: 'tours-edit',
prefix: 'BCP-TEDIT',
cases: [
{
tool: 'update_tour_details',
needs: ['tour'],
setup: planHike,
args: (s) => ({
tripId: s.trip.id,
placeId: s.tourPlace.id,
expected_revision: 1,
website: 'https://a.example/',
}),
// The planner saves the tour's words and times with its route.
rest: { method: 'put', path: tourPath, body: () => hikeBody({ website: 'https://a.example/' }) },
expect: ['tours:changed', 'place:updated'],
},
{
tool: 'set_tour_route_color',
needs: ['tour'],
args: (s) => ({ tripId: s.trip.id, placeId: s.tourPlace.id, route_color: '#e11d48' }),
// The tour detail's colour picker writes the place.
rest: {
method: 'put',
path: (s) => `/api/trips/${s.trip.id}/places/${s.tourPlace.id}`,
body: () => ({ route_color: '#e11d48' }),
},
expect: ['place:updated'],
},
{
tool: 'copy_tour',
needs: ['tour'],
setup: planHike,
args: (s) => ({ tripId: s.trip.id, placeId: s.tourPlace.id, name: 'Ridge (short)' }),
rest: { method: 'post', path: (s) => `${tourPath(s)}/copy`, body: () => ({ name: 'Ridge (short)' }) },
expect: ['tours:changed', 'place:created'],
},
{
tool: 'classify_ski_track',
needs: ['tour'],
setup: skiTrack,
args: (s) => ({ tripId: s.trip.id, placeId: s.tourPlace.id, ski_discipline: 'snowshoe' }),
rest: { method: 'put', path: (s) => `${tourPath(s)}/ski`, body: () => ({ ski_discipline: 'snowshoe' }) },
expect: ['tours:changed'],
},
{
tool: 'set_paddle_plan',
needs: ['tour'],
setup: paddleTrack(true),
args: (s) => ({
tripId: s.trip.id,
placeId: s.tourPlace.id,
paddle_speed_kmh: 5,
portage_speed_kmh: null,
track_marks: MARKS,
}),
rest: {
method: 'put',
path: (s) => `${tourPath(s)}/paddle-plan`,
body: () => ({ paddle_speed_kmh: 5, portage_speed_kmh: null, track_marks: MARKS }),
},
expect: ['tours:changed'],
},
{
// A track without a line has nothing to read against the obstacle data, so no data is asked for.
tool: 'decide_paddle_obstacles',
needs: ['tour'],
setup: paddleTrack(false),
args: (s) => ({ tripId: s.trip.id, placeId: s.tourPlace.id, ops: CLEAR }),
rest: { method: 'put', path: (s) => `${tourPath(s)}/obstacle-reviews`, body: () => ({ ops: CLEAR }) },
expect: ['tours:changed'],
},
{
tool: 'restore_recorded_track',
needs: ['tour'],
setup: plannedImport,
args: (s) => ({ tripId: s.trip.id, placeId: s.tourPlace.id }),
rest: { method: 'post', path: (s) => `${tourPath(s)}/restore-recorded` },
expect: ['tours:changed', 'place:updated'],
},
],
exempt: [],
};
@@ -0,0 +1,4 @@
import { runGroup } from './run-group';
import { toursEdit } from './tours-edit.cases';
runGroup(toursEdit);
@@ -0,0 +1,172 @@
/**
* ASTCARD-TOUR-080 to ASTCARD-TOUR-088
*
* P8 and P9 in the chat: a saved tour's words and times, the colour of its
* line, a copy, a hike turned round or taken back to its start, and the
* writes a ski or paddle tour has of its own. Each card names the tour; where
* the answer keeps what was there before, the way back writes it again while
* the tour is still at the revision the change raised it to.
*/
import { buildTripCard } from '../../../../../../src/nest/assistant/assistant-context';
import { unchangedSinceApplied } from '../../../../../../src/nest/assistant/assistant-undo.helpers';
import type { PreviewContext } from '../../../../../../src/nest/assistant/cards/card-types';
import { readKey } from '../../../../../../src/nest/assistant/cards/card-values';
import { ASSISTANT_UNDO_MAP, ASSISTANT_WRITE_TOOLS } from '../../../../../../src/nest/assistant/cards/registry';
import { replans, reshapedPoints } from '../../../../../../src/nest/assistant/cards/tour/tour-change.cards';
import { describeToolCall } from '../../../../../../src/nest/assistant/tool-call-preview.helpers';
import { assistantChangePreviewSchema } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
const logMock = vi.hoisted(() => ({ logInfo: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn() }));
vi.mock('../../../../../../src/nest/audit/audit-log.logger', () => logMock);
const card = buildTripCard(
1,
7,
{ trip: { title: 'Alps' }, days: [] },
{ places: [{ id: 61, name: 'Ridge walk', lat: 47, lng: 11, description: 'Up and down' }] },
null,
);
const ctx: PreviewContext = { card, categories: new Map(), locale: 'en-GB', today: '2026-06-01', airport: () => null };
/** A snapshot holding the plan of the tour at a revision. */
function atRevision(revision: number) {
return {
summary: { days: [] },
places: { places: [] },
reads: new Map([[readKey('get_tour_plan', { placeId: 61 }), { tour: { place_id: 61, revision } }]]),
};
}
describe('the edits of a saved tour in the chat', () => {
it('ASTCARD-TOUR-080: new words and times name the tour and each field, and go back to the values before', () => {
const args = { placeId: 61, expected_revision: 3, name: 'High ridge', break_additional_minutes: 90, website: null };
const preview = describeToolCall('update_tour_details', 'tc-1', args, ctx, null);
expect(assistantChangePreviewSchema.safeParse(preview).success).toBe(true);
expect(preview).toMatchObject({ subject: 'Ridge walk', verbKey: 'updateTour', placeIds: [61] });
expect(preview.rows.map((row) => row.labelKey)).toEqual(['name', 'website', 'breaks']);
expect(preview.rows.find((row) => row.labelKey === 'breaks')?.after).toBe('1:30 h');
const result = {
tour: { place_id: 61, revision: 4 },
previous: { revision: 3, name: 'Ridge walk', website: 'https://a.example/', break_additional_minutes: null },
};
const undo = ASSISTANT_UNDO_MAP.update_tour_details;
expect(undo.args(args, result, null)).toEqual({
placeId: 61,
expected_revision: 4,
name: 'Ridge walk',
website: 'https://a.example/',
break_additional_minutes: null,
});
expect(undo.args(args, { tour: {}, previous: {} }, null)).toBeNull();
const call = { args, preview: null };
expect(unchangedSinceApplied('update_tour_details', result, atRevision(4), call)).toBe(true);
expect(unchangedSinceApplied('update_tour_details', result, atRevision(5), call)).toBe(false);
});
it('ASTCARD-TOUR-081: a new colour goes back to the colour before while the place still has the new one', () => {
const args = { placeId: 61, route_color: '#e11d48' };
expect(describeToolCall('set_tour_route_color', 'tc-2', args, ctx, null)).toMatchObject({
verbKey: 'setRouteColor',
rows: [expect.objectContaining({ labelKey: 'color', after: '#e11d48' })],
});
const result = { place_id: 61, route_color: '#e11d48', previous_route_color: null };
expect(ASSISTANT_UNDO_MAP.set_tour_route_color.args(args, result, null)).toEqual({
placeId: 61,
route_color: null,
});
const places = (color: string | null) => ({
summary: { days: [] },
places: { places: [{ id: 61, route_color: color }] },
reads: new Map(),
});
expect(unchangedSinceApplied('set_tour_route_color', result, places('#e11d48'))).toBe(true);
expect(unchangedSinceApplied('set_tour_route_color', result, places('#000000'))).toBe(false);
});
it('ASTCARD-TOUR-082: a copy names its new name and the tour it is made from, and goes back by deleting it', () => {
const preview = describeToolCall('copy_tour', 'tc-3', { placeId: 61, name: 'Ridge (short)' }, ctx, null);
expect(preview).toMatchObject({ subject: 'Ridge (short)', verbKey: 'copyTour', placeIds: [61] });
expect(preview.rows).toEqual([expect.objectContaining({ labelKey: 'route', after: 'Ridge walk' })]);
expect(ASSISTANT_UNDO_MAP.copy_tour.tool).toBe('delete_place');
expect(ASSISTANT_UNDO_MAP.copy_tour.args({}, { tour: { place_id: 90 } }, null)).toEqual({ placeId: 90 });
});
it('ASTCARD-TOUR-083: a hike turned round or closed to a loop says so, and is planned anew through the turned points', () => {
const reverse = describeToolCall(
'update_tour',
'tc-4',
{ placeId: 61, expected_revision: 1, reshape: 'reverse' },
ctx,
null,
);
expect(reverse.verbKey).toBe('reverseTour');
const loop = describeToolCall(
'update_tour',
'tc-5',
{ placeId: 61, expected_revision: 1, reshape: 'close_loop' },
ctx,
null,
);
expect(loop.verbKey).toBe('closeLoop');
const saved = { tour: { max_hiking_difficulty: 2 }, line: { point_count: 2 } };
expect(replans({ reshape: 'reverse' }, saved)).toBe(true);
expect(replans({ name: 'X' }, saved)).toBe(false);
const points = [
{ lat: 47, lng: 11 },
{ lat: 47.1, lng: 11 },
];
expect(reshapedPoints('reverse', points)).toEqual([...points].reverse());
expect(reshapedPoints('close_loop', points)).toEqual([...points, { lat: 47, lng: 11 }]);
expect(reshapedPoints(undefined, points)).toBeNull();
});
});
describe('the writes of a ski or paddle tour in the chat', () => {
it('ASTCARD-TOUR-084: a ski classification names the discipline and goes back to the one before', () => {
const args = { placeId: 61, ski_discipline: 'cross_country', ski_technique: 'classic' };
expect(describeToolCall('classify_ski_track', 'tc-6', args, ctx, null)).toMatchObject({
verbKey: 'classifySki',
rows: [expect.objectContaining({ labelKey: 'type', after: 'cross_country (classic)' })],
});
const result = { tour: { revision: 2 }, previous: { ski_discipline: 'alpine', ski_technique: null } };
expect(ASSISTANT_UNDO_MAP.classify_ski_track.args(args, result, null)).toEqual({
placeId: 61,
ski_discipline: 'alpine',
ski_technique: null,
expected_revision: 2,
});
expect(unchangedSinceApplied('classify_ski_track', result, atRevision(2), { args, preview: null })).toBe(true);
});
it('ASTCARD-TOUR-085: a paddle plan goes back to the plan before', () => {
const args = {
placeId: 61,
paddle_speed_kmh: 5,
portage_speed_kmh: null,
track_marks: { portages: [], locks: [] },
};
expect(describeToolCall('set_paddle_plan', 'tc-7', args, ctx, null)).toMatchObject({ verbKey: 'setPaddlePlan' });
const previous = { paddle_speed_kmh: null, portage_speed_kmh: 2, track_marks: { portages: [], locks: [] } };
expect(ASSISTANT_UNDO_MAP.set_paddle_plan.args(args, { tour: { revision: 7 }, previous }, null)).toEqual({
placeId: 61,
...previous,
expected_revision: 7,
});
});
it('ASTCARD-TOUR-086: decisions about obstacles and a track restored have a card and no way back', () => {
for (const [tool, verbKey] of [
['decide_paddle_obstacles', 'decideObstacles'],
['restore_recorded_track', 'restoreTrack'],
] as const) {
expect(describeToolCall(tool, 'tc-8', { placeId: 61 }, ctx, null)).toMatchObject({
subject: 'Ridge walk',
verbKey,
});
expect(ASSISTANT_UNDO_MAP[tool]).toBeUndefined();
expect(ASSISTANT_WRITE_TOOLS[tool]).toMatchObject({ entity: 'tour' });
}
});
});
@@ -0,0 +1,145 @@
/**
* update_tour with reshape (P8, PR1-01 and PR1-02 for a saved hike): the saved
* points turned round or taken back to their start, then planned anew as new
* points are. The way is planned by a stub of HikeRoutingService; the write
* is the real one over a snapshot test db.
*/
import type { McpContext } from '../../../src/nest-mcp';
import { HikePlanMcp } from '../../../src/nest/tours/hike-plan.mcp';
import { HikeRoutingService, type PlannedHike } from '../../../src/nest/tours/hike-routing.service';
import { ToursModule } from '../../../src/nest/tours/tours.module';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { makePlace } from '../../helpers/factories/places';
import { addTourWaypoints, makeTour } from '../../helpers/factories/tours';
import { 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 type { GeometryPoint, HikePlanRequest } from '@trek/shared';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
// HIKE-RESHAPE-001 to HIKE-RESHAPE-004
const db = createSnapshotTestDb();
const plan = vi.fn<HikeRoutingService['plan']>();
let t: TestModule;
let mcp: HikePlanMcp;
const orm = () => t.get(MikroORM);
/** The stub's plan: a straight way through the points asked for, with heights. */
function routed(request: HikePlanRequest): PlannedHike {
const line: GeometryPoint[] = request.waypoints.map(({ lat, lng }, index) => [lat, lng, 800 + index * 50]);
return {
plan: {
kind: 'routed',
max_hiking_difficulty: request.max_hiking_difficulty ?? 2,
hiking_preferences: request.hiking_preferences ?? null,
distance_m: 2000,
ascent_m: 100,
descent_m: 0,
duration_minutes: 40,
duration_basis: 'din33466',
has_heights: true,
line,
profile: [],
grades: null,
},
line,
snapped: line.map(() => 0),
legs: line.slice(1).map(() => 1000),
};
}
beforeAll(async () => {
t = await createTestModule({
db,
imports: [ToursModule],
overrides: [{ provide: HikeRoutingService, useValue: { plan } }],
});
mcp = t.get(HikePlanMcp);
});
beforeEach(() => {
resetTestDb(db);
plan.mockReset().mockImplementation(async (_userId, _tripId, request) => routed(request));
});
afterAll(async () => {
await t.close();
db.close();
});
const ctx = (userId: number) => ({ userId }) as McpContext;
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);
}
const POINTS = [
{ lat: 47.5, lng: 11.1, station_key: 'stop-carpark', name: 'Car park' },
{ lat: 47.51, lng: 11.1, station_key: 'stop-hut0001', name: 'Hut' },
{ lat: 47.52, lng: 11.1, station_key: 'stop-summit1', name: 'Summit' },
];
async function hike(points = POINTS, type: 'hike' | 'walk' = 'hike') {
const { user } = await makeUser(orm());
const trip = await makeTrip(orm(), user.id);
const place = await makePlace(orm(), trip.id, {
name: 'Herzogstand',
route_geometry: JSON.stringify(points.map(({ lat, lng }) => [lat, lng, 800])),
});
await makeTour(orm(), place.id, { tourTypeRef: type, max_hiking_difficulty: type === 'hike' ? 2 : null });
await addTourWaypoints(orm(), place.id, points);
return { user, trip, place };
}
describe('update_tour with reshape', () => {
it('HIKE-RESHAPE-001: reverse plans the saved points the other way round, each keeping its name and key', async () => {
const { user, trip, place } = await hike();
const changed = read(
await mcp.updateTour(
{ tripId: trip.id, placeId: place.id, expected_revision: 1, reshape: 'reverse' },
ctx(user.id),
),
);
expect(plan.mock.calls[0]?.[2].waypoints.map(({ lat }) => lat)).toEqual([47.52, 47.51, 47.5]);
expect(changed).toMatchObject({ replanned: true, tour: { revision: 2 } });
expect(changed.waypoints.map((point: { name: string; key: string }) => [point.name, point.key])).toEqual([
['Summit', 'stop-summit1'],
['Hut', 'stop-hut0001'],
['Car park', 'stop-carpark'],
]);
});
it('HIKE-RESHAPE-002: close_loop adds the way back to the start, and a second one is refused', async () => {
const { user, trip, place } = await hike();
const closed = read(
await mcp.updateTour(
{ tripId: trip.id, placeId: place.id, expected_revision: 1, reshape: 'close_loop' },
ctx(user.id),
),
);
expect(closed.waypoints).toHaveLength(4);
expect(closed.waypoints[3]).toMatchObject({ lat: 47.5, lng: 11.1, role: 'end' });
await expect(
mcp.updateTour({ tripId: trip.id, placeId: place.id, expected_revision: 2, reshape: 'close_loop' }, ctx(user.id)),
).rejects.toThrow('This hike already ends where it starts.');
});
it('HIKE-RESHAPE-003: a hike split into stages is not turned round, and nothing is planned', async () => {
const staged = [POINTS[0], { ...POINTS[1], stage_end: 1 }, POINTS[2]] as typeof POINTS;
const { user, trip, place } = await hike(staged);
await expect(
mcp.updateTour({ tripId: trip.id, placeId: place.id, expected_revision: 1, reshape: 'reverse' }, ctx(user.id)),
).rejects.toThrow('split into stages over several days');
expect(plan).not.toHaveBeenCalled();
});
it('HIKE-RESHAPE-004: any other type keeps being refused here', async () => {
const { user, trip, place } = await hike(POINTS, 'walk');
await expect(
mcp.updateTour({ tripId: trip.id, placeId: place.id, expected_revision: 1, reshape: 'reverse' }, ctx(user.id)),
).rejects.toThrow('Only a hike is changed here');
});
});
@@ -0,0 +1,201 @@
import {
applyChanges,
editsOnly,
refusalText,
reversedPoints,
savedPoint,
turnedLeg,
type ChangeBasis,
type DraftPoint,
} from '../../../src/nest/tours/tour-change-apply';
import type { TourChange, TourWaypoint } from '@trek/shared';
import { describe, expect, it } from 'vitest';
// TCHG-EDIT-001 to TCHG-EDIT-012: P8, a city walk's stations edited, the walk reversed and closed to a loop.
const point = (key: string, sequence: number, extra: Partial<TourWaypoint> = {}): TourWaypoint => ({
lat: 48 + sequence * 0.01,
lng: 11,
role: 'via',
sequence,
key,
name: key.replace('stop-', ''),
...extra,
});
/** A walk from the gate past the museum to the park. */
const walk = (): TourWaypoint[] => [
point('stop-gate', 0, { role: 'start', stay_minutes: 5 }),
point('stop-museum', 1, { stay_minutes: 45, importance: 'must' }),
point('stop-park', 2, { role: 'end' }),
];
/** The same walk as a loop: the way back stands on the gate again. */
const loop = (): TourWaypoint[] => [
...walk().slice(0, 2),
point('stop-park', 2),
{ lat: 48, lng: 11, role: 'end', sequence: 3, key: 'back-0001' },
];
const basis = (over: Partial<ChangeBasis> = {}): ChangeBasis => ({
tourType: 'walk',
recorded: false,
points: walk(),
done: new Map(),
linkable: new Set(),
...over,
});
function ready(outcome: ReturnType<typeof applyChanges>) {
if (outcome.kind !== 'ready') throw new Error(`refused: ${JSON.stringify(outcome.refused)}`);
return outcome;
}
function refused(outcome: ReturnType<typeof applyChanges>) {
if (outcome.kind !== 'refused') throw new Error('was laid');
return outcome.refused;
}
const refs = (points: readonly DraftPoint[]) => points.map((p) => p.ref);
describe('edits of a station (P8)', () => {
it('TCHG-EDIT-001: an edit writes only the fields it names, null clears one, and the station stays where it is', () => {
const changes: TourChange[] = [
{ op: 'edit', key: 'stop-museum', name: 'Art museum', stay_minutes: null, importance: null, protected: true },
{ op: 'edit', key: 'stop-park', opening_hours: 'Mo-Su 06:00-22:00' },
];
const { points } = ready(applyChanges(basis(), changes));
expect(refs(points)).toEqual(['stop-gate', 'stop-museum', 'stop-park']);
expect(points[1]).toMatchObject({
name: 'Art museum',
stay_minutes: null,
importance: null,
protected: true,
origin: 'edited',
change: 1,
});
expect(points[2]).toMatchObject({ name: 'park', opening_hours: 'Mo-Su 06:00-22:00', origin: 'edited', change: 2 });
expect(points[0]).toMatchObject({ stay_minutes: 5, origin: 'saved' });
});
it('TCHG-EDIT-002: a protected or already visited station takes an edit; an unknown key is refused', () => {
const points = walk().map((one) => (one.key === 'stop-museum' ? { ...one, protected: true } : one));
const done = new Map([['stop-museum', [{ day_id: 3, status: 'visited' as const }]]]);
const edited = ready(applyChanges(basis({ points, done }), [{ op: 'edit', key: 'stop-museum', stay_minutes: 60 }]));
expect(edited.points[1]?.stay_minutes).toBe(60);
const unknown = refused(applyChanges(basis(), [{ op: 'edit', key: 'stop-nowhere', name: 'X' }]));
expect(unknown).toEqual([{ change: 1, op: 'edit', key: 'stop-nowhere', reason: 'unknown_station' }]);
});
it('TCHG-EDIT-003: editsOnly tells a proposal of edits from one that moves stations', () => {
expect(editsOnly([{ op: 'edit', key: 'stop-park', name: 'P' }])).toBe(true);
expect(editsOnly([{ op: 'edit', key: 'stop-park', name: 'P' }, { op: 'reverse' }])).toBe(false);
});
});
describe('a walk turned round (PR1-02)', () => {
it('TCHG-EDIT-004: the last station starts it, every station is marked moved but one that keeps its place', () => {
const { points } = ready(applyChanges(basis(), [{ op: 'reverse' }]));
expect(refs(points)).toEqual(['stop-park', 'stop-museum', 'stop-gate']);
expect(points.map((p) => [p.role, p.sequence])).toEqual([
['start', 0],
['via', 1],
['end', 2],
]);
expect(points.map((p) => p.origin)).toEqual(['moved', 'saved', 'moved']);
});
it('TCHG-EDIT-005: a loop stays a loop from the same start, which keeps its key and stay', () => {
const { points } = ready(applyChanges(basis({ points: loop() }), [{ op: 'reverse' }]));
expect(points.map((p) => p.key)).toEqual(['stop-gate', 'stop-park', 'stop-museum', 'back-0001']);
expect(points[0]).toMatchObject({ lat: 48, lng: 11, stay_minutes: 5, role: 'start' });
expect(points[3]).toMatchObject({ lat: 48, lng: 11, role: 'end' });
expect(points[3]?.name).toBeUndefined();
});
it('TCHG-EDIT-006: a walk under way or with a protected station is not turned round', () => {
const done = new Map([['stop-gate', [{ day_id: 2, status: 'skipped' as const }]]]);
expect(refused(applyChanges(basis({ done }), [{ op: 'reverse' }]))).toEqual([
{ change: 1, op: 'reverse', key: null, reason: 'walk_under_way' },
]);
const points = walk().map((one) => (one.key === 'stop-museum' ? { ...one, protected: true } : one));
expect(refused(applyChanges(basis({ points }), [{ op: 'reverse' }]))).toEqual([
{ change: 1, op: 'reverse', key: 'stop-museum', reason: 'protected' },
]);
});
it('TCHG-EDIT-007: a reverse goes with edits only, and only once', () => {
const withMove = refused(
applyChanges(basis(), [{ op: 'reverse' }, { op: 'move', key: 'stop-gate', after_key: 'stop-museum' }]),
);
expect(withMove).toEqual([{ change: null, op: null, key: null, reason: 'reshape_not_alone' }]);
expect(refused(applyChanges(basis(), [{ op: 'reverse' }, { op: 'close_loop' }]))[0]?.reason).toBe(
'reshape_not_alone',
);
const withEdit = ready(applyChanges(basis(), [{ op: 'reverse' }, { op: 'edit', key: 'stop-park', name: 'Park' }]));
expect(withEdit.points[0]).toMatchObject({ key: 'stop-park', name: 'Park' });
});
it('TCHG-EDIT-008: a leg drawn by hand stays on its stretch, its line turned; a crossing by boat keeps its points', () => {
const free = { kind: 'free' as const, geometry: [[48, 11] as [number, number], [48.01, 11] as [number, number]] };
const ferry = {
kind: 'ferry' as const,
geometry: [
[48.01, 11],
[48.012, 11],
[48.016, 11],
[48.02, 11],
] as Array<[number, number]>,
crossings: [[1, 2]] as Array<[number, number]>,
minutes: 10,
walk_minutes: 4,
};
const points = [
point('stop-gate', 0, { role: 'start', fixed_leg: free }),
point('stop-museum', 1, { fixed_leg: ferry }),
point('stop-park', 2, { role: 'end' }),
];
const turned = reversedPoints(points.map(savedPoint));
expect(turned.map((p) => p.fixed_leg?.kind ?? null)).toEqual(['ferry', 'free', null]);
expect(turned[0]?.fixed_leg).toMatchObject({ geometry: [...ferry.geometry].reverse(), crossings: [[1, 2]] });
expect(turned[1]?.fixed_leg?.geometry).toEqual([...free.geometry].reverse());
expect(turnedLeg({ ...ferry, crossings: [[0, 1]] })).toMatchObject({ crossings: [[2, 3]] });
});
});
describe('a walk taken back to its start (PR1-01)', () => {
it('TCHG-EDIT-009: a way back on the start is added, no station of its own and nothing to keep as a place', () => {
const { points } = ready(applyChanges(basis(), [{ op: 'close_loop' }]));
expect(points).toHaveLength(4);
expect(points[3]).toMatchObject({ lat: 48, lng: 11, role: 'end', origin: 'loop', ref: 'new:1' });
expect(points[3]?.key).toBeUndefined();
expect(points[2]).toMatchObject({ key: 'stop-park', role: 'via' });
});
it('TCHG-EDIT-010: a walk that already ends at its start is refused, with the reason in words', () => {
const outcome = refused(applyChanges(basis({ points: loop() }), [{ op: 'close_loop' }]));
expect(outcome).toEqual([{ change: 1, op: 'close_loop', key: null, reason: 'already_loop' }]);
expect(refusalText(outcome, 'prepared')).toBe(
'Nothing was prepared and nothing changed.\nChange 1 (close_loop): this walk already ends where it starts.',
);
});
it('TCHG-EDIT-011: the refusal names a reverse without a station and an edit with its key', () => {
const text = refusalText(
[
{ change: 1, op: 'reverse', key: null, reason: 'walk_under_way' },
{ change: 2, op: 'edit', key: 'stop-x', reason: 'unknown_station' },
],
'saved',
);
expect(text).toContain('Change 1 (reverse): the travellers already visited or skipped stations');
expect(text).toContain('Change 2 (edit stop-x): no station');
});
it('TCHG-EDIT-012: a hike still takes no prepared change at all', () => {
expect(refused(applyChanges(basis({ tourType: 'hike' }), [{ op: 'close_loop' }]))[0]?.reason).toBe(
'type_not_supported',
);
});
});
@@ -0,0 +1,159 @@
/**
* prepare_tour_change and save_tour_change with the changes P8 brings: a
* station edited (its name, stay, importance, protection, opening hours), the
* walk turned round and closed to a loop. Edits alone keep the walk's saved
* way and walking time and never ask the router; a reverse or a loop plans
* the way on foot anew. The road trip's router is a stub; the suite takes its
* subject from the container over a snapshot test db.
*/
import { Places } from '../../../src/db/entities/Places.entity';
import { TourChangeSteps } from '../../../src/db/entities/TourChangeSteps.entity';
import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
import type { McpContext } from '../../../src/nest-mcp';
import { RoadtripRouterService } from '../../../src/nest/roadtrip/roadtrip-router.service';
import { proposalIdOf } from '../../../src/nest/tours/tour-change-proposal';
import { TourChangeMcp } from '../../../src/nest/tours/tour-change.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 } from '../../helpers/factories/rows';
import { addTourWaypoints, makeTour } from '../../helpers/factories/tours';
import { 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 type { TourChange } from '@trek/shared';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
// TCHG-EDIT-MCP-001 to TCHG-EDIT-MCP-004
const db = createSnapshotTestDb();
const route = vi.fn();
let t: TestModule;
let mcp: TourChangeMcp;
const orm = () => t.get(MikroORM);
/** Every leg is 500 m and 400 s on foot, along the points asked. */
async function routedOnFoot(_u: number, _t: number, _d: number, points: Array<{ lat: number; lng: number }>) {
return {
leg: { line: points.map((point): [number, number] => [point.lat, point.lng]) },
parts: points.slice(1).map(() => ({ distance: 500, duration: 400 })),
snapped: points.map(() => ({ meters: 4 })),
avoidMissed: [],
};
}
beforeAll(async () => {
t = await createTestModule({
db,
imports: [ToursModule],
overrides: [{ provide: RoadtripRouterService, useValue: { route } }],
});
mcp = t.get(TourChangeMcp);
});
beforeEach(() => {
resetTestDb(db);
route.mockReset().mockImplementation(routedOnFoot);
});
afterAll(async () => {
await t.close();
db.close();
});
const ctx = (userId: number) => ({ userId }) as McpContext;
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);
}
/** The walk's saved line, with heights the router on foot never gives. */
const LINE = [
[48.1, 11.5, 520],
[48.104, 11.5, 522],
[48.108, 11.5, 525],
];
async function walk() {
const { user } = await makeUser(orm());
const trip = await makeTrip(orm(), user.id);
const place = await makePlace(orm(), trip.id, { name: 'Old town', route_geometry: JSON.stringify(LINE) });
await makeTour(orm(), place.id, { tourTypeRef: 'walk', duration: 25 });
await addTourWaypoints(orm(), place.id, [
{ lat: 48.1, lng: 11.5, station_key: 'walk-gate', name: 'Gate' },
{ lat: 48.104, lng: 11.5, station_key: 'walk-market', name: 'Market', stay_minutes: 20 },
{ lat: 48.108, lng: 11.5, station_key: 'walk-park', name: 'Park' },
]);
return { user, trip, place };
}
async function saved(f: Awaited<ReturnType<typeof walk>>, changes: TourChange[]) {
const args = { tripId: f.trip.id, placeId: f.place.id };
return read(
await mcp.saveTourChange(
{
...args,
changes,
expected_revision: 1,
proposal_id: proposalIdOf({ ...args, revision: 1, changes }),
confirmed: true,
},
ctx(f.user.id),
),
);
}
describe('P8 changes of a saved city walk', () => {
it('TCHG-EDIT-MCP-001: an edit is prepared with the router asked once, the station marked edited', async () => {
const f = await walk();
const changes: TourChange[] = [{ op: 'edit', key: 'walk-market', stay_minutes: 45, importance: 'must' }];
const proposal = read(
await mcp.prepareTourChange({ tripId: f.trip.id, placeId: f.place.id, changes }, ctx(f.user.id)),
);
expect(route).toHaveBeenCalledTimes(1);
expect(proposal.stations_after[1]).toMatchObject({
key: 'walk-market',
change: 'edited',
stay_minutes: 45,
importance: 'must',
});
expect(proposal.effects.stays.change_minutes_known).toBe(25);
});
it('TCHG-EDIT-MCP-002: edits alone keep the saved line with its heights and the walking time, and record a step', async () => {
const f = await walk();
route.mockClear();
const answer = await saved(f, [{ op: 'edit', key: 'walk-gate', name: 'Old gate', protected: true }]);
expect(route).not.toHaveBeenCalled();
expect(answer).toMatchObject({ saved: true, tour: { revision: 2, walking_minutes: 25, route_pending: false } });
const place = await findRow(orm(), Places, { id: f.place.id });
expect(JSON.parse(String(place?.route_geometry))).toEqual(LINE);
const gate = await findRow(orm(), TourWaypoints, { place: f.place.id, station_key: 'walk-gate' });
expect(gate?.name).toBe('Old gate');
expect(Boolean(gate?.protected)).toBe(true);
expect(await countRows(orm(), TourChangeSteps)).toBe(1);
});
it('TCHG-EDIT-MCP-003: a reverse plans the walk anew from its last station, keeping every key', async () => {
const f = await walk();
route.mockClear();
const answer = await saved(f, [{ op: 'reverse' }]);
expect(route).toHaveBeenCalledTimes(1);
expect(answer.stations.map((station: { key: string }) => station.key)).toEqual([
'walk-park',
'walk-market',
'walk-gate',
]);
});
it('TCHG-EDIT-MCP-004: a close_loop adds the way back, which no station counts', async () => {
const f = await walk();
const answer = await saved(f, [{ op: 'close_loop' }]);
expect(answer.stations).toHaveLength(3);
const points = await findRows(orm(), TourWaypoints, { place: f.place.id });
expect(points).toHaveLength(4);
expect(points.find((point) => point.sequence === 3)).toMatchObject({ lat: 48.1, lng: 11.5, role: 'end' });
});
});
@@ -0,0 +1,212 @@
/**
* TourEditMcp (P8): a saved tour's words and planned times, the colour of its
* line and a copy of it, over MCP with the services and checks of their REST
* routes (trip access, then 'place_edit'). The suite takes its subject from
* the container over a snapshot test db.
*/
import { Places } from '../../../src/db/entities/Places.entity';
import { Tours } from '../../../src/db/entities/Tours.entity';
import type { McpContext } from '../../../src/nest-mcp';
import { McpToolGuardsService } from '../../../src/nest/mcp-shared/mcp-tool-guards.service';
import { TourEditMcp } from '../../../src/nest/tours/tour-edit.mcp';
import { ToursModule } from '../../../src/nest/tours/tours.module';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { makePlace } from '../../helpers/factories/places';
import { countRows, findRow } from '../../helpers/factories/rows';
import { addTourWaypoints, makeTour } from '../../helpers/factories/tours';
import { 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 { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const db = createSnapshotTestDb();
let t: TestModule;
let mcp: TourEditMcp;
const orm = () => t.get(MikroORM);
beforeAll(async () => {
t = await createTestModule({ db, imports: [ToursModule] });
mcp = t.get(TourEditMcp);
});
beforeEach(() => {
resetTestDb(db);
t.realtime.broadcastMock.mockReset();
});
afterAll(async () => {
await t.close();
db.close();
});
const ctx = (userId: number) => ({ userId }) as McpContext;
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);
}
const LINE = JSON.stringify([
[47, 11, 600],
[47.01, 11, 700],
]);
async function fixture(duration = 120) {
const { user } = await makeUser(orm());
const { user: outsider } = await makeUser(orm());
const trip = await makeTrip(orm(), user.id);
const hike = await makePlace(orm(), trip.id, { name: 'Ridge', route_geometry: LINE, website: 'https://old.example' });
await makeTour(orm(), hike.id, { duration });
await addTourWaypoints(orm(), hike.id, [
{ lat: 47, lng: 11, station_key: 'stop-start1', name: 'Car park' },
{ lat: 47.01, lng: 11, station_key: 'stop-summit', name: 'Summit' },
]);
const plain = await makePlace(orm(), trip.id, { name: 'Bakery' });
return { user, outsider, trip, hike, plain };
}
const events = () => t.realtime.broadcastMock.mock.calls.map(([, event]) => event);
describe('update_tour_details', () => {
it('TEDIT-MCP-001: changes the website and the breaks against the revision, answering the values before', async () => {
const { user, trip, hike } = await fixture();
const result = read(
await mcp.updateTourDetails(
{
tripId: trip.id,
placeId: hike.id,
expected_revision: 1,
website: 'https://new.example/ridge',
break_additional_minutes: 30,
},
ctx(user.id),
),
);
expect(result.tour).toMatchObject({
place_id: hike.id,
revision: 2,
name: 'Ridge',
website: 'https://new.example/ridge',
break_additional_minutes: 30,
});
expect(result.previous).toMatchObject({
revision: 1,
website: 'https://old.example',
break_additional_minutes: null,
});
expect(await findRow(orm(), Tours, { place: hike.id })).toMatchObject({
break_additional_minutes: 30,
revision: 2,
});
expect(events()).toEqual(expect.arrayContaining(['tours:changed', 'place:updated']));
});
it('TEDIT-MCP-002: clears a description with null and keeps the route and its points', async () => {
const { user, trip, hike } = await fixture();
await mcp.updateTourDetails(
{ tripId: trip.id, placeId: hike.id, expected_revision: 1, description: null, name: 'High ridge' },
ctx(user.id),
);
const place = await findRow(orm(), Places, { id: hike.id });
expect(place).toMatchObject({ name: 'High ridge', description: null, route_geometry: LINE });
});
it('TEDIT-MCP-003: refuses a tour changed since, breaks past the day and a change of nothing', async () => {
const { user, trip, hike } = await fixture(1400);
await expect(
mcp.updateTourDetails({ tripId: trip.id, placeId: hike.id, expected_revision: 5, name: 'X' }, ctx(user.id)),
).rejects.toThrow('This tour was changed in the meantime');
await expect(
mcp.updateTourDetails(
{ tripId: trip.id, placeId: hike.id, expected_revision: 1, break_additional_minutes: 60 },
ctx(user.id),
),
).rejects.toThrow('walking time plus breaks must be 1440 minutes or less');
// With a planned total the breaks are the travellers' own.
const planned = read(
await mcp.updateTourDetails(
{
tripId: trip.id,
placeId: hike.id,
expected_revision: 1,
break_additional_minutes: 60,
planned_duration_minutes: 1440,
},
ctx(user.id),
),
);
expect(planned.tour.break_additional_minutes).toBe(60);
expect(
read(await mcp.updateTourDetails({ tripId: trip.id, placeId: hike.id, expected_revision: 2 }, ctx(user.id))),
).toEqual({ error: 'give at least one field to change' });
});
it('TEDIT-MCP-004: a place that is no tour is not found', async () => {
const { user, trip, plain } = await fixture();
expect(
read(
await mcp.updateTourDetails(
{ tripId: trip.id, placeId: plain.id, expected_revision: 1, name: 'X' },
ctx(user.id),
),
),
).toEqual({ error: 'Tour not found.' });
});
});
describe('set_tour_route_color', () => {
it('TEDIT-MCP-005: draws the line in another colour and back, without raising the revision', async () => {
const { user, trip, hike } = await fixture();
const set = read(
await mcp.setTourRouteColor({ tripId: trip.id, placeId: hike.id, route_color: '#e11d48' }, ctx(user.id)),
);
expect(set).toEqual({ place_id: hike.id, route_color: '#e11d48', previous_route_color: null });
const back = read(
await mcp.setTourRouteColor({ tripId: trip.id, placeId: hike.id, route_color: null }, ctx(user.id)),
);
expect(back.previous_route_color).toBe('#e11d48');
expect((await findRow(orm(), Places, { id: hike.id }))?.route_color).toBeNull();
expect((await findRow(orm(), Tours, { place: hike.id }))?.revision).toBe(1);
expect(events()).toEqual(['place:updated', 'place:updated']);
});
it('TEDIT-MCP-006: a place that is no tour is not found and keeps its colour', async () => {
const { user, trip, plain } = await fixture();
expect(
read(await mcp.setTourRouteColor({ tripId: trip.id, placeId: plain.id, route_color: '#000000' }, ctx(user.id))),
).toEqual({ error: 'Tour not found.' });
expect((await findRow(orm(), Places, { id: plain.id }))?.route_color).toBeNull();
});
});
describe('copy_tour', () => {
it('TEDIT-MCP-007: saves the tour again as a new one of the trip under its own name', async () => {
const { user, trip, hike } = await fixture();
const before = await countRows(orm(), Tours);
const copy = read(await mcp.copyTour({ tripId: trip.id, placeId: hike.id, name: 'Ridge (short)' }, ctx(user.id)));
expect(copy.tour).toMatchObject({ name: 'Ridge (short)', tour_type: 'hike', derived_from_place_id: hike.id });
expect(copy.tour.place_id).not.toBe(hike.id);
expect(copy.waypoints).toHaveLength(2);
expect(await countRows(orm(), Tours)).toBe(before + 1);
});
});
describe('the checks of the REST routes', () => {
it('TEDIT-MCP-008: refuses a trip the user cannot open, and without place_edit writes nothing', async () => {
const { user, outsider, trip, hike } = await fixture();
const name = { tripId: trip.id, placeId: hike.id, name: 'X' };
expect(read(await mcp.copyTour(name, ctx(outsider.id)))).toEqual({ error: 'Trip not found or access denied.' });
const denied = vi.spyOn(t.get(McpToolGuardsService), 'hasTripPermission').mockResolvedValue(false);
const results = [
await mcp.updateTourDetails({ ...name, expected_revision: 1 }, ctx(user.id)),
await mcp.setTourRouteColor({ tripId: trip.id, placeId: hike.id, route_color: '#ffffff' }, ctx(user.id)),
await mcp.copyTour(name, ctx(user.id)),
];
expect(results.every((result) => result.isError === true)).toBe(true);
expect(denied).toHaveBeenCalledWith('place_edit', trip.id, user.id);
expect(await findRow(orm(), Places, { id: hike.id })).toMatchObject({ name: 'Ridge', route_color: null });
expect(await countRows(orm(), Tours)).toBe(1);
denied.mockRestore();
});
});
@@ -0,0 +1,321 @@
/**
* TourSportWritesMcp (P9): the writes a ski or paddle tour has of its own, an
* import taken back to its recording, and the reads beside them (the trail
* details along a saved tour, the horse routes in a map view), over MCP with
* the services and checks of their REST routes. The router, the trail grades
* and the horse routes stand in for the network. The suite takes its subject
* from the container over a snapshot test db.
*/
import { Tours } from '../../../src/db/entities/Tours.entity';
import type { McpContext } from '../../../src/nest-mcp';
import { McpToolGuardsService } from '../../../src/nest/mcp-shared/mcp-tool-guards.service';
import { RoadtripRouterService } from '../../../src/nest/roadtrip/roadtrip-router.service';
import { TourHorseRoutesService } from '../../../src/nest/tours/tour-horse-routes.service';
import { TourObstacleReviewsService } from '../../../src/nest/tours/tour-obstacle-reviews.service';
import { TourSportWritesMcp } from '../../../src/nest/tours/tour-sport-writes.mcp';
import { TourTrailGradeService } from '../../../src/nest/tours/tour-trail-grade.service';
import { ToursModule } from '../../../src/nest/tours/tours.module';
import { ToursService } from '../../../src/nest/tours/tours.service';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { makePlace } from '../../helpers/factories/places';
import { findRow, updateRows } from '../../helpers/factories/rows';
import { addTourWaypoints, makeTour } from '../../helpers/factories/tours';
import { 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 { HORSE_ROUTES_ATTRIBUTION } from '@trek/shared';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const db = createSnapshotTestDb();
let t: TestModule;
let mcp: TourSportWritesMcp;
const orm = () => t.get(MikroORM);
beforeAll(async () => {
t = await createTestModule({ db, imports: [ToursModule] });
mcp = t.get(TourSportWritesMcp);
});
beforeEach(() => resetTestDb(db));
afterEach(() => vi.restoreAllMocks());
afterAll(async () => {
await t.close();
db.close();
});
const ctx = (userId: number) => ({ userId }) as McpContext;
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);
}
/** About 1.1 km north, with heights. */
const LINE = JSON.stringify([
[47, 11, 600],
[47.01, 11, 700],
]);
async function fixture() {
const { user } = await makeUser(orm());
const { user: outsider } = await makeUser(orm());
const trip = await makeTrip(orm(), user.id);
const ski = await makePlace(orm(), trip.id, { name: 'Morning run', route_geometry: LINE });
await makeTour(orm(), ski.id, { tourTypeRef: 'ski', origin: 'gpx', ski_discipline: 'alpine' });
const paddle = await makePlace(orm(), trip.id, { name: 'River day', route_geometry: LINE });
await makeTour(orm(), paddle.id, { tourTypeRef: 'kayak', origin: 'gpx', boat_type: 'kayak', water_context: 'river' });
const hike = await makePlace(orm(), trip.id, { name: 'Ridge', route_geometry: LINE });
await makeTour(orm(), hike.id);
await addTourWaypoints(orm(), hike.id, [
{ lat: 47, lng: 11 },
{ lat: 47.01, lng: 11 },
]);
return { user, outsider, trip, ski, paddle, hike };
}
describe('classify_ski_track', () => {
it('TSPORT-MCP-001: classifies a ski track again and answers the classification before', async () => {
const { user, trip, ski } = await fixture();
const result = read(
await mcp.classifySkiTrack(
{ tripId: trip.id, placeId: ski.id, ski_discipline: 'cross_country', ski_technique: 'skating' },
ctx(user.id),
),
);
expect(result.tour).toMatchObject({ ski_discipline: 'cross_country', ski_technique: 'skating', revision: 2 });
expect(result.previous).toEqual({ revision: 1, ski_discipline: 'alpine', ski_technique: null });
});
it('TSPORT-MCP-002: refuses what the REST contract refuses, and a tour that is no ski track', async () => {
const { user, trip, ski, hike } = await fixture();
const technique = await mcp.classifySkiTrack(
{ tripId: trip.id, placeId: ski.id, ski_discipline: 'alpine', ski_technique: 'classic' },
ctx(user.id),
);
expect(technique.isError).toBe(true);
await expect(
mcp.classifySkiTrack({ tripId: trip.id, placeId: hike.id, ski_discipline: 'alpine' }, ctx(user.id)),
).rejects.toThrow('Only a ski track has a ski classification');
expect((await findRow(orm(), Tours, { place: ski.id }))?.ski_discipline).toBe('alpine');
});
});
describe('set_paddle_plan', () => {
const marks = { portages: [{ from_m: 100, to_m: 300, extra_minutes: null }], locks: [] };
it('TSPORT-MCP-003: lays a portage onto a recorded paddle track and answers the plan before', async () => {
const { user, trip, paddle } = await fixture();
const result = read(
await mcp.setPaddlePlan(
{ tripId: trip.id, placeId: paddle.id, paddle_speed_kmh: 5, portage_speed_kmh: null, track_marks: marks },
ctx(user.id),
),
);
expect(result.tour).toMatchObject({ paddle_speed_kmh: 5, track_marks: marks, revision: 2 });
expect(result.previous).toEqual({
revision: 1,
paddle_speed_kmh: null,
portage_speed_kmh: null,
track_marks: { portages: [], locks: [] },
});
});
it('TSPORT-MCP-004: a planned tour, a mark past the track and a mark the contract refuses are refused', async () => {
const { user, trip, paddle, hike } = await fixture();
await expect(
mcp.setPaddlePlan(
{ tripId: trip.id, placeId: hike.id, paddle_speed_kmh: null, portage_speed_kmh: null, track_marks: marks },
ctx(user.id),
),
).rejects.toThrow('Only a paddle tour has a paddle plan');
const far = { portages: [{ from_m: 100, to_m: 90_000, extra_minutes: null }], locks: [] };
await expect(
mcp.setPaddlePlan(
{ tripId: trip.id, placeId: paddle.id, paddle_speed_kmh: null, portage_speed_kmh: null, track_marks: far },
ctx(user.id),
),
).rejects.toThrow('A mark lies beyond the end of the track');
const backwards = { portages: [{ from_m: 300, to_m: 100, extra_minutes: null }], locks: [] };
const refused = await mcp.setPaddlePlan(
{ tripId: trip.id, placeId: paddle.id, paddle_speed_kmh: null, portage_speed_kmh: null, track_marks: backwards },
ctx(user.id),
);
expect(read(refused)).toEqual({ error: 'a portage ends after it begins' });
});
});
describe('decide_paddle_obstacles', () => {
const op = { key: 'node/12', op: 'note' as const, decision: null, note: 'carry left', basis: null };
it('TSPORT-MCP-005: saves through the checklist service as the user and answers the checklist', async () => {
const { user, trip, paddle } = await fixture();
const detail = await t.get(ToursService).getTour(String(trip.id), String(paddle.id));
const check = { revision: 'r1', evaluated_at: '2026-10-23T08:00:00Z', entries: [], own: [] };
const save = vi
.spyOn(t.get(TourObstacleReviewsService), 'save')
.mockResolvedValue({ ...detail, obstacle_check: check });
const result = read(
await mcp.decidePaddleObstacles({ tripId: trip.id, placeId: paddle.id, ops: [op] }, ctx(user.id)),
);
expect(save).toHaveBeenCalledWith(String(trip.id), String(paddle.id), { ops: [op] }, user.id);
expect(result).toMatchObject({ tour: { place_id: paddle.id }, obstacle_check: check });
});
it('TSPORT-MCP-006: one obstacle twice is refused before anything is saved', async () => {
const { user, trip, paddle } = await fixture();
const save = vi.spyOn(t.get(TourObstacleReviewsService), 'save');
const result = await mcp.decidePaddleObstacles(
{ tripId: trip.id, placeId: paddle.id, ops: [op, op] },
ctx(user.id),
);
expect(read(result)).toEqual({ error: 'each obstacle at most once' });
expect(save).not.toHaveBeenCalled();
});
});
describe('restore_recorded_track', () => {
it('TSPORT-MCP-007: takes a planned import back to its recording; one without a recording is refused', async () => {
const { user, trip, hike, paddle } = await fixture();
const recorded = JSON.stringify([
[47, 11, 600],
[47.005, 11.002, 650],
[47.01, 11, 700],
]);
await updateRows(orm(), Tours, { place: hike.id }, { recorded_route_geometry: recorded });
const result = read(await mcp.restoreRecordedTrack({ tripId: trip.id, placeId: hike.id }, ctx(user.id)));
expect(result.tour).toMatchObject({ place_id: hike.id, has_waypoints: false });
await expect(mcp.restoreRecordedTrack({ tripId: trip.id, placeId: paddle.id }, ctx(user.id))).rejects.toThrow(
'This tour keeps no recorded track to go back to',
);
});
});
describe('get_tour_trail_details', () => {
it('TSPORT-MCP-008: lays the grades of the ways under the saved line onto it', async () => {
const { user, trip, hike } = await fixture();
const ways = vi.spyOn(t.get(RoadtripRouterService), 'wayIds').mockResolvedValue([
{ wayId: 1, metres: 600 },
{ wayId: 2, metres: 400 },
]);
vi.spyOn(t.get(TourTrailGradeService), 'grades').mockResolvedValue({
ways: [
{
id: 1,
mtb_scale: '1',
mtb_scale_uphill: null,
mtb_scale_imba: null,
sac_scale: 'mountain_hiking',
sac_scale_unclear: null,
surface: 'gravel',
highway: 'track',
},
],
unanswered: [2],
});
const result = read(await mcp.getTourTrailDetails({ tripId: trip.id, placeId: hike.id }, ctx(user.id)));
expect(ways).toHaveBeenCalledWith(user.id, [
[47, 11],
[47.01, 11],
]);
expect(result).toMatchObject({
place_id: hike.id,
tour_type: 'hike',
sac: { highest: 2 },
mtb: { highest: '1' },
surface: [{ value: 'gravel', share: 0.6 }],
highway: [{ value: 'track', share: 0.6 }],
unknown_share: 0.4,
ways_unanswered: 1,
truncated: false,
});
});
it('TSPORT-MCP-009: a router that does not answer is said so; reading needs only the trip', async () => {
const { user, outsider, trip, hike } = await fixture();
vi.spyOn(t.get(RoadtripRouterService), 'wayIds').mockRejectedValue(new Error('down'));
await expect(mcp.getTourTrailDetails({ tripId: trip.id, placeId: hike.id }, ctx(user.id))).rejects.toThrow(
'The ways under this tour cannot be read right now',
);
expect(read(await mcp.getTourTrailDetails({ tripId: trip.id, placeId: hike.id }, ctx(outsider.id)))).toEqual({
error: 'Trip not found or access denied.',
});
});
});
describe('find_horse_routes', () => {
it('TSPORT-MCP-010: answers each section without its line, its ends instead; a box too large is refused', async () => {
const { user, trip } = await fixture();
const list = vi.spyOn(t.get(TourHorseRoutesService), 'list').mockResolvedValue({
routes: [
{
id: 'relation/5',
name: 'Reitweg',
ref: null,
osmUrl: 'https://www.openstreetmap.org/relation/5',
continues: false,
sections: [
{
number: 1,
geometry: [
[47.5, 11.2],
[47.51, 11.21],
[47.52, 11.22],
],
length_m: 2600,
access: [{ value: 'designated', length_m: 2600 }],
signposted: null,
},
],
},
],
truncated: false,
source: 'openstreetmap',
attribution: HORSE_ROUTES_ATTRIBUTION,
});
const result = read(await mcp.findHorseRoutes({ tripId: trip.id, bbox: '47.5,11.2,47.6,11.3' }, ctx(user.id)));
expect(list).toHaveBeenCalledWith({ south: 47.5, west: 11.2, north: 47.6, east: 11.3 });
expect(result.routes[0].sections[0]).toEqual({
number: 1,
length_m: 2600,
access: [{ value: 'designated', length_m: 2600 }],
signposted: null,
from: [47.5, 11.2],
to: [47.52, 11.22],
});
await expect(mcp.findHorseRoutes({ tripId: trip.id, bbox: '40,0,50,20' }, ctx(user.id))).rejects.toThrow();
});
});
describe('the checks of the REST routes', () => {
it('TSPORT-MCP-011: without place_edit no write runs', async () => {
const { user, trip, ski, paddle, hike } = await fixture();
const denied = vi.spyOn(t.get(McpToolGuardsService), 'hasTripPermission').mockResolvedValue(false);
const results = [
await mcp.classifySkiTrack({ tripId: trip.id, placeId: ski.id, ski_discipline: 'snowshoe' }, ctx(user.id)),
await mcp.setPaddlePlan(
{
tripId: trip.id,
placeId: paddle.id,
paddle_speed_kmh: 5,
portage_speed_kmh: null,
track_marks: { portages: [], locks: [] },
},
ctx(user.id),
),
await mcp.decidePaddleObstacles(
{
tripId: trip.id,
placeId: paddle.id,
ops: [{ key: 'node/1', op: 'clear', decision: null, note: null, basis: null }],
},
ctx(user.id),
),
await mcp.restoreRecordedTrack({ tripId: trip.id, placeId: hike.id }, ctx(user.id)),
];
expect(results.every((result) => result.isError === true)).toBe(true);
expect(denied).toHaveBeenCalledWith('place_edit', trip.id, user.id);
expect((await findRow(orm(), Tours, { place: ski.id }))?.ski_discipline).toBe('alpine');
});
});
@@ -0,0 +1,94 @@
import { reshapedHikePoints } from '../../../src/nest/tours/tour-reshape.helpers';
import { trailDetailsOf } from '../../../src/nest/tours/tour-trail-details.helpers';
import type { TourTrailGrade, TourWaypoint } from '@trek/shared';
import { describe, expect, it } from 'vitest';
// TRAIL-DETAILS-001 to TRAIL-DETAILS-003, RESHAPE-HELPER-001 to RESHAPE-HELPER-003
const grade = (id: number, over: Partial<TourTrailGrade> = {}): TourTrailGrade => ({
id,
mtb_scale: null,
mtb_scale_uphill: null,
mtb_scale_imba: null,
sac_scale: null,
sac_scale_unclear: null,
surface: null,
highway: null,
...over,
});
describe('the trail details laid onto a saved line', () => {
it('TRAIL-DETAILS-001: sums each value by the metres of its ways, scaled onto the saved length', () => {
const ways = [
{ wayId: 1, metres: 500 },
{ wayId: 2, metres: 300 },
{ wayId: null, metres: 200 },
];
const grades = new Map([
[1, grade(1, { surface: 'asphalt', highway: 'residential' })],
[2, grade(2, { surface: 'gravel', highway: 'track', sac_scale: 'hiking', mtb_scale: '2+' })],
]);
const details = trailDetailsOf(ways, grades, 2000);
expect(details.length_m).toBe(2000);
expect(details.surface).toEqual([
{ value: 'asphalt', metres: 1000, share: 0.5 },
{ value: 'gravel', metres: 600, share: 0.3 },
]);
expect(details.highway.map(({ value }) => value)).toEqual(['residential', 'track']);
expect(details.sac).toMatchObject({ highest: 1, segments: [{ from_m: 1000, to_m: 1600, sac: 1 }] });
expect(details.mtb).toEqual({ highest: '2+', values: [{ value: '2+', metres: 600, share: 0.3 }] });
expect(details.unknown_share).toBe(0.2);
});
it('TRAIL-DETAILS-002: the hardest mountain bike step wins, a plus above its plain step', () => {
const ways = [1, 2, 3].map((wayId) => ({ wayId, metres: 100 }));
const grades = new Map([
[1, grade(1, { mtb_scale: '1' })],
[2, grade(2, { mtb_scale: '2' })],
[3, grade(3, { mtb_scale: '2+' })],
]);
expect(trailDetailsOf(ways, grades, 300).mtb.highest).toBe('2+');
expect(trailDetailsOf(ways.slice(0, 2), grades, 200).mtb.highest).toBe('2');
});
it('TRAIL-DETAILS-003: without ways the whole line is unknown, never easy', () => {
const details = trailDetailsOf([], new Map(), 1000);
expect(details).toMatchObject({ length_m: 0, surface: [], highway: [], unknown_share: 1, mtb: { highest: null } });
expect(details.sac.highest).toBeNull();
});
});
const saved = (): TourWaypoint[] => [
{ lat: 47, lng: 11, role: 'start', sequence: 0, name: 'Car park', key: 'stop-carpark' },
{ lat: 47.1, lng: 11, role: 'via', sequence: 1, poi_category: 'shelter', key: 'stop-hut0001' },
{ lat: 47.2, lng: 11, role: 'end', sequence: 2, key: 'stop-summit1' },
];
describe('a saved hike reshaped', () => {
it('RESHAPE-HELPER-001: reverse turns the points round, each with its name and kind of place', () => {
expect(reshapedHikePoints(saved(), 'reverse')).toEqual({
points: [
{ lat: 47.2, lng: 11 },
{ lat: 47.1, lng: 11, poi_category: 'shelter' },
{ lat: 47, lng: 11, name: 'Car park' },
],
});
});
it('RESHAPE-HELPER-002: close_loop adds the start again at the end, once', () => {
const closed = reshapedHikePoints(saved(), 'close_loop');
expect(closed).toMatchObject({ points: [{ name: 'Car park' }, {}, {}, { lat: 47, lng: 11 }] });
const loop = [...saved(), { lat: 47, lng: 11, role: 'end' as const, sequence: 3 }];
expect(reshapedHikePoints(loop, 'close_loop')).toEqual({ refused: 'This hike already ends where it starts.' });
});
it('RESHAPE-HELPER-003: a recorded track and a hike in stages are refused with why', () => {
expect(reshapedHikePoints([], 'reverse')).toMatchObject({ refused: expect.stringContaining('recorded track') });
const staged = saved().map((point, index) => (index === 1 ? { ...point, stage_end: true } : point));
expect(reshapedHikePoints(staged, 'reverse')).toMatchObject({ refused: expect.stringContaining('stages') });
expect(reshapedHikePoints(staged, 'close_loop')).toMatchObject({
points: expect.arrayContaining([expect.objectContaining({ stage_end: true })]),
});
});
});
+2
View File
@@ -170,6 +170,8 @@ export const ASSISTANT_CHANGE_LABEL_KEYS = [
// The recorded track a tour was done on; the part of the packing list a tour points at.
'track',
'packing',
// The minutes of breaks a tour plans on top of its moving time.
'breaks',
// A new order of a day's places or of the days, as the names in that order.
'order',
// An argument of a tool without a card of its own; the row names it in `field`.
@@ -27,6 +27,16 @@ export const TOUR_VERB_KEYS = [
// A tour pointed at its trip's packing list, or let go of it.
'linkPacking',
'unlinkPacking',
// A saved tour turned round or taken back to its start, copied, or its line drawn in another colour.
'reverseTour',
'closeLoop',
'copyTour',
'setRouteColor',
// The writes of a ski or paddle tour of its own, and a planned import taken back to its recording.
'classifySki',
'setPaddlePlan',
'decideObstacles',
'restoreTrack',
] as const;
/** 'tour' is a core entity already. */
export const TOUR_APPLIED_ENTITIES = [] as const;
+9
View File
@@ -498,11 +498,20 @@ const assistant: TranslationStrings = {
'assistant.change.verb.countTour': 'احتسابها في الإحصاءات مجددًا',
'assistant.change.verb.linkPacking': 'ربط قائمة التعبئة',
'assistant.change.verb.unlinkPacking': 'فك ربط قائمة التعبئة',
'assistant.change.verb.reverseTour': 'عكس اتجاه الجولة',
'assistant.change.verb.closeLoop': 'إغلاق الحلقة',
'assistant.change.verb.copyTour': 'نسخ الجولة',
'assistant.change.verb.setRouteColor': 'تغيير لون المسار',
'assistant.change.verb.classifySki': 'تغيير نوع التزلج',
'assistant.change.verb.setPaddlePlan': 'تغيير خطة التجديف',
'assistant.change.verb.decideObstacles': 'اتخاذ قرار بشأن العوائق',
'assistant.change.verb.restoreTrack': 'العودة إلى المسار المسجل',
'assistant.change.verb.importTour': 'إضافة جولة من ملف',
'assistant.change.label.field': 'القيمة',
'assistant.change.label.difficulty': 'أقصى مستوى لصعوبة الدرب',
'assistant.change.label.track': 'مسار مسجل',
'assistant.change.label.packing': 'قائمة التعبئة',
'assistant.change.label.breaks': 'الاستراحات',
'assistant.domain.tours': 'الجولات',
'assistant.domain.roadtrip': 'رحلة برية',
'assistant.domain.journey': 'يوميات السفر',
+9
View File
@@ -421,11 +421,20 @@ const assistant: TranslationStrings = {
'assistant.change.verb.countTour': 'Yenidən statistikaya daxil et',
'assistant.change.verb.linkPacking': 'Əşya siyahısını bağla',
'assistant.change.verb.unlinkPacking': 'Əşya siyahısını ayır',
'assistant.change.verb.reverseTour': 'Turu tərsinə çevir',
'assistant.change.verb.closeLoop': 'Dairəni bağla',
'assistant.change.verb.copyTour': 'Turu kopyala',
'assistant.change.verb.setRouteColor': 'Marşrutun rəngini dəyiş',
'assistant.change.verb.classifySki': 'Xizək növünü dəyiş',
'assistant.change.verb.setPaddlePlan': 'Avarçəkmə planını dəyiş',
'assistant.change.verb.decideObstacles': 'Maneələr barədə qərar ver',
'assistant.change.verb.restoreTrack': 'Yazılmış treke qayıt',
'assistant.change.verb.importTour': 'Fayldan tur əlavə et',
'assistant.change.label.field': 'Dəyər',
'assistant.change.label.difficulty': 'Maksimum cığır çətinliyi',
'assistant.change.label.track': 'Qeydə alınmış marşrut',
'assistant.change.label.packing': 'Əşya siyahısı',
'assistant.change.label.breaks': 'Fasilələr',
'assistant.domain.tours': 'Turlar',
'assistant.domain.roadtrip': 'Avtosəyahət',
'assistant.domain.journey': 'Səyahət gündəliyi',
+9
View File
@@ -421,11 +421,20 @@ const assistant: TranslationStrings = {
'assistant.change.verb.countTour': 'Contar nas estatísticas de novo',
'assistant.change.verb.linkPacking': 'Vincular lista de bagagem',
'assistant.change.verb.unlinkPacking': 'Desvincular lista de bagagem',
'assistant.change.verb.reverseTour': 'Inverter passeio',
'assistant.change.verb.closeLoop': 'Fechar o circuito',
'assistant.change.verb.copyTour': 'Copiar passeio',
'assistant.change.verb.setRouteColor': 'Alterar cor da rota',
'assistant.change.verb.classifySki': 'Alterar modalidade de esqui',
'assistant.change.verb.setPaddlePlan': 'Alterar plano de remada',
'assistant.change.verb.decideObstacles': 'Decidir sobre obstáculos',
'assistant.change.verb.restoreTrack': 'Voltar ao trajeto gravado',
'assistant.change.verb.importTour': 'Adicionar passeio a partir de arquivo',
'assistant.change.label.field': 'Valor',
'assistant.change.label.difficulty': 'Dificuldade máxima da trilha',
'assistant.change.label.track': 'Trilha gravada',
'assistant.change.label.packing': 'Lista de bagagem',
'assistant.change.label.breaks': 'Pausas',
'assistant.domain.tours': 'Passeios',
'assistant.domain.roadtrip': 'Viagem de carro',
'assistant.domain.journey': 'Jornada',
+9
View File
@@ -424,11 +424,20 @@ const assistant: TranslationStrings = {
'assistant.change.verb.countTour': 'Torna a comptar a les estadístiques',
'assistant.change.verb.linkPacking': 'Enllaça la llista d’equipatge',
'assistant.change.verb.unlinkPacking': 'Desenllaça la llista d’equipatge',
'assistant.change.verb.reverseTour': "Invertir l'excursió",
'assistant.change.verb.closeLoop': 'Tancar el circuit',
'assistant.change.verb.copyTour': "Copiar l'excursió",
'assistant.change.verb.setRouteColor': 'Canviar el color de la ruta',
'assistant.change.verb.classifySki': "Canviar la modalitat d'esquí",
'assistant.change.verb.setPaddlePlan': 'Canviar el pla de rem',
'assistant.change.verb.decideObstacles': 'Decidir sobre els obstacles',
'assistant.change.verb.restoreTrack': 'Tornar a la traça gravada',
'assistant.change.verb.importTour': 'Afegeix una excursió des d’un fitxer',
'assistant.change.label.field': 'Valor',
'assistant.change.label.difficulty': 'Dificultat màxima del sender',
'assistant.change.label.track': 'Traç enregistrat',
'assistant.change.label.packing': 'Llista d’equipatge',
'assistant.change.label.breaks': 'Pauses',
'assistant.domain.tours': 'Rutes',
'assistant.domain.roadtrip': 'Viatge per carretera',
'assistant.domain.journey': 'Diari de viatge',
+9
View File
@@ -439,11 +439,20 @@ const assistant: TranslationStrings = {
'assistant.change.verb.countTour': 'Znovu počítat do statistik',
'assistant.change.verb.linkPacking': 'Propojit seznam balení',
'assistant.change.verb.unlinkPacking': 'Odpojit seznam balení',
'assistant.change.verb.reverseTour': 'Otočit túru',
'assistant.change.verb.closeLoop': 'Uzavřít okruh',
'assistant.change.verb.copyTour': 'Kopírovat túru',
'assistant.change.verb.setRouteColor': 'Změnit barvu trasy',
'assistant.change.verb.classifySki': 'Změnit lyžařskou disciplínu',
'assistant.change.verb.setPaddlePlan': 'Změnit plán pádlování',
'assistant.change.verb.decideObstacles': 'Rozhodnout o překážkách',
'assistant.change.verb.restoreTrack': 'Zpět k zaznamenané stopě',
'assistant.change.verb.importTour': 'Přidat výlet ze souboru',
'assistant.change.label.field': 'Hodnota',
'assistant.change.label.difficulty': 'Maximální obtížnost stezky',
'assistant.change.label.track': 'Zaznamenaná trasa',
'assistant.change.label.packing': 'Seznam balení',
'assistant.change.label.breaks': 'Přestávky',
'assistant.domain.tours': 'Túry',
'assistant.domain.roadtrip': 'Cesta autem',
'assistant.domain.journey': 'Cestovní deník',
+9
View File
@@ -421,11 +421,20 @@ const assistant: TranslationStrings = {
'assistant.change.verb.countTour': 'Wieder in Statistik zählen',
'assistant.change.verb.linkPacking': 'Packliste verknüpfen',
'assistant.change.verb.unlinkPacking': 'Packliste lösen',
'assistant.change.verb.reverseTour': 'Tour umdrehen',
'assistant.change.verb.closeLoop': 'Rundtour schließen',
'assistant.change.verb.copyTour': 'Tour kopieren',
'assistant.change.verb.setRouteColor': 'Routenfarbe ändern',
'assistant.change.verb.classifySki': 'Ski-Disziplin ändern',
'assistant.change.verb.setPaddlePlan': 'Paddelplan ändern',
'assistant.change.verb.decideObstacles': 'Über Hindernisse entscheiden',
'assistant.change.verb.restoreTrack': 'Zurück zur Aufzeichnung',
'assistant.change.verb.importTour': 'Tour aus Datei hinzufügen',
'assistant.change.label.field': 'Wert',
'assistant.change.label.difficulty': 'Maximale Wegschwierigkeit',
'assistant.change.label.track': 'Aufzeichnung',
'assistant.change.label.packing': 'Packliste',
'assistant.change.label.breaks': 'Pausen',
'assistant.domain.tours': 'Touren',
'assistant.domain.roadtrip': 'Roadtrip',
'assistant.domain.journey': 'Reisetagebuch',
+9
View File
@@ -415,11 +415,20 @@ const assistant: TranslationStrings = {
'assistant.change.verb.countTour': 'Count in stats again',
'assistant.change.verb.linkPacking': 'Link packing list',
'assistant.change.verb.unlinkPacking': 'Unlink packing list',
'assistant.change.verb.reverseTour': 'Reverse tour',
'assistant.change.verb.closeLoop': 'Close the loop',
'assistant.change.verb.copyTour': 'Copy tour',
'assistant.change.verb.setRouteColor': 'Change route colour',
'assistant.change.verb.classifySki': 'Change ski discipline',
'assistant.change.verb.setPaddlePlan': 'Change paddle plan',
'assistant.change.verb.decideObstacles': 'Decide on obstacles',
'assistant.change.verb.restoreTrack': 'Back to recorded track',
'assistant.change.verb.importTour': 'Add tour from file',
'assistant.change.label.field': 'Value',
'assistant.change.label.difficulty': 'Maximum trail difficulty',
'assistant.change.label.track': 'Recorded track',
'assistant.change.label.packing': 'Packing list',
'assistant.change.label.breaks': 'Breaks',
'assistant.domain.tours': 'Tours',
'assistant.domain.roadtrip': 'Road trip',
'assistant.domain.journey': 'Journey',
+9
View File
@@ -419,11 +419,20 @@ const assistant: TranslationStrings = {
'assistant.change.verb.countTour': 'Volver a contar en estadísticas',
'assistant.change.verb.linkPacking': 'Vincular lista de equipaje',
'assistant.change.verb.unlinkPacking': 'Desvincular lista de equipaje',
'assistant.change.verb.reverseTour': 'Invertir ruta',
'assistant.change.verb.closeLoop': 'Cerrar el circuito',
'assistant.change.verb.copyTour': 'Copiar ruta',
'assistant.change.verb.setRouteColor': 'Cambiar color de la ruta',
'assistant.change.verb.classifySki': 'Cambiar modalidad de esquí',
'assistant.change.verb.setPaddlePlan': 'Cambiar plan de remo',
'assistant.change.verb.decideObstacles': 'Decidir sobre obstáculos',
'assistant.change.verb.restoreTrack': 'Volver al track grabado',
'assistant.change.verb.importTour': 'Añadir excursión desde archivo',
'assistant.change.label.field': 'Valor',
'assistant.change.label.difficulty': 'Dificultad máxima del sendero',
'assistant.change.label.track': 'Track grabado',
'assistant.change.label.packing': 'Lista de equipaje',
'assistant.change.label.breaks': 'Pausas',
'assistant.domain.tours': 'Rutas',
'assistant.domain.roadtrip': 'Viaje por carretera',
'assistant.domain.journey': 'Diario de viaje',
+9
View File
@@ -420,11 +420,20 @@ const assistant: TranslationStrings = {
'assistant.change.verb.countTour': 'Arvesta taas statistikasse',
'assistant.change.verb.linkPacking': 'Seo pakkimisnimekiri',
'assistant.change.verb.unlinkPacking': 'Eemalda pakkimisnimekirja seos',
'assistant.change.verb.reverseTour': 'Pööra retk ümber',
'assistant.change.verb.closeLoop': 'Sulge ring',
'assistant.change.verb.copyTour': 'Kopeeri retk',
'assistant.change.verb.setRouteColor': 'Muuda marsruudi värvi',
'assistant.change.verb.classifySki': 'Muuda suusaala',
'assistant.change.verb.setPaddlePlan': 'Muuda aerutamisplaani',
'assistant.change.verb.decideObstacles': 'Otsusta takistuste üle',
'assistant.change.verb.restoreTrack': 'Tagasi salvestatud raja juurde',
'assistant.change.verb.importTour': 'Lisa retk failist',
'assistant.change.label.field': 'Väärtus',
'assistant.change.label.difficulty': 'Raja suurim raskusaste',
'assistant.change.label.track': 'Salvestatud rada',
'assistant.change.label.packing': 'Pakkimisnimekiri',
'assistant.change.label.breaks': 'Pausid',
'assistant.domain.tours': 'Matkad',
'assistant.domain.roadtrip': 'Autoreis',
'assistant.domain.journey': 'Reisipäevik',
+9
View File
@@ -426,11 +426,20 @@ const assistant: TranslationStrings = {
'assistant.change.verb.countTour': 'Recompter dans les statistiques',
'assistant.change.verb.linkPacking': 'Lier la liste de bagages',
'assistant.change.verb.unlinkPacking': 'Délier la liste de bagages',
'assistant.change.verb.reverseTour': "Inverser l'itinéraire",
'assistant.change.verb.closeLoop': 'Fermer la boucle',
'assistant.change.verb.copyTour': "Copier l'itinéraire",
'assistant.change.verb.setRouteColor': 'Changer la couleur du tracé',
'assistant.change.verb.classifySki': 'Changer la discipline de ski',
'assistant.change.verb.setPaddlePlan': 'Modifier le plan de pagaie',
'assistant.change.verb.decideObstacles': 'Décider des obstacles',
'assistant.change.verb.restoreTrack': 'Revenir à la trace enregistrée',
'assistant.change.verb.importTour': 'Ajouter une sortie depuis un fichier',
'assistant.change.label.field': 'Valeur',
'assistant.change.label.difficulty': 'Difficulté maximale du sentier',
'assistant.change.label.track': 'Trace enregistrée',
'assistant.change.label.packing': 'Liste de bagages',
'assistant.change.label.breaks': 'Pauses',
'assistant.domain.tours': 'Randonnées',
'assistant.domain.roadtrip': 'Voyage en voiture',
'assistant.domain.journey': 'Carnet de voyage',
+9
View File
@@ -425,11 +425,20 @@ const assistant: TranslationStrings = {
'assistant.change.verb.countTour': 'Ξανά μέτρηση στα στατιστικά',
'assistant.change.verb.linkPacking': 'Σύνδεση λίστας αποσκευών',
'assistant.change.verb.unlinkPacking': 'Αποσύνδεση λίστας αποσκευών',
'assistant.change.verb.reverseTour': 'Αντιστροφή εκδρομής',
'assistant.change.verb.closeLoop': 'Κλείσιμο κυκλικής διαδρομής',
'assistant.change.verb.copyTour': 'Αντιγραφή εκδρομής',
'assistant.change.verb.setRouteColor': 'Αλλαγή χρώματος διαδρομής',
'assistant.change.verb.classifySki': 'Αλλαγή είδους σκι',
'assistant.change.verb.setPaddlePlan': 'Αλλαγή σχεδίου κουπιού',
'assistant.change.verb.decideObstacles': 'Απόφαση για εμπόδια',
'assistant.change.verb.restoreTrack': 'Επιστροφή στην καταγεγραμμένη διαδρομή',
'assistant.change.verb.importTour': 'Προσθήκη εκδρομής από αρχείο',
'assistant.change.label.field': 'Τιμή',
'assistant.change.label.difficulty': 'Μέγιστη δυσκολία μονοπατιού',
'assistant.change.label.track': 'Καταγεγραμμένη διαδρομή',
'assistant.change.label.packing': 'Λίστα αποσκευών',
'assistant.change.label.breaks': 'Διαλείμματα',
'assistant.domain.tours': 'Διαδρομές',
'assistant.domain.roadtrip': 'Οδικό ταξίδι',
'assistant.domain.journey': 'Ημερολόγιο ταξιδιού',
+9
View File
@@ -426,11 +426,20 @@ const assistant: TranslationStrings = {
'assistant.change.verb.countTour': 'Újra beszámítás a statisztikába',
'assistant.change.verb.linkPacking': 'Csomaglista összekapcsolása',
'assistant.change.verb.unlinkPacking': 'Csomaglista leválasztása',
'assistant.change.verb.reverseTour': 'Túra megfordítása',
'assistant.change.verb.closeLoop': 'Kör bezárása',
'assistant.change.verb.copyTour': 'Túra másolása',
'assistant.change.verb.setRouteColor': 'Útvonal színének módosítása',
'assistant.change.verb.classifySki': 'Sísportág módosítása',
'assistant.change.verb.setPaddlePlan': 'Evezési terv módosítása',
'assistant.change.verb.decideObstacles': 'Döntés az akadályokról',
'assistant.change.verb.restoreTrack': 'Vissza a rögzített nyomvonalhoz',
'assistant.change.verb.importTour': 'Túra hozzáadása fájlból',
'assistant.change.label.field': 'Érték',
'assistant.change.label.difficulty': 'Ösvény legnagyobb nehézsége',
'assistant.change.label.track': 'Rögzített nyomvonal',
'assistant.change.label.packing': 'Csomaglista',
'assistant.change.label.breaks': 'Szünetek',
'assistant.domain.tours': 'Túrák',
'assistant.domain.roadtrip': 'Autós körút',
'assistant.domain.journey': 'Útinapló',
+9
View File
@@ -400,11 +400,20 @@ const assistant: TranslationStrings = {
'assistant.change.verb.countTour': 'Hitung lagi di statistik',
'assistant.change.verb.linkPacking': 'Tautkan daftar bawaan',
'assistant.change.verb.unlinkPacking': 'Lepas tautan daftar bawaan',
'assistant.change.verb.reverseTour': 'Balik arah tur',
'assistant.change.verb.closeLoop': 'Tutup putaran',
'assistant.change.verb.copyTour': 'Salin tur',
'assistant.change.verb.setRouteColor': 'Ubah warna rute',
'assistant.change.verb.classifySki': 'Ubah disiplin ski',
'assistant.change.verb.setPaddlePlan': 'Ubah rencana dayung',
'assistant.change.verb.decideObstacles': 'Putuskan soal rintangan',
'assistant.change.verb.restoreTrack': 'Kembali ke jejak rekaman',
'assistant.change.verb.importTour': 'Tambah tur dari file',
'assistant.change.label.field': 'Nilai',
'assistant.change.label.difficulty': 'Tingkat kesulitan jalur maksimum',
'assistant.change.label.track': 'Jejak terekam',
'assistant.change.label.packing': 'Daftar bawaan',
'assistant.change.label.breaks': 'Istirahat',
'assistant.domain.tours': 'Tur',
'assistant.domain.roadtrip': 'Perjalanan darat',
'assistant.domain.journey': 'Jurnal perjalanan',
+9
View File
@@ -427,11 +427,20 @@ const assistant: TranslationStrings = {
'assistant.change.verb.countTour': 'Conta di nuovo nelle statistiche',
'assistant.change.verb.linkPacking': 'Collega lista bagagli',
'assistant.change.verb.unlinkPacking': 'Scollega lista bagagli',
'assistant.change.verb.reverseTour': 'Inverti il tour',
'assistant.change.verb.closeLoop': 'Chiudi il giro',
'assistant.change.verb.copyTour': 'Copia il tour',
'assistant.change.verb.setRouteColor': 'Cambia colore del percorso',
'assistant.change.verb.classifySki': 'Cambia disciplina sciistica',
'assistant.change.verb.setPaddlePlan': 'Cambia piano di pagaiata',
'assistant.change.verb.decideObstacles': 'Decidi sugli ostacoli',
'assistant.change.verb.restoreTrack': 'Torna alla traccia registrata',
'assistant.change.verb.importTour': 'Aggiungi escursione da file',
'assistant.change.label.field': 'Valore',
'assistant.change.label.difficulty': 'Difficoltà massima del sentiero',
'assistant.change.label.track': 'Traccia registrata',
'assistant.change.label.packing': 'Lista bagagli',
'assistant.change.label.breaks': 'Soste',
'assistant.domain.tours': 'Percorsi',
'assistant.domain.roadtrip': 'Viaggio su strada',
'assistant.domain.journey': 'Diario di viaggio',
+9
View File
@@ -395,11 +395,20 @@ const assistant: TranslationStrings = {
'assistant.change.verb.countTour': '統計に再び含める',
'assistant.change.verb.linkPacking': '持ち物リストを紐付け',
'assistant.change.verb.unlinkPacking': '持ち物リストの紐付けを解除',
'assistant.change.verb.reverseTour': 'ツアーを逆向きにする',
'assistant.change.verb.closeLoop': '周回ルートにする',
'assistant.change.verb.copyTour': 'ツアーをコピー',
'assistant.change.verb.setRouteColor': 'ルートの色を変更',
'assistant.change.verb.classifySki': 'スキーの種目を変更',
'assistant.change.verb.setPaddlePlan': 'パドル計画を変更',
'assistant.change.verb.decideObstacles': '障害物について決める',
'assistant.change.verb.restoreTrack': '記録したトラックに戻す',
'assistant.change.verb.importTour': 'ファイルからツアーを追加',
'assistant.change.label.field': '値',
'assistant.change.label.difficulty': '登山道の難易度上限',
'assistant.change.label.track': '記録トラック',
'assistant.change.label.packing': '持ち物リスト',
'assistant.change.label.breaks': '休憩',
'assistant.domain.tours': 'ツアー',
'assistant.domain.roadtrip': 'ロードトリップ',
'assistant.domain.journey': '旅行記',
+9
View File
@@ -391,11 +391,20 @@ const assistant: TranslationStrings = {
'assistant.change.verb.countTour': '통계에 다시 포함',
'assistant.change.verb.linkPacking': '짐 목록 연결',
'assistant.change.verb.unlinkPacking': '짐 목록 연결 해제',
'assistant.change.verb.reverseTour': '투어 방향 뒤집기',
'assistant.change.verb.closeLoop': '순환 코스로 만들기',
'assistant.change.verb.copyTour': '투어 복사',
'assistant.change.verb.setRouteColor': '경로 색상 변경',
'assistant.change.verb.classifySki': '스키 종목 변경',
'assistant.change.verb.setPaddlePlan': '패들 계획 변경',
'assistant.change.verb.decideObstacles': '장애물 결정',
'assistant.change.verb.restoreTrack': '기록된 트랙으로 되돌리기',
'assistant.change.verb.importTour': '파일에서 투어 추가',
'assistant.change.label.field': '값',
'assistant.change.label.difficulty': '탐방로 최대 난이도',
'assistant.change.label.track': '기록된 트랙',
'assistant.change.label.packing': '짐 목록',
'assistant.change.label.breaks': '휴식',
'assistant.domain.tours': '투어',
'assistant.domain.roadtrip': '로드 트립',
'assistant.domain.journey': '여행 일지',
+9
View File
@@ -422,11 +422,20 @@ const assistant: TranslationStrings = {
'assistant.change.verb.countTour': 'Weer meetellen in statistieken',
'assistant.change.verb.linkPacking': 'Paklijst koppelen',
'assistant.change.verb.unlinkPacking': 'Paklijst ontkoppelen',
'assistant.change.verb.reverseTour': 'Tour omdraaien',
'assistant.change.verb.closeLoop': 'Rondje sluiten',
'assistant.change.verb.copyTour': 'Tour kopiëren',
'assistant.change.verb.setRouteColor': 'Routekleur wijzigen',
'assistant.change.verb.classifySki': 'Skidiscipline wijzigen',
'assistant.change.verb.setPaddlePlan': 'Peddelplan wijzigen',
'assistant.change.verb.decideObstacles': 'Over obstakels beslissen',
'assistant.change.verb.restoreTrack': 'Terug naar opgenomen track',
'assistant.change.verb.importTour': 'Tocht uit bestand toevoegen',
'assistant.change.label.field': 'Waarde',
'assistant.change.label.difficulty': 'Maximale moeilijkheid van het pad',
'assistant.change.label.track': 'Opgenomen track',
'assistant.change.label.packing': 'Paklijst',
'assistant.change.label.breaks': 'Pauzes',
'assistant.domain.tours': 'Routes',
'assistant.domain.roadtrip': 'Roadtrip',
'assistant.domain.journey': 'Reisdagboek',
+9
View File
@@ -464,11 +464,20 @@ const assistant: TranslationStrings = {
'assistant.change.verb.countTour': 'Licz ponownie w statystykach',
'assistant.change.verb.linkPacking': 'Połącz listę pakowania',
'assistant.change.verb.unlinkPacking': 'Odłącz listę pakowania',
'assistant.change.verb.reverseTour': 'Odwróć trasę',
'assistant.change.verb.closeLoop': 'Zamknij pętlę',
'assistant.change.verb.copyTour': 'Kopiuj trasę',
'assistant.change.verb.setRouteColor': 'Zmień kolor trasy',
'assistant.change.verb.classifySki': 'Zmień dyscyplinę narciarską',
'assistant.change.verb.setPaddlePlan': 'Zmień plan spływu',
'assistant.change.verb.decideObstacles': 'Zdecyduj o przeszkodach',
'assistant.change.verb.restoreTrack': 'Wróć do nagranego śladu',
'assistant.change.verb.importTour': 'Dodaj wycieczkę z pliku',
'assistant.change.label.field': 'Wartość',
'assistant.change.label.difficulty': 'Maksymalna trudność szlaku',
'assistant.change.label.track': 'Nagrany ślad',
'assistant.change.label.packing': 'Lista pakowania',
'assistant.change.label.breaks': 'Przerwy',
'assistant.domain.tours': 'Trasy',
'assistant.domain.roadtrip': 'Podróż samochodem',
'assistant.domain.journey': 'Dziennik podróży',
+9
View File
@@ -464,11 +464,20 @@ const assistant: TranslationStrings = {
'assistant.change.verb.countTour': 'Снова учитывать в статистике',
'assistant.change.verb.linkPacking': 'Связать список вещей',
'assistant.change.verb.unlinkPacking': 'Отвязать список вещей',
'assistant.change.verb.reverseTour': 'Развернуть маршрут',
'assistant.change.verb.closeLoop': 'Замкнуть петлю',
'assistant.change.verb.copyTour': 'Копировать маршрут',
'assistant.change.verb.setRouteColor': 'Изменить цвет маршрута',
'assistant.change.verb.classifySki': 'Изменить лыжную дисциплину',
'assistant.change.verb.setPaddlePlan': 'Изменить план сплава',
'assistant.change.verb.decideObstacles': 'Решить по препятствиям',
'assistant.change.verb.restoreTrack': 'Вернуть записанный трек',
'assistant.change.verb.importTour': 'Добавить тур из файла',
'assistant.change.label.field': 'Значение',
'assistant.change.label.difficulty': 'Максимальная сложность тропы',
'assistant.change.label.track': 'Записанный трек',
'assistant.change.label.packing': 'Список вещей',
'assistant.change.label.breaks': 'Перерывы',
'assistant.domain.tours': 'Маршруты',
'assistant.domain.roadtrip': 'Автопутешествие',
'assistant.domain.journey': 'Дневник путешествий',
+9
View File
@@ -439,11 +439,20 @@ const assistant: TranslationStrings = {
'assistant.change.verb.countTour': 'Znova počítať do štatistík',
'assistant.change.verb.linkPacking': 'Prepojiť zoznam balenia',
'assistant.change.verb.unlinkPacking': 'Odpojiť zoznam balenia',
'assistant.change.verb.reverseTour': 'Otočiť túru',
'assistant.change.verb.closeLoop': 'Uzavrieť okruh',
'assistant.change.verb.copyTour': 'Kopírovať túru',
'assistant.change.verb.setRouteColor': 'Zmeniť farbu trasy',
'assistant.change.verb.classifySki': 'Zmeniť lyžiarsku disciplínu',
'assistant.change.verb.setPaddlePlan': 'Zmeniť plán pádlovania',
'assistant.change.verb.decideObstacles': 'Rozhodnúť o prekážkach',
'assistant.change.verb.restoreTrack': 'Späť na zaznamenanú stopu',
'assistant.change.verb.importTour': 'Pridať túru zo súboru',
'assistant.change.label.field': 'Hodnota',
'assistant.change.label.difficulty': 'Maximálna náročnosť chodníka',
'assistant.change.label.track': 'Zaznamenaná trasa',
'assistant.change.label.packing': 'Zoznam balenia',
'assistant.change.label.breaks': 'Prestávky',
'assistant.domain.tours': 'Túry',
'assistant.domain.roadtrip': 'Cesta autom',
'assistant.domain.journey': 'Cestovný denník',
+9
View File
@@ -420,11 +420,20 @@ const assistant: TranslationStrings = {
'assistant.change.verb.countTour': 'Räkna med i statistiken igen',
'assistant.change.verb.linkPacking': 'Koppla packlista',
'assistant.change.verb.unlinkPacking': 'Koppla från packlista',
'assistant.change.verb.reverseTour': 'Vänd turen',
'assistant.change.verb.closeLoop': 'Stäng slingan',
'assistant.change.verb.copyTour': 'Kopiera tur',
'assistant.change.verb.setRouteColor': 'Ändra ruttfärg',
'assistant.change.verb.classifySki': 'Ändra skidgren',
'assistant.change.verb.setPaddlePlan': 'Ändra paddelplan',
'assistant.change.verb.decideObstacles': 'Besluta om hinder',
'assistant.change.verb.restoreTrack': 'Tillbaka till inspelat spår',
'assistant.change.verb.importTour': 'Lägg till tur från fil',
'assistant.change.label.field': 'Värde',
'assistant.change.label.difficulty': 'Högsta svårighetsgrad för leden',
'assistant.change.label.track': 'Inspelat spår',
'assistant.change.label.packing': 'Packlista',
'assistant.change.label.breaks': 'Pauser',
'assistant.domain.tours': 'Turer',
'assistant.domain.roadtrip': 'Bilresa',
'assistant.domain.journey': 'Resedagbok',
+9
View File
@@ -392,11 +392,20 @@ const assistant: TranslationStrings = {
'assistant.change.verb.countTour': 'นับในสถิติอีกครั้ง',
'assistant.change.verb.linkPacking': 'เชื่อมรายการของที่ต้องเตรียม',
'assistant.change.verb.unlinkPacking': 'ยกเลิกการเชื่อมรายการของ',
'assistant.change.verb.reverseTour': 'กลับทิศทัวร์',
'assistant.change.verb.closeLoop': 'ปิดเป็นเส้นทางวน',
'assistant.change.verb.copyTour': 'คัดลอกทัวร์',
'assistant.change.verb.setRouteColor': 'เปลี่ยนสีเส้นทาง',
'assistant.change.verb.classifySki': 'เปลี่ยนประเภทสกี',
'assistant.change.verb.setPaddlePlan': 'เปลี่ยนแผนพายเรือ',
'assistant.change.verb.decideObstacles': 'ตัดสินใจเรื่องสิ่งกีดขวาง',
'assistant.change.verb.restoreTrack': 'กลับไปใช้เส้นทางที่บันทึกไว้',
'assistant.change.verb.importTour': 'เพิ่มทัวร์จากไฟล์',
'assistant.change.label.field': 'ค่า',
'assistant.change.label.difficulty': 'ระดับความยากสูงสุดของทางเดิน',
'assistant.change.label.track': 'เส้นทางที่บันทึก',
'assistant.change.label.packing': 'รายการของที่ต้องเตรียม',
'assistant.change.label.breaks': 'ช่วงพัก',
'assistant.domain.tours': 'ทัวร์',
'assistant.domain.roadtrip': 'โรดทริป',
'assistant.domain.journey': 'บันทึกการเดินทาง',
+9
View File
@@ -420,11 +420,20 @@ const assistant: TranslationStrings = {
'assistant.change.verb.countTour': 'İstatistiklere yeniden kat',
'assistant.change.verb.linkPacking': 'Bavul listesini bağla',
'assistant.change.verb.unlinkPacking': 'Bavul listesinin bağını kaldır',
'assistant.change.verb.reverseTour': 'Turu ters çevir',
'assistant.change.verb.closeLoop': 'Döngüyü kapat',
'assistant.change.verb.copyTour': 'Turu kopyala',
'assistant.change.verb.setRouteColor': 'Rota rengini değiştir',
'assistant.change.verb.classifySki': 'Kayak disiplinini değiştir',
'assistant.change.verb.setPaddlePlan': 'Kürek planını değiştir',
'assistant.change.verb.decideObstacles': 'Engeller hakkında karar ver',
'assistant.change.verb.restoreTrack': 'Kayıtlı izlemeye dön',
'assistant.change.verb.importTour': 'Dosyadan tur ekle',
'assistant.change.label.field': 'Değer',
'assistant.change.label.difficulty': 'En yüksek patika zorluğu',
'assistant.change.label.track': 'Kayıtlı iz',
'assistant.change.label.packing': 'Bavul listesi',
'assistant.change.label.breaks': 'Molalar',
'assistant.domain.tours': 'Turlar',
'assistant.domain.roadtrip': 'Yolculuk',
'assistant.domain.journey': 'Seyahat günlüğü',
+9
View File
@@ -462,11 +462,20 @@ const assistant: TranslationStrings = {
'assistant.change.verb.countTour': 'Знову враховувати в статистиці',
'assistant.change.verb.linkPacking': 'Пов’язати список речей',
'assistant.change.verb.unlinkPacking': 'Відв’язати список речей',
'assistant.change.verb.reverseTour': 'Розвернути маршрут',
'assistant.change.verb.closeLoop': 'Замкнути петлю',
'assistant.change.verb.copyTour': 'Копіювати маршрут',
'assistant.change.verb.setRouteColor': 'Змінити колір маршруту',
'assistant.change.verb.classifySki': 'Змінити лижну дисципліну',
'assistant.change.verb.setPaddlePlan': 'Змінити план сплаву',
'assistant.change.verb.decideObstacles': 'Вирішити щодо перешкод',
'assistant.change.verb.restoreTrack': 'Повернути записаний трек',
'assistant.change.verb.importTour': 'Додати тур із файлу',
'assistant.change.label.field': 'Значення',
'assistant.change.label.difficulty': 'Максимальна складність стежки',
'assistant.change.label.track': 'Записаний трек',
'assistant.change.label.packing': 'Список речей',
'assistant.change.label.breaks': 'Перерви',
'assistant.domain.tours': 'Маршрути',
'assistant.domain.roadtrip': 'Автоподорож',
'assistant.domain.journey': 'Щоденник подорожей',
+9
View File
@@ -395,11 +395,20 @@ const assistant: TranslationStrings = {
'assistant.change.verb.countTour': 'Tính lại vào thống kê',
'assistant.change.verb.linkPacking': 'Liên kết danh sách đồ',
'assistant.change.verb.unlinkPacking': 'Bỏ liên kết danh sách đồ',
'assistant.change.verb.reverseTour': 'Đảo chiều chuyến đi',
'assistant.change.verb.closeLoop': 'Khép vòng',
'assistant.change.verb.copyTour': 'Sao chép chuyến đi',
'assistant.change.verb.setRouteColor': 'Đổi màu tuyến đường',
'assistant.change.verb.classifySki': 'Đổi môn trượt tuyết',
'assistant.change.verb.setPaddlePlan': 'Đổi kế hoạch chèo',
'assistant.change.verb.decideObstacles': 'Quyết định về chướng ngại vật',
'assistant.change.verb.restoreTrack': 'Quay lại đường đã ghi',
'assistant.change.verb.importTour': 'Thêm chuyến đi từ tệp',
'assistant.change.label.field': 'Giá trị',
'assistant.change.label.difficulty': 'Độ khó tối đa của đường mòn',
'assistant.change.label.track': 'Hành trình đã ghi',
'assistant.change.label.packing': 'Danh sách đồ',
'assistant.change.label.breaks': 'Nghỉ giải lao',
'assistant.domain.tours': 'Tuyến đi',
'assistant.domain.roadtrip': 'Đi đường bộ',
'assistant.domain.journey': 'Nhật ký du lịch',
+9
View File
@@ -387,11 +387,20 @@ const assistant: TranslationStrings = {
'assistant.change.verb.countTour': '重新計入統計',
'assistant.change.verb.linkPacking': '連結打包清單',
'assistant.change.verb.unlinkPacking': '取消連結打包清單',
'assistant.change.verb.reverseTour': '反轉路線',
'assistant.change.verb.closeLoop': '閉合環線',
'assistant.change.verb.copyTour': '複製路線',
'assistant.change.verb.setRouteColor': '變更路線顏色',
'assistant.change.verb.classifySki': '變更滑雪項目',
'assistant.change.verb.setPaddlePlan': '變更划槳計畫',
'assistant.change.verb.decideObstacles': '決定障礙物處理',
'assistant.change.verb.restoreTrack': '恢復為記錄的軌跡',
'assistant.change.verb.importTour': '從檔案新增路線',
'assistant.change.label.field': '值',
'assistant.change.label.difficulty': '步道最高難度',
'assistant.change.label.track': '記錄軌跡',
'assistant.change.label.packing': '打包清單',
'assistant.change.label.breaks': '休息',
'assistant.domain.tours': '路線',
'assistant.domain.roadtrip': '公路旅行',
'assistant.domain.journey': '旅行日誌',
+9
View File
@@ -387,11 +387,20 @@ const assistant: TranslationStrings = {
'assistant.change.verb.countTour': '重新计入统计',
'assistant.change.verb.linkPacking': '关联行李清单',
'assistant.change.verb.unlinkPacking': '取消关联行李清单',
'assistant.change.verb.reverseTour': '反转路线',
'assistant.change.verb.closeLoop': '闭合环线',
'assistant.change.verb.copyTour': '复制路线',
'assistant.change.verb.setRouteColor': '更改路线颜色',
'assistant.change.verb.classifySki': '更改滑雪项目',
'assistant.change.verb.setPaddlePlan': '更改划桨计划',
'assistant.change.verb.decideObstacles': '决定障碍物处理',
'assistant.change.verb.restoreTrack': '恢复为记录的轨迹',
'assistant.change.verb.importTour': '从文件添加路线',
'assistant.change.label.field': '值',
'assistant.change.label.difficulty': '步道最高难度',
'assistant.change.label.track': '记录轨迹',
'assistant.change.label.packing': '行李清单',
'assistant.change.label.breaks': '休息',
'assistant.domain.tours': '路线',
'assistant.domain.roadtrip': '自驾游',
'assistant.domain.journey': '旅行日记',
+1
View File
@@ -79,6 +79,7 @@ export * from './tours/tour-walk.schema';
export * from './tours/tour-walk';
export * from './tours/tour-walk-briefing.schema';
export * from './tours/tour-change.schema';
export * from './tours/tour-edit.schema';
export * from './tours/walk-hours';
export * from './tours/walk-remaining';
export * from './tours/walk-ferry';
+68 -3
View File
@@ -1,6 +1,13 @@
import { idSchema } from '../common/primitives.schema';
import { cityWalkStationSchema } from './tour-walk-briefing.schema';
import { stationKeySchema, tourRevisionSchema } from './tours.schema';
import {
MAX_STATION_STAY_MINUTES,
stationImportanceSchema,
stationKeySchema,
TOUR_STATION_HOURS_MAX,
TOUR_STATION_NAME_MAX,
tourRevisionSchema,
} from './tours.schema';
import { z } from 'zod';
@@ -11,14 +18,17 @@ import { z } from 'zod';
* and what could not be. Every change names the stations it touches by their
* keys, the same across saves, so it cannot drift onto another station. A
* spontaneous stop is a change like any other: an `add` after the station the
* traveller is at.
* traveller is at. An `edit` changes what a station says of itself (its name,
* stay, importance, protection, opening hours) and leaves it where it is; a
* `reverse` turns the whole walk round and a `close_loop` takes it back to its
* start, each the only change of its proposal besides edits.
*/
/** The most changes one proposal carries: enough to rework a walk, few enough to review at once. */
export const TOUR_CHANGE_MAX = 10;
/** The operations a proposal can hold; which of them a tour type takes is the server's rule. */
export const TOUR_CHANGE_OPS = ['add', 'remove', 'replace', 'move'] as const;
export const TOUR_CHANGE_OPS = ['add', 'remove', 'replace', 'move', 'edit', 'reverse', 'close_loop'] as const;
export type TourChangeOp = (typeof TOUR_CHANGE_OPS)[number];
const key = stationKeySchema.describe('The key of a station of the saved tour, as get_tour_plan gives it');
@@ -29,11 +39,66 @@ const station = cityWalkStationSchema.describe(
'The new station: its position, name, stay in minutes (0 passed through, null not known), importance, protected, opening hours as OpenStreetMap writes them, and the place of this trip it stands for',
);
/** The fields an `edit` changes, each left out where it stays as it is. */
export const TOUR_STATION_EDIT_FIELDS = ['name', 'stay_minutes', 'importance', 'protected', 'opening_hours'] as const;
/**
* An edit of one station: only the fields given change, null clears one (a
* name, a stay that is then not known, the importance back to normal, the
* opening hours). A stay of 0 is a point passed through, as in the planner.
*/
const stationEdit = z
.object({
op: z.literal('edit'),
key,
name: z
.string()
.trim()
.min(1)
.max(TOUR_STATION_NAME_MAX)
.nullable()
.optional()
.describe('The new name; null clears it'),
stay_minutes: z
.number()
.int()
.min(0)
.max(MAX_STATION_STAY_MINUTES)
.nullable()
.optional()
.describe('The stay in minutes, 0 for a point only passed through; null when it is not known'),
importance: stationImportanceSchema
.nullable()
.optional()
.describe('optional or must; null makes it a normal station again'),
protected: z
.boolean()
.optional()
.describe('true keeps the station where it is in every change proposed later; false lifts that'),
opening_hours: z
.string()
.trim()
.min(1)
.max(TOUR_STATION_HOURS_MAX)
.nullable()
.optional()
.describe('Opening hours as OpenStreetMap writes them, such as "Mo-Fr 09:00-18:00"; null clears them'),
})
.strict()
.refine((edit) => TOUR_STATION_EDIT_FIELDS.some((field) => edit[field] !== undefined), {
message: 'an edit changes at least one field of its station',
});
export const tourChangeSchema = z.discriminatedUnion('op', [
z.object({ op: z.literal('add'), station, after_key: afterKey }).strict(),
z.object({ op: z.literal('remove'), key }).strict(),
z.object({ op: z.literal('replace'), key, station }).strict(),
z.object({ op: z.literal('move'), key, after_key: afterKey }).strict(),
stationEdit,
// The walk the other way round: its last station starts it; a loop stays a loop from the same start.
z.object({ op: z.literal('reverse') }).strict(),
// The walk back to its start: a way back from its last station is added.
z.object({ op: z.literal('close_loop') }).strict(),
]);
export type TourChange = z.infer<typeof tourChangeSchema>;
+105
View File
@@ -0,0 +1,105 @@
import { tourChangeRequestSchema } from './tour-change.schema';
import {
horseRoutesToolRequestSchema,
obstacleDecisionsToolRequestSchema,
skiClassifyToolRequestSchema,
tourCopyToolRequestSchema,
tourDetailsUpdateRequestSchema,
tourRefToolRequestSchema,
tourRouteColorRequestSchema,
} from './tour-edit.schema';
import { tourUpdateRequestSchema } from './tour-hike.schema';
import { describe, expect, it } from 'vitest';
describe('the words and times of a saved tour (P8)', () => {
it('takes any of its fields with the revision, null clearing one', () => {
const body = {
placeId: 4,
expected_revision: 2,
website: 'https://example.org/tour',
break_additional_minutes: 45,
planned_duration_minutes: null,
description: null,
};
expect(tourDetailsUpdateRequestSchema.parse(body)).toEqual(body);
});
it('refuses a change of nothing, a plain http page, breaks past a day and a field it does not take', () => {
const refused = [
{ placeId: 4, expected_revision: 2 },
{ placeId: 4, expected_revision: 2, website: 'http://example.org' },
{ placeId: 4, expected_revision: 2, break_additional_minutes: 1441 },
{ placeId: 4, expected_revision: 2, name: 'Ridge', route_color: '#fff' },
{ placeId: 4, name: 'Ridge' },
];
for (const body of refused) {
expect(tourDetailsUpdateRequestSchema.safeParse(body).success, JSON.stringify(body)).toBe(false);
}
});
it('takes a route colour as hex or null, and a copy by its name', () => {
expect(tourRouteColorRequestSchema.parse({ placeId: 1, route_color: '#e11d48' }).route_color).toBe('#e11d48');
expect(tourRouteColorRequestSchema.parse({ placeId: 1, route_color: null }).route_color).toBeNull();
expect(tourRouteColorRequestSchema.safeParse({ placeId: 1, route_color: 'red' }).success).toBe(false);
expect(tourCopyToolRequestSchema.parse({ placeId: 1, name: ' Short way ' }).name).toBe('Short way');
expect(tourCopyToolRequestSchema.safeParse({ placeId: 1, name: '' }).success).toBe(false);
});
});
describe('the writes of a ski or paddle tour and the reads beside them (P9)', () => {
it('takes a ski classification, a decision about an obstacle, a tour and a map view', () => {
expect(
skiClassifyToolRequestSchema.parse({ placeId: 3, ski_discipline: 'cross_country', ski_technique: 'classic' }),
).toMatchObject({ ski_technique: 'classic' });
expect(skiClassifyToolRequestSchema.safeParse({ placeId: 3, ski_discipline: 'sledge' }).success).toBe(false);
const op = { key: 'node/12', op: 'note', decision: null, note: 'portage on the left', basis: null };
expect(obstacleDecisionsToolRequestSchema.parse({ placeId: 3, ops: [op] }).ops).toEqual([op]);
expect(obstacleDecisionsToolRequestSchema.safeParse({ placeId: 3, ops: [] }).success).toBe(false);
expect(tourRefToolRequestSchema.safeParse({ placeId: 3, extra: true }).success).toBe(false);
expect(horseRoutesToolRequestSchema.parse({ bbox: '47.5,11.2,47.7,11.5' }).bbox).toBe('47.5,11.2,47.7,11.5');
});
});
describe('the new changes of a saved city walk and a hike (P8)', () => {
it('takes an edit of at least one field, a reverse and a close_loop', () => {
const changes = [
{ op: 'edit', key: 'stop-museum', stay_minutes: 0, importance: null, protected: false },
{ op: 'reverse' },
{ op: 'close_loop' },
];
expect(tourChangeRequestSchema.parse({ changes })).toEqual({ changes });
});
it('refuses an edit of nothing, of a field no station has, or a reverse naming a station', () => {
const refused = [
[{ op: 'edit', key: 'stop-museum' }],
[{ op: 'edit', key: 'stop-museum', lat: 48 }],
[{ op: 'edit', key: 'stop-museum', stay_minutes: -1 }],
[{ op: 'edit', key: 'stop-museum', name: '' }],
[{ op: 'reverse', key: 'stop-museum' }],
];
for (const changes of refused) {
expect(tourChangeRequestSchema.safeParse({ changes }).success, JSON.stringify(changes)).toBe(false);
}
});
it('takes a hike reshaped without new points, never with them', () => {
expect(tourUpdateRequestSchema.parse({ placeId: 2, expected_revision: 1, reshape: 'reverse' }).reshape).toBe(
'reverse',
);
const both = {
placeId: 2,
expected_revision: 1,
reshape: 'close_loop',
waypoints: [
{ lat: 47, lng: 11 },
{ lat: 47.1, lng: 11 },
],
};
expect(tourUpdateRequestSchema.safeParse(both).success).toBe(false);
expect(tourUpdateRequestSchema.safeParse({ placeId: 2, expected_revision: 1, reshape: 'flip' }).success).toBe(
false,
);
});
});
+129
View File
@@ -0,0 +1,129 @@
import { idSchema } from '../common/primitives.schema';
import { hexColorSchema } from '../place/place.schema';
import { obstacleReviewOpSchema } from './paddle-check.schema';
import { skiDisciplineSchema, skiTechniqueSchema } from './ski.schema';
import {
plannedTourDurationMinutesSchema,
tourDescriptionSchema,
tourNameSchema,
tourRevisionSchema,
tourWebsiteSchema,
} from './tours.schema';
import { z } from 'zod';
/**
* The edits of a saved tour an assistant makes besides its route, for every
* type, with the rules the planner's save and the tour detail hold them to:
* the words and times of a tour, the colour its line is drawn in, a copy, and
* the writes a ski or paddle tour has of its own. Each names the tour by its
* place id; a write that changes the tour names the revision it was read at.
*/
const placeId = idSchema.describe('The place id of the tour, as list_tours gives it');
const expectedRevision = tourRevisionSchema.describe(
'The revision of the tour as last read (get_tour_plan or list_tours); a tour changed since is refused, not overwritten',
);
/** The fields update_tour_details changes, each left out where it stays as it is. */
export const TOUR_DETAIL_FIELDS = [
'name',
'description',
'website',
'break_additional_minutes',
'planned_duration_minutes',
] as const;
/**
* P8: a tour's words and planned times, for any type and a recorded track
* too, without touching its route, its points or its days. Null clears a
* field; the breaks are minutes on top of the moving time, the planned
* duration a total the travellers set themselves, breaks included.
*/
export const tourDetailsUpdateRequestSchema = z
.object({
placeId,
expected_revision: expectedRevision,
name: tourNameSchema.optional().describe('A new name for the tour'),
description: tourDescriptionSchema.describe('A new description; null clears it'),
website: tourWebsiteSchema.nullable().optional().describe('The web page of the tour, an https URL; null clears it'),
break_additional_minutes: plannedTourDurationMinutesSchema
.optional()
.describe('Minutes of breaks on top of the moving time, 0 to 1440; null clears them'),
planned_duration_minutes: plannedTourDurationMinutesSchema
.optional()
.describe('How long the travellers plan to be out in all, breaks included, in minutes; null clears it'),
})
.strict()
.refine((value) => TOUR_DETAIL_FIELDS.some((field) => value[field] !== undefined), {
message: 'give at least one field to change',
});
export type TourDetailsUpdateRequest = z.infer<typeof tourDetailsUpdateRequestSchema>;
/** P8: the colour a tour's line is drawn in on the map, as the tour detail sets it; null takes the type's colour. */
export const tourRouteColorRequestSchema = z
.object({
placeId,
route_color: hexColorSchema
.nullable()
.describe('A hex colour such as "#e11d48"; null draws the line in the colour of its tour type again'),
})
.strict();
export type TourRouteColorRequest = z.infer<typeof tourRouteColorRequestSchema>;
/** P2C-07 over MCP: the tour saved again as a new tour of the same trip, under its own name, without its days. */
export const tourCopyToolRequestSchema = z
.object({
placeId,
name: tourNameSchema.describe('The name of the copy, such as "Ridge walk (short)"'),
})
.strict();
export type TourCopyToolRequest = z.infer<typeof tourCopyToolRequestSchema>;
/** SKI-02 over MCP: a ski track classified again, its discipline and for cross-country its technique. */
export const skiClassifyToolRequestSchema = z
.object({
placeId,
ski_discipline: skiDisciplineSchema.describe(
'alpine, snowboard, cross_country, touring (ski touring), snowshoe or winter_hike',
),
ski_technique: skiTechniqueSchema
.nullable()
.optional()
.describe('For cross_country only: classic, skating, or unknown when the travellers do not know'),
expected_revision: expectedRevision.optional(),
})
.strict();
export type SkiClassifyToolRequest = z.infer<typeof skiClassifyToolRequestSchema>;
/** PAD-06 over MCP: decisions about the obstacles of a paddle tour, at most one change per obstacle. */
export const obstacleDecisionsToolRequestSchema = z
.object({
placeId,
ops: z
.array(obstacleReviewOpSchema)
.min(1)
.max(100)
.describe(
"Each change to the checklist: key and basis copied from the entry of get_tour_plan's obstacle_check, op decide (with decision pass, portage, reroute or not_affected, and the entry's basis), note (the note only), clear (the decision taken back) or keep (a decision about an object the data no longer holds)",
),
})
.strict();
export type ObstacleDecisionsToolRequest = z.infer<typeof obstacleDecisionsToolRequestSchema>;
/** A read about one saved tour: get_tour_trail_details, restore_recorded_track. */
export const tourRefToolRequestSchema = z.object({ placeId }).strict();
export type TourRefToolRequest = z.infer<typeof tourRefToolRequestSchema>;
/** HOR-01 over MCP: the map view to find the signposted horse routes in. */
export const horseRoutesToolRequestSchema = z
.object({
bbox: z
.string()
.max(200)
.describe(
'The map view as "south,west,north,east" in degrees, such as "47.5,11.2,47.7,11.5"; at most about half a degree on each side',
),
})
.strict();
export type HorseRoutesToolRequest = z.infer<typeof horseRoutesToolRequestSchema>;
+20 -1
View File
@@ -291,6 +291,20 @@ export const hikeCreateRequestSchema = z
.superRefine(refineStages);
export type HikeCreateRequest = z.infer<typeof hikeCreateRequestSchema>;
/**
* PR1-01 and PR1-02 for a saved hike: its points the other way round
* (`reverse`, the last point starts it) or taken back to its start
* (`close_loop`, a way back from its last point), as the planner's route
* tools do. The way is planned anew through the points as they then stand.
*/
export const TOUR_RESHAPES = ['reverse', 'close_loop'] as const;
export const tourReshapeSchema = z
.enum(TOUR_RESHAPES)
.describe(
'reverse turns the saved hike round, its last point starting it; close_loop adds the way back from its last point to its start. Give it without waypoints',
);
export type TourReshape = z.infer<typeof tourReshapeSchema>;
export const tourUpdateRequestSchema = z
.object({
placeId: idSchema.describe('The place id of the hike, as list_tours gives it'),
@@ -307,9 +321,14 @@ export const tourUpdateRequestSchema = z
max_hiking_difficulty: hikeLimitSchema.optional(),
hiking_preferences: hikePreferencesSchema.optional(),
planned_duration_minutes: plannedMinutesSchema.optional(),
reshape: tourReshapeSchema.optional(),
})
.strict()
.superRefine(refineStages);
.superRefine(refineStages)
.refine((value) => value.reshape === undefined || value.waypoints === undefined, {
path: ['reshape'],
message: 'reshape turns the saved points; give it without waypoints',
});
export type TourUpdateRequest = z.infer<typeof tourUpdateRequestSchema>;
/**
+12 -3
View File
@@ -275,7 +275,7 @@ Each tool asks for the scope of what it writes, so the scopes differ per tool. T
### Tours _(Tours addon required)_
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, a city walk prepared from a briefing, a change to a saved city walk prepared for review and saved once the traveller confirmed it, a saved change taken back the same way, a hike planned on the server with its heights and SAC grades, created, changed or taken in from a GPX file once the traveller confirmed it, and the tours the user has done, summed across their trips as the Atlas shows them. `take_over_tour`, `create_city_walk`, `create_hike`, `update_tour`, `import_tour_gpx`, `save_tour_change`, `undo_tour_change`, `assign_tour_track`, `unassign_tour_track`, `set_tour_activity_excluded`, `save_tour_to_collection`, `take_over_collection_tour`, `delete_collection_tour`, `link_tour_packing` and `unlink_tour_packing` write; every other tool here only reads. The collection tools need the Collections addon as well, the packing tools the Lists addon. 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, a city walk prepared from a briefing, a change to a saved city walk prepared for review and saved once the traveller confirmed it, a saved change taken back the same way, a hike planned on the server with its heights and SAC grades, created, changed or taken in from a GPX file once the traveller confirmed it, and the tours the user has done, summed across their trips as the Atlas shows them. `take_over_tour`, `create_city_walk`, `create_hike`, `update_tour`, `import_tour_gpx`, `save_tour_change`, `undo_tour_change`, `assign_tour_track`, `unassign_tour_track`, `set_tour_activity_excluded`, `save_tour_to_collection`, `take_over_collection_tour`, `delete_collection_tour`, `link_tour_packing`, `unlink_tour_packing`, `update_tour_details`, `set_tour_route_color`, `copy_tour`, `classify_ski_track`, `set_paddle_plan`, `decide_paddle_obstacles` and `restore_recorded_track` write; every other tool here only reads. The collection tools need the Collections addon as well, the packing tools the Lists addon. Tours someone is still drawing in the planner are not saved yet and do not show here.
| Tool | Description | Scope |
|---|---|---|
@@ -305,14 +305,23 @@ The saved tours of a trip, as the Tours list and the tour detail show them, how
| `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` |
| `propose_hike` | Plans a named hike for the traveller to look at, without saving anything: the way on foot through its points in order (start, points on the way, each with its kind of place where it is one, end; `round_trip` comes back to the start), on paths no harder than `max_hiking_difficulty` on the SAC scale (T1 to T6, T2 when left out) and avoiding steep climbs, steps or ferries where there is a way round (`hiking_preferences`). The way is planned by TREK's Valhalla router with the planner's hiking costing (the instance an admin set, else the public FOSSGIS one), through the same paced queue as the road trip. Answers the distance, ascent and descent, the walking time without breaks (by DIN 33466 from the heights where every point has one, else the router's), the lowest and highest point (with where it lies along the way), the SAC grades OpenStreetMap records along the way (the hardest, up to 80 graded stretches, neighbours joined with the harder grade kept, and how much is unknown, which is never read as easy), each point with where it lies along the way, how far it was moved onto a way, the walking minutes from the start and, with `start_time`, its arrival, and up to 12 summits, huts, drinking water and viewpoints within 250 m of the way (`along`; left out with `along: false`, and none where they could not be searched). With `day_id` (whose date it takes) or `date` it plans the day: the arrival (on the next day past midnight), sunrise and sunset and the weather by the hour at the highest point (to about a kilometre), from the forecast, as observed for a past day (which knows no likelihood of rain), or, further out, from last year's day; without a start the latest one that is back half an hour before sunset. It warns of a start before sunrise, an arrival after sunset or within the hour before it, and of the first walking hour with thunderstorms or rain more likely than not, these two on a forecast only; without the weather the day still answers, without sun, hours or warnings. With `include_geometry` it adds the line (up to 300 points) and height profile (up to 200 samples) for drawing (`line_geometry`, `profile_geometry`). Where no way is planned it says why: no Valhalla (a routing server of the traveller's own knows no hiking limit), the router not answering, no way within the limit, a point more than 300 m from any way, or a way too long for one hike (past the router's own distance limit, or more than 10,000 points, whose heights would hold the router's queue up for everyone). A planned way with every height is kept for 15 minutes (the last 64 per instance, up to 200,000 line points together), so a hike created or changed from the proposal saves the line it showed. `variant_label` is given back, so several ways can be compared | `trips:read` |
| `create_hike` | Creates a hike from points the traveller confirmed (usually those of `propose_hike`), through the same creation the planner's save uses: its points in order as start, points on the way and end with their names and kinds of place, the way planned by the same router (the line a proposal of the last 15 minutes showed) and saved whole with the height of every point, its distance, ascent, descent, walking time, hiking limit, preferences and planned time. Where the router does not answer, for the way or for its heights, the hike is saved with its points only (`route_pending`), says so, and TREK plans the way when someone opens it in the planner. Any other reason the proposal names (no Valhalla, no way within the limit, a point off every way, a way too long) refuses the hike with why, and nothing is saved, since the planner could not plan it either. With `day_id` it is put on that day of the trip in the same write, `start_time` kept as the start of that visit, and the trip is told of the tour and the visit once both are saved (`assignment` names the visit; `day_edit` is needed as well). Needs `place_edit` on the trip | `trips:write` |
| `update_tour` | Changes a saved hike against the `revision` it was read at: a new name, description or planned time keeps its way; new points, a new hiking limit or new preferences plan the way anew as `propose_hike` does, and so does a hike still waiting for its way. A hike changed since that revision is refused as a conflict before anything is planned, and a new way that cannot be planned, or not with its heights, is refused with the reason, leaving the hike as it was. A new point standing where a saved one stood (within 25 m) keeps that point's link to a place of the trip and its name. A hike with a section drawn by hand (a via ferrata, a free line, a ferry) or split into stages takes new points only in the planner, the hand-drawn one a new limit or new preferences too. A recorded track without points of its own takes only new points, which plan it over and keep the recording; every other tour type is refused (a city walk changes through `prepare_tour_change` and `save_tour_change`). The hike stays on its days. Needs `place_edit` on the trip | `trips:write` |
| `update_tour` | Changes a saved hike against the `revision` it was read at: a new name, description or planned time keeps its way; new points, a new hiking limit or new preferences plan the way anew as `propose_hike` does, and so does a hike still waiting for its way. A hike changed since that revision is refused as a conflict before anything is planned, and a new way that cannot be planned, or not with its heights, is refused with the reason, leaving the hike as it was. A new point standing where a saved one stood (within 25 m) keeps that point's link to a place of the trip and its name. A hike with a section drawn by hand (a via ferrata, a free line, a ferry) or split into stages takes new points only in the planner, the hand-drawn one a new limit or new preferences too. With `reshape` it turns the saved points instead: `reverse` runs the hike the other way round, `close_loop` adds the way back from its last point to its start, and the way is planned anew through them (a hike split into stages is not reversed here). A recorded track without points of its own takes only new points, which plan it over and keep the recording; every other tour type is refused (a city walk changes through `prepare_tour_change` and `save_tour_change`). The hike stays on its days. Needs `place_edit` on the trip | `trips:write` |
| `import_tour_gpx` | Takes in the tracks and routes of a GPX file, passed as its text, as tours of the trip, one each, through the same import as the planner's file import: a hike unless another type is named (a bike import with its profile, a run with its profile, a paddle import with its boat and water, a ski import with its discipline). A track already in the trip under the same name and start is skipped and counted; a file without a track or route is refused. Needs `place_edit` on the trip | `trips:write` |
| `prepare_tour_change` | Prepares a change to a saved city walk for the traveller to review, without saving anything: no tour, station, visit or day plan changes, and turning it down keeps the plan as it was. The changes apply in order and name the saved stations by their keys: `add` (a new station after a station; a spontaneous stop on the way is an add after the station the traveller is at), `remove`, `replace` and `move`. A protected station, or one visited or skipped on any day of the walk, is never removed, replaced or moved, nothing is put before the last station already done, and a leg drawn by hand stays as drawn, the closing leg of a loop included; such a change is refused, and with it the whole proposal, naming every change and why. Another type of tour, or a walk that is a recorded track without stations of its own, is refused as well: so far only a city walk takes prepared changes. The answer names the tour and the `revision` it was prepared on (a save has to name it, and a tour changed since is refused rather than overwritten), the stations after the change with what changed each and where it stood before, what was removed, the way on foot before and after planned by the same router TREK uses for city walks and its change in metres and minutes (the distance and walking time stored with the walk given apart), the stays as far as known before and after, every leg after the change with the new ones marked, `data_gaps` for what could not be worked out (no way from the router, stays not known, no arrival times, shaping points dropped because the leg they steered changed), a warning for a station of importance must removed or replaced, each new station that is not yet a place of the trip as `create_place` takes it to keep it as a place instead (a station linked to a place of the trip is left out), whether the traveller may change the trip's places at all (`can_save`) and the `proposal_id` a save of it names | `trips:read` |
| `prepare_tour_change` | Prepares a change to a saved city walk for the traveller to review, without saving anything: no tour, station, visit or day plan changes, and turning it down keeps the plan as it was. The changes apply in order and name the saved stations by their keys: `add` (a new station after a station; a spontaneous stop on the way is an add after the station the traveller is at), `remove`, `replace`, `move` and `edit` (a station's name, stay, importance, protection or opening hours; it stays where it is, and a protected or already visited station takes an edit too). `reverse` turns the whole walk round, a loop staying a loop from the same start, and `close_loop` adds the way back to its start; each is the only change of its proposal besides edits, and a walk under way or with a protected station off its start is not reversed. Edits alone keep the walk's saved way and walking time. A protected station, or one visited or skipped on any day of the walk, is never removed, replaced or moved, nothing is put before the last station already done, and a leg drawn by hand stays as drawn, the closing leg of a loop included; such a change is refused, and with it the whole proposal, naming every change and why. Another type of tour, or a walk that is a recorded track without stations of its own, is refused as well: so far only a city walk takes prepared changes. The answer names the tour and the `revision` it was prepared on (a save has to name it, and a tour changed since is refused rather than overwritten), the stations after the change with what changed each and where it stood before, what was removed, the way on foot before and after planned by the same router TREK uses for city walks and its change in metres and minutes (the distance and walking time stored with the walk given apart), the stays as far as known before and after, every leg after the change with the new ones marked, `data_gaps` for what could not be worked out (no way from the router, stays not known, no arrival times, shaping points dropped because the leg they steered changed), a warning for a station of importance must removed or replaced, each new station that is not yet a place of the trip as `create_place` takes it to keep it as a place instead (a station linked to a place of the trip is left out), whether the traveller may change the trip's places at all (`can_save`) and the `proposal_id` a save of it names | `trips:read` |
| `save_tour_change` | Saves a change `prepare_tour_change` prepared, once the traveller said yes to it exactly as shown: the same changes, the `revision` it was prepared on, its `proposal_id` and `confirmed` set to true. Anything else is refused and nothing is saved. The trip permission and the tour are checked again before anything is written: a tour changed since the proposal was prepared is refused as a conflict rather than overwritten (prepare the change again), a proposal already saved, sent again because its answer was lost, writes nothing and answers `already_saved` with the step it was saved as, and a change the rules no longer allow, a station visited or skipped meanwhile or a place of the trip gone, is refused with every reason. The change is saved whole or not at all, through the same write the planner's save uses: the stations in their order, the stations kept keep their keys and their visits, and the way on foot is planned by the same router as the proposal; where it gives no way the walk keeps its stations only (`route_pending`) until someone opens it in the planner. Every save is kept as a step of the tour with the revision it was prepared on and the one it raised the tour to, the changes and the tour as it was before them, so it can be followed and taken back with `undo_tour_change`; nothing of the conversation is kept. A new walk has no revision yet and is made with `create_city_walk`. Needs `place_edit` on the trip | `trips:write` |
| `list_tour_change_steps` | Lists the changes saved to a tour with `save_tour_change`, the latest first: each step with when it was saved, the revision it was prepared on and the one it raised the tour to, and the changes as confirmed. `standing` says whether it can be taken back: `latest` (the tour's latest change), `undone` (taken back already, with when and the revision that raised the tour to), `later_change` (something was saved on top of it since, so taking it back would lose that) or `no_earlier_state` (its record of the tour before cannot be read). Edits saved in the planner are no steps, and a draft in the planner is never seen | `trips:read` |
| `prepare_tour_undo` | Shows what taking back a saved step would do, without changing anything: the step and its changes, the tour now (distance and walking time as stored, each station with whether it was visited or skipped on a day) and the tour exactly as it was before the step (its stations, each kept or coming back with where it stands now, and the distance and walking time stored then), the stations taking back removes, and the `step_id` and `expected_revision` that `undo_tour_change` names. Only the tour's latest change can be taken back; a step something was saved on top of since is refused as a conflict, so no later edit is lost, and so is one that would remove a station visited or skipped since or bring back a station whose place of the trip is gone. Visits of stations that stay, the days and the tour's name and notes are not touched. Not the planner's undo: discarding a draft in the planner never changes a saved tour. Also says whether the traveller may change the trip's places (`can_undo`) | `trips:read` |
| `undo_tour_change` | Takes back a saved step once the traveller said yes to the preview of exactly that step: `step_id` and `expected_revision` as `prepare_tour_undo` gave them, and `confirmed` set to true. Everything is checked again before anything is written, and a tour changed since the preview, a step no longer the latest change or taken back already, or a station visited or skipped meanwhile that would go, refuses it and leaves the tour as it is. Restores the tour whole or not at all, exactly as it was before the step: its line, its stations with their keys and the distance and walking time stored then. A step is taken back once, and taking it back raises the tour's revision like any other save. 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) with where each starts and ends in metres along the leg; 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. With `include_geometry` each planned leg adds its line for drawing (`line_geometry`), all legs together up to 300 points shared by length | `trips:read` |
| `update_tour_details` | Changes the words and planned times of a saved tour of any type, a recorded track too, without touching its route, points or days: name, description, website (https), the breaks on top of the moving time and the planned time in all; only the fields given change, `null` clears one. Names the `revision` it was read at, and a tour changed since is refused as a conflict. The moving time and the breaks stay within a day (a day a stage for a tour over several days) unless a planned total is set, as in the planner. Answers the tour with its new revision and the values before. Needs `place_edit` on the trip | `trips:write` |
| `set_tour_route_color` | Draws a saved tour's line in another colour, as the tour detail's colour picker does: a hex colour, or `null` for the colour of its type. Only the colour changes; the revision stays. Answers the colour before too. Needs `place_edit` on the trip | `trips:write` |
| `copy_tour` | Saves a tour again as a new, independent tour of the same trip under its own name, as the planner's "save as new tour" does: its own id, line and control points, the same type, measures and settings, no days. Needs `place_edit` on the trip | `trips:write` |
| `classify_ski_track` | Classifies a ski and winter tour again: its discipline and, for cross-country, its technique. Only the classification changes, nothing is planned; any other type is refused. Answers the classification before too. Needs `place_edit` on the trip | `trips:write` |
| `set_paddle_plan` | Lays the paddle plan onto a recorded paddle track as its plan card does: the portages and locks as metres along the track, the paddling and carrying speeds. The track is never redrawn, its moving time is reckoned anew; the plan given replaces the whole plan. A planned paddle tour saves these with its route in the planner and is refused. Answers the plan before too. Needs `place_edit` on the trip | `trips:write` |
| `decide_paddle_obstacles` | Records what the paddlers decided about the obstacles of a paddle tour, each bound to the basis of its checklist entry in `get_tour_plan`: decide (pass, portage, reroute, not affected), change a note, clear a decision or keep one about an object the data no longer holds. A planning aid, never a safety or legal clearance. Answers the checklist as it now stands. Needs `place_edit` on the trip | `trips:write` |
| `restore_recorded_track` | Takes an import that was planned over in place back to the track it was recorded with; the planning is dropped. The tour keeps its id, days and notes. Needs `place_edit` on the trip | `trips:write` |
| `get_tour_trail_details` | What OpenStreetMap records for the ways under a saved tour's line, as the planner's trail details show it: the SAC grades along it, the mountain bike scale, the surfaces and kinds of way with their metres and shares, and the part that is unknown. A way without a value is unknown, never easy or paved. A tour without a line yet is refused | `trips:read` |
| `find_horse_routes` | The signposted horse routes OpenStreetMap knows inside a map view, as the horse tour planner shows them: each route with its sections, their length, `horse=*` access and signposting, and where each starts and ends. Being on a route is no permission to ride it | `trips:read` |
| `plan_ski_route` | Plans the legs of a ski and winter tour on the runs, lifts, cross-country trails, ski touring and winter paths of OpenStreetMap, which the Places host serves as one graph per ski area: for a `discipline` (`alpine`, `snowboard`, `cross_country` with a `technique`, `touring`, `snowshoe`, `winter_hike`) and its `options` (difficulty limit, freeride, lift classes to avoid, riding down, priority, pace, groomed or lit trails only, ascent along pistes, lifts at all). Answers per leg its length, climb and descent, its time split into skiing, lifts, waiting and walking, how far its ends were moved onto the network, its stretches with where each starts and ends in metres along the leg, and notes on what they rest on; a leg that cannot be planned says why (`not_on_piste`, `no_area`, `cross_area`, `disconnected`, `blocked_by_difficulty`, `blocked_by_lift_avoid`, `blocked_by_technique`, `needs_ride_down`, `uphill_only`, `loading`, `places_unavailable`, `disabled`, `busy`, `timeout`) and, where one helps, the one change that would plan it. No clicked point leaves the server. Times are estimates, nothing says what is open, groomed or safe today; nothing is saved. With `include_geometry` each planned leg adds its line for drawing (`line_geometry`, with its heights), all legs together up to 300 points shared by length | `trips:read` |
### Road trip _(Road trip addon required)_