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
@@ -30,6 +30,7 @@ export class DayAssignments {
end_day: number & Opt = 0;
accommodation_id?: number | null;
route_excluded: number & Opt = 0;
tour_stage_key?: string | null;
assignment_participants_collection = new Collection<AssignmentParticipants>(this);
file_links_collection = new Collection<FileLinks>(this);
reservations_collection = new Collection<Reservations>(this);
@@ -42,6 +43,13 @@ export class DayAssignments {
export const DayAssignmentsSchema = defineEntity({
class: DayAssignments,
repository: () => DayAssignmentsRepository,
indexes: [
{
name: 'idx_day_assignments_tour_stage',
where: 'tour_stage_key IS NOT NULL',
properties: ['place_id', 'tour_stage_key'],
},
],
properties: {
id: p.integer().primary(),
day: () => p.manyToOne(Days).ref().deleteRule('cascade').hidden().index('idx_day_assignments_day_id'),
@@ -61,6 +69,7 @@ export const DayAssignmentsSchema = defineEntity({
end_day: p.integer().default(0),
accommodation_id: p.integer().nullable().index('idx_day_assignments_accommodation_id'),
route_excluded: p.integer().default(0),
tour_stage_key: p.text().nullable(),
assignment_participants_collection: () => p.oneToMany(AssignmentParticipants).mappedBy('assignment').hidden(),
file_links_collection: () => p.oneToMany(FileLinks).mappedBy('assignment').hidden(),
reservations_collection: () => p.oneToMany(Reservations).mappedBy('assignment').hidden(),
@@ -19,4 +19,5 @@ export interface DayAssignmentsTable {
end_day: InsertOptional<number>;
accommodation_id: InsertOptional<number | null>;
route_excluded: InsertOptional<number>;
tour_stage_key: InsertOptional<string | null>;
}
@@ -0,0 +1,19 @@
import { Migration } from '@mikro-orm/migrations';
/**
* MDT-02: a day of the plan can hold one stage of a multi-day tour instead of
* the whole tour, named by the key of the stop the stage starts at (the key a
* save keeps for every point of a tour). NULL, as on every assignment made
* before, is the whole tour or an ordinary place. The index finds the day a
* stage is on, and the stages a changed tour no longer has.
*/
export class Migration20200101046010_a_day_can_hold_one_stage_of_a_tour extends Migration {
override name = 'Migration20200101046010_a_day_can_hold_one_stage_of_a_tour';
override up(): void {
this.addSql(`ALTER TABLE day_assignments ADD COLUMN tour_stage_key TEXT`);
this.addSql(
`CREATE INDEX idx_day_assignments_tour_stage ON day_assignments (place_id, tour_stage_key) WHERE tour_stage_key IS NOT NULL`,
);
}
}
@@ -105,6 +105,8 @@ export interface DayAssignmentRow {
end_day: number;
accommodation_id: number | null;
route_excluded: number;
/** MDT-02: the stage of a multi-day tour the day holds, by its starting stop's key; null for anything else. */
tour_stage_key: string | null;
}
const _dayAssignmentRowKeys: AssertRowKeys<DayAssignmentRow, DayAssignments> = true;
@@ -433,6 +435,8 @@ export class DayAssignmentsRepository extends TrekRepository<DayAssignments> {
order_index: number;
notes: string | null;
accommodation_id: number | null;
/** MDT-02: one stage of a multi-day tour, by its starting stop's key; absent for the whole tour or a place. */
tour_stage_key?: string | null;
}): Promise<number> {
return await this.insert({
day: input.day_id,
@@ -440,9 +444,43 @@ export class DayAssignmentsRepository extends TrekRepository<DayAssignments> {
order_index: input.order_index,
notes: input.notes,
accommodation_id: input.accommodation_id,
tour_stage_key: input.tour_stage_key ?? null,
});
}
/** MDT-02: the assignment holding this stage of a tour, with its day; `undefined` while the stage is on no day. */
async findTourStage(place_id: number, tour_stage_key: string): Promise<{ id: number; day_id: number } | undefined> {
return await this.kysely<Pick<DB, 'day_assignments'>>()
.selectFrom('day_assignments')
.select(['id', 'day_id'])
.where('place_id', '=', place_id)
.where('tour_stage_key', '=', tour_stage_key)
.executeTakeFirst();
}
/**
* MDT-02: takes off their days the stages of a tour whose starting stop is
* not among `keys` (every stage, for none), and answers what it took, so a
* stage a changed tour no longer has leaves no day holding it. The caller
* owns the transaction: the tour's points are written in the same one.
*/
async deleteTourStagesNotIn(place_id: number, keys: readonly string[]): Promise<Array<{ id: number; day_id: number }>> {
let query = this.kysely<Pick<DB, 'day_assignments'>>()
.selectFrom('day_assignments')
.select(['id', 'day_id'])
.where('place_id', '=', place_id)
.where('tour_stage_key', 'is not', null);
if (keys.length > 0) query = query.where('tour_stage_key', 'not in', [...keys]);
const gone = await query.orderBy('id', 'asc').execute();
if (gone.length > 0) {
await this.kysely<Pick<DB, 'day_assignments'>>()
.deleteFrom('day_assignments')
.where('id', 'in', gone.map((row) => row.id))
.execute();
}
return gone;
}
/** AS10 — `DELETE FROM day_assignments WHERE id = ?`. */
async deleteById(id: number): Promise<void> {
await this.nativeDelete({ id });
@@ -706,6 +744,8 @@ export class DayAssignmentsRepository extends TrekRepository<DayAssignments> {
assignment_time: string | null;
assignment_end_time: string | null;
end_day: number;
/** MDT-02: the copied tour keeps its stops' keys, so a day holding one of its stages keeps holding it. */
tour_stage_key?: string | null;
}): Promise<number> {
return await this.insert({
day: input.day_id,
@@ -718,6 +758,7 @@ export class DayAssignmentsRepository extends TrekRepository<DayAssignments> {
assignment_time: input.assignment_time,
assignment_end_time: input.assignment_end_time,
end_day: input.end_day,
tour_stage_key: input.tour_stage_key ?? null,
});
}
+38 -1
View File
@@ -219,12 +219,24 @@ type ToursKyselyDB = Pick<
>;
/** One day a tour of the trip sits on, in the order of the trip. */
/** MDT-02: a point of a tour as the stages a day can hold are read from it. */
export interface TourStagePointRow {
role: 'start' | 'via' | 'end';
lat: number;
lng: number;
key?: string;
stage_end?: boolean;
shaping?: boolean;
}
export interface TourDayRow {
place_id: number;
day_id: number;
day_number: number;
date: string | null;
title: string | null;
/** MDT-02: the stage of a multi-day tour the day holds, by its starting stop's key; null for the whole tour. */
tour_stage_key: string | null;
}
/**
@@ -557,7 +569,7 @@ export class ToursRepository extends TrekRepository<Tours> {
.execute();
}
/** The days every tour of a trip sits on, each tour and day once, in day order. */
/** The days every tour of a trip sits on, each tour and day once, in day order, with the stage a day holds (MDT-02). */
async listTourDays(trip_id: number): Promise<TourDayRow[]> {
return await this.db()
.selectFrom('day_assignments as da')
@@ -569,6 +581,7 @@ export class ToursRepository extends TrekRepository<Tours> {
'd.day_number as day_number',
'd.date as date',
'd.title as title',
'da.tour_stage_key as tour_stage_key',
])
.where('d.trip_id', '=', trip_id)
.distinct()
@@ -624,6 +637,30 @@ export class ToursRepository extends TrekRepository<Tours> {
return !!(await query.executeTakeFirst());
}
/**
* MDT-02: the points of a tour as its stages are read from them, in route
* order: role, key, stage end and where each sits. None for a place that is
* no tour, or a tour without points of its own.
*/
async listStagePoints(place_id: number): Promise<TourStagePointRow[]> {
const rows = await this.db()
.selectFrom('tour_waypoints')
.select(['role', 'station_key', 'stage_end', 'shaping', 'lat', 'lng'])
.where('place_id', '=', place_id)
.orderBy('sequence', 'asc')
// The column's CHECK holds it to the three roles.
.$narrowType<{ role: TourStagePointRow['role'] }>()
.execute();
return rows.map((row) => ({
role: row.role,
lat: row.lat,
lng: row.lng,
...(row.station_key ? { key: row.station_key } : {}),
...(row.stage_end ? { stage_end: true } : {}),
...(row.shaping ? { shaping: true } : {}),
}));
}
/** TO6 — every `tours` row of a trip's places, for the trip copy to remap. */
async listRowsForTrip(trip_id: number): Promise<TourRow[]> {
return await this.db()