feat(tours): show a multi-day tour's stages in the list and put each on a day

A multi-day tour in the planner's list of the trip's tours now unfolds into
its stages (MDT-02), so the stages can be seen together and fitted into the
trip without leaving the one route they are cut from.

- The fold beside a multi-day tour lists its stages in route order, each
  from stop to stop with its distance, climb, descent and moving time, read
  off the saved tour the way the planner reads them, and below them what the
  whole tour adds up to. A value no stage tells is left out, a total some
  stages do not tell is marked as at least that much and says for how many,
  and a stage whose route is not planned yet says so.
- Each stage has its own day: a pill opens the trip's days, moves the stage
  to another one or takes it off again. A stage without a day shows a dashed
  "No day" pill. A day holds a tour, or one of its stages, once, and a stage
  is on one day at most; the plan's own offline path writes it.
- A stage opens in the planner: the tour opens as any tour does and the
  stage is shown once its points are in.
- The day plan names the stage a day holds with its own figures, and the
  day's route runs to that stage's start and on from its end; the trip map
  draws that stretch only. The tour list still counts a tour once, however
  many days hold it or its stages.
- A saved change that takes a stage away (its stage end gone, the tour a day
  tour again, back to the recorded track) takes it off its day in the same
  write and tells every client; the planner says so before the save.
- REST and the assign_place_to_day tool take the stage by the key of the
  stop it starts at, with the same checks; list_tours names the stops
  between the stages and the stage each day holds.

A day assignment remembers its stage by that key (migration 046010); every
assignment made before holds the whole tour, as it did.
This commit is contained in:
Maurice
2026-10-20 13:21:10 +02:00
parent 6ec06c90f4
commit 8fa75b3c4e
100 changed files with 2787 additions and 127 deletions
+56 -1
View File
@@ -1,4 +1,12 @@
import { MAX_TOUR_STAGES, maxTourMinutesFor, TOUR_DAY_MINUTES, tourStageCountOf, tourStageIssues } from './tour-stages';
import {
MAX_TOUR_STAGES,
maxTourMinutesFor,
TOUR_DAY_MINUTES,
tourStageCountOf,
tourStageIssues,
tourStageKeysOf,
tourStageStopsOf,
} from './tour-stages';
import {
MAX_PLANNED_TOUR_DURATION_MINUTES,
plannedTimeLimitMessage,
@@ -132,4 +140,51 @@ describe('MDT-01 tour stages', () => {
expect(tourListItemSchema.parse(item).stage_count).toBeUndefined();
expect(tourListItemSchema.safeParse({ ...item, stage_count: 1 }).success).toBe(false);
});
it('MDT-SH-009: reads the stops between the stages, keyed, the start through the end', () => {
const keyed = points(5, (i) => ({ key: `stop-${i}xx`, ...(i === 2 ? { stage_end: true } : {}) }));
expect(tourStageStopsOf(keyed)).toEqual([
{ key: 'stop-0xx', lat: 47, lng: 11 },
{ key: 'stop-2xx', lat: 47.02, lng: 11 },
{ key: 'stop-4xx', lat: 47.04, lng: 11 },
]);
// A stage is known by the stop it starts at: the end starts none.
expect(tourStageKeysOf(keyed)).toEqual(['stop-0xx', 'stop-2xx']);
});
it('MDT-SH-010: has no stage stops for a day tour, a lone point or a stop no save has keyed', () => {
expect(tourStageStopsOf(points(3, (i) => ({ key: `stop-${i}xx` })))).toEqual([]);
expect(tourStageStopsOf([{ lat: 47, lng: 11, role: 'start', key: 'stop-0xx' }])).toEqual([]);
const unkeyed = points(3, (i) => (i === 1 ? { stage_end: true, key: 'stop-1xx' } : {}));
expect(tourStageStopsOf(unkeyed)).toEqual([]);
expect(tourStageKeysOf(unkeyed)).toEqual([]);
});
it('MDT-SH-011: lists where the stages of a multi-day tour start and end, and reads a list cached before it', () => {
const item = {
place_id: 1,
tour_type: 'hike',
distance: 12,
elevation_gain: null,
elevation_loss: null,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: 2,
name: 'Hut to hut',
planned: false,
caution: false,
};
const stops = [
{ key: 'stop-0xx', lat: 47, lng: 11 },
{ key: 'stop-1xx', lat: 47.1, lng: 11 },
{ key: 'stop-2xx', lat: 47.2, lng: 11 },
];
expect(tourListItemSchema.parse({ ...item, stage_stops: stops }).stage_stops).toEqual(stops);
expect(tourListItemSchema.parse({ ...item, stage_stops: null }).stage_stops).toBeNull();
expect(tourListItemSchema.parse(item).stage_stops).toBeUndefined();
expect(tourListItemSchema.safeParse({ ...item, stage_stops: [stops[0]] }).success).toBe(false);
expect(tourListItemSchema.safeParse({ ...item, stage_stops: [{ ...stops[0], key: '' }, stops[1]] }).success).toBe(false);
});
});
+39
View File
@@ -55,3 +55,42 @@ export function tourStageIssues(points: readonly StagePoint[]): StageIssue[] {
issues.push({ path: ['waypoints'], message: `a tour has at most ${MAX_TOUR_STAGES} stages` });
return issues;
}
/** MDT-02: a stop a stage starts or ends at, as a list or a day plan places the stages. */
export interface TourStageStop {
/** The point's own key, kept across saves; a stage is known by the key of the stop it starts at. */
key: string;
lat: number;
lng: number;
}
/** A saved point as the stops between the stages are read from it. */
interface StoredStagePoint extends StagePoint {
key?: string;
lat: number;
lng: number;
}
/**
* MDT-02: the stops the stages of a multi-day tour run between, in route
* order: the start, every stop that ends a stage, and the end, so stage `n`
* runs from stop `n - 1` to stop `n`. A stage is put on a day by the key of
* the stop it starts at, which a save keeps, so a stage end set or taken away
* elsewhere leaves the other stages on their days. None for a day tour, and
* none where a stop has no key yet (a point no save has kept), since such a
* stage could not be found again.
*/
export function tourStageStopsOf(points: readonly StoredStagePoint[]): TourStageStop[] {
if (points.length < 2 || tourStageCountOf(points) < 2) return [];
const last = points.length - 1;
const stops = points.filter((point, index) => index === 0 || index === last || point.stage_end === true);
const keyed = stops.flatMap((point) => (point.key ? [{ key: point.key, lat: point.lat, lng: point.lng }] : []));
return keyed.length === stops.length ? keyed : [];
}
/** The keys a stage can be put on a day by: those of the stops the stages start at, the last stop starting none. */
export function tourStageKeysOf(points: readonly StoredStagePoint[]): string[] {
return tourStageStopsOf(points)
.slice(0, -1)
.map((stop) => stop.key);
}
+16 -2
View File
@@ -182,6 +182,16 @@ export const tourTypeSchema = z.object({
});
export type TourType = z.infer<typeof tourTypeSchema>;
/** CW-03: a station's key, the same across saves. */
export const stationKeySchema = z.string().regex(/^[A-Za-z0-9_-]{6,64}$/);
/** MDT-02: a stop a stage of a multi-day tour starts or ends at, by its key and where it is. */
export const tourStageStopSchema = z.object({
key: stationKeySchema,
lat: z.number(),
lng: z.number(),
});
export const tourSchema = z.object({
place_id: z.number().int().positive(),
tour_type: tourTypeKeySchema,
@@ -312,6 +322,12 @@ export const tourListItemSchema = tourSchema.extend({
* tour. Absent in a list cached before it existed.
*/
stage_count: z.number().int().min(2).nullable().optional(),
/**
* MDT-02: the stops the stages run between, in route order: the start, every
* stop that ends a stage, and the end. A stage is put on a day by the key of
* the stop it starts at. Null for a day tour; absent in a list cached before it existed.
*/
stage_stops: z.array(tourStageStopSchema).min(3).nullable().optional(),
});
export type TourListItem = z.infer<typeof tourListItemSchema>;
@@ -401,8 +417,6 @@ export const TOUR_STATION_HOURS_MAX = 500;
* the server's for a city walk it plans on foot (CW-11).
*/
export const TOUR_MAX_SNAP_M = 300;
/** CW-03: a station's key, the same across saves. */
export const stationKeySchema = z.string().regex(/^[A-Za-z0-9_-]{6,64}$/);
/** CW-03: how much a station matters, besides normal (null). */
export const STATION_IMPORTANCES = ['optional', 'must'] as const;
export const stationImportanceSchema = z.enum(STATION_IMPORTANCES);