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),
};
}