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()
@@ -33,7 +33,8 @@ import { Module } from '@nestjs/common';
* `DaysModule`/`PlacesModule`/`RoadtripModule` modules themselves, so the
* loop this module's docstring already avoids stays avoided (`DaysModule`'s
* own precedent for pulling in `Trips` the same way). `Tours` backs the
* one-Tour-per-day check create and move share.
* one-Tour-per-day check create and move share, and the stages of a
* multi-day tour a day can hold (MDT-02).
*/
@Module({
imports: [
@@ -56,7 +56,9 @@ export class DayAssignmentsController {
if (!(await this.assignments.placeExists(body.place_id, tripId))) {
throw new HttpException({ error: 'Place not found' }, 404);
}
const assignment = await this.assignments.createAssignment(dayId, body.place_id, body.notes);
const assignment = await this.assignments.createAssignment(dayId, body.place_id, body.notes, {
...(body.tour_stage_key !== undefined ? { tourStageKey: body.tour_stage_key } : {}),
});
this.assignments.broadcast(tripId, 'assignment:created', { assignment }, socketId);
await this.assignments.reconcile(tripId, socketId);
return { assignment };
+21 -4
View File
@@ -13,7 +13,12 @@ import {
import { DaysService } from '../days/days.service';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { AssignmentsService } from './assignments.service';
import { idSchema, assignmentEndDayRequestSchema, type AssignmentEndDayRequest } from '@trek/shared';
import {
idSchema,
assignmentCreateRequestSchema,
assignmentEndDayRequestSchema,
type AssignmentEndDayRequest,
} from '@trek/shared';
import { z } from 'zod';
@@ -36,25 +41,37 @@ export class AssignmentsMcp {
@Tool({
name: 'assign_place_to_day',
description: 'Assign a place to a specific day in a trip.',
description:
'Assign a place to a specific day in a trip. For a multi-day tour, pass tourStageKey to put one of its stages on the day instead of the whole tour: the key of the stop the stage starts at, from the stage_stops list_tours gives (every stop but the last starts a stage). A stage is on one day at most, and a day holds a tour, or one of its stages, once.',
inputSchema: {
tripId: idSchema,
dayId: idSchema,
placeId: idSchema,
notes: z.string().max(500).optional(),
tourStageKey: assignmentCreateRequestSchema.shape.tour_stage_key.describe(
'The key of the stop a stage of a multi-day tour starts at, to put that stage on the day instead of the whole tour',
),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
access: { group: 'places', mode: 'write' },
})
async assignPlaceToDay(
{ tripId, dayId, placeId, notes }: { tripId: number; dayId: number; placeId: number; notes?: string },
{
tripId,
dayId,
placeId,
notes,
tourStageKey,
}: { tripId: number; dayId: number; placeId: number; notes?: string; tourStageKey?: string },
ctx: McpContext,
) {
if (!(await this.assignments.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!(await this.assignments.dayExists(dayId, tripId))) return errorResult('Day not found.');
if (!(await this.assignments.placeExists(placeId, tripId))) return errorResult('Place not found.');
const assignment = await this.assignments.createAssignment(dayId, placeId, notes || null);
const assignment = await this.assignments.createAssignment(dayId, placeId, notes || null, {
...(tourStageKey !== undefined ? { tourStageKey } : {}),
});
this.guards.safeBroadcast(tripId, 'assignment:created', { assignment });
await this.assignments.reconcile(tripId);
return ok({ assignment });
@@ -14,6 +14,7 @@ import type { ToursRepository } from '../../db/repositories/Tours.repository';
import type { TripMembersRepository } from '../../db/repositories/TripMembers.repository';
import type { TripAccess } from '../../db/repositories/Trips.repository';
import type { User } from '../../types';
import { DomainError } from '../common/domain-error';
import { toRowId } from '../common/row-id';
import { formatAssignmentWithPlace } from '../common/rowShape';
import { UnitOfWork } from '../database/unit-of-work';
@@ -24,7 +25,13 @@ import { RealtimeService } from '../realtime/realtime.service';
import { TripAccessService } from '../trip-membership/trip-access.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { ConflictException, Injectable } from '@nestjs/common';
import { chronoOrder, type RoadtripVia, type TrekWsPayload, type TrekWsTripEventName } from '@trek/shared';
import {
chronoOrder,
tourStageKeysOf,
type RoadtripVia,
type TrekWsPayload,
type TrekWsTripEventName,
} from '@trek/shared';
import { isEmptyReanchoring, reanchorByStopOrder } from '@trek/shared/roadtrip';
type Trip = TripAccess;
@@ -261,12 +268,14 @@ export class AssignmentsService {
* afterwards: the row this returns is what the answer hands the client, and a
* stop that reaches it without its booking id is one the day list cannot tell
* from a place the traveller added, so it draws the hotel a second time.
* @param opts.tourStageKey MDT-02: one stage of a multi-day tour instead of
* the whole tour, by the key of the stop it starts at.
*/
async createAssignment(
dayId: string | number,
placeId: unknown,
notes?: string | null,
opts: { accommodationId?: number; orderIndex?: number } = {},
opts: { accommodationId?: number; orderIndex?: number; tourStageKey?: string } = {},
) {
// Downstream of the caller's own dayExists/placeExists gate in the same
// request (every real call site), so the non-null assertion is
@@ -276,6 +285,7 @@ export class AssignmentsService {
const insertedId = await this.uow.transactional(async () => {
await this.assertTourFreeOnDay(dayIdNum, placeIdNum);
if (opts.tourStageKey !== undefined) await this.assertTourStageFree(placeIdNum, opts.tourStageKey);
// AS6 — the explicit null check (not `||`) is load-bearing: a stored 0
// order_index must survive, which `maxOrder || -1` would not.
const maxOrder = await this.dayAssignmentsRepo.maxOrderIndex(dayIdNum);
@@ -295,6 +305,7 @@ export class AssignmentsService {
order_index: orderIndex,
notes: notes || null,
accommodation_id: opts.accommodationId ?? null,
tour_stage_key: opts.tourStageKey ?? null,
});
});
@@ -395,6 +406,30 @@ export class AssignmentsService {
}
}
/**
* MDT-02: a stage goes on a day only as a stage the tour has (read off its
* saved points, so a place that is no tour has none) and on one day at most;
* moving it to another day is a move, not a second stage. Asked inside the
* transaction that writes it, as the one-tour-a-day check is.
*/
private async assertTourStageFree(placeId: number, key: string): Promise<void> {
if (!tourStageKeysOf(await this.toursRepo.listStagePoints(placeId)).includes(key)) {
throw new DomainError(400, 'Tour has no such stage', { code: 'tour_stage_unknown' });
}
if (await this.dayAssignmentsRepo.findTourStage(placeId, key)) {
throw new DomainError(409, 'Stage is already on a day', { code: 'tour_stage_taken' });
}
}
/**
* MDT-02: takes the stages a saved change of a tour no longer has off their
* days, inside the caller's transaction, and answers which, so the caller
* can tell the trip once the change is committed.
*/
async dropStaleTourStages(placeId: number, keys: readonly string[]): Promise<Array<{ id: number; day_id: number }>> {
return await this.dayAssignmentsRepo.deleteTourStagesNotIn(placeId, keys);
}
/** AS15 — `AssignmentParticipantsRepository.listWithDisplayName`, the same AS2/AS31 statement. */
async getParticipants(assignmentId: string | number) {
return await this.assignmentParticipantsRepo.listWithDisplayName(toRowId(assignmentId)!);
+2
View File
@@ -50,6 +50,8 @@ export function formatAssignmentWithPlace(
created_at: a.created_at,
tour_place_id: a.tour_place_id ?? null,
tour_route_geometry: a.tour_route_geometry ?? null,
// MDT-02: the stage of a multi-day tour the day holds; null for the whole tour or a place.
tour_stage_key: a.tour_stage_key ?? null,
place: {
id: a.place_id,
name: a.place_name,
+1
View File
@@ -206,6 +206,7 @@ export class DaysService {
created_at: a.created_at,
tour_place_id: a.tour_place_id ?? null,
tour_route_geometry: a.tour_route_geometry ?? null,
tour_stage_key: a.tour_stage_key ?? null,
place: {
id: a.place_id,
name: a.place_name,
@@ -269,6 +269,7 @@ export const PLUGIN_ENTITY_CONTRACT = {
'incoming_leg_transport_mode',
'route_excluded',
'accommodation_id',
'tour_stage_key',
'created_at',
],
withheld: [],
@@ -136,7 +136,7 @@ export class TourChangeSaveService {
// Laid once more on the tour as it is now: a tour moved on, a station done or a place gone refuses here.
const now = await this.preview.lay(tripId, placeId, changes, { action: 'saved', preparedOn });
const before = await this.beforeState(tripId, placeId, now);
const { revision, visitsDropped } = await this.tours.replaceRoute(tripId, placeId, write);
const { revision, visitsDropped, stagesDropped } = await this.tours.replaceRoute(tripId, placeId, write);
const id = await this.stepsRepo.record({
place_id: placeId,
proposal_id: proposalId,
@@ -146,10 +146,10 @@ export class TourChangeSaveService {
before_state: before,
saved_by: userId,
});
return { step: { id, revision }, visitsDropped };
return { step: { id, revision }, visitsDropped, stagesDropped };
});
if ('earlier' in saved) return alreadySavedAnswer(saved.earlier);
const detail = await this.tours.announceRouteUpdate(String(tripId), placeId, saved.visitsDropped);
const detail = await this.tours.announceRouteUpdate(String(tripId), placeId, saved);
return savedAnswer(detail, saved.step, changed);
}
@@ -177,7 +177,7 @@ export class TourChangeUndoService {
}
return { step, ...written };
});
const detail = await this.tours.announceRouteUpdate(String(tripId), placeId, restored.visitsDropped);
const detail = await this.tours.announceRouteUpdate(String(tripId), placeId, restored);
return {
undone: true as const,
step: { id: restored.step.id, revision: restored.step.revision, undone_revision: restored.revision },
@@ -78,7 +78,7 @@ export class TourLibraryService {
const needle = query.q?.toLocaleLowerCase();
const matching = needle ? rows.filter((row) => row.name.toLocaleLowerCase().includes(needle)) : rows;
const shown = matching.slice(0, TOUR_LIBRARY_LIMIT);
const walkPoints = await this.tours.walkPointsByPlace(shown);
const walkPoints = await this.tours.listedPointsByPlace(shown);
return {
tours: shown.map((row) => toItem(row, walkPoints.get(row.place_id))),
trips,
@@ -90,7 +90,7 @@ export class TourLibraryService {
const row = await this.toursRepo.findForLibrary(await this.reachableTrips(userId), placeId);
if (!row) throw new NotFoundException('Tour not found');
const line = parseRouteGeometry(row.route_geometry).map((point): [number, number] => [point[0], point[1]]);
const walkPoints = await this.tours.walkPointsByPlace([row]);
const walkPoints = await this.tours.listedPointsByPlace([row]);
return {
tour: toItem(row, walkPoints.get(row.place_id)),
line: thinnedLine(line, TOUR_LIBRARY_PREVIEW_POINTS),
+2 -2
View File
@@ -21,7 +21,7 @@ import {
type TourPoiSearchRequest,
} from '@trek/shared';
type TourDay = Pick<TourDayRow, 'day_id' | 'day_number' | 'date' | 'title'>;
type TourDay = Pick<TourDayRow, 'day_id' | 'day_number' | 'date' | 'title' | 'tour_stage_key'>;
const poiFields = tourPoiSearchRequestSchema.shape;
@@ -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), 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.',
'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) and the stops they run between (stage_stops, in route order with key and coordinates: stage n runs from stop n-1 to stop n, and is known by the key of the stop it starts at), 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 (tour_stage_key names the stage a day holds by its starting stop, null where the day holds the whole tour; a stage may also be on no day yet).',
inputSchema: { tripId: idSchema },
annotations: TOOL_ANNOTATIONS_READONLY,
when: addonGate(ADDON_IDS.TOURS),
+3
View File
@@ -9,6 +9,7 @@ import { TourTypes } from '../../db/entities/TourTypes.entity';
import { TourWaypoints } from '../../db/entities/TourWaypoints.entity';
import { Tours } from '../../db/entities/Tours.entity';
import { AddonsModule } from '../addons/addons.module';
import { AssignmentsDomainModule } from '../assignments/assignments-domain.module';
import { MapsModule } from '../maps/maps.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { OverpassModule } from '../overpass/overpass.module';
@@ -104,6 +105,8 @@ import { Module } from '@nestjs/common';
PaddleObstaclesModule,
// A city walk prepared over MCP is planned on foot by the router the road trip uses (CW-11).
RoadtripModule,
// A saved change takes the stages a tour no longer has off their days (MDT-02).
AssignmentsDomainModule,
MikroOrmModule.forFeature([
Tours,
TourTypes,
+66 -16
View File
@@ -23,6 +23,7 @@ import type {
} from '../../db/repositories/Tours.repository';
import { utcSuffix } from '../../db/types';
import { decodeJson, encodeJson } from '../../utils/json-column';
import { AssignmentsService } from '../assignments/assignments.service';
import { badRequest, DomainError } from '../common/domain-error';
import { toRowId } from '../common/row-id';
import { UnitOfWork } from '../database/unit-of-work';
@@ -67,6 +68,8 @@ import {
type TourWaypoint,
tourStageCountOf,
tourStageIssues,
tourStageKeysOf,
tourStageStopsOf,
type WandererDifficulty,
type WinterActivity,
walkStationFacts,
@@ -109,6 +112,12 @@ export interface TourRouteWrite {
fields: TourUpdate;
}
/** What a route write took along: the visits of stations that are gone, and the days that held a stage that is gone (MDT-02). */
export interface TourRouteDrops {
visitsDropped: readonly number[];
stagesDropped: ReadonlyArray<{ id: number; day_id: number }>;
}
/** The way on foot the server's router planned for a city walk (CW-11): its line, and how long it walks in seconds. */
interface CityWalkFootRoute {
line: Array<[number, number]>;
@@ -155,6 +164,12 @@ function keyedWaypoints(waypoints: TourWaypoint[]): Array<TourWaypoint & { key:
});
}
/** MDT-02: the stops a multi-day tour's stages run between; null where its points do not tell them. */
function stageStopsOrNull(points: Parameters<typeof tourStageStopsOf>[0]): TourListItem['stage_stops'] {
const stops = tourStageStopsOf(points);
return stops.length > 0 ? stops : null;
}
@Injectable()
export class ToursService {
private readonly logger = new Logger(ToursService.name);
@@ -170,6 +185,7 @@ export class ToursService {
@InjectRepository(TourActivities) private readonly activitiesRepo: TourActivitiesRepository,
private readonly placeImport: PlaceImportService,
private readonly paddleChecks: TourPaddleCheckService,
private readonly assignments: AssignmentsService,
) {}
/**
@@ -224,6 +240,8 @@ export class ToursService {
...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,
// MDT-02: the stops they run between, by which a day holds one of them.
stage_stops: r.stage_ends > 0 ? stageStopsOrNull(points) : null,
};
}
@@ -317,25 +335,28 @@ export class ToursService {
const tid = toRowId(tripId);
if (tid === null) return [];
const rows = await this.toursRepo.listForTrip(tid);
const walkPoints = await this.walkPointsByPlace(rows);
const listedPoints = await this.listedPointsByPlace(rows);
const activities = await this.activitiesRepo.listForTrip(tid);
return await this.paddleChecks.forTrip(
tid,
rows.map((r) => this.toItem(r, walkPoints.get(r.place_id) ?? [], activities)),
rows.map((r) => this.toItem(r, listedPoints.get(r.place_id) ?? [], activities)),
);
}
/**
* CW-02, CW-03: the points of the city walks among these tours, by tour, for
* a list that counts their stations and stays; the points of another type
* are not read.
* The points a list reads, by tour: a city walk's, which it counts the
* stations and stays of (CW-02, CW-03), and a multi-day tour's, which it
* reads the stops between the stages off (MDT-02). The points of any other
* tour are not read.
*/
async walkPointsByPlace(
rows: readonly { place_id: number; tour_type: string }[],
async listedPointsByPlace(
rows: readonly { place_id: number; tour_type: string; stage_ends?: number }[],
): Promise<Map<number, TourWaypointWithPlaceRow[]>> {
const byPlace = new Map<number, TourWaypointWithPlaceRow[]>();
const walks = rows.filter((row) => row.tour_type === 'walk').map((row) => row.place_id);
for (const point of await this.waypointsRepo.listForPlaces(walks)) {
const read = rows
.filter((row) => row.tour_type === 'walk' || (row.stage_ends ?? 0) > 0)
.map((row) => row.place_id);
for (const point of await this.waypointsRepo.listForPlaces(read)) {
const points = byPlace.get(point.place_id);
if (points) points.push(point);
else byPlace.set(point.place_id, [point]);
@@ -545,7 +566,7 @@ export class ToursService {
const pid = toRowId(placeId);
if (tid === null || pid === null) throw new NotFoundException('Tour not found');
await this.uow.transactional(async () => {
const stagesDropped = await this.uow.transactional(async () => {
const row = await this.toursRepo.findInTrip(tid, pid);
if (!row) throw new NotFoundException('Tour not found');
const recorded = await this.toursRepo.findRecordedTrack(tid, pid);
@@ -595,6 +616,8 @@ export class ToursService {
});
await this.waypointsRepo.deleteForPlace(pid);
await this.toursRepo.clearRecordedTrack(pid);
// MDT-02: back to the recorded track the tour has no stages, so no day holds one.
return await this.assignments.dropStaleTourStages(pid, []);
});
const result = await this.getTour(tripId, placeId);
@@ -606,6 +629,7 @@ export class ToursService {
} catch {
this.logger.warn(`Committed Tour ${placeId}: place notification failed`);
}
await this.announceStagesDropped(tripId, stagesDropped);
return result;
}
@@ -758,8 +782,8 @@ export class ToursService {
const pid = toRowId(placeId);
if (tid === null || pid === null) throw new NotFoundException('Tour not found');
const write = await this.routeWrite(input);
const { visitsDropped } = await this.uow.transactional(() => this.replaceRoute(tid, pid, write));
return await this.announceRouteUpdate(tripId, pid, visitsDropped, socketId);
const dropped = await this.uow.transactional(() => this.replaceRoute(tid, pid, write));
return await this.announceRouteUpdate(tripId, pid, dropped, socketId);
}
/**
@@ -788,7 +812,7 @@ export class ToursService {
tripId: number,
placeId: number,
{ input, start, fields }: TourRouteWrite,
): Promise<{ revision: number; visitsDropped: number[] }> {
): Promise<{ revision: number } & TourRouteDrops> {
// The trip predicate is the cross-trip boundary. It runs inside the write
// so an id from another accessible trip cannot be moved into the current
// one, and a place deleted meanwhile answers 404 instead of failing a
@@ -813,14 +837,19 @@ export class ToursService {
await this.waypointsRepo.insertForPlace(placeId, waypoints);
// CW-03: a station that is gone takes its visits with it; a tour that is no city walk keeps none.
const stations = input.tour_type === 'walk' ? walkStationsOf(waypoints).map((point) => point.key) : [];
return { revision, visitsDropped: await this.visitsRepo.dropStationsNotIn(placeId, stations) };
return {
revision,
visitsDropped: await this.visitsRepo.dropStationsNotIn(placeId, stations),
// MDT-02: a stage the change took away (its stage end gone, the tour a day tour again) leaves its day.
stagesDropped: await this.assignments.dropStaleTourStages(placeId, tourStageKeysOf(waypoints)),
};
}
/** After a route write committed: the tour read back, and the trip told of it and of the visits it took along. */
/** After a route write committed: the tour read back, and the trip told of it and of the visits and stages it took along. */
async announceRouteUpdate(
tripId: string,
placeId: number,
visitsDropped: readonly number[],
{ visitsDropped, stagesDropped }: TourRouteDrops,
socketId?: string,
): Promise<TourDetailResponse> {
const result = await this.getTour(tripId, String(placeId));
@@ -833,9 +862,30 @@ export class ToursService {
this.logger.warn(`Committed Tour ${placeId}: place notification failed`);
}
await this.announceVisits(tripId, visitsDropped);
await this.announceStagesDropped(tripId, stagesDropped);
return result;
}
/**
* MDT-02: the days that held a stage a change took away, told to every
* client of the trip, the one that saved included, since its day plan still
* shows them; the journey follows the days as after any unassign.
*/
private async announceStagesDropped(
tripId: string,
dropped: ReadonlyArray<{ id: number; day_id: number }>,
): Promise<void> {
if (dropped.length === 0) return;
for (const { id, day_id } of dropped) {
try {
this.places.broadcast(tripId, 'assignment:deleted', { assignmentId: id, dayId: day_id }, undefined);
} catch {
this.logger.warn(`Committed Tour stage change: realtime notification of assignment ${id} failed`);
}
}
await this.assignments.reconcile(tripId);
}
/**
* CW-03: the days whose visits an edit took along, told to every client of
* the trip, the one that saved included, so no day plan goes on counting a
+1
View File
@@ -768,6 +768,7 @@ export class TripsService {
reservation_status: a.reservation_status, reservation_notes: a.reservation_notes,
reservation_datetime: a.reservation_datetime, assignment_time: a.assignment_time,
assignment_end_time: a.assignment_end_time, end_day: a.end_day ?? 0,
tour_stage_key: a.tour_stage_key,
});
assignmentMap.set(a.id, newAssignmentId);
}