feat(tours): plan a multi-day tour as one route split into stages

A hut-to-hut hike or a paddle trip over several days is now planned in the
Tours planner as one tour: the whole route is planned as before, and stops
along it are marked as stage ends (MDT-01). The stages are read off that one
route in its order, never kept as copies, so a change inside a stage changes
the whole tour and every figure on it.

- A Day tour / Multi-day tour switch beside the tour type; a stop's menu
  ends a stage there or lets it go on. Back to a day tour removes every stage
  end after a confirmation, as one step undo brings back.
- The list of points is headed per stage with its distance and moving time;
  a stop that ends a stage carries a moon at its number, on the map too.
- The overview lists the stages from stop to stop with distance, climb,
  descent and time (hiking rule per stage, paddle estimate per stage, the
  router's time shared by distance otherwise), marks where each ends across
  the elevation profile, and a stage of more than a day on the move warns.
- A stage end a change cannot keep (its stop removed, moved to an end or
  made a shaping point) is dropped and said so, with undo, never moved
  to another point.
- A multi-day tour may take a day a stage, so its time limit grows with its
  stages; the tour keeps its one type, portages included.
- The list and the MCP tools show the stage count and where stages end.

Stage ends are a flag on the waypoint (migration 046000); a tour without one
is a day tour, as every tour saved before.
This commit is contained in:
Maurice
2026-10-20 12:39:08 +02:00
parent acc294dd8e
commit 2449616ee9
76 changed files with 2669 additions and 55 deletions
@@ -24,6 +24,7 @@ export class TourWaypoints {
protected: number & Opt = 0;
opening_hours?: string | null;
poi_category?: string | null;
stage_end: number & Opt = 0;
}
export const TourWaypointsImportance = {
@@ -51,6 +52,7 @@ export const TourWaypointsSchema = defineEntity({
expression: 'stay_minutes IS NULL OR (stay_minutes >= 0 AND stay_minutes <= 1440)',
},
{ name: 'tour_waypoints_shaping_check', expression: 'shaping IN (0, 1)' },
{ name: 'tour_waypoints_stage_end_check', expression: 'stage_end IN (0, 1)' },
],
properties: {
id: p.integer().primary(),
@@ -72,5 +74,6 @@ export const TourWaypointsSchema = defineEntity({
protected: p.integer().default(0),
opening_hours: p.text().nullable(),
poi_category: p.text().nullable(),
stage_end: p.integer().default(0),
},
});
@@ -20,4 +20,5 @@ export interface TourWaypointsTable {
protected: InsertOptional<number>;
opening_hours: InsertOptional<string | null>;
poi_category: InsertOptional<string | null>;
stage_end: InsertOptional<number>;
}
@@ -0,0 +1,17 @@
import { Migration } from '@mikro-orm/migrations';
/**
* MDT-01: a stop of a tour can end a stage, so one route planned whole splits
* into the days of a multi-day tour, each stage running from one such stop to
* the next. 0 or 1; every waypoint saved before ends none, and a tour without
* one stays a day tour, as it was.
*/
export class Migration20200101046000_a_tour_waypoint_can_end_a_stage extends Migration {
override name = 'Migration20200101046000_a_tour_waypoint_can_end_a_stage';
override up(): void {
this.addSql(
`ALTER TABLE tour_waypoints ADD COLUMN stage_end INTEGER NOT NULL DEFAULT 0 CHECK (stage_end IN (0, 1))`,
);
}
}
@@ -33,6 +33,8 @@ export interface TourWaypointRow {
opening_hours?: string | null;
/** PLN-03: the kind of place the point was taken in from along the tour; absent on a point set by hand. */
poi_category?: TourPoiCategory | null;
/** MDT-01: a stop where a stage of a multi-day tour ends; absent where the route goes on. */
stage_end?: boolean;
}
/** The row as stored, the fixed leg still JSON text. */
@@ -52,6 +54,7 @@ interface StoredWaypoint {
protected: number;
opening_hours: string | null;
poi_category: string | null;
stage_end: number;
}
/** The columns a waypoint is read with, as the stored row names them. */
@@ -71,6 +74,7 @@ const WAYPOINT_COLUMNS = [
'protected',
'opening_hours',
'poi_category',
'stage_end',
] as const;
/** A stored kind the contract knows; one it does not (dropped since) reads as none. */
@@ -100,6 +104,7 @@ function readWaypoint(row: StoredWaypoint): TourWaypointRow {
...(row.protected ? { protected: true } : {}),
...(opening_hours ? { opening_hours } : {}),
...(poi_category ? { poi_category } : {}),
...(row.stage_end ? { stage_end: true } : {}),
};
}
@@ -196,6 +201,7 @@ export class TourWaypointsRepository extends TrekRepository<TourWaypoints> {
protected: w.protected ? 1 : 0,
opening_hours: w.opening_hours ?? null,
poi_category: w.poi_category ?? null,
stage_end: w.stage_end ? 1 : 0,
})),
);
}
+12 -1
View File
@@ -66,6 +66,8 @@ export interface TourListRow {
/** ACT-01: the first and last time on an imported track's points; NULL without times. */
track_started_at: string | null;
track_ended_at: string | null;
/** MDT-01: how many of its stops end a stage; 0 for a day tour. */
stage_ends: number;
}
/** LIB-02: a tour of another trip as the take-over list reads it, with the trip it is in. */
@@ -314,6 +316,12 @@ export class ToursRepository extends TrekRepository<Tours> {
eb
.exists(eb.selectFrom('tour_waypoints as tw').select('tw.id').whereRef('tw.place_id', '=', 'p.id'))
.as('has_waypoints'),
eb
.selectFrom('tour_waypoints as ts')
.select((count) => count.fn.countAll<number>().as('n'))
.whereRef('ts.place_id', '=', 'p.id')
.where('ts.stage_end', '=', 1)
.as('stage_ends'),
]);
}
@@ -879,11 +887,12 @@ function toLibraryRow<R extends Omit<TourLibraryRow, 'has_waypoints'> & { has_wa
}
function toListRow<
R extends Omit<TourListRow, 'planned' | 'has_waypoints' | 'has_recorded_track' | 'route_missing'> & {
R extends Omit<TourListRow, 'planned' | 'has_waypoints' | 'has_recorded_track' | 'route_missing' | 'stage_ends'> & {
planned: unknown;
has_waypoints: unknown;
has_recorded_track: unknown;
route_missing: unknown;
stage_ends: unknown;
},
>(r: R): TourListRow {
return {
@@ -892,5 +901,7 @@ function toListRow<
has_waypoints: Number(r.has_waypoints),
has_recorded_track: Number(r.has_recorded_track),
route_missing: Number(r.route_missing),
// Postgres counts in a bigint, which its driver hands back as text.
stage_ends: Number(r.stage_ends),
};
}
+2 -2
View File
@@ -101,7 +101,7 @@ export class ToursMcp {
@Tool({
name: 'list_tours',
description:
'List the saved tours of a trip (hikes, city walks, runs, bike tours, paddle tours, rides on horseback and ski tracks planned or imported in the Tours addon): name, type (hike, walk for a city walk, run, bike, kayak for a paddle tour, horse for a ride, or ski for a ski track; a ride has no riding time worked out, so its duration is null), where it came from (origin: trek, gpx or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_climbs for gentler ways, avoid_steps, avoid_ferries; weightings, never a grade of the path), the profile a bike tour is routed for (bike_profile: touring for paved cycle paths and quiet roads, road for asphalt, gravel for gravel and farm tracks, mtb for rough tracks; a routing preference, never a statement that a way is rideable or safe), the winter label of a hike (winter_activity: winter_hike or snowshoe, null for none; a label the travellers chose, while the route and its time are planned as for any hike, never a statement that a way can be walked in winter or anything about snow or avalanches), the profile and pace of a run (run_profile: running for runnable, mostly paved ways in towns and parks, trail for forest tracks and natural paths up to mountain paths (T2); a routing preference, never a statement that a way can be run; run_pace_seconds_per_km, the pace the runners planned with, null while they entered none; its duration is the distance at that pace and nothing else, no climb, descent or difficulty is in it, and it is a plan, never a recorded time), the classification of a ski track (ski_discipline: alpine, snowboard, cross_country or touring for ski touring; ski_technique for cross-country only: classic, skating or unknown when the travellers did not know; a ski track is imported from a GPX file and kept as recorded, never routed, its distance and heights come from the file, it has no ski time worked out, and nothing in it says anything about snow, avalanches or whether a run or trail is open), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), for a city walk the number of its stations (station_count: its points without the shaping points and the way back of a loop), their stays as far as entered (station_stay_minutes) and how many have none (station_stays_unknown), whether a tour still waits for its way to be planned (route_pending: saved with its points only, so its distance and walking time stay null until someone opens it in the planner and saves it), the revision (raised by every saved change of the tour), the recorded tracks assigned to a tour (activity_tracks) and, for a recorded track, the tour it documents (activity_of), when an imported track was recorded (track_started_at, track_ended_at; null when the file carried no times), and the days of the trip each tour is on.',
'List the saved tours of a trip (hikes, city walks, runs, bike tours, paddle tours, rides on horseback and ski tracks planned or imported in the Tours addon): name, type (hike, walk for a city walk, run, bike, kayak for a paddle tour, horse for a ride, or ski for a ski track; a ride has no riding time worked out, so its duration is null), where it came from (origin: trek, gpx or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_climbs for gentler ways, avoid_steps, avoid_ferries; weightings, never a grade of the path), the profile a bike tour is routed for (bike_profile: touring for paved cycle paths and quiet roads, road for asphalt, gravel for gravel and farm tracks, mtb for rough tracks; a routing preference, never a statement that a way is rideable or safe), the winter label of a hike (winter_activity: winter_hike or snowshoe, null for none; a label the travellers chose, while the route and its time are planned as for any hike, never a statement that a way can be walked in winter or anything about snow or avalanches), the profile and pace of a run (run_profile: running for runnable, mostly paved ways in towns and parks, trail for forest tracks and natural paths up to mountain paths (T2); a routing preference, never a statement that a way can be run; run_pace_seconds_per_km, the pace the runners planned with, null while they entered none; its duration is the distance at that pace and nothing else, no climb, descent or difficulty is in it, and it is a plan, never a recorded time), the classification of a ski track (ski_discipline: alpine, snowboard, cross_country or touring for ski touring; ski_technique for cross-country only: classic, skating or unknown when the travellers did not know; a ski track is imported from a GPX file and kept as recorded, never routed, its distance and heights come from the file, it has no ski time worked out, and nothing in it says anything about snow, avalanches or whether a run or trail is open), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), for a city walk the number of its stations (station_count: its points without the shaping points and the way back of a loop), their stays as far as entered (station_stay_minutes) and how many have none (station_stays_unknown), whether a tour still waits for its way to be planned (route_pending: saved with its points only, so its distance and walking time stay null until someone opens it in the planner and saves it), for a multi-day tour the number of stages its route is split into (stage_count: one route planned whole, each stage ending at a stop marked as its end; null for a tour of one day), the revision (raised by every saved change of the tour), the recorded tracks assigned to a tour (activity_tracks) and, for a recorded track, the tour it documents (activity_of), when an imported track was recorded (track_started_at, track_ended_at; null when the file carried no times), and the days of the trip each tour is on.',
inputSchema: { tripId: idSchema },
annotations: TOOL_ANNOTATIONS_READONLY,
when: addonGate(ADDON_IDS.TOURS),
@@ -116,7 +116,7 @@ export class ToursMcp {
@Tool({
name: 'get_tour_plan',
description:
'Read one saved tour of a trip as the travellers saved it: its facts (tour: place_id, name, tour_type, distance, heights and walking time as stored, null when not known; the routing options as list_tours describes them; revision, raised by every saved change; the days it is on), its ordered control points (start, via and end, each with its sequence and its key, the same across saves; an import without its own shows the two ends of its track), where it came from (provenance) and its legs. Drafts in a browser are never visible: only saved tours exist here, and reading changes nothing, not the tour, its revision nor any visit. A control point with linked_place_id stands for that place of the trip (linked_place_name names it); it keeps its own coordinates. legs lists every step from one control point to the next (from_sequence, to_sequence, from_key, to_key) with its kind: routed (planned by the router), track (part of a recorded line, an import without control points of its own, which no router planned), ferrata (follows a via ferrata, name gives it), free (drawn by hand and checked by nothing), portage (a paddle tour carried over land) or horse_route (a ride only: a section of a signposted horse route from OpenStreetMap in the direction the rider chose; the fixed_leg of its control point says signposted, with or against the direction the route is signposted in, null where it is not signposted one way, and access, the metres of each horse=* value its ways carried when it was taken, unknown where untagged; being on a horse route is no permission to ride it); length_km is the length of the line a fixed leg keeps and null for a routed or track leg, whose length is not stored apart. line gives the saved line in brief: point_count, has_heights and a preview thinned to 200 points; null while the tour waits for its way to be planned. A paddle tour also carries its obstacle checklist (obstacle_check): the obstacles of the OpenStreetMap data along its line, the decisions the travellers made about them and their own portages and locks; a planning aid, never a safety or legal clearance. A city walk (tour_type walk) has stations for control points, shaping points and the way back of a loop aside, each with every field spelled out: key (the visits of a day are kept by it), name, stay_minutes (the stay in minutes; 0 for a point only passed through, null when not known), importance (optional or must; null for a normal station), protected (true for a station any change you propose has to keep where it is: neither removed, replaced nor moved; the travellers can still edit it themselves in the app), opening_hours (as OpenStreetMap writes them; a hint to check, never a promise; null when none is known) and visit_status. Visits belong to one day of the walk: name that day with dayId (one of its days) to read the status of each station that day (planned, visited or skipped) and run (the day, its assignment and what is still open); without dayId visit_status and run are null. A station visited or skipped that day is done: do not ask about it again or move it to another day. dayId is for a city walk only. The recorded tracks assigned to the tour (activities, ACT-01) each name their execution (assignment_id and day_id, null for none), the recording (track: its tour in the trip, origin, file name, when it came in) and what the recording measures (metrics: distance_km, elevation_gain_m and elevation_loss_m, null without heights; started_at, ended_at and elapsed_minutes from the first to the last time on its points, pauses included, null when the file carried no times, never estimated); these are the values of the recording, the planned values of the tour stay apart and unchanged. derived names the fields worked out on reading rather than stored with the tour: never present them as saved facts.',
'Read one saved tour of a trip as the travellers saved it: its facts (tour: place_id, name, tour_type, distance, heights and walking time as stored, null when not known; the routing options as list_tours describes them; revision, raised by every saved change; the days it is on), its ordered control points (start, via and end, each with its sequence and its key, the same across saves; an import without its own shows the two ends of its track), where it came from (provenance) and its legs. Drafts in a browser are never visible: only saved tours exist here, and reading changes nothing, not the tour, its revision nor any visit. A control point with linked_place_id stands for that place of the trip (linked_place_name names it); it keeps its own coordinates. A control point with stage_end true ends a stage of a multi-day tour, and the next stage begins there; the stages run in the order of the route, the first from the start, the last to the end, and a tour without such a point is a tour of one day. legs lists every step from one control point to the next (from_sequence, to_sequence, from_key, to_key) with its kind: routed (planned by the router), track (part of a recorded line, an import without control points of its own, which no router planned), ferrata (follows a via ferrata, name gives it), free (drawn by hand and checked by nothing), portage (a paddle tour carried over land) or horse_route (a ride only: a section of a signposted horse route from OpenStreetMap in the direction the rider chose; the fixed_leg of its control point says signposted, with or against the direction the route is signposted in, null where it is not signposted one way, and access, the metres of each horse=* value its ways carried when it was taken, unknown where untagged; being on a horse route is no permission to ride it); length_km is the length of the line a fixed leg keeps and null for a routed or track leg, whose length is not stored apart. line gives the saved line in brief: point_count, has_heights and a preview thinned to 200 points; null while the tour waits for its way to be planned. A paddle tour also carries its obstacle checklist (obstacle_check): the obstacles of the OpenStreetMap data along its line, the decisions the travellers made about them and their own portages and locks; a planning aid, never a safety or legal clearance. A city walk (tour_type walk) has stations for control points, shaping points and the way back of a loop aside, each with every field spelled out: key (the visits of a day are kept by it), name, stay_minutes (the stay in minutes; 0 for a point only passed through, null when not known), importance (optional or must; null for a normal station), protected (true for a station any change you propose has to keep where it is: neither removed, replaced nor moved; the travellers can still edit it themselves in the app), opening_hours (as OpenStreetMap writes them; a hint to check, never a promise; null when none is known) and visit_status. Visits belong to one day of the walk: name that day with dayId (one of its days) to read the status of each station that day (planned, visited or skipped) and run (the day, its assignment and what is still open); without dayId visit_status and run are null. A station visited or skipped that day is done: do not ask about it again or move it to another day. dayId is for a city walk only. The recorded tracks assigned to the tour (activities, ACT-01) each name their execution (assignment_id and day_id, null for none), the recording (track: its tour in the trip, origin, file name, when it came in) and what the recording measures (metrics: distance_km, elevation_gain_m and elevation_loss_m, null without heights; started_at, ended_at and elapsed_minutes from the first to the last time on its points, pauses included, null when the file carried no times, never estimated); these are the values of the recording, the planned values of the tour stay apart and unchanged. derived names the fields worked out on reading rather than stored with the tour: never present them as saved facts.',
inputSchema: {
tripId: idSchema,
placeId: idSchema.describe('The place id of the tour, as list_tours gives it'),
+22 -4
View File
@@ -43,7 +43,8 @@ import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import {
hikingPreferencesOf,
MAX_PLANNED_TOUR_DURATION_MINUTES,
maxTourMinutesFor,
plannedTimeLimitMessage,
plannedTourDurationMinutesSchema,
tourWebsiteSchema,
type BikeProfile,
@@ -64,6 +65,8 @@ import {
type TourProvenance,
type TourProvenanceSource,
type TourWaypoint,
tourStageCountOf,
tourStageIssues,
type WandererDifficulty,
type WinterActivity,
walkStationFacts,
@@ -219,6 +222,8 @@ export class ToursService {
track_started_at: utcSuffix(r.track_started_at),
track_ended_at: utcSuffix(r.track_ended_at),
...activityFacts(r.place_id, activities),
// MDT-01: a multi-day tour's stages, one more than the stops that end one.
stage_count: r.stage_ends > 0 ? r.stage_ends + 1 : null,
};
}
@@ -286,16 +291,27 @@ export class ToursService {
throw badRequest('planned Tour times must be whole minutes from 0 to 1440');
}
}
// MDT-01: a multi-day tour is held to a day a stage, a day tour to one day.
const stages = tourStageCountOf(input.waypoints);
if (
input.planned_duration_minutes == null &&
input.duration_seconds != null &&
Math.round(input.duration_seconds / 60) + (input.break_additional_minutes ?? 0) >
MAX_PLANNED_TOUR_DURATION_MINUTES
Math.round(input.duration_seconds / 60) + (input.break_additional_minutes ?? 0) > maxTourMinutesFor(stages)
) {
throw badRequest('walking time plus breaks must be 1440 minutes or less unless a total is overridden');
throw badRequest(plannedTimeLimitMessage(stages));
}
}
/**
* MDT-01: the stage ends sit at stops between start and end, as the contract
* holds a request to; checked here too for a write that did not come through
* it (an assistant's city walk), so no surface can store one out of place.
*/
private validateStageEnds(input: TourCreateCommand): void {
const [issue] = tourStageIssues(input.waypoints);
if (issue) throw badRequest(issue.message);
}
/** All tours (the facet + owning place) for a trip, newest first; a paddle tour with its check bench (PAD-09). */
async listTours(tripId: string): Promise<TourListItem[]> {
const tid = toRowId(tripId);
@@ -425,6 +441,7 @@ export class ToursService {
const tid = toRowId(tripId)!;
await this.assertTourTypeEnabled(input.tour_type);
this.validateInformationalMetadata(input);
this.validateStageEnds(input);
this.validatePlannedTimes(input);
const start = input.route_geometry ? input.route_geometry[0] : firstPointOf(input.waypoints);
if (!start || (input.route_geometry === null && input.waypoints.length < 2)) {
@@ -755,6 +772,7 @@ export class ToursService {
async routeWrite(input: TourCreateCommand): Promise<TourRouteWrite> {
await this.assertTourTypeEnabled(input.tour_type);
this.validateInformationalMetadata(input);
this.validateStageEnds(input);
this.validatePlannedTimes(input);
const start = input.route_geometry ? input.route_geometry[0] : firstPointOf(input.waypoints);
if (!start) throw badRequest('A tour needs at least two points');