feat(tours): a hike's day with sun, weather and warnings, and the proposed line kept for create

This commit is contained in:
Maurice
2026-10-22 12:14:21 +02:00
parent 72ff9600f4
commit 621b08d444
31 changed files with 2405 additions and 186 deletions
@@ -1,28 +1,62 @@
import { ADDON_IDS } from '../../addons';
import { noAccess } from '../../mcp/tools/_shared';
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '../../nest-mcp';
import { thinLine } from '../../utils/polyline';
import { addonGate } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
import { TripAccessService } from '../trip-membership/trip-access.service';
import { PaddleRoutingService } from './paddle-routing.service';
import { idSchema, paddleRouteRequestSchema, type PaddleRouteInput, type PaddleRouteLeg } from '@trek/shared';
import {
cumulativeMetres,
idSchema,
includeGeometryFlagSchema,
paddleRouteRequestSchema,
pointsPerLeg,
TOUR_DISPLAY_LINE_POINTS,
type PaddleRouteInput,
type PaddleRouteLeg,
} from '@trek/shared';
/** A planned leg as a client reads it: its length and its stretches, not its thousands of points. */
function summary(leg: PaddleRouteLeg) {
/**
* A planned leg as a client reads it: its length and its stretches, each
* placed in metres along the leg, not its thousands of points. With a share
* of the points for drawing (`points`), its line thinned to that many.
*/
function summary(leg: PaddleRouteLeg, points: number | null) {
if (leg.status === 'failed') return leg;
const along = cumulativeMetres(leg.geometry.map(([lat, lng]) => [lat, lng] as const));
return {
status: leg.status,
distance_m: leg.distance_m,
snapped_m: leg.snapped_m,
points: leg.geometry.length,
segments: leg.segments.map(({ kind, name, source, osm_ref }) => ({ kind, name, source, osm_ref })),
segments: leg.segments.map(({ kind, name, source, osm_ref, from, to }) => ({
kind,
name,
source,
osm_ref,
// Both indexes point into the leg's own geometry, so each has its place along it.
from_m: Math.round(along[from]),
to_m: Math.round(along[to]),
})),
...(points === null ? {} : { line_geometry: thinLine(leg.geometry, points) }),
};
}
/** The points each planned leg draws with, all of them together at most TOUR_DISPLAY_LINE_POINTS, by length. */
function linePoints(legs: readonly PaddleRouteLeg[]): Array<number | null> {
return pointsPerLeg(
legs.map((leg) => (leg.status === 'ok' ? { lengthM: leg.distance_m, points: leg.geometry.length } : null)),
TOUR_DISPLAY_LINE_POINTS,
);
}
/**
* PAD-11: the paddle router for a client, read only and with the REST
* route's rules: the caller must see the trip, Tours must be on, and the
* request is the same contract. It plans and answers; nothing is saved.
* request is the same contract. It plans and answers; nothing is saved. With
* include_geometry each planned leg adds its line for drawing, thinned so all
* legs together hold at most TOUR_DISPLAY_LINE_POINTS points.
*/
@McpController()
export class PaddleRoutingMcp {
@@ -36,15 +70,22 @@ export class PaddleRoutingMcp {
@Tool({
name: 'plan_paddle_route',
description:
'Plan the legs of a paddle tour on the water, on the water network TREK builds from the OpenStreetMap data of the paddle regions an admin added. Each leg (from, to) is planned along rivers and canals and across lakes, with short portages where a weir, dam or waterfall bars the way and through or around locks (lock_policy). lake_style direct crosses lakes straight, shore keeps near the shore (for wind). Answers per leg its length, how far its ends were moved onto the water, and its stretches (water, lake, portage with its source, lock, gap); a leg that cannot be planned says why (not_on_water, no_region, no_water_path, cross_region). A planning aid, never a safety or legal clearance; nothing is saved.',
inputSchema: { tripId: idSchema, ...paddleRouteRequestSchema.shape },
'Plan the legs of a paddle tour on the water, on the water network TREK builds from the OpenStreetMap data of the paddle regions an admin added. Each leg (from, to) is planned along rivers and canals and across lakes, with short portages where a weir, dam or waterfall bars the way and through or around locks (lock_policy). lake_style direct crosses lakes straight, shore keeps near the shore (for wind). Answers per leg its length, how far its ends were moved onto the water, and its stretches (water, lake, portage with its source, lock, gap) with where each starts and ends in metres along the leg (from_m, to_m); a leg that cannot be planned says why (not_on_water, no_region, no_water_path, cross_region). A planning aid, never a safety or legal clearance; nothing is saved.',
inputSchema: { tripId: idSchema, ...paddleRouteRequestSchema.shape, include_geometry: includeGeometryFlagSchema },
annotations: TOOL_ANNOTATIONS_READONLY,
when: addonGate(ADDON_IDS.TOURS),
access: { group: 'trips', mode: 'read' },
})
async planPaddleRoute({ tripId, ...request }: PaddleRouteInput & { tripId: number }, ctx: McpContext) {
async planPaddleRoute(
{ tripId, include_geometry, ...request }: PaddleRouteInput & { tripId: number; include_geometry?: boolean },
ctx: McpContext,
) {
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
const planned = await this.routing.route(paddleRouteRequestSchema.parse(request));
return ok({ legs: planned.legs.map(summary), graph_rev: planned.graph_rev });
const points = include_geometry === true ? linePoints(planned.legs) : planned.legs.map(() => null);
return ok({
legs: planned.legs.map((leg, index) => summary(leg, points[index] ?? null)),
graph_rev: planned.graph_rev,
});
}
}
+45 -9
View File
@@ -1,15 +1,31 @@
import { ADDON_IDS } from '../../addons';
import { noAccess } from '../../mcp/tools/_shared';
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '../../nest-mcp';
import { thinLine } from '../../utils/polyline';
import { addonGate } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
import { TripAccessService } from '../trip-membership/trip-access.service';
import { SkiRoutingService } from './ski-routing.service';
import { idSchema, skiRouteRequestSchema, type SkiRouteInput, type SkiRouteLeg } from '@trek/shared';
import {
cumulativeMetres,
idSchema,
includeGeometryFlagSchema,
pointsPerLeg,
skiRouteRequestSchema,
TOUR_DISPLAY_LINE_POINTS,
type SkiRouteInput,
type SkiRouteLeg,
} from '@trek/shared';
/** A planned leg as a client reads it: its measures and its stretches, not its thousands of points. */
function summary(leg: SkiRouteLeg) {
/**
* A planned leg as a client reads it: its measures and its stretches, each
* placed in metres along the leg, not its thousands of points. With a share
* of the points for drawing (`points`), its line thinned to that many, every
* point keeping its height.
*/
function summary(leg: SkiRouteLeg, points: number | null) {
if (leg.status === 'failed') return leg;
const along = cumulativeMetres(leg.geometry.map(([lat, lng]) => [lat, lng] as const));
return {
status: leg.status,
distance_m: leg.distance_m,
@@ -18,17 +34,33 @@ function summary(leg: SkiRouteLeg) {
seconds: leg.seconds,
snapped_m: leg.snapped_m,
points: leg.geometry.length,
segments: leg.segments.map(({ from: _from, to: _to, grooming: _grooming, osm_way: _way, ...segment }) => segment),
segments: leg.segments.map(({ from, to, grooming: _grooming, osm_way: _way, ...segment }) => ({
...segment,
// Both indexes point into the leg's own geometry, so each has its place along it.
from_m: Math.round(along[from]),
to_m: Math.round(along[to]),
})),
why: leg.why,
alternatives: leg.alternatives.length,
...(points === null ? {} : { line_geometry: thinLine(leg.geometry, points) }),
};
}
/** The points each planned leg draws with, all of them together at most TOUR_DISPLAY_LINE_POINTS, by length. */
function linePoints(legs: readonly SkiRouteLeg[]): Array<number | null> {
return pointsPerLeg(
legs.map((leg) => (leg.status === 'ok' ? { lengthM: leg.distance_m, points: leg.geometry.length } : null)),
TOUR_DISPLAY_LINE_POINTS,
);
}
/**
* The piste router for a client, read only and with the REST route's rules:
* the caller must see the trip, Tours must be on, and the request is the same
* contract, planned by the same service. It plans and answers; nothing is
* saved, and no clicked point leaves the server.
* saved, and no clicked point leaves the server. With include_geometry each
* planned leg adds its line for drawing with its heights, thinned so all legs
* together hold at most TOUR_DISPLAY_LINE_POINTS points.
*/
@McpController()
export class SkiRoutingMcp {
@@ -42,17 +74,21 @@ export class SkiRoutingMcp {
@Tool({
name: 'plan_ski_route',
description:
'Plan the legs of a ski and winter tour on the runs, lifts, cross-country trails, ski touring and winter paths of OpenStreetMap, for a discipline (alpine, snowboard, cross_country with a technique of classic, skating or unknown, touring, snowshoe, winter_hike) and its options (max_difficulty from novice to expert, allow_freeride, avoid_lifts by class, allow_lift_down, priority fastest, most_skiing or easiest, pace, groomed_only and lit_only for cross-country, touring_uphill_on_pistes, use_lifts, avoid_inferred_links). Each leg (from, to) answers its length, climb and descent, its time split into skiing, lifts, waiting at lifts and walking, how far its ends were moved onto the network, its stretches (run with difficulty, lift with type, link the map data does not hold, and so on) and notes on what it rests on; a leg that cannot be planned says why (not_on_piste, no_area, cross_area, disconnected, blocked_by_difficulty with the level it needs, blocked_by_lift_avoid, blocked_by_technique, needs_ride_down, uphill_only, loading while an area downloads, places_unavailable, disabled). Times are estimates from the run difficulties and lift types, waits are guesses, and nothing says what is open, groomed or safe today; nothing is saved.',
inputSchema: { tripId: idSchema, ...skiRouteRequestSchema.shape },
'Plan the legs of a ski and winter tour on the runs, lifts, cross-country trails, ski touring and winter paths of OpenStreetMap, for a discipline (alpine, snowboard, cross_country with a technique of classic, skating or unknown, touring, snowshoe, winter_hike) and its options (max_difficulty from novice to expert, allow_freeride, avoid_lifts by class, allow_lift_down, priority fastest, most_skiing or easiest, pace, groomed_only and lit_only for cross-country, touring_uphill_on_pistes, use_lifts, avoid_inferred_links). Each leg (from, to) answers its length, climb and descent, its time split into skiing, lifts, waiting at lifts and walking, how far its ends were moved onto the network, its stretches (run with difficulty, lift with type, link the map data does not hold, and so on, each with where it starts and ends in metres along the leg, from_m and to_m) and notes on what it rests on; a leg that cannot be planned says why (not_on_piste, no_area, cross_area, disconnected, blocked_by_difficulty with the level it needs, blocked_by_lift_avoid, blocked_by_technique, needs_ride_down, uphill_only, loading while an area downloads, places_unavailable, disabled). Times are estimates from the run difficulties and lift types, waits are guesses, and nothing says what is open, groomed or safe today; nothing is saved.',
inputSchema: { tripId: idSchema, ...skiRouteRequestSchema.shape, include_geometry: includeGeometryFlagSchema },
annotations: TOOL_ANNOTATIONS_READONLY,
when: addonGate(ADDON_IDS.TOURS),
access: { group: 'trips', mode: 'read' },
})
async planSkiRoute({ tripId, ...request }: SkiRouteInput & { tripId: number }, ctx: McpContext) {
async planSkiRoute(
{ tripId, include_geometry, ...request }: SkiRouteInput & { tripId: number; include_geometry?: boolean },
ctx: McpContext,
) {
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
const planned = await this.routing.route(skiRouteRequestSchema.parse(request));
const points = include_geometry === true ? linePoints(planned.legs) : planned.legs.map(() => null);
return ok({
legs: planned.legs.map(summary),
legs: planned.legs.map((leg, index) => summary(leg, points[index] ?? null)),
build: planned.build,
stale: planned.stale,
area_names: planned.area_names,
@@ -1,10 +1,12 @@
import { badRequest } from '../common/domain-error';
import { thinnedLine } from './tours.helpers';
import {
clockOf,
haversineMetres,
minutesOf,
stationFit,
stationHoursOn,
TOUR_DISPLAY_LINE_POINTS,
TOUR_MAX_SNAP_M,
TOUR_POI_MAX_CATEGORIES,
TOUR_STATION_HOURS_MAX,
@@ -576,6 +578,19 @@ function estimateOf(way: FootWay) {
};
}
/**
* The way on foot for drawing, thinned to TOUR_DISPLAY_LINE_POINTS, where it
* was planned and the caller asked for it (include_geometry); nothing else.
*/
export function walkLineGeometry(
way: FootWay,
asked: boolean | undefined,
): { line_geometry?: Array<[number, number]> } {
return asked === true && way.kind === 'routed'
? { line_geometry: thinnedLine(way.line, TOUR_DISPLAY_LINE_POINTS) }
: {};
}
/** The walk as the proposal says it: its stations with their times, the gaps, the totals and what create_city_walk takes. */
export function describeWalk(route: readonly WalkCandidate[], frame: WalkFrame, way: FootWay) {
const timeline = timelineOf(route, frame, way.kind === 'routed' ? way.legs : undefined);
@@ -11,6 +11,7 @@ import {
frameOf,
planWalk,
themeCandidates,
walkLineGeometry,
type LeftOut,
type LeftOutReason,
type WalkCandidate,
@@ -135,8 +136,10 @@ export class CityWalkBriefingService {
themes: theme.candidates,
themeLimit: theme.limit,
});
const way = await this.footWays.plan(userId, tripId, plan.route);
return {
...describeWalk(plan.route, frame, await this.footWays.plan(userId, tripId, plan.route)),
...describeWalk(plan.route, frame, way),
...walkLineGeometry(way, briefing.include_geometry),
left_out: [...leftOut, ...plan.leftOut],
searched: theme.searched,
};
+136 -14
View File
@@ -2,12 +2,20 @@ import { addStretch, metresAlong } from './hike-routing.helpers';
import type { PlannedHike } from './hike-routing.service';
import type { TourCreateCommand } from './tours.service';
import {
arrivalsOf,
cumulativeMetres,
haversineMetres,
HIKE_ALONG_MAX,
HIKE_PROPOSAL_SAC_SEGMENTS,
hikeScheduleOf,
hikingPreferencesOf,
legMinutes,
nearestAlongM,
TOUR_MAX_SNAP_M,
type GeometryPoint,
type HikeAlongPlace,
type HikeCreateRequest,
type HikeHighPoint,
type HikePlan,
type HikePlanPoint,
type HikeProposal,
@@ -16,14 +24,18 @@ import {
type HikeRouteOutcome,
type HikeSacSegment,
type HikeTrailGrades,
type TourPoi,
type TourWaypoint,
type UnroutedHikePlan,
type WeatherResult,
} from '@trek/shared';
/**
* The pure halves of a hike an assistant plans over MCP (HikePlanService):
* the points it is planned through, the proposal shown from a plan, the way
* a saved hike keeps from a plan, and the write a created hike makes.
* the points it is planned through, the proposal shown from a plan (where
* and when each point is reached, the highest point, the places along the
* way, the day), the way a saved hike keeps from a plan, and the write a
* created hike makes.
*/
/** The points a hike is planned through: as asked, and for a round trip back to its start. */
@@ -40,10 +52,11 @@ export function routeOutcomeOf(plan: HikePlan): HikeRouteOutcome {
/** The points as a tour keeps them: in order, the first the start and the last the end, with their names. */
export function hikeWaypointsOf(points: readonly HikePlanPoint[]): TourWaypoint[] {
const last = points.length - 1;
return points.map(({ lat, lng, name }, sequence) => ({
return points.map(({ lat, lng, name, poi_category }, sequence) => ({
lat,
lng,
...(name ? { name } : {}),
...(poi_category ? { poi_category } : {}),
role: sequence === 0 ? 'start' : sequence === last ? 'end' : 'via',
sequence,
}));
@@ -166,8 +179,8 @@ export function heightRangeOf(line: readonly GeometryPoint[]): { min: number; ma
const heights = line.flatMap((point) => (point.length === 3 ? [point[2]] : []));
if (heights.length === 0 || heights.length !== line.length) return null;
return {
min: Math.round(heights.reduce((low, height) => Math.min(low, height))),
max: Math.round(heights.reduce((high, height) => Math.max(high, height))),
min: Math.round(heights.reduce((low, height) => Math.min(low, height), Number.POSITIVE_INFINITY)),
max: Math.round(heights.reduce((high, height) => Math.max(high, height), Number.NEGATIVE_INFINITY)),
};
}
@@ -217,22 +230,121 @@ function unreachableOf(plan: HikePlan): HikeProposal['unreachable'] {
return { position: plan.position, snapped_m: plan.snapped_m };
}
/**
* Where each point lies along the line, in metres: at the end of its leg as
* the router measured the legs, laid onto the line's own length, so a point
* the way passes twice (a loop's start and end, a summit walked up and down
* one path) lies where the way reaches it in order. Where the router gave no
* length for each leg, the line's nearest pass in order stands in.
*/
export function waypointPositionsOf({ line, legs }: PlannedHike, points: readonly HikePlanPoint[]): number[] | null {
if (!line) return null;
if (!legs || legs.length !== points.length - 1) return positionsAlong(line, points);
const length = cumulativeMetres(line).at(-1) ?? 0;
const measured = legs.reduce((sum, leg) => sum + leg, 0);
const scale = measured > 0 ? length / measured : 0;
return runningTotals(legs).map((along) => Math.round(along * scale));
}
/** Each value summed with all before it, after a leading 0: where each point lies when the legs are walked in order. */
function runningTotals(legs: readonly number[]): number[] {
const totals = [0];
let sum = 0;
for (const leg of legs) {
sum += leg;
totals.push(sum);
}
return totals;
}
/** The walking minutes from the start to each point, the whole time split over the legs by the signposts' rule. */
export function waypointMinutesOf(
line: readonly GeometryPoint[],
positions: readonly number[],
total: number,
): number[] {
return runningTotals(legMinutes(line, positions, total));
}
/** The highest point of a line with every height, the first where two are as high, and where along the line it lies. */
export function highPointOf(line: readonly GeometryPoint[] | null): HikeHighPoint | null {
if (!line || !heightRangeOf(line)) return null;
let top = 0;
line.forEach((point, index) => {
if ((point[2] ?? 0) > (line[top][2] ?? 0)) top = index;
});
const [lat, lng, height] = line[top];
return { lat, lng, ele_m: Math.round(height ?? 0), at_m: Math.round(cumulativeMetres(line)[top]) };
}
/** Where the day's weather of a hike is read: at its highest point where the heights are known, else at its start. */
export function weatherPointOf(points: readonly HikePlanPoint[], { line }: PlannedHike): { lat: number; lng: number } {
const high = highPointOf(line);
return high ?? { lat: points[0].lat, lng: points[0].lng };
}
/**
* The places along a way a proposal names: the nearest to it, at most
* HIKE_ALONG_MAX, in the order the way passes them, each placed along the
* whole line as the points of the hike are.
*/
export function alongPlacesOf(pois: readonly TourPoi[], line: readonly GeometryPoint[]): HikeAlongPlace[] {
return [...pois]
.sort((a, b) => a.distance_m - b.distance_m)
.slice(0, HIKE_ALONG_MAX)
.map((poi) => ({
name: poi.name.trim() || null,
category: poi.category,
lat: poi.lat,
lng: poi.lng,
at_m: Math.round(nearestAlongM(line, poi.lat, poi.lng)),
off_m: Math.round(poi.distance_m),
}))
.sort((a, b) => a.at_m - b.at_m);
}
/** What a proposal reads besides its plan: the day it is for, the places along the way and that day's weather. */
export interface HikeProposalContext {
dayId: number | null;
date: string | null;
/** Null where none were looked for or the search failed. */
along: HikeAlongPlace[] | null;
/** Null where none was asked or it could not be had. */
weather: WeatherResult | null;
}
/**
* The proposal of a hike as a plan gives it: the way and its measures, its
* profile and grades, each point with where it lies along the way, and the
* request's own words given back. Without a way the points are still shown,
* grades and highest point, each point with where, after how long and when
* it is reached, the places along the way, the day's schedule and warnings,
* and the request's own words given back. The line and the profile only when
* asked for (include_geometry). Without a way the points are still shown,
* with why there is none.
*/
export function hikeProposalOf(
request: HikeProposeRequest,
points: readonly HikePlanPoint[],
{ plan, line }: PlannedHike,
planned: PlannedHike,
context: HikeProposalContext,
): HikeProposal {
const { plan, line } = planned;
const routed = plan.kind === 'routed' ? plan : null;
const positions = routed && line ? positionsAlong(line, points) : null;
const positions = routed ? waypointPositionsOf(planned, points) : null;
const heights = routed?.has_heights && line ? heightRangeOf(line) : null;
const minutes = routed && line && positions ? waypointMinutesOf(line, positions, routed.duration_minutes) : null;
const arrivals = arrivalsOf(request.start_time ?? null, minutes ?? points.map(() => null));
const day =
routed && context.date
? hikeScheduleOf({
date: context.date,
dayId: context.dayId,
start: request.start_time ?? null,
minutes: routed.duration_minutes,
weather: context.weather,
})
: null;
return {
name: request.name ?? null,
name: request.name,
variant_label: request.variant_label ?? null,
route: routeOutcomeOf(plan),
unreachable: unreachableOf(plan),
@@ -244,20 +356,30 @@ export function hikeProposalOf(
heights_known: heights !== null,
min_ele_m: heights?.min ?? null,
max_ele_m: heights?.max ?? null,
line: routed?.line ?? [],
profile: routed?.profile ?? [],
...(routed && request.include_geometry === true
? { line_geometry: routed.line, profile_geometry: routed.profile }
: {}),
sac: proposalSacOf(routed?.grades ?? null),
waypoints: points.map(({ lat, lng, name }, index) => ({
high_point: routed ? highPointOf(line) : null,
waypoints: points.map(({ lat, lng, name, poi_category }, index) => ({
lat,
lng,
name: name || null,
poi_category: poi_category ?? null,
at_m: positions?.[index] ?? null,
snapped_m: routed ? (planned.snapped?.[index] ?? null) : null,
minutes: minutes?.[index] ?? null,
arrival: arrivals[index] ?? null,
})),
along: routed ? context.along : null,
max_hiking_difficulty: plan.max_hiking_difficulty,
hiking_preferences: routed ? routed.hiking_preferences : hikingPreferencesOf(request.hiking_preferences),
round_trip: request.round_trip === true,
date: request.date ?? null,
day_id: context.dayId,
date: context.date,
start_time: request.start_time ?? null,
schedule: day?.schedule ?? null,
warnings: day?.warnings ?? [],
};
}
+11 -5
View File
@@ -34,9 +34,11 @@ import {
* created, changed or taken in from a GPX file once the traveller said yes,
* through the writes the planner and the file import use, with 'place_edit'
* on the trip as REST asks (ToursController for a create and an edit,
* ToursImportController for the file). Those writes tell the trip about the
* tour (tours:changed with place:created or place:updated) without leaving
* out any socket, so the tab that asked shows it too. A refusal of the
* ToursImportController for the file), and 'day_edit' as well for a hike put
* on a day (AssignmentsController). Those writes tell the trip about the
* tour (tours:changed with place:created or place:updated, and
* assignment:created for its day) without leaving out any socket, so the tab
* that asked shows it too. A refusal of the
* service is a DomainError, which the registry answers as an error result
* with the text REST sends.
*/
@@ -53,7 +55,7 @@ export class HikePlanMcp {
@Tool({
name: 'propose_hike',
description:
"Plan a hike for a trip and show it to the traveller, without saving anything. The way on foot runs through waypoints in order (the start, the points on the way such as a hut or a summit, the end; with round_trip it comes back to the start) and takes no path harder than max_hiking_difficulty on the SAC scale (1 for T1 hiking to 6 for T6 difficult alpine hiking, 2 when left out), avoiding steep climbs, steps or ferries where there is a way round when hiking_preferences asks so. TREK's router plans it as the tour planner does. The answer gives distance_m, ascent_m and descent_m, the walking time without breaks (duration_minutes: by DIN 33466, the rule hiking signposts use, where the heights are known, duration_basis din33466; else the router's, duration_basis router), min_ele_m and max_ele_m, the SAC grades OpenStreetMap records along the way (sac: highest, the graded stretches in metres along the way, and unknown_share, the part with no grade, which is unknown and never easy), every point with at_m, where it lies along the way, and the line and height profile for display. Where no way was planned, route says why: no_valhalla (this TREK has no router that knows hiking grades), router_unavailable (try again later), no_way (nothing within the limit joins the points: a higher limit or other points may), point_off_the_way (unreachable names the point and how far it is from any way) or too_long (longer than one hike is planned for: split it into shorter hikes). To compare ways, propose each one with its own variant_label, such as 'short and steep' or 'by the lake'; date and start_time are given back for the schedule. The chat shows the route, its profile and grades as a card: never list coordinates or the profile, sum the hike up in a sentence and say which grade it reaches. Propose first and create the hike with create_hike only after the traveller said yes.",
"Plan a hike for a trip and show it to the traveller, without saving anything; give it a name. The way on foot runs through waypoints in order (the start, the points on the way such as a hut or a summit, each with its poi_category where it is one, the end; with round_trip it comes back to the start) and takes no path harder than max_hiking_difficulty on the SAC scale (1 for T1 hiking to 6 for T6 difficult alpine hiking, 2 when left out), avoiding steep climbs, steps or ferries where there is a way round when hiking_preferences asks so. TREK's router plans it as the tour planner does. The answer gives distance_m, ascent_m and descent_m, the walking time without breaks (duration_minutes: by DIN 33466, the rule hiking signposts use, where the heights are known, duration_basis din33466; else the router's, duration_basis router), min_ele_m, max_ele_m and high_point, the SAC grades OpenStreetMap records along the way (sac: highest, the graded stretches in metres along the way, and unknown_share, the part with no grade, which is unknown and never easy), every point with at_m (where it lies along the way), snapped_m (how far it was moved onto a way), minutes from the start and, with start_time, its arrival, and along: summits, huts, water and viewpoints near the way (null with along false or where they could not be searched). With day_id (a day of the trip, whose date it takes) or date, schedule gives the day: the arrival (next_day past midnight), sunrise and sunset, the weather by the hour at the highest point (weather forecast, or climate further out: last year's day) and, without start_time, latest_start to be back half an hour before sunset; warnings name a start before sunrise (dark_start), an arrival after sunset (dark_end) or within the hour before it (dusk), and the first walking hour with thunderstorms (thunder) or likely rain (rain). Where no way was planned, route says why: no_valhalla (this TREK has no router that knows hiking grades), router_unavailable (try again later), no_way (nothing within the limit joins the points: a higher limit or other points may), point_off_the_way (unreachable names the point and how far it is from any way) or too_long (longer than one hike is planned for: split it into shorter hikes). To compare ways, propose each one with its own variant_label, such as 'short and steep' or 'by the lake', and along false. The chat shows the route, its profile, times, weather and warnings as a card: never list coordinates, the hours or the figures, sum the hike up in a sentence and say what matters. Propose first and create the hike with create_hike only after the traveller said yes.",
inputSchema: { tripId: idSchema, ...hikeProposeRequestSchema.shape },
annotations: TOOL_ANNOTATIONS_READONLY,
when: addonGate(ADDON_IDS.TOURS),
@@ -67,7 +69,7 @@ export class HikePlanMcp {
@Tool({
name: 'create_hike',
description:
"Create a hike in a trip from points the traveller confirmed, usually the waypoints of a proposal of propose_hike with its max_hiking_difficulty, hiking_preferences and round_trip. It is saved the way the planner saves a new hike: a tour of type hike with its points in order (start, points on the way, end), the way on foot planned by the same router as the proposal and kept whole, with its heights where every point has one, its distance, ascent, descent and walking time, its hiking limit and preferences, and planned_duration_minutes (the time out, breaks included) where given. Where the router did not answer, for the way or its heights (route planned false, why router_unavailable), the hike is saved with its points only (route_pending true) and TREK plans the way when someone opens it in the planner; no straight line is stored. Any other reason the proposal names (no_valhalla, no_way, point_off_the_way, too_long) refuses the hike with why, and nothing is saved. The answer is the tour as list_tours reads it (tour.place_id is the tour's id, its revision 1) and route. Put it on a day with assign_place_to_day, link it to the packing list with link_tour_packing, export it with export_tour_gpx, change it with update_tour. Needs place editing permission on the trip. Create it only after the traveller said yes.",
"Create a hike in a trip from points the traveller confirmed, usually the name and waypoints of a proposal of propose_hike with its max_hiking_difficulty, hiking_preferences and round_trip. It is saved the way the planner saves a new hike: a tour of type hike with its points in order (start, points on the way, end, with their names and poi_category), the way on foot planned by the same router as the proposal and kept whole (the very line a proposal of the last minutes showed), with its heights where every point has one, its distance, ascent, descent and walking time, its hiking limit and preferences, and planned_duration_minutes (the time out, breaks included) where given. Where the router did not answer, for the way or its heights (route planned false, why router_unavailable), the hike is saved with its points only (route_pending true) and TREK plans the way when someone opens it in the planner; no straight line is stored. Any other reason the proposal names (no_valhalla, no_way, point_off_the_way, too_long) refuses the hike with why, and nothing is saved. With day_id the hike is put on that day of the trip in the same write, start_time kept as the start of that visit (which needs day editing permission too); assignment names the visit, null without a day. The answer is the tour as list_tours reads it (tour.place_id is the tour's id, its revision 1), route and assignment. Put it on a day later with assign_place_to_day, link it to the packing list with link_tour_packing, export it with export_tour_gpx, change it with update_tour. Needs place editing permission on the trip. Create it only after the traveller said yes.",
inputSchema: { tripId: idSchema.describe('The trip to create the hike in'), ...hikeCreateRequestSchema.shape },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
when: addonGate(ADDON_IDS.TOURS),
@@ -76,6 +78,10 @@ export class HikePlanMcp {
async createHike({ tripId, ...request }: { tripId: number } & HikeCreateRequest, ctx: McpContext) {
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
// Putting it on a day is what REST's assignment route asks 'day_edit' for.
if (request.day_id !== undefined && !(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) {
return permissionDenied();
}
return ok(await this.hikes.create(ctx.userId, tripId, request));
}
+131 -6
View File
@@ -1,6 +1,12 @@
import { badRequest, DomainError } from '../common/domain-error';
import { Tours } from '../../db/entities/Tours.entity';
import type { ToursRepository } from '../../db/repositories/Tours.repository';
import { AssignmentsService } from '../assignments/assignments.service';
import { logWarn } from '../audit/audit-log.logger';
import { badRequest, DomainError, notFound } from '../common/domain-error';
import { UnitOfWork } from '../database/unit-of-work';
import { WeatherService } from '../weather/weather.service';
import {
alongPlacesOf,
hikeCreateCommandOf,
hikePointsOf,
hikeProposalOf,
@@ -8,18 +14,27 @@ import {
repointedWaypoints,
unplannedChangeMessage,
unplannedCreateMessage,
weatherPointOf,
type KeptWay,
} from './hike-plan.helpers';
import { HikeRoutingService, type PlannedHike } from './hike-routing.service';
import { noTrackFound } from './tour-file-import.helpers';
import { TourPoiSearchService } from './tour-poi-search.service';
import { tourRevisionConflict } from './tour-revision';
import { thinnedLine } from './tours.helpers';
import { ToursService, type ImportGpxAsTourResult, type TourCreateCommand } from './tours.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import {
HIKE_ALONG_CATEGORIES,
HIKE_ALONG_CORRIDOR_M,
HIKE_PLAN_MAX_WAYPOINTS,
hikingPreferencesOf,
TOUR_POI_MAX_LINE_POINTS,
tourGpxImportFieldsSchema,
TOURS_MAX_HIKING_DIFFICULTY,
type GeometryPoint,
type HikeAlongPlace,
type HikeCreateRequest,
type HikePlanPoint,
type HikeProposal,
@@ -31,10 +46,38 @@ import {
type HikeRouteOutcome,
type TourUpdateRequest,
type TourWaypoint,
type WeatherResult,
} from '@trek/shared';
/** What a hike created over MCP answers: the tour as the planner's save answers it, and whether its way was planned. */
export type HikeCreated = TourCreateResponse & { route: HikeRouteOutcome };
/** The day a hike created over MCP was put on: the visit's id and the day's. */
export interface HikeAssignment {
id: number;
day_id: number;
}
/**
* What a hike created over MCP answers: the tour as the planner's save
* answers it, whether its way was planned, and the visit it was put on its
* day with (null without a day).
*/
export type HikeCreated = TourCreateResponse & { route: HikeRouteOutcome; assignment: HikeAssignment | null };
/** The language the day's weather is asked in; only its one-word kinds are read, which are the same in each. */
const WEATHER_LANG = 'en';
/**
* The decimals the weather's point is asked to, about a kilometre: the
* variants of one hike then share the weather's cache and its one request.
*/
const WEATHER_DECIMALS = 2;
/** A coordinate as the weather is asked for it. */
function weatherCoordinate(value: number): string {
return String(Number(value.toFixed(WEATHER_DECIMALS)));
}
function reasonOf(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/** What a change answers: the tour as now saved, whether its way was planned anew, and how that went. */
export type HikeChanged = TourDetailResponse & { route: HikeRouteOutcome; replanned: boolean };
@@ -91,6 +134,10 @@ export class HikePlanService {
private readonly uow: UnitOfWork,
private readonly routing: HikeRoutingService,
private readonly tours: ToursService,
private readonly assignments: AssignmentsService,
private readonly poiSearch: TourPoiSearchService,
private readonly weather: WeatherService,
@InjectRepository(Tours) private readonly toursRepo: ToursRepository,
) {}
/** The way through the points; `grades` for a proposal only, which shows them. */
@@ -110,11 +157,65 @@ export class HikePlanService {
);
}
/**
* A hike proposed for the traveller to look at, with its day: the date of
* the trip's day it is on (a day of another trip is not found) or the date
* given, the places along the way unless asked to leave them out, and the
* day's weather at the way's highest point. The places and the weather are
* best effort: without them the proposal still answers.
*/
async propose(userId: number, tripId: number, request: HikeProposeRequest): Promise<HikeProposal> {
const day = await this.dayOf(tripId, request.day_id, request.date);
const points = hikePointsOf(request.waypoints, request.round_trip);
const { max_hiking_difficulty: limit, hiking_preferences: preferences } = request;
const planned = await this.plan(userId, tripId, points, limit, preferences, true);
return hikeProposalOf(request, points, planned);
const line = planned.plan.kind === 'routed' ? planned.line : null;
const [along, weather] = await Promise.all([
line && request.along !== false ? this.alongOf(line) : null,
line && day.date ? this.weatherOf(weatherPointOf(points, planned), day.date) : null,
]);
return hikeProposalOf(request, points, planned, { ...day, along, weather });
}
/** The trip's day a hike is planned on and its date, or the date given without a day. */
private async dayOf(
tripId: number,
dayId: number | undefined,
date: string | undefined,
): Promise<{ dayId: number | null; date: string | null }> {
if (dayId === undefined) return { dayId: null, date: date ?? null };
const day = await this.toursRepo.dayOfTrip(tripId, dayId);
if (!day) throw notFound('Day not found');
return { dayId: day.id, date: day.date };
}
/** Summits, huts, water and viewpoints near the way, or null where the search could not be made. */
private async alongOf(line: readonly GeometryPoint[]): Promise<HikeAlongPlace[] | null> {
try {
const found = await this.poiSearch.search({
tour_type: 'hike',
categories: [...HIKE_ALONG_CATEGORIES],
corridor_m: HIKE_ALONG_CORRIDOR_M,
line: thinnedLine(line, TOUR_POI_MAX_LINE_POINTS).map(([lat, lng]): [number, number] => [lat, lng]),
});
// Not one kind could be searched: nothing found would read as nothing there.
if (found.unavailable.length === HIKE_ALONG_CATEGORIES.length) return null;
return alongPlacesOf(found.pois, line);
} catch (err: unknown) {
logWarn(`[Hike] no places along the way: ${reasonOf(err)}`);
return null;
}
}
/** The day's detailed weather near a point, or null where it could not be had. */
private async weatherOf(point: { lat: number; lng: number }, date: string): Promise<WeatherResult | null> {
try {
const [lat, lng] = [weatherCoordinate(point.lat), weatherCoordinate(point.lng)];
return await this.weather.getDetailed(lat, lng, date, WEATHER_LANG);
} catch (err: unknown) {
logWarn(`[Hike] no weather for ${date}: ${reasonOf(err)}`);
return null;
}
}
/**
@@ -124,8 +225,13 @@ export class HikePlanService {
* the planner plans the way when it is opened. Any other reason stands in
* the planner as well (no Valhalla, no way within the limit, a point off
* every way, a way too long), so the hike is refused and nothing is saved.
* With a day it is put on that day in the same write (a day of another trip
* is not found), its start kept as the visit's, and the trip is told of the
* tour and the visit once both are saved, as the planner's save and its
* drop onto the day tell it.
*/
async create(userId: number, tripId: number, request: HikeCreateRequest): Promise<HikeCreated> {
const day = request.day_id === undefined ? null : await this.dayOf(tripId, request.day_id, undefined);
const points = hikePointsOf(request.waypoints, request.round_trip);
const { max_hiking_difficulty: limit, hiking_preferences: preferences } = request;
const planned = await this.plan(userId, tripId, points, limit, preferences, false);
@@ -134,8 +240,27 @@ export class HikePlanService {
throw notRouted(unplannedCreateMessage(way.route));
}
const command = hikeCreateCommandOf(request, points, way, planned.plan.max_hiking_difficulty);
const created = await this.tours.createTour(String(tripId), command);
return { ...created, route: way.route };
if (day?.dayId == null) {
const created = await this.tours.createTour(String(tripId), command);
return { ...created, route: way.route, assignment: null };
}
const dayId = day.dayId;
const written = await this.uow.transactional(async () => {
const placeId = await this.tours.insertTour(tripId, command);
const added = await this.assignments.createAssignment(dayId, placeId, null);
if (!added) throw new Error('Created visit could not be loaded');
if (!request.start_time) return { placeId, assignment: added, reordered: null, vias: null };
const timed = await this.assignments.updateTime(added.id, request.start_time, null);
return { placeId, ...timed, assignment: timed.assignment ?? added };
});
const trip = String(tripId);
const created = await this.tours.announceCreatedTour(trip, written.placeId);
// As REST tells the trip of a visit added (and of a start that put its day in another order), to every tab.
this.assignments.broadcast(trip, 'assignment:created', { assignment: written.assignment }, undefined);
if (written.reordered) this.assignments.broadcast(trip, 'assignment:reordered', written.reordered, undefined);
if (written.vias) this.assignments.broadcast(trip, 'roadtripVia:changed', written.vias, undefined);
await this.assignments.reconcile(tripId);
return { ...created, route: way.route, assignment: { id: written.assignment.id, day_id: dayId } };
}
/**
+106 -1
View File
@@ -41,6 +41,52 @@ export interface PlannedHike {
* null where no way was planned.
*/
line: GeometryPoint[] | null;
/**
* For each point asked through, how far the router moved it onto a way, in
* whole metres; null where no way was planned or the router did not say.
*/
snapped: number[] | null;
/**
* The length of each leg between two points asked through, as the router
* measured it, in metres; null where no way was planned.
*/
legs: number[] | null;
}
/**
* How long a planned hike is kept: a hike created or changed from the way a
* proposal showed saves that very line, and a proposal asked again (another
* label, another start) asks the router nothing.
*/
const PLAN_MEMO_TTL_MS = 15 * 60_000;
/** The most planned hikes kept; the one used longest ago goes first. */
const PLAN_MEMO_MAX = 64;
/**
* The most line points all kept hikes hold together, besides PLAN_MEMO_MAX: a
* kept line can run to HIKE_MAX_LINE_POINTS, and the memo shares the box with
* everything else.
*/
const PLAN_MEMO_MAX_POINTS = 200_000;
/** Points nearer than this many decimal places of a degree (about a metre) are one point for the memo. */
const PLAN_MEMO_DECIMALS = 5;
interface KeptPlan {
at: number;
/** Whether the grades were read with it, so a proposal is never answered from a save that read none. */
grades: boolean;
/** How many points its whole line holds. */
points: number;
planned: PlannedHike;
}
/** The request as the memo keys it: who asked in which trip, the points to about a metre, the limit, the preferences. */
function memoKeyOf(userId: number, tripId: number, request: HikePlanRequest): string {
const points = request.waypoints.map(({ lat, lng }) => [
Number(lat.toFixed(PLAN_MEMO_DECIMALS)),
Number(lng.toFixed(PLAN_MEMO_DECIMALS)),
]);
const limit = request.max_hiking_difficulty ?? TOURS_MAX_HIKING_DIFFICULTY;
return JSON.stringify([userId, tripId, points, limit, hikingPreferencesOf(request.hiking_preferences)]);
}
/** What a plan asks for besides the way and its heights. */
@@ -74,10 +120,14 @@ function whyNot(err: unknown): UnroutedHikePlan['why'] {
* heights the line stays flat and its climb unknown, without grades the plan
* says they could not be read. A way of more points than HIKE_MAX_LINE_POINTS
* is not measured: its heights alone would hold the router's queue up for
* every user. Writes nothing.
* every user. A way planned with every height is kept for a while (PLAN_MEMO_TTL_MS),
* so the hike created from a proposal is the line the proposal showed. Writes nothing.
*/
@Injectable()
export class HikeRoutingService {
/** The planned hikes kept, the one used last at the end. */
private readonly memo = new Map<string, KeptPlan>();
constructor(
private readonly router: RoadtripRouterService,
private readonly trailGrades: TourTrailGradeService,
@@ -92,11 +142,64 @@ export class HikeRoutingService {
// The tools check their own requests; a change plans through the points a hike has saved, held here to the same.
const checked = hikePlanRequestSchema.safeParse(request);
if (!checked.success) throw badRequest(checked.error.issues.map((issue) => issue.message).join('; '));
const key = memoKeyOf(userId, tripId, request);
const kept = this.recall(key, withGrades);
if (kept) return kept;
const planned = await this.planned(userId, tripId, request, withGrades);
// Only a way with every height is kept: one without is asked again, and its heights with it.
if (planned.plan.kind === 'routed' && planned.plan.has_heights && planned.line) {
const grades = withGrades && planned.plan.grades !== null;
this.remember(key, { at: Date.now(), grades, points: planned.line.length, planned });
}
return planned;
}
/** A hike kept from before for the same request, where it is fresh and read the grades it is asked for. */
private recall(key: string, withGrades: boolean): PlannedHike | null {
const kept = this.memo.get(key);
if (!kept) return null;
this.memo.delete(key);
if (Date.now() - kept.at >= PLAN_MEMO_TTL_MS) return null;
// Used now: it goes to the end, the last to be dropped.
this.memo.set(key, kept);
return withGrades && !kept.grades ? null : kept.planned;
}
/**
* Keeps a planned hike. Stale ones go first, then the ones used longest ago
* until the memo holds at most PLAN_MEMO_MAX hikes of PLAN_MEMO_MAX_POINTS
* line points together.
*/
private remember(key: string, kept: KeptPlan): void {
this.memo.delete(key);
this.memo.set(key, kept);
let points = 0;
for (const [other, entry] of this.memo) {
if (kept.at - entry.at >= PLAN_MEMO_TTL_MS) this.memo.delete(other);
else points += entry.points;
}
for (const [oldest, entry] of this.memo) {
// The hike just kept is last and stays: one line holds far fewer points than the cap.
if (this.memo.size <= PLAN_MEMO_MAX && points <= PLAN_MEMO_MAX_POINTS) break;
this.memo.delete(oldest);
points -= entry.points;
}
}
/** The hike planned anew: the way, its heights and, where asked, its grades. */
private async planned(
userId: number,
tripId: number,
request: HikePlanRequest,
withGrades: boolean,
): Promise<PlannedHike> {
const limit = request.max_hiking_difficulty ?? TOURS_MAX_HIKING_DIFFICULTY;
const preferences = hikingPreferencesOf(request.hiking_preferences);
const unrouted = (why: UnroutedHikePlan['why'], position: number | null = null, snapped: number | null = null) => ({
plan: { kind: 'unrouted' as const, max_hiking_difficulty: limit, why, position, snapped_m: snapped },
line: null,
snapped: null,
legs: null,
});
let route: RoadtripRoute;
try {
@@ -140,6 +243,8 @@ export class HikeRoutingService {
grades,
},
line,
snapped: route.snapped ? snapped.map((point) => Math.round(point.meters)) : null,
legs: route.parts.map((part) => part.distance),
};
}
+7 -1
View File
@@ -27,6 +27,7 @@ import { RoadtripModule } from '../roadtrip/roadtrip.module';
import { SchedulingModule } from '../scheduling/scheduling.module';
import { TripMembershipModule } from '../trip-membership/trip-membership.module';
import { WandererModule } from '../wanderer/wanderer.module';
import { WeatherModule } from '../weather/weather.module';
import { CityWalkBriefingService } from './city-walk-briefing.service';
import { CityWalkFootWayService } from './city-walk-foot-way.service';
import { CityWalkMcp } from './city-walk.mcp';
@@ -95,7 +96,10 @@ import { Module } from '@nestjs/common';
* tours' own repositories, is planned on foot by RoadtripModule's router and
* is created through ToursService (CW-11). A hike an assistant plans with the
* traveller is proposed, created, changed or taken in from a GPX file through
* the same writes, its way planned on the server by HikeRoutingService. A
* the same writes, its way planned on the server by HikeRoutingService, its
* day read from WeatherModule's detailed weather and the places along it from
* the tours' own search; one created on a day is put there through
* AssignmentsService in the same write. A
* change to a saved city walk an assistant prepares for review is laid onto
* its saved points and planned on foot by the same router, and saves nothing
* (MCP-T02); once the traveller
@@ -144,6 +148,8 @@ import { Module } from '@nestjs/common';
// The wanderer watch tells the user of a new hint (WAN-02) and runs as a cron.
NotificationsModule,
SchedulingModule,
// A proposed hike's day reads the detailed weather at its highest point: sun, hours and warnings.
WeatherModule,
MikroOrmModule.forFeature([
CollectionTours,
Tours,
+39 -27
View File
@@ -262,6 +262,17 @@ export class ToursService {
async createTour(tripId: string, input: TourCreateCommand, socketId?: string): Promise<TourCreateResponse> {
// The controller's TripAccessGuard already resolved this trip id.
const tid = toRowId(tripId)!;
const placeId = await this.uow.transactional(() => this.insertTour(tid, input));
return await this.announceCreatedTour(tripId, placeId, socketId);
}
/**
* The rows of a new tour (its place, its facet, its control points), written
* inside the caller's transaction after the same checks, so a caller can put
* more into that write (a hike put on its day). Answers the new place's id;
* the trip is told by announceCreatedTour once the write committed.
*/
async insertTour(tid: number, input: TourCreateCommand): Promise<number> {
await this.assertTourTypeEnabled(input.tour_type);
checkTourCommand(input);
const start = input.route_geometry ? input.route_geometry[0] : firstPointOf(input.waypoints);
@@ -270,36 +281,37 @@ export class ToursService {
}
const fields = tourRouteFields(input);
const derivedFrom = input.derived_from_place_id ?? null;
const placeId = await this.uow.transactional(async () => {
// What it was planned from has to be an import (no waypoints of its own) of this same trip,
// read in the write so it cannot go meanwhile.
if (derivedFrom !== null) {
const source = await this.toursRepo.findInTrip(tid, derivedFrom);
if (!source || source.origin === 'trek' || source.has_waypoints) {
throw new DomainError(400, 'derived_from_place_id is not an imported tour of this trip');
}
// What it was planned from has to be an import (no waypoints of its own) of this same trip,
// read in the write so it cannot go meanwhile.
if (derivedFrom !== null) {
const source = await this.toursRepo.findInTrip(tid, derivedFrom);
if (!source || source.origin === 'trek' || source.has_waypoints) {
throw new DomainError(400, 'derived_from_place_id is not an imported tour of this trip');
}
const id = await this.placesRepo.insertTourPlace({
trip_id: tid,
name: input.name,
lat: start[0],
lng: start[1],
route_geometry: input.route_geometry ? JSON.stringify(input.route_geometry) : null,
description: input.description ?? null,
website: input.website ?? null,
});
await this.toursRepo.insertTour({
place_id: id,
...fields,
planned_duration_minutes: fields.planned_duration_minutes ?? null,
break_additional_minutes: fields.break_additional_minutes ?? null,
derived_from_place_id: derivedFrom,
});
await this.waypointsRepo.insertForPlace(id, keyedWaypoints(await this.linkedWaypoints(tid, input.waypoints)));
return id;
}
const id = await this.placesRepo.insertTourPlace({
trip_id: tid,
name: input.name,
lat: start[0],
lng: start[1],
route_geometry: input.route_geometry ? JSON.stringify(input.route_geometry) : null,
description: input.description ?? null,
website: input.website ?? null,
});
await this.toursRepo.insertTour({
place_id: id,
...fields,
planned_duration_minutes: fields.planned_duration_minutes ?? null,
break_additional_minutes: fields.break_additional_minutes ?? null,
derived_from_place_id: derivedFrom,
});
await this.waypointsRepo.insertForPlace(id, keyedWaypoints(await this.linkedWaypoints(tid, input.waypoints)));
return id;
}
/** A tour just written, read back as a create answers it, and the trip told once every row committed. */
async announceCreatedTour(tripId: string, placeId: number, socketId?: string): Promise<TourCreateResponse> {
const tid = toRowId(tripId)!;
const row = await this.toursRepo.findInTrip(tid, placeId);
const place = await this.placesRepo.findWithTagsAndRatings(placeId);
if (!row || !place) throw new Error('Created tour could not be loaded');
+6 -1
View File
@@ -5,9 +5,14 @@ import { WeatherService } from './weather.service';
import { WindService } from './wind.service';
import { Module } from '@nestjs/common';
/** Weather domain (pilot leaf module). Registered in AppModule. */
/**
* Weather domain (pilot leaf module). Registered in AppModule. WeatherService
* is exported for the hikes a traveller plans, whose day reads the detailed
* weather at the way's highest point.
*/
@Module({
controllers: [WeatherController],
providers: [WeatherService, WindService, WeatherMcp, WeatherRpc],
exports: [WeatherService],
})
export class WeatherModule {}
+89 -5
View File
@@ -6,8 +6,10 @@
* services and a migrated temp SQLite). The way is planned by the road
* trip's router with its paced queue; only the routing host behind
* safeFetchAdminConfigured is a stub answering /route, /height and
* /trace_attributes as Valhalla does, and the trail grades are a spy on the
* container's TourTrailGradeService, so nothing leaves the process. The
* /trace_attributes as Valhalla does, and the trail grades, the places along
* the way and the day's weather are spies on the container's
* TourTrailGradeService, TourPoiSearchService and WeatherService, so nothing
* leaves the process. The
* router's pacing runs on the test's clock during a call, moved on until the
* call is done, so the suite does not wait it out. The broadcasts are spied
* on the container's RealtimeService.
@@ -19,7 +21,9 @@ import { Tours } from '../../src/db/entities/Tours.entity';
import { createLocalMcpServer, McpRegistryService } from '../../src/nest-mcp';
import { unchangedSinceApplied } from '../../src/nest/assistant/assistant-undo.helpers';
import { withRequestContext } from '../../src/nest/database/request-context';
import { TourPoiSearchService } from '../../src/nest/tours/tour-poi-search.service';
import { TourTrailGradeService } from '../../src/nest/tours/tour-trail-grade.service';
import { WeatherService } from '../../src/nest/weather/weather.service';
import { safeFetchAdminConfigured } from '../../src/utils/ssrfGuard';
import { countRows, findRow } from '../helpers/factories/rows';
import { setAddonEnabled } from '../helpers/factories/settings';
@@ -187,6 +191,47 @@ beforeAll(async () => {
ways: [grade(501, 'hiking'), grade(502, 'mountain_hiking')],
unanswered: [],
});
vi.spyOn(app.get(TourPoiSearchService), 'search').mockResolvedValue({
pois: [
{
id: 'node:1',
name: 'Hut',
lat: at(10)[0],
lng: 11.3001,
category: 'shelter',
kind: 'amenity=shelter',
source: 'openstreetmap',
address: null,
website: null,
phone: null,
opening_hours: null,
distance_m: 8,
along_m: 556,
},
],
truncated: false,
partial: false,
capped: false,
unavailable: [],
});
vi.spyOn(app.get(WeatherService), 'getDetailed').mockResolvedValue({
type: 'forecast',
temp: 14,
main: 'Clear',
description: '',
sunrise: '05:30',
sunset: '21:00',
precipitation_probability_max: 70,
hourly: Array.from({ length: 24 }, (_, hour) => ({
hour,
temp: 12,
precipitation: 0,
precipitation_probability: hour === 9 ? 70 : 5,
main: 'Clear',
wind: 10,
humidity: 50,
})),
});
valhalla();
}, 60_000);
@@ -207,8 +252,9 @@ describe('a hike planned with an assistant over MCP', () => {
waypoints: WAYPOINTS,
max_hiking_difficulty: 2,
variant_label: 'by the hut',
date: '2027-07-10',
day_id: day.id,
start_time: '08:30',
include_geometry: true,
});
expect(hikeProposalSchema.parse(proposal)).toEqual(proposal);
expect(proposal).toMatchObject({
@@ -221,10 +267,21 @@ describe('a hike planned with an assistant over MCP', () => {
max_ele_m: 1600,
duration_basis: 'din33466',
sac: { highest: 2, unknown_share: 0 },
high_point: { lat: at(20)[0], lng: 11.3, ele_m: 1600, at_m: 1112 },
day_id: day.id,
date: '2027-07-10',
start_time: '08:30',
schedule: { date: '2027-07-10', day_id: day.id, start: '08:30', weather: 'forecast', sunset: '21:00' },
warnings: [{ kind: 'rain', at: '09:00' }],
});
expect(proposal.waypoints.map((point: { at_m: number }) => point.at_m)).toEqual([0, 556, 1112]);
expect(proposal.waypoints.map((point: { arrival: string }) => point.arrival)[0]).toBe('08:30');
expect(proposal.along).toEqual([
{ name: 'Hut', category: 'shelter', lat: at(10)[0], lng: 11.3001, at_m: 556, off_m: 8 },
]);
expect(proposal.line_geometry).toHaveLength(21);
// The highest point, asked to about a kilometre.
expect(app.get(WeatherService).getDetailed).toHaveBeenCalledWith('47.61', '11.3', '2027-07-10', 'en');
expect(await countRows(orm, Tours, {})).toBe(0);
expect(sent()).toEqual([]);
expect(askedSince()).toEqual(['/route', '/height', '/trace_attributes']);
@@ -254,7 +311,8 @@ describe('a hike planned with an assistant over MCP', () => {
['tours:changed', undefined],
['place:created', undefined],
]);
// The way and its heights as the proposal had them, kept for five minutes, and no grades: the host is not asked.
// The way and its heights as the proposal had them, and no grades: the host is not asked. The hike memo
// (fifteen minutes) and the router's own caches both answer here; HIKE-ROUTE-021 tells the memo apart.
expect(askedSince()).toEqual([]);
// Just made, the hike can be taken back: the trip as the undo reads it has it as it was created.
const summary = await callTool(user.id, 'get_trip_summary', { tripId: trip.id });
@@ -304,6 +362,30 @@ describe('a hike planned with an assistant over MCP', () => {
expect(askedSince()).toEqual([]);
});
it('creates a hike straight onto a day in one write, its start kept, and tells the trip of the tour and the visit', async () => {
const { user } = await makeUser(orm);
const trip = await makeTrip(orm, user.id, { title: 'Alps' });
const day = await makeDay(orm, trip.id, { day_number: 1, date: '2027-07-10' });
sent();
const created = await callTool(user.id, 'create_hike', {
tripId: trip.id,
name: 'Up to the summit',
waypoints: WAYPOINTS,
day_id: day.id,
start_time: '08:30',
});
expect(created.route).toEqual({ planned: true });
expect(created.assignment).toEqual({ id: expect.any(Number), day_id: day.id });
const visit = await findRow(orm, DayAssignments, { id: created.assignment.id });
expect(visit).toMatchObject({ place_id: created.tour.place_id, assignment_time: '08:30' });
expect(sent()).toEqual([
['tours:changed', undefined],
['place:created', undefined],
['assignment:created', undefined],
]);
askedSince();
});
it('takes a GPX text in as a hike, and refuses the tools to someone outside the trip', async () => {
const { user } = await makeUser(orm);
const { user: outsider } = await makeUser(orm);
@@ -320,7 +402,9 @@ describe('a hike planned with an assistant over MCP', () => {
]);
const refused = { error: 'Trip not found or access denied.' };
expect(await callTool(outsider.id, 'propose_hike', { tripId: trip.id, waypoints: WAYPOINTS })).toEqual(refused);
expect(
await callTool(outsider.id, 'propose_hike', { tripId: trip.id, name: 'Ridge', waypoints: WAYPOINTS }),
).toEqual(refused);
expect(await callTool(outsider.id, 'import_tour_gpx', { tripId: trip.id, gpx })).toEqual(refused);
expect(await callTool(user.id, 'import_tour_gpx', { tripId: trip.id, gpx, tour_type: 'kayak' })).toMatchObject({
error: expect.stringContaining('a paddle import needs boat_type'),
+46 -1
View File
@@ -28,7 +28,12 @@ import { makeUser } from '../../helpers/factories/users';
import { resetTestDb } from '../../helpers/test-db';
import { createTestModule, type TestModule } from '../../helpers/test-module';
import { MikroORM } from '@mikro-orm/core';
import { stationKeySchema, type CityWalkBriefingRequest, type CityWalkStation } from '@trek/shared';
import {
stationKeySchema,
TOUR_DISPLAY_LINE_POINTS,
type CityWalkBriefingRequest,
type CityWalkStation,
} from '@trek/shared';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -480,6 +485,46 @@ describe('CityWalkMcp (CW-11)', () => {
expect(offTheWay.tour).toMatchObject({ route_pending: true, distance: null, duration: null });
});
it('CWALK-MCP-008: adds the way on foot for drawing only when asked, thinned to what a card draws, and none without a way', async () => {
const f = await fixture();
// A way of a thousand points from the hotel to the station.
const line = Array.from({ length: 1000 }, (_, index): [number, number] => [48 + index * 0.000018, 11]);
route.mockResolvedValue({
leg: { line },
parts: [
{ distance: 1000, duration: 750 },
{ distance: 1000, duration: 750 },
],
snapped: [{ meters: 2 }, { meters: 2 }, { meters: 2 }],
avoidMissed: [],
});
const briefing = briefingOf(f, {
day_id: undefined,
theme_places_max: 0,
trip_places: undefined,
appointments: undefined,
end: { name: 'Station', lat: 48.018, lng: 11 },
});
const drawn = read(
await mcp.proposeCityWalk({ tripId: f.trip.id, ...briefing, include_geometry: true }, ctx(f.user.id)),
);
expect(drawn.estimate).toEqual({ kind: 'routed', profile: 'walking' });
expect(drawn.line_geometry).toHaveLength(TOUR_DISPLAY_LINE_POINTS);
expect(drawn.line_geometry[0]).toEqual(line[0]);
expect(drawn.line_geometry.at(-1)).toEqual(line.at(-1));
const plain = read(await mcp.proposeCityWalk({ tripId: f.trip.id, ...briefing }, ctx(f.user.id)));
expect(plain).not.toHaveProperty('line_geometry');
expect(plain.stations).toEqual(drawn.stations);
route.mockRejectedValue(new Error('Routing provider returned 503'));
const unrouted = read(
await mcp.proposeCityWalk({ tripId: f.trip.id, ...briefing, include_geometry: true }, ctx(f.user.id)),
);
expect(unrouted.estimate.kind).toBe('straight_line');
expect(unrouted).not.toHaveProperty('line_geometry');
});
it('CWALK-MCP-005: the proposal reads only and the creation writes, both on the Tours addon', async () => {
const options = (method: string) => {
const entry = getEntry(CityWalkMcp as unknown as ClassRef, method);
+114 -30
View File
@@ -4,7 +4,9 @@
* the words of a change whose way could not be planned.
*/
import {
alongPlacesOf,
heightRangeOf,
highPointOf,
hikeCreateCommandOf,
hikePointsOf,
hikeProposalOf,
@@ -16,19 +18,35 @@ import {
routeOutcomeOf,
unplannedChangeMessage,
unplannedCreateMessage,
waypointPositionsOf,
weatherPointOf,
type HikeProposalContext,
} from '../../../src/nest/tours/hike-plan.helpers';
import type { PlannedHike } from '../../../src/nest/tours/hike-routing.service';
import {
HIKE_PROPOSAL_SAC_SEGMENTS,
hikeProposalSchema,
type GeometryPoint,
type HikePlan,
type HikeSacSegment,
type RoutedHikePlan,
type TourPoi,
} from '@trek/shared';
import { describe, expect, it } from 'vitest';
// HIKE-PLAN-001 to HIKE-PLAN-011
// HIKE-PLAN-001 to HIKE-PLAN-014
/** A plan with its line, as the router answers it: without the router's legs and snaps unless given. */
const planned = (
plan: HikePlan,
line: GeometryPoint[] | null,
legs: number[] | null = null,
snapped: number[] | null = null,
): PlannedHike => ({ plan, line, legs, snapped });
/** A proposal read without a day, places along the way or weather. */
const ALONE: HikeProposalContext = { dayId: null, date: null, along: null, weather: null };
/** A point `k` thousandths of a degree north of 47° N on 11° E, about 111 m a step. */
const at = (k: number): [number, number] => [47 + k / 1000, 11];
@@ -70,9 +88,9 @@ describe('the points and the write of a hike', () => {
});
it('HIKE-PLAN-002: a created hike keeps the whole line, the walking time and the limit; without a way its points only', () => {
const planned: PlannedHike = { plan: ROUTED, line: LINE };
const kept = planned(ROUTED, LINE);
const request = { name: 'Summit', waypoints: [START, SUMMIT], hiking_preferences: { avoid_steps: true } };
expect(hikeCreateCommandOf(request, [START, SUMMIT], keptWayOf(planned), 3)).toEqual({
expect(hikeCreateCommandOf(request, [START, SUMMIT], keptWayOf(kept), 3)).toEqual({
name: 'Summit',
description: undefined,
tour_type: 'hike',
@@ -82,10 +100,10 @@ describe('the points and the write of a hike', () => {
hiking_preferences: { avoid_steps: true },
waypoints: hikeWaypointsOf([START, SUMMIT]),
});
const unplanned: PlannedHike = {
plan: { kind: 'unrouted', max_hiking_difficulty: 2, why: 'no_way', position: null, snapped_m: null },
line: null,
};
const unplanned = planned(
{ kind: 'unrouted', max_hiking_difficulty: 2, why: 'no_way', position: null, snapped_m: null },
null,
);
expect(
hikeCreateCommandOf(
{ ...request, planned_duration_minutes: 300, hiking_preferences: null },
@@ -105,19 +123,19 @@ describe('the points and the write of a hike', () => {
});
it('HIKE-PLAN-009: keeps a way only with the height of every point, and reads one without as a router that did not answer', () => {
expect(keptWayOf({ plan: ROUTED, line: LINE })).toEqual({ line: LINE, seconds: 720, route: { planned: true } });
const flat: PlannedHike = {
plan: { ...ROUTED, has_heights: false, ascent_m: null, descent_m: null, duration_basis: 'router' },
line: [at(0), at(4)],
};
expect(keptWayOf(planned(ROUTED, LINE))).toEqual({ line: LINE, seconds: 720, route: { planned: true } });
const flat = planned({ ...ROUTED, has_heights: false, ascent_m: null, descent_m: null, duration_basis: 'router' }, [
at(0),
at(4),
]);
const none = { line: null, seconds: null, route: { planned: false, why: 'router_unavailable', position: null } };
expect(keptWayOf(flat)).toEqual(none);
// A plan that says it has heights but came without its line keeps none either.
expect(keptWayOf({ plan: ROUTED, line: null })).toEqual(none);
const off: PlannedHike = {
plan: { kind: 'unrouted', max_hiking_difficulty: 2, why: 'point_off_the_way', position: 2, snapped_m: 400 },
line: null,
};
expect(keptWayOf(planned(ROUTED, null))).toEqual(none);
const off = planned(
{ kind: 'unrouted', max_hiking_difficulty: 2, why: 'point_off_the_way', position: 2, snapped_m: 400 },
null,
);
expect(keptWayOf(off)).toEqual({
line: null,
seconds: null,
@@ -235,7 +253,7 @@ describe('a proposal from a plan', () => {
hiking_preferences: { avoid_steps: true },
};
const points = hikePointsOf(request.waypoints, false);
const routed = hikeProposalOf(request, points, { plan: ROUTED, line: LINE });
const routed = hikeProposalOf(request, points, planned(ROUTED, LINE), { ...ALONE, date: request.date });
expect(hikeProposalSchema.parse(routed)).toEqual(routed);
expect(routed).toMatchObject({
name: 'Summit',
@@ -254,25 +272,33 @@ describe('a proposal from a plan', () => {
['Summit', 445],
]);
const flat = hikeProposalOf({ waypoints: request.waypoints }, points, {
plan: { ...ROUTED, has_heights: false, ascent_m: null, descent_m: null, grades: null },
line: [at(0), at(2), at(4)],
});
expect(flat).toMatchObject({ heights_known: false, min_ele_m: null, sac: null, name: null, date: null });
const flat = hikeProposalOf(
{ name: 'Flat', waypoints: request.waypoints },
points,
planned({ ...ROUTED, has_heights: false, ascent_m: null, descent_m: null, grades: null }, [at(0), at(2), at(4)]),
ALONE,
);
expect(flat).toMatchObject({ heights_known: false, min_ele_m: null, sac: null, name: 'Flat', date: null });
const off = hikeProposalOf({ waypoints: request.waypoints, hiking_preferences: { avoid_climbs: true } }, points, {
plan: { kind: 'unrouted', max_hiking_difficulty: 2, why: 'point_off_the_way', position: 3, snapped_m: 512 },
line: null,
});
const off = hikeProposalOf(
{ name: 'Off', waypoints: request.waypoints, hiking_preferences: { avoid_climbs: true } },
points,
planned(
{ kind: 'unrouted', max_hiking_difficulty: 2, why: 'point_off_the_way', position: 3, snapped_m: 512 },
null,
),
ALONE,
);
expect(hikeProposalSchema.parse(off)).toEqual(off);
expect(off).toMatchObject({
route: { planned: false, why: 'point_off_the_way', position: 3 },
unreachable: { position: 3, snapped_m: 512 },
distance_m: null,
line: [],
profile: [],
high_point: null,
along: null,
hiking_preferences: { avoid_climbs: true },
});
expect(off).not.toHaveProperty('line_geometry');
expect(off.waypoints.map((point) => point.at_m)).toEqual([null, null, null]);
// A refusal without a point, and one whose distance is not known, names none.
for (const plan of [
@@ -285,7 +311,9 @@ describe('a proposal from a plan', () => {
snapped_m: null,
},
]) {
expect(hikeProposalOf({ waypoints: request.waypoints }, points, { plan, line: null }).unreachable).toBeNull();
expect(
hikeProposalOf({ name: 'X', waypoints: request.waypoints }, points, planned(plan, null), ALONE).unreachable,
).toBeNull();
}
});
@@ -309,3 +337,59 @@ describe('a proposal from a plan', () => {
}
});
});
describe('where and when a proposed hike reaches its points', () => {
const routedTo = (line: GeometryPoint[]) => ({ ...ROUTED, line });
it("HIKE-PLAN-012: lays the router's legs onto the line, and takes the nearest pass where it gave none for each", () => {
const points = [START, { lat: 47.002, lng: 11 }, SUMMIT];
// The router measured 200 m and 300 m; the line is 444.8 m long.
expect(waypointPositionsOf(planned(routedTo(LINE), LINE, [200, 300]), points)).toEqual([0, 178, 445]);
expect(waypointPositionsOf(planned(routedTo(LINE), LINE, [200]), points)).toEqual(positionsAlong(LINE, points));
expect(waypointPositionsOf(planned(routedTo(LINE), LINE, null), points)).toEqual([0, 222, 445]);
expect(waypointPositionsOf(planned(routedTo(LINE), LINE, [0, 0]), points)).toEqual([0, 0, 0]);
expect(waypointPositionsOf(planned(routedTo(LINE), null, [200, 300]), points)).toBeNull();
});
it('HIKE-PLAN-013: finds the highest point, the first of two as high, and reads the weather there or at the start', () => {
const ridge: GeometryPoint[] = [
[...at(0), 600],
[...at(1), 700],
[...at(2), 700],
[...at(3), 650],
];
expect(highPointOf(ridge)).toEqual({ lat: 47.001, lng: 11, ele_m: 700, at_m: 111 });
expect(highPointOf([at(0), at(1)])).toBeNull();
expect(highPointOf(null)).toBeNull();
expect(weatherPointOf([START, SUMMIT], planned(routedTo(ridge), ridge))).toMatchObject({ lat: 47.001, lng: 11 });
expect(weatherPointOf([START, SUMMIT], planned(routedTo([at(0), at(4)]), [at(0), at(4)]))).toEqual({
lat: 47,
lng: 11,
});
});
it('HIKE-PLAN-014: names the nearest places along the way in the order passed, each placed on the whole line', () => {
const place = (name: string, k: number, off: number): TourPoi => ({
id: `node:${name}`,
name,
lat: at(k)[0],
lng: 11.0001,
category: 'shelter',
kind: 'amenity=shelter',
source: 'openstreetmap',
address: null,
website: null,
phone: null,
opening_hours: null,
distance_m: off,
along_m: 0,
});
const along = alongPlacesOf([place('Far', 1, 240), place('Top', 4, 8.4), place('Hut', 2, 7.6)], LINE);
expect(along.map(({ name, at_m, off_m }) => [name, at_m, off_m])).toEqual([
['Far', 111, 240],
['Hut', 222, 8],
['Top', 445, 8],
]);
expect(alongPlacesOf([], LINE)).toEqual([]);
});
});
+434 -17
View File
@@ -1,47 +1,63 @@
/**
* HikePlanMcp: a hike planned with the traveller over MCP. The proposal
* reads only; the hike is created through ToursService.createTour, changed
* through the route write the planner's edit uses, and a GPX text taken in
* through the tours' file import, all real here over a snapshot test db. The
* way is planned by HikeRoutingService, a stub in this suite (its own suite
* plans against a stubbed Valhalla). The suite takes its subject from the
* container and seeds through the ORM factories.
* reads only; the hike is created through ToursService's create (put on its
* day through AssignmentsService in the same write), changed through the
* route write the planner's edit uses, and a GPX text taken in through the
* tours' file import, all real here over a snapshot test db. The way is
* planned by HikeRoutingService, a stub in this suite (its own suite plans
* against a stubbed Valhalla); the places along it and the day's weather come
* from stubs of TourPoiSearchService and WeatherService. The suite takes its
* subject from the container and seeds through the ORM factories.
*/
import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
import { Places } from '../../../src/db/entities/Places.entity';
import { TourTypes } from '../../../src/db/entities/TourTypes.entity';
import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
import { Tours } from '../../../src/db/entities/Tours.entity';
import type { McpContext } from '../../../src/nest-mcp';
import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata';
import { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
import { DomainError } from '../../../src/nest/common/domain-error';
import { McpToolGuardsService } from '../../../src/nest/mcp-shared/mcp-tool-guards.service';
import { HikePlanMcp } from '../../../src/nest/tours/hike-plan.mcp';
import { HikeRoutingService, type PlannedHike } from '../../../src/nest/tours/hike-routing.service';
import { TourPoiSearchService } from '../../../src/nest/tours/tour-poi-search.service';
import { ToursModule } from '../../../src/nest/tours/tours.module';
import { WeatherService } from '../../../src/nest/weather/weather.service';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { makeDayAssignment } from '../../helpers/factories/itinerary';
import { makePlace } from '../../helpers/factories/places';
import { countRows, findRow, findRows, updateRows } from '../../helpers/factories/rows';
import { addTourWaypoints, makeTour } from '../../helpers/factories/tours';
import { makeTrip } from '../../helpers/factories/trips';
import { makeDay, makeTrip } from '../../helpers/factories/trips';
import { makeUser } from '../../helpers/factories/users';
import { resetTestDb } from '../../helpers/test-db';
import { createTestModule, type TestModule } from '../../helpers/test-module';
import { MikroORM } from '@mikro-orm/core';
import {
HIKE_ALONG_CATEGORIES,
HIKE_ALONG_MAX,
HIKE_PLAN_LINE_POINTS,
HIKE_PLAN_MAX_WAYPOINTS,
hikeProposalSchema,
type GeometryPoint,
type HikePlanRequest,
type HikeSacSegment,
type HourlyEntry,
type TourPoi,
type TourPoiSearchResponse,
type UnroutedHikePlan,
type WeatherResult,
} from '@trek/shared';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
// HIKE-MCP-001 to HIKE-MCP-017
// HIKE-MCP-001 to HIKE-MCP-023
const db = createSnapshotTestDb();
const plan = vi.fn<HikeRoutingService['plan']>();
const search = vi.fn<TourPoiSearchService['search']>();
const getDetailed = vi.fn<WeatherService['getDetailed']>();
let t: TestModule;
let mcp: HikePlanMcp;
const orm = () => t.get(MikroORM);
@@ -50,13 +66,19 @@ beforeAll(async () => {
t = await createTestModule({
db,
imports: [ToursModule],
overrides: [{ provide: HikeRoutingService, useValue: { plan } }],
overrides: [
{ provide: HikeRoutingService, useValue: { plan } },
{ provide: TourPoiSearchService, useValue: { search } },
{ provide: WeatherService, useValue: { getDetailed } },
],
});
mcp = t.get(HikePlanMcp);
});
beforeEach(() => {
resetTestDb(db);
plan.mockReset().mockImplementation(async (_userId, _tripId, request) => routed(request));
search.mockReset().mockResolvedValue(found([]));
getDetailed.mockReset().mockResolvedValue(day());
t.realtime.broadcastMock.mockReset();
});
afterAll(async () => {
@@ -83,8 +105,12 @@ const GRADES: HikeSacSegment[] = [
{ from_m: 600, to_m: 1112, sac: 3 },
];
/** The stub's plan: the way above (or `line`) under the limit and preferences asked for. */
function routed(request: HikePlanRequest, line: GeometryPoint[] = LINE): PlannedHike {
/** The stub's plan: the way above (or `line`) under the limit and preferences asked for, its legs as the router measured them. */
function routed(
request: HikePlanRequest,
line: GeometryPoint[] = LINE,
legs: number[] | null = [556, 556],
): PlannedHike {
return {
plan: {
kind: 'routed',
@@ -105,6 +131,8 @@ function routed(request: HikePlanRequest, line: GeometryPoint[] = LINE): Planned
grades: { segments: GRADES, highest: 3, unknown_share: 0 },
},
line,
snapped: legs ? [12, 3, 40] : null,
legs,
};
}
@@ -127,6 +155,8 @@ function flat(): PlannedHike {
grades: null,
},
line,
snapped: [12, 3, 40],
legs: [556, 556],
};
}
@@ -134,9 +164,52 @@ function unrouted(why: UnroutedHikePlan['why'], position: number | null = null):
return {
plan: { kind: 'unrouted', max_hiking_difficulty: 2, why, position, snapped_m: position === null ? null : 420 },
line: null,
snapped: null,
legs: null,
};
}
/** What the search for places along a way answers: these places, every kind searched. */
function found(pois: TourPoi[], unavailable: TourPoiSearchResponse['unavailable'] = []): TourPoiSearchResponse {
return { pois, truncated: false, partial: false, capped: false, unavailable };
}
/** A place the search found, `off` metres from the way. */
function poi(name: string, lat: number, off: number, category: TourPoi['category'] = 'viewpoint'): TourPoi {
const at = { lat, lng: 11.1 };
return {
id: `node:${name}`,
name,
...at,
category,
kind: `tourism=${category}`,
source: 'openstreetmap',
address: null,
website: null,
phone: null,
opening_hours: null,
distance_m: off,
along_m: 0,
};
}
/** A day's detailed weather: clear, sunrise 05:30, sunset 21:00, the hours `over` names changed. */
function day(type = 'forecast', over: Record<number, Partial<HourlyEntry>> = {}): WeatherResult {
const hourly = Array.from({ length: 24 }, (_, hour) => ({
hour,
temp: 12,
precipitation: 0,
precipitation_probability: 5,
main: 'Clear',
wind: 10,
humidity: 50,
...over[hour],
}));
// A forecast says the day's highest likelihood of rain beside its hours, as Open-Meteo's does.
const likelihood = type === 'forecast' ? { precipitation_probability_max: 5 } : {};
return { type, temp: 14, main: 'Clear', description: '', sunrise: '05:30', sunset: '21:00', ...likelihood, hourly };
}
const WAYPOINTS = [
{ lat: 47.5, lng: 11.1, name: 'Car park' },
{ lat: 47.505, lng: 11.1 },
@@ -200,7 +273,9 @@ describe('HikePlanMcp: the tools and their gates', () => {
const f = await fixture();
const outsider = ctx(f.outsider.id);
const noAccess = { error: 'Trip not found or access denied.' };
expect(read(await mcp.proposeHike({ tripId: f.trip.id, waypoints: WAYPOINTS }, outsider))).toEqual(noAccess);
expect(read(await mcp.proposeHike({ tripId: f.trip.id, name: 'X', waypoints: WAYPOINTS }, outsider))).toEqual(
noAccess,
);
expect(read(await mcp.createHike({ tripId: f.trip.id, name: 'X', waypoints: WAYPOINTS }, outsider))).toEqual(
noAccess,
);
@@ -228,7 +303,7 @@ describe('HikePlanMcp: the tools and their gates', () => {
);
expect(denied).toHaveBeenCalledWith('place_edit', f.trip.id, f.user.id);
// The proposal only reads, and is open to anyone who sees the trip.
expect(read(await mcp.proposeHike({ tripId: f.trip.id, waypoints: WAYPOINTS }, own)).route).toEqual({
expect(read(await mcp.proposeHike({ tripId: f.trip.id, name: 'X', waypoints: WAYPOINTS }, own)).route).toEqual({
planned: true,
});
} finally {
@@ -282,6 +357,8 @@ describe('HikePlanMcp: propose_hike', () => {
[null, 556],
['Summit', 1112],
]);
// Read as text by every client that did not ask for the line: none of it is in the answer.
expect(Object.keys(proposal).filter((key) => key.includes('geometry'))).toEqual([]);
expect(plan).toHaveBeenCalledWith(
f.user.id,
f.trip.id,
@@ -295,9 +372,13 @@ describe('HikePlanMcp: propose_hike', () => {
it('HIKE-MCP-004: a round trip is planned back to its start, and a proposal without a way says why', async () => {
const f = await fixture();
// Up the way and back down it.
plan.mockResolvedValueOnce(routed({ waypoints: WAYPOINTS }, [...LINE, ...LINE.slice(0, -1).reverse()]));
// Without the router's legs each point is placed on the line's nearest pass, in order.
plan.mockResolvedValueOnce(routed({ waypoints: WAYPOINTS }, [...LINE, ...LINE.slice(0, -1).reverse()], null));
const round = read(
await mcp.proposeHike({ tripId: f.trip.id, waypoints: WAYPOINTS.slice(0, 2), round_trip: true }, ctx(f.user.id)),
await mcp.proposeHike(
{ tripId: f.trip.id, name: 'Loop', waypoints: WAYPOINTS.slice(0, 2), round_trip: true },
ctx(f.user.id),
),
);
expect(plan.mock.calls[0][2].waypoints).toEqual([WAYPOINTS[0], WAYPOINTS[1], WAYPOINTS[0]]);
expect(round.round_trip).toBe(true);
@@ -307,8 +388,15 @@ describe('HikePlanMcp: propose_hike', () => {
['Car park', 2224],
]);
expect(round.waypoints.map((point: { snapped_m: number | null }) => point.snapped_m)).toEqual([null, null, null]);
plan.mockResolvedValueOnce(unrouted('point_off_the_way', 2));
const off = read(await mcp.proposeHike({ tripId: f.trip.id, waypoints: WAYPOINTS }, ctx(f.user.id)));
const off = read(
await mcp.proposeHike(
{ tripId: f.trip.id, name: 'Off', waypoints: WAYPOINTS, date: '2027-07-10', include_geometry: true },
ctx(f.user.id),
),
);
expect(hikeProposalSchema.parse(off)).toEqual(off);
expect(off).toMatchObject({
route: { planned: false, why: 'point_off_the_way', position: 2 },
@@ -316,10 +404,236 @@ describe('HikePlanMcp: propose_hike', () => {
distance_m: null,
duration_minutes: null,
heights_known: false,
line: [],
sac: null,
high_point: null,
along: null,
schedule: null,
warnings: [],
});
expect(off).not.toHaveProperty('line_geometry');
expect(off.waypoints.every((point: { at_m: number | null }) => point.at_m === null)).toBe(true);
// Without a way there is nothing to look along and no walking time to put on a day.
expect(search).toHaveBeenCalledTimes(1);
expect(getDetailed).not.toHaveBeenCalled();
});
it('HIKE-MCP-018: adds the line and the profile for drawing only when asked, within what a card draws', async () => {
const f = await fixture();
const asked = read(
await mcp.proposeHike(
{ tripId: f.trip.id, name: 'Herzogstand', waypoints: WAYPOINTS, include_geometry: true },
ctx(f.user.id),
),
);
expect(hikeProposalSchema.parse(asked)).toEqual(asked);
expect(asked.line_geometry).toEqual(LINE);
expect(asked.profile_geometry).toEqual([
[0, 800],
[556, 900],
[1112, 1000],
]);
expect(asked.line_geometry.length).toBeLessThanOrEqual(HIKE_PLAN_LINE_POINTS);
const left = read(
await mcp.proposeHike(
{ tripId: f.trip.id, name: 'Herzogstand', waypoints: WAYPOINTS, include_geometry: false },
ctx(f.user.id),
),
);
expect(left).not.toHaveProperty('line_geometry');
expect(left).not.toHaveProperty('profile_geometry');
// The flag is the tool's own: the plan is asked for the same way either way.
expect(plan.mock.calls.map((call) => call[2])).toEqual([
{ waypoints: WAYPOINTS, max_hiking_difficulty: undefined, hiking_preferences: undefined },
{ waypoints: WAYPOINTS, max_hiking_difficulty: undefined, hiking_preferences: undefined },
]);
});
it('HIKE-MCP-019: gives each point its place along the way by the legs, its minutes, arrival and snap, and the highest point', async () => {
const f = await fixture();
const waypoints = [WAYPOINTS[0], WAYPOINTS[1], { ...WAYPOINTS[2], poi_category: 'peak' as const }];
const proposal = read(
await mcp.proposeHike({ tripId: f.trip.id, name: 'Herzogstand', waypoints, start_time: '08:30' }, ctx(f.user.id)),
);
expect(proposal.waypoints).toEqual([
{
lat: 47.5,
lng: 11.1,
name: 'Car park',
poi_category: null,
at_m: 0,
snapped_m: 12,
minutes: 0,
arrival: '08:30',
},
{
lat: 47.505,
lng: 11.1,
name: null,
poi_category: null,
at_m: 556,
snapped_m: 3,
minutes: 26,
arrival: '08:56',
},
{
lat: 47.51,
lng: 11.1,
name: 'Summit',
poi_category: 'peak',
at_m: 1112,
snapped_m: 40,
minutes: 52,
arrival: '09:22',
},
]);
expect(proposal.high_point).toEqual({ lat: 47.51, lng: 11.1, ele_m: 1000, at_m: 1112 });
// No day and no date: the start and the arrivals stand, with no schedule to put them on.
expect(proposal).toMatchObject({ day_id: null, date: null, start_time: '08:30', schedule: null, warnings: [] });
// A way without heights has no highest point, and its legs weigh by their length.
plan.mockResolvedValueOnce(flat());
const level = read(
await mcp.proposeHike({ tripId: f.trip.id, name: 'Level', waypoints: WAYPOINTS }, ctx(f.user.id)),
);
expect(level.high_point).toBeNull();
expect(level.waypoints.map((point: { minutes: number }) => point.minutes)).toEqual([0, 9, 17]);
expect(level.waypoints.map((point: { arrival: string | null }) => point.arrival)).toEqual([null, null, null]);
});
it('HIKE-MCP-020: names the nearest summits, huts, water and viewpoints along the way, and none when not looked for', async () => {
const f = await fixture();
const near = Array.from({ length: HIKE_ALONG_MAX + 2 }, (_, index) =>
poi(`Place ${index}`, 47.51 - index * 0.0007, index * 10, index === 0 ? 'peak' : 'viewpoint'),
);
search.mockResolvedValueOnce(found([{ ...near[3], name: ' ' }, ...near.filter((_, index) => index !== 3)]));
const proposal = read(
await mcp.proposeHike({ tripId: f.trip.id, name: 'Herzogstand', waypoints: WAYPOINTS }, ctx(f.user.id)),
);
expect(search).toHaveBeenCalledWith({
tour_type: 'hike',
categories: [...HIKE_ALONG_CATEGORIES],
corridor_m: 250,
line: LINE.map(([lat, lng]) => [lat, lng]),
});
expect(proposal.along).toHaveLength(HIKE_ALONG_MAX);
// The two furthest from the way are left out, the rest in the order the way passes them.
expect(proposal.along.map((place: { name: string | null }) => place.name)).toEqual([
'Place 11',
'Place 10',
'Place 9',
'Place 8',
'Place 7',
'Place 6',
'Place 5',
'Place 4',
null,
'Place 2',
'Place 1',
'Place 0',
]);
expect(proposal.along.at(-1)).toEqual({
name: 'Place 0',
category: 'peak',
lat: 47.51,
lng: 11.1,
at_m: 1112,
off_m: 0,
});
search.mockClear();
const compared = read(
await mcp.proposeHike(
{ tripId: f.trip.id, name: 'Herzogstand', waypoints: WAYPOINTS, along: false },
ctx(f.user.id),
),
);
expect(compared.along).toBeNull();
expect(search).not.toHaveBeenCalled();
// A search that fails, or that could ask for no kind at all, is no answer of nothing there.
search.mockRejectedValueOnce(new Error('Overpass timed out'));
search.mockResolvedValueOnce(found([poi('Hut', 47.505, 20, 'shelter')], [...HIKE_ALONG_CATEGORIES]));
search.mockResolvedValueOnce(found([poi('Hut', 47.505, 20, 'shelter')], ['peak']));
const propose = async () =>
read(await mcp.proposeHike({ tripId: f.trip.id, name: 'Herzogstand', waypoints: WAYPOINTS }, ctx(f.user.id)));
// One after the other, so each takes the next search answer in turn.
const answers = [await propose(), await propose(), await propose()];
expect(answers.map((answer) => answer.along?.length ?? null)).toEqual([null, null, 1]);
});
it('HIKE-MCP-021: puts the hike on its day with the weather at its highest point, and answers without the weather', async () => {
const f = await fixture();
const tripDay = await makeDay(orm(), f.trip.id, { date: '2027-07-10' });
getDetailed.mockResolvedValueOnce(day('forecast', { 9: { main: 'Thunderstorm' } }));
const proposal = read(
await mcp.proposeHike(
{ tripId: f.trip.id, name: 'Herzogstand', waypoints: WAYPOINTS, day_id: tripDay.id, start_time: '08:30' },
ctx(f.user.id),
),
);
expect(hikeProposalSchema.parse(proposal)).toEqual(proposal);
expect(getDetailed).toHaveBeenCalledWith('47.51', '11.1', '2027-07-10', 'en');
expect(proposal).toMatchObject({ day_id: tripDay.id, date: '2027-07-10' });
expect(proposal.schedule).toMatchObject({
date: '2027-07-10',
day_id: tripDay.id,
start: '08:30',
arrive: '09:22',
next_day: false,
sunrise: '05:30',
sunset: '21:00',
latest_start: null,
weather: 'forecast',
});
expect(proposal.schedule.hours.map((hour: { hour: number }) => hour.hour)).toEqual([7, 8, 9, 10]);
expect(proposal.warnings).toEqual([{ kind: 'thunder', at: '09:00' }]);
// The highest point is asked to about a kilometre, so the variants of one hike share the day's weather.
plan.mockImplementationOnce(async (_userId, _tripId, request) =>
routed(request, [
[47.5, 11.1, 800],
[47.505, 11.1, 900],
[47.51234, 11.10987, 1000],
]),
);
await mcp.proposeHike(
{ tripId: f.trip.id, name: 'Variant', waypoints: WAYPOINTS, day_id: tripDay.id },
ctx(f.user.id),
);
expect(getDetailed).toHaveBeenLastCalledWith('47.51', '11.11', '2027-07-10', 'en');
// A date alone, the weather out of reach and a way without heights: asked at the start, answered without it.
plan.mockResolvedValueOnce(flat());
getDetailed.mockRejectedValueOnce(new Error('Open-Meteo API error'));
const unknown = read(
await mcp.proposeHike(
{ tripId: f.trip.id, name: 'Level', waypoints: WAYPOINTS, date: '2027-07-11' },
ctx(f.user.id),
),
);
expect(getDetailed).toHaveBeenLastCalledWith('47.5', '11.1', '2027-07-11', 'en');
expect(unknown.schedule).toMatchObject({ date: '2027-07-11', day_id: null, weather: null, hours: [] });
expect(unknown.warnings).toEqual([]);
// A day without a date has no schedule; a day of another trip is not found.
const undated = await makeDay(orm(), f.trip.id, { date: null });
getDetailed.mockClear();
const open = read(
await mcp.proposeHike(
{ tripId: f.trip.id, name: 'Open', waypoints: WAYPOINTS, day_id: undated.id },
ctx(f.user.id),
),
);
expect(open).toMatchObject({ day_id: undated.id, date: null, schedule: null });
expect(getDetailed).not.toHaveBeenCalled();
const other = await makeTrip(orm(), f.user.id);
const elsewhere = await makeDay(orm(), other.id, { date: '2027-07-10' });
await expect(
mcp.proposeHike(
{ tripId: f.trip.id, name: 'Elsewhere', waypoints: WAYPOINTS, day_id: elsewhere.id },
ctx(f.user.id),
),
).rejects.toThrow('Day not found');
});
});
@@ -420,6 +734,109 @@ describe('HikePlanMcp: create_hike', () => {
expect(await countRows(orm(), Places, { trip: f.trip.id })).toBe(0);
expect(t.realtime.broadcastMock).not.toHaveBeenCalled();
});
it('HIKE-MCP-022: puts a hike created for a day on that day in the same write, its start kept, and tells the trip after', async () => {
const f = await fixture();
const tripDay = await makeDay(orm(), f.trip.id, { date: '2027-07-10' });
const created = await createdHike(f, { day_id: tripDay.id, start_time: '08:00' });
expect(created.route).toEqual({ planned: true });
expect(created.assignment).toEqual({ id: expect.any(Number), day_id: tripDay.id });
const visit = await findRow(orm(), DayAssignments, { id: created.assignment.id });
expect(visit).toMatchObject({ day_id: tripDay.id, place_id: created.tour.place_id, assignment_time: '08:00' });
// As the planner's save and its drop onto the day tell it, to every tab, once both were saved.
expect(events()).toEqual([
[String(f.trip.id), 'tours:changed', undefined],
[String(f.trip.id), 'place:created', undefined],
[String(f.trip.id), 'assignment:created', undefined],
]);
const added = t.realtime.broadcastMock.mock.calls[2]?.[2] as { assignment: { id: number; place_id: number } };
expect(added.assignment).toMatchObject({ id: created.assignment.id, place_id: created.tour.place_id });
// Without a day there is no visit, and a start alone changes nothing.
t.realtime.broadcastMock.mockReset();
const loose = await createdHike(f, { name: 'Loose', start_time: '09:00' });
expect(loose.assignment).toBeNull();
expect(events().map(([, event]) => event)).toEqual(['tours:changed', 'place:created']);
expect(await countRows(orm(), DayAssignments, {})).toBe(1);
// A start before a visit the day already has puts the day in time order, told after the visit.
const second = await makeDay(orm(), f.trip.id, { date: '2027-07-11' });
const lunchPlace = await makePlace(orm(), f.trip.id, { name: 'Lunch' });
const lunch = await makeDayAssignment(orm(), second.id, lunchPlace.id, { assignment_time: '10:00' });
t.realtime.broadcastMock.mockReset();
const early = await createdHike(f, { name: 'Early', day_id: second.id, start_time: '08:00' });
expect(events()).toEqual([
[String(f.trip.id), 'tours:changed', undefined],
[String(f.trip.id), 'place:created', undefined],
[String(f.trip.id), 'assignment:created', undefined],
[String(f.trip.id), 'assignment:reordered', undefined],
]);
expect(t.realtime.broadcastMock.mock.calls[3]?.[2]).toEqual({
dayId: second.id,
orderedIds: [early.assignment.id, lunch.id],
});
// Roads drawn on the day that the new order moved are told last, as REST tells them.
const assignments = t.get(AssignmentsService);
const realUpdate = assignments.updateTime.bind(assignments);
const vias = { dayId: second.id, vias: [] };
const timed = vi
.spyOn(assignments, 'updateTime')
.mockImplementationOnce(async (id, start, end) => ({ ...(await realUpdate(id, start, end)), vias }));
t.realtime.broadcastMock.mockReset();
try {
await createdHike(f, { name: 'Earlier', day_id: second.id, start_time: '07:00' });
} finally {
timed.mockRestore();
}
expect(events().map(([, event]) => event)).toEqual([
'tours:changed',
'place:created',
'assignment:created',
'assignment:reordered',
'roadtripVia:changed',
]);
expect(t.realtime.broadcastMock.mock.calls[4]?.[2]).toEqual(vias);
});
it('HIKE-MCP-023: writes nothing where the day cannot take the hike, nor without day_edit', async () => {
const f = await fixture();
const own = ctx(f.user.id);
const other = await makeTrip(orm(), f.user.id);
const elsewhere = await makeDay(orm(), other.id);
await expect(createdHike(f, { day_id: elsewhere.id })).rejects.toThrow('Day not found');
expect(plan).not.toHaveBeenCalled();
const tripDay = await makeDay(orm(), f.trip.id);
const failing = vi
.spyOn(t.get(AssignmentsService), 'createAssignment')
.mockRejectedValueOnce(new Error('Tour is already assigned to this day'));
await expect(createdHike(f, { day_id: tripDay.id })).rejects.toThrow('Tour is already assigned to this day');
failing.mockRestore();
// One write: the tour the visit failed for is gone with it, and nobody was told of it.
expect(await countRows(orm(), Tours, {})).toBe(0);
expect(await countRows(orm(), Places, { trip: f.trip.id })).toBe(0);
expect(t.realtime.broadcastMock).not.toHaveBeenCalled();
const guards = t.get(McpToolGuardsService);
const denied = vi.spyOn(guards, 'hasTripPermission').mockImplementation(async (action) => action !== 'day_edit');
try {
expect(
read(await mcp.createHike({ tripId: f.trip.id, name: 'X', waypoints: WAYPOINTS, day_id: tripDay.id }, own)),
).toEqual({
error: 'You do not have permission to perform this action on this trip.',
});
expect(denied).toHaveBeenCalledWith('day_edit', f.trip.id, f.user.id);
// Without a day the hike is the trip's places alone, which place_edit covers.
expect(
read(await mcp.createHike({ tripId: f.trip.id, name: 'Y', waypoints: WAYPOINTS }, own)).assignment,
).toBeNull();
} finally {
denied.mockRestore();
}
expect(await countRows(orm(), Tours, {})).toBe(1);
expect(await countRows(orm(), DayAssignments, {})).toBe(0);
});
});
describe('HikePlanMcp: update_tour', () => {
@@ -4,18 +4,25 @@
* city walks use, with its paced queue; only the routing host behind
* safeFetchAdminConfigured is a stub, answering /route, /height and
* /trace_attributes as Valhalla does, and the trail grades come from a stub
* of TourTrailGradeService. The suite takes its subject from the container.
* of TourTrailGradeService. The suite takes its subject from the container;
* its last case creates a hike through the real HikePlanService.
*/
import { Places } from '../../../src/db/entities/Places.entity';
import { DomainError } from '../../../src/nest/common/domain-error';
import { RoadtripRouterService } from '../../../src/nest/roadtrip/roadtrip-router.service';
import { SettingsService } from '../../../src/nest/settings/settings.service';
import { HikePlanService } from '../../../src/nest/tours/hike-plan.service';
import { HikeRoutingService, type PlannedHike } from '../../../src/nest/tours/hike-routing.service';
import { TourTrailGradeService } from '../../../src/nest/tours/tour-trail-grade.service';
import { ToursModule } from '../../../src/nest/tours/tours.module';
import { safeFetchAdminConfigured } from '../../../src/utils/ssrfGuard';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { findRow } from '../../helpers/factories/rows';
import { makeTrip } from '../../helpers/factories/trips';
import { makeUser } from '../../helpers/factories/users';
import { createTestModule, type TestModule } from '../../helpers/test-module';
import { valhallaRoute } from '../../helpers/valhalla';
import { MikroORM } from '@mikro-orm/core';
import {
HIKE_MAX_LINE_POINTS,
HIKE_PLAN_LINE_POINTS,
@@ -33,7 +40,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
vi.mock('../../../src/utils/ssrfGuard', () => ({ safeFetchAdminConfigured: vi.fn() }));
// HIKE-ROUTE-001 to HIKE-ROUTE-015
// HIKE-ROUTE-001 to HIKE-ROUTE-021
const db = createSnapshotTestDb();
const grades = vi.fn<TourTrailGradeService['grades']>();
@@ -237,6 +244,8 @@ describe('HikeRoutingService: no way planned', () => {
expect(planned).toEqual({
plan: { kind: 'unrouted', max_hiking_difficulty: 3, why: 'router_unavailable', position: null, snapped_m: null },
line: null,
snapped: null,
legs: null,
});
expect(asked('/height')).toHaveLength(0);
expect(asked('/trace_attributes')).toHaveLength(0);
@@ -346,9 +355,11 @@ describe('HikeRoutingService: heights and grades are best effort', () => {
const route = vi
.spyOn(router, 'route')
.mockImplementationOnce(async (...args) => ({ ...(await real(...args)), snapped: undefined }));
const hike = routed(await plan());
const planned = await plan();
const hike = routed(planned);
expect(hike.has_heights).toBe(false);
expect(hike.distance_m).toBe(222);
expect(planned.snapped).toBeNull();
expect(heights).toHaveBeenCalledTimes(1);
route.mockRestore();
heights.mockRestore();
@@ -403,3 +414,163 @@ describe('HikeRoutingService: what it plans at all', () => {
expect(safeFetchAdminConfigured).not.toHaveBeenCalled();
});
});
describe('HikeRoutingService: a planned hike kept for a while', () => {
/** A plan for one user and trip of its own (or the trip given), the same pair each time it is asked. */
function planner(id: number, tripId = id) {
return async (request: Partial<HikePlanRequest> = {}, withGrades = true) => {
const pending = hikes.plan(id, tripId, { waypoints: WAYPOINTS, ...request }, { grades: withGrades });
await vi.runAllTimersAsync();
return pending;
};
}
const requests = () => vi.mocked(safeFetchAdminConfigured).mock.calls.length;
/** How many hikes the memo holds right now, stale ones included. */
const keptCount = () => (Reflect.get(hikes, 'memo') as Map<string, unknown>).size;
it('HIKE-ROUTE-016: answers the same request again with the very way, asking the router nothing, for a save too', async () => {
trip += 1;
const ask = planner(trip);
const first = await ask();
expect(first.snapped).toEqual([0, 0, 0]);
expect(first.legs).toEqual([111, 111]);
const before = requests();
// Points a millionth of a degree apart and named otherwise are the same points; a save reads no grades.
const nudged = WAYPOINTS.map((point) => ({ ...point, lat: point.lat + 0.000001, name: 'Elsewhere' }));
expect(await ask({ waypoints: nudged }, false)).toBe(first);
expect(await ask({ max_hiking_difficulty: 2, hiking_preferences: null })).toBe(first);
expect(requests()).toBe(before);
// Another limit, other preferences, another user or trip plan anew.
expect(await ask({ max_hiking_difficulty: 3 })).not.toBe(first);
expect(await ask({ hiking_preferences: { avoid_steps: true } })).not.toBe(first);
const other = planner(trip + 50_000);
expect(await other()).not.toBe(first);
});
it('HIKE-ROUTE-017: asks again after fifteen minutes, for grades a save did not read, and for a way without heights', async () => {
trip += 1;
const ask = planner(trip);
const saved = await ask({}, false);
expect(asked('/trace_attributes')).toHaveLength(0);
const shown = await ask({}, true);
expect(shown).not.toBe(saved);
expect(routed(shown).grades).not.toBeNull();
expect(asked('/trace_attributes')).toHaveLength(1);
expect(await ask({}, false)).toBe(shown);
vi.advanceTimersByTime(15 * 60_000);
expect(await ask({}, true)).not.toBe(shown);
trip += 1;
const level = planner(trip);
valhalla({ height: () => new Response('', { status: 502 }) });
const flatHike = await level();
expect(routed(flatHike).has_heights).toBe(false);
const heights = asked('/height').length;
expect(await level()).not.toBe(flatHike);
expect(asked('/height').length).toBeGreaterThan(heights);
// A way not planned is not kept either: the router is asked again.
trip += 1;
const down = planner(trip);
valhalla({ route: () => new Response('', { status: 503 }) });
await down();
await down();
expect(asked('/route')).toHaveLength(2);
});
it('HIKE-ROUTE-018: keeps at most 64 ways, the one used longest ago going first', async () => {
const base = 100_000;
const asks = Array.from({ length: 65 }, (_, index) => planner(base + index));
// Asked one after the other, so the first is the one used longest ago.
const kept = await asks
.slice(0, 64)
.reduce<Promise<PlannedHike[]>>(
async (before, ask) => [...(await before), await ask({}, false)],
Promise.resolve([]),
);
// The first is used again, so the second is now the one used longest ago.
expect(await asks[0]({}, false)).toBe(kept[0]);
await asks[64]({}, false);
expect(await asks[0]({}, false)).toBe(kept[0]);
expect(await asks[2]({}, false)).toBe(kept[2]);
expect(await asks[1]({}, false)).not.toBe(kept[1]);
});
it('HIKE-ROUTE-019: keeps one user apart from another in the same trip, and one trip apart from another of the same user', async () => {
trip += 1;
const first = await planner(trip)({}, false);
expect(await planner(trip)({}, false)).toBe(first);
expect(await planner(trip + 60_000, trip)({}, false)).not.toBe(first);
expect(await planner(trip, trip + 60_000)({}, false)).not.toBe(first);
expect(await planner(trip)({}, false)).toBe(first);
});
it('HIKE-ROUTE-020: drops stale hikes when it keeps a new one, and keeps no more than 200000 line points together', async () => {
const before = keptCount();
await planner(200_000)({}, false);
expect(keptCount()).toBe(Math.min(before + 1, 64));
// Well past fifteen minutes, also for a hike an earlier case kept on its own advanced clock.
vi.advanceTimersByTime(30 * 60_000);
await planner(200_001)({}, false);
// Every hike planned before is stale now, and goes as soon as another is kept.
expect(keptCount()).toBe(1);
// Lines of 10000 points: the twentieth pushes the small hike out, the twenty first the oldest long one.
const long = { waypoints: [WAYPOINTS[0], { lat: at(HIKE_MAX_LINE_POINTS - 1)[0], lng: 11 }] };
const line = stepsOf(0, HIKE_MAX_LINE_POINTS - 1);
valhalla({ route: () => json(valhallaRoute([{ line, km: 111.1, seconds: 90_000 }])) });
const asks = Array.from({ length: 21 }, (_, index) => planner(300_000 + index));
const kept = await asks
.slice(0, 20)
.reduce<Promise<PlannedHike[]>>(
async (earlier, ask) => [...(await earlier), await ask(long, false)],
Promise.resolve([]),
);
expect(kept[0].line).toHaveLength(HIKE_MAX_LINE_POINTS);
expect(keptCount()).toBe(20);
await asks[20](long, false);
expect(keptCount()).toBe(20);
expect(await asks[1](long, false)).toBe(kept[1]);
expect(await asks[0](long, false)).not.toBe(kept[0]);
});
});
describe('HikeRoutingService: the hike created from a proposal', () => {
it('HIKE-ROUTE-021: saves the very line the proposal showed while the router would now answer another, and the new one once the memo let it go', async () => {
const orm = t.get(MikroORM);
const { user } = await makeUser(orm);
const tripRow = await makeTrip(orm, user.id);
const hiking = t.get(HikePlanService);
const settled = async <T>(pending: Promise<T>): Promise<T> => {
await vi.runAllTimersAsync();
return await pending;
};
// Points two metres off the other cases' points, so neither the memo nor the router's caches hold them yet.
const request = {
name: 'Herzogstand',
waypoints: WAYPOINTS.map((point) => ({ ...point, lat: point.lat + 0.00002 })),
};
const proposal = await settled(hiking.propose(user.id, tripRow.id, { ...request, along: false }));
expect(proposal.route).toEqual({ planned: true });
const savedLine = async (placeId: number) =>
JSON.parse(String((await findRow(orm, Places, { id: placeId }))?.route_geometry)) as number[][];
// The router would now plan another way, and its own caches (five minutes) have let the first go.
valhalla({ route: () => json(valhallaRoute([{ line: stepsOf(0, 20, 11.01), km: 0.222, seconds: 330 }])) });
vi.advanceTimersByTime(6 * 60_000);
const created = await settled(hiking.create(user.id, tripRow.id, request));
expect(asked('/route')).toHaveLength(0);
const shown = await savedLine(created.tour.place_id);
expect(shown).toHaveLength(21);
expect(shown.every(([, lng]) => lng === 11)).toBe(true);
expect(shown.map(([lat, , ele]) => ele - heightAt(lat))).toEqual(Array.from({ length: 21 }, () => 0));
// Past fifteen minutes the memo is gone, and the hike created is the way the router plans now.
vi.advanceTimersByTime(10 * 60_000);
const later = await settled(hiking.create(user.id, tripRow.id, { ...request, name: 'Later' }));
expect(asked('/route')).toHaveLength(1);
expect((await savedLine(later.tour.place_id)).every(([, lng]) => lng === 11.01)).toBe(true);
});
});
@@ -114,6 +114,8 @@ describe('PaddleRoutingMcp', () => {
expect(input.safeParse({ tripId: 3, legs: [] }).success).toBe(false);
expect(input.safeParse({ tripId: 3, legs: [{ from: A, to: B }], lake_style: 'zigzag' }).success).toBe(false);
expect(input.safeParse({ legs: [{ from: A, to: B }] }).success).toBe(false);
expect(input.safeParse({ tripId: 3, legs: [{ from: A, to: B }], include_geometry: true }).success).toBe(true);
expect(input.safeParse({ tripId: 3, legs: [{ from: A, to: B }], include_geometry: 'yes' }).success).toBe(false);
});
it('PADROUTE-MCP-003: plans for a trip the caller sees, the same request as REST, and answers each leg without its points', async () => {
@@ -131,8 +133,8 @@ describe('PaddleRoutingMcp', () => {
snapped_m: { from: 12, to: 0 },
points: 3,
segments: [
{ kind: 'water', name: 'Regen', source: null, osm_ref: null },
{ kind: 'portage', name: 'Wehr', source: 'tagged', osm_ref: 'way/1' },
{ kind: 'water', name: 'Regen', source: null, osm_ref: null, from_m: 0, to_m: 6526 },
{ kind: 'portage', name: 'Wehr', source: 'tagged', osm_ref: 'way/1', from_m: 6526, to_m: 11903 },
],
},
{ status: 'failed', code: 'no_region', at: B },
@@ -146,4 +148,54 @@ describe('PaddleRoutingMcp', () => {
expect(result.isError).toBe(true);
expect(routing.route).not.toHaveBeenCalled();
});
it("PADROUTE-MCP-005: adds each planned leg's line for drawing only when asked, all legs within 300 points by length", async () => {
const { mcp, routing } = makeMcp(true);
const long = (from: number, count: number): Array<[number, number]> =>
Array.from({ length: count }, (_, index) => [49 + (from + index) * 0.0001, 12]);
const leg = (geometry: Array<[number, number]>, distance: number) => ({
status: 'ok' as const,
geometry,
segments: [
{
kind: 'water' as const,
from: 0,
to: geometry.length - 1,
name: null,
source: null,
osm_ref: null,
grade: null,
},
],
distance_m: distance,
snapped_m: { from: 0, to: 0 },
});
routing.route.mockResolvedValueOnce({
graph_rev: 'r2',
legs: [
leg(long(0, 1000), 11_100),
{ status: 'failed', code: 'no_water_path', at: A },
leg(long(1000, 500), 5_550),
leg(long(1500, 2), 11),
],
});
const legs = [
{ from: A, to: B },
{ from: B, to: A },
{ from: A, to: B },
{ from: B, to: A },
];
const result = (await mcp.planPaddleRoute({ tripId: 3, legs, include_geometry: true }, ctx)) as McpTextResult;
// The flag is the tool's own: the router gets the REST request.
expect(routing.route).toHaveBeenCalledWith(paddleRouteRequestSchema.parse({ legs }));
const answer = JSON.parse(result.content[0].text) as { legs: Array<{ line_geometry?: Array<[number, number]> }> };
const drawn = answer.legs.map((planned) => planned.line_geometry?.length ?? null);
expect(drawn).toEqual([198, null, 100, 2]);
expect(drawn.reduce<number>((sum, count) => sum + (count ?? 0), 0)).toBeLessThanOrEqual(300);
expect(answer.legs[0].line_geometry?.[0]).toEqual([49, 12]);
expect(answer.legs[0].line_geometry?.at(-1)).toEqual(long(0, 1000)[999]);
const plain = (await mcp.planPaddleRoute({ tripId: 3, legs: [{ from: A, to: B }] }, ctx)) as McpTextResult;
expect(JSON.parse(plain.content[0].text).legs[0]).not.toHaveProperty('line_geometry');
});
});
@@ -179,6 +179,9 @@ describe('SkiRoutingMcp', () => {
const three = { tripId: 3, legs: [{ from: A, to: B }], discipline: 'alpine', alternatives: 3 };
expect(input.safeParse(three).success).toBe(false);
expect(input.safeParse({ legs: [{ from: A, to: B }], discipline: 'alpine' }).success).toBe(false);
const drawn = { tripId: 3, legs: [{ from: A, to: B }], discipline: 'alpine', include_geometry: true };
expect(input.safeParse(drawn).success).toBe(true);
expect(input.safeParse({ ...drawn, include_geometry: 1 }).success).toBe(false);
});
it('SKIMCP-003: plans for a trip the caller sees, the same request as REST, legs without their points', async () => {
@@ -215,6 +218,8 @@ describe('SkiRoutingMcp', () => {
wait_seconds: 0,
length_m: 2500,
flags: [],
from_m: 0,
to_m: 1354,
},
],
why: [],
@@ -232,4 +237,37 @@ describe('SkiRoutingMcp', () => {
expect(result.isError).toBe(true);
expect(routing.route).not.toHaveBeenCalled();
});
it("SKIMCP-005: adds each planned leg's line with its heights only when asked, all legs within 300 points by length", async () => {
const { mcp, routing } = makeMcp(true);
const [planned] = ANSWER.legs;
if (planned.status !== 'ok') throw new Error('the fixture plans its first leg');
const geometry = Array.from({ length: 800 }, (_, index): [number, number, number | null] => [
45.98 - index * 0.0001,
7.75,
index === 5 ? null : 2500 - index,
]);
routing.route.mockResolvedValueOnce({ ...ANSWER, legs: [{ ...planned, geometry }, ANSWER.legs[1]] });
const asked = {
tripId: 3,
legs: [
{ from: A, to: B },
{ from: B, to: A },
],
discipline: 'alpine' as const,
};
const result = (await mcp.planSkiRoute({ ...asked, include_geometry: true }, ctx)) as McpTextResult;
const { tripId: _trip, ...asRest } = asked;
expect(routing.route).toHaveBeenCalledWith(skiRouteRequestSchema.parse(asRest));
const answer = JSON.parse(result.content[0].text) as {
legs: Array<{ line_geometry?: Array<[number, number, number | null]> }>;
};
expect(answer.legs[0].line_geometry).toHaveLength(300);
expect(answer.legs[0].line_geometry?.[0]).toEqual([45.98, 7.75, 2500]);
expect(answer.legs[0].line_geometry?.at(-1)).toEqual(geometry[799]);
expect(answer.legs[1]).not.toHaveProperty('line_geometry');
const plain = (await mcp.planSkiRoute(asked, ctx)) as McpTextResult;
expect(JSON.parse(plain.content[0].text).legs[0]).not.toHaveProperty('line_geometry');
});
});
+8 -1
View File
@@ -38,7 +38,11 @@ describe('a hike the server plans through its points', () => {
it('HIKE-PLAN-SCHEMA-001: takes two points and nothing else, and the limit, the preferences and the names it can be given', () => {
expect(hikePlanRequestSchema.safeParse({ waypoints: [point(0), point(1)] }).success).toBe(true);
const full = {
waypoints: [{ ...point(0), name: 'Parking' }, point(1), { ...point(2), name: 'Summit' }],
waypoints: [
{ ...point(0), name: 'Parking' },
{ ...point(1), poi_category: 'shelter' },
{ ...point(2), name: 'Summit', poi_category: 'peak' },
],
max_hiking_difficulty: 4,
hiking_preferences: { avoid_steps: true },
};
@@ -58,6 +62,9 @@ describe('a hike the server plans through its points', () => {
);
expect(hikePlanRequestSchema.safeParse({ waypoints: two, tripId: 1 }).success).toBe(false);
expect(hikePlanRequestSchema.safeParse({ waypoints: [{ lat: 91, lng: 0 }, point(1)] }).success).toBe(false);
expect(
hikePlanRequestSchema.safeParse({ waypoints: [{ ...point(0), poi_category: 'volcano' }, point(1)] }).success,
).toBe(false);
});
it('HIKE-PLAN-SCHEMA-003: reads a routed plan with or without heights and grades', () => {
+4 -1
View File
@@ -28,13 +28,16 @@ export const HIKE_PLAN_PROFILE_SAMPLES = 200;
*/
export const HIKE_MAX_LINE_POINTS = 10_000;
const { lat, lng, name } = tourWaypointSchema.shape;
const { lat, lng, name, poi_category } = tourWaypointSchema.shape;
export const hikePlanPointSchema = z
.object({
lat,
lng,
name: name.describe('What the point is, such as a car park, a hut or a summit, where it has a name'),
poi_category: poi_category.describe(
'What kind of place the point is, where it is one: peak for a summit, shelter for a hut, drinking_water, viewpoint',
),
})
.strict();
export type HikePlanPoint = z.infer<typeof hikePlanPointSchema>;
+192
View File
@@ -0,0 +1,192 @@
import type { HourlyEntry, WeatherResult } from '../weather/weather.schema';
import {
arrivalsOf,
HIKE_DAYLIGHT_HOURS,
HIKE_SCHEDULE_HOURS,
hikeScheduleOf,
hikeScheduleSchema,
hikeWarningSchema,
type HikeScheduleInput,
} from './hike-schedule';
import { describe, expect, it } from 'vitest';
/** A day's hours, clear, 10 % rain, 12 km/h of wind, with the hours `over` names changed. */
function hoursOf(over: Record<number, Partial<HourlyEntry>> = {}): HourlyEntry[] {
return Array.from({ length: 24 }, (_, hour) => ({
hour,
temp: 8 + hour / 2,
precipitation: 0,
precipitation_probability: 10,
main: 'Clear',
wind: 12.4,
humidity: 60,
...over[hour],
}));
}
/** A day's weather; a forecast says the day's highest likelihood of rain beside its hours, as Open-Meteo's does. */
function day(type: 'forecast' | 'climate', over: Record<number, Partial<HourlyEntry>> = {}): WeatherResult {
return {
type,
temp: 14,
main: 'Clear',
description: '',
sunrise: '06:30',
sunset: '19:00',
...(type === 'forecast' ? { precipitation_probability_max: 10 } : {}),
hourly: hoursOf(over),
};
}
const input = (over: Partial<HikeScheduleInput> = {}): HikeScheduleInput => ({
date: '2027-07-10',
dayId: 4,
start: '08:00',
minutes: 200,
weather: day('forecast'),
...over,
});
const kinds = (over: Partial<HikeScheduleInput>) => hikeScheduleOf(input(over)).warnings.map(({ kind }) => kind);
describe('the day of a hike with a start', () => {
it('HIKE-SCHED-001: arrives the walking time after the start, and shows the hours from one before to one after', () => {
const { schedule, warnings } = hikeScheduleOf(input());
expect(hikeScheduleSchema.parse(schedule)).toEqual(schedule);
expect(schedule).toMatchObject({
date: '2027-07-10',
day_id: 4,
start: '08:00',
arrive: '11:20',
next_day: false,
sunrise: '06:30',
sunset: '19:00',
latest_start: null,
weather: 'forecast',
});
expect(schedule.hours.map(({ hour }) => hour)).toEqual([7, 8, 9, 10, 11, 12]);
expect(schedule.hours[1]).toEqual({ hour: 8, main: 'Clear', temp_c: 12, rain_pct: 10, wind_kmh: 12 });
expect(warnings).toEqual([]);
});
it('HIKE-SCHED-002: warns of a start before sunrise and of an arrival after sunset, at the sun times', () => {
expect(hikeScheduleOf(input({ start: '05:45' })).warnings).toEqual([{ kind: 'dark_start', at: '06:30' }]);
const late = hikeScheduleOf(input({ start: '16:00' }));
expect(late.schedule.arrive).toBe('19:20');
expect(late.warnings).toEqual([{ kind: 'dark_end', at: '19:00' }]);
expect(kinds({ start: '04:00', minutes: 16 * 60 })).toEqual(['dark_start', 'dark_end']);
});
it('HIKE-SCHED-003: warns of dusk for an arrival within the hour before sunset, 59 minutes yes and 61 no', () => {
// A walk of 3 h 20 min against a sunset at 19:00.
expect(kinds({ start: '14:41' })).toEqual(['dusk']);
expect(kinds({ start: '14:39' })).toEqual([]);
expect(kinds({ start: '14:40' })).toEqual(['dusk']);
expect(kinds({ start: '15:40' })).toEqual(['dusk']);
expect(hikeScheduleOf(input({ start: '14:41' })).warnings).toEqual([{ kind: 'dusk', at: '19:00' }]);
});
it('HIKE-SCHED-004: warns of thunder and rain only in an hour walked, and only on a forecast', () => {
const stormy = { 10: { main: 'Thunderstorm' }, 13: { main: 'Thunderstorm' }, 9: { precipitation_probability: 60 } };
const { warnings } = hikeScheduleOf(input({ weather: day('forecast', stormy) }));
expect(warnings).toEqual([
{ kind: 'thunder', at: '10:00' },
{ kind: 'rain', at: '09:00' },
]);
for (const warning of warnings) expect(hikeWarningSchema.parse(warning)).toEqual(warning);
// A storm after the arrival (11:20) or before the start is no warning; rain at 59 % neither.
const outside = { 12: { main: 'Thunderstorm' }, 7: { main: 'Thunderstorm' }, 9: { precipitation_probability: 59 } };
expect(kinds({ weather: day('forecast', outside) })).toEqual([]);
// The hour the hike starts in warns from the start, not from the full hour.
expect(
hikeScheduleOf(input({ start: '08:30', weather: day('forecast', { 8: { main: 'Thunderstorm' } }) })).warnings,
).toEqual([{ kind: 'thunder', at: '08:30' }]);
// An arrival on the full hour walks nothing of the hour that follows.
expect(kinds({ minutes: 180, weather: day('forecast', { 11: { main: 'Thunderstorm' } }) })).toEqual([]);
const climate = hikeScheduleOf(input({ weather: day('climate', stormy) }));
expect(climate.warnings).toEqual([]);
expect(climate.schedule.weather).toBe('climate');
expect(climate.schedule.hours.every((hour) => hour.rain_pct === null)).toBe(true);
});
it('HIKE-SCHED-012: shows no likelihood of rain for a past day as observed, and warns of its storms only', () => {
// A past day is answered as a forecast without the day's highest likelihood, its hours at 0 %.
const { precipitation_probability_max: _max, ...observed } = day('forecast', {
9: { main: 'Thunderstorm', precipitation_probability: 0 },
10: { precipitation_probability: 90 },
});
const past = hikeScheduleOf(input({ weather: observed }));
expect(past.schedule.weather).toBe('forecast');
expect(past.schedule.hours.every((hour) => hour.rain_pct === null)).toBe(true);
expect(past.warnings).toEqual([{ kind: 'thunder', at: '09:00' }]);
});
it('HIKE-SCHED-005: puts an arrival past midnight on the next day, after sunset', () => {
const { schedule, warnings } = hikeScheduleOf(input({ start: '20:30', minutes: 240 }));
expect(schedule).toMatchObject({ arrive: '00:30', next_day: true });
expect(schedule.hours.map(({ hour }) => hour)).toEqual([19, 20, 21, 22, 23]);
expect(warnings.map(({ kind }) => kind)).toEqual(['dark_end']);
});
it('HIKE-SCHED-006: shows at most the first hours of a very long hike', () => {
const { schedule } = hikeScheduleOf(input({ start: '00:10', minutes: 23 * 60 }));
expect(schedule.hours).toHaveLength(HIKE_SCHEDULE_HOURS);
expect(schedule.hours[0]?.hour).toBe(0);
});
});
describe('the day of a hike without a start', () => {
it('HIKE-SCHED-007: says the latest start that ends half an hour before sunset, with every second hour of daylight', () => {
const { schedule, warnings } = hikeScheduleOf(input({ start: null }));
expect(schedule).toMatchObject({ start: null, arrive: null, next_day: false, latest_start: '15:10' });
expect(schedule.hours.map(({ hour }) => hour)).toEqual([7, 9, 11, 13, 15, 17]);
expect(warnings).toEqual([]);
const long = hikeScheduleOf(
input({ start: null, weather: { ...day('forecast'), sunrise: '04:00', sunset: '22:00' } }),
);
expect(long.schedule.hours).toHaveLength(HIKE_DAYLIGHT_HOURS);
});
it('HIKE-SCHED-008: has no latest start where the hike does not fit into the daylight', () => {
expect(hikeScheduleOf(input({ start: null, minutes: 720 })).schedule.latest_start).toBe('06:30');
expect(hikeScheduleOf(input({ start: null, minutes: 721 })).schedule.latest_start).toBeNull();
});
});
describe('a hike whose weather could not be had', () => {
it('HIKE-SCHED-009: keeps the start and arrival, without sun, hours or warnings', () => {
for (const weather of [null, { temp: 0, main: '', description: '', type: '', error: 'no_forecast' }]) {
const { schedule, warnings } = hikeScheduleOf(input({ start: '05:00', weather }));
expect(schedule).toEqual({
date: '2027-07-10',
day_id: 4,
start: '05:00',
arrive: '08:20',
next_day: false,
sunrise: null,
sunset: null,
latest_start: null,
weather: null,
hours: [],
});
expect(warnings).toEqual([]);
}
const unknown = hikeScheduleOf(input({ start: null, weather: { ...day('forecast'), type: 'current' } }));
expect(unknown.schedule).toMatchObject({ weather: null, latest_start: null, hours: [] });
});
it('HIKE-SCHED-010: reads no sun from a time it cannot read, and no hours without the sun', () => {
const { schedule } = hikeScheduleOf(
input({ start: null, weather: { ...day('forecast'), sunrise: '', sunset: null } }),
);
expect(schedule).toMatchObject({ sunrise: null, sunset: null, latest_start: null, hours: [] });
});
});
describe('when each point is reached', () => {
it('HIKE-SCHED-011: adds the walking minutes to the start, past midnight onto the next day, none without a start', () => {
expect(arrivalsOf('08:00', [0, 95, null, 1000])).toEqual(['08:00', '09:35', null, '00:40']);
expect(arrivalsOf(null, [0, 95])).toEqual([null, null]);
});
});
+216
View File
@@ -0,0 +1,216 @@
import { idSchema } from '../common/primitives.schema';
import { clockTimeSchema } from '../place/place-hours';
import type { WeatherResult } from '../weather/weather.schema';
import { z } from 'zod';
/**
* A hike's day: when it starts and arrives, the sun, the weather by the hour
* and what to look out for, from the day's detailed weather at one point of
* the way (its highest where the heights are known). Pure: the server asks the
* weather and hands it in, the rules are here once.
*
* - The arrival is the start plus the walking time; past midnight it is on
* the next day.
* - With a start, the hours run from the one before it to the one after the
* arrival. Without one, every second hour of daylight is shown, and the
* latest start that still ends half an hour before sunset (none where that
* lies before sunrise).
* - A start before sunrise, an arrival after sunset or within the hour before
* it are warned of; so is the first walking hour with a thunderstorm or
* rain more likely than not, on a forecast only, since a day further out is
* read from last year's weather.
* - How likely rain is shows only where a forecast says it; a past day,
* answered as observed, shows none and warns of no rain.
* - Without the weather there are no sun times, no hours and no warnings; the
* start and the arrival still stand.
*/
/** The most hours a schedule shows; a longer hike shows its first ones. */
export const HIKE_SCHEDULE_HOURS = 18;
/** The most hours of daylight shown for a hike without a start, every second one. */
export const HIKE_DAYLIGHT_HOURS = 8;
/** How long before sunset an arrival is close enough to dusk to be warned of, in minutes. */
export const HIKE_DUSK_MINUTES = 60;
/** How long before sunset the latest start has a hike back, in minutes. */
export const HIKE_SUNSET_MARGIN_MINUTES = 30;
/** How likely rain is, in percent, before a walking hour warns of it. */
export const HIKE_RAIN_PCT = 60;
/** The weather a schedule warns of: thunderstorms, rain, an end after dark, a start before light, an end at dusk. */
export const HIKE_WARNING_KINDS = ['thunder', 'rain', 'dark_end', 'dark_start', 'dusk'] as const;
const MINUTES_A_DAY = 24 * 60;
export const hikeScheduleHourSchema = z.object({
hour: z.number().int().min(0).max(23),
/** The hour's weather in one word, as the weather of a day names it (Clear, Rain, Thunderstorm). */
main: z.string(),
temp_c: z.number().int(),
/** Null where the day's weather knows no likelihood: last year's weather, or a past day as observed. */
rain_pct: z.number().int().min(0).max(100).nullable(),
wind_kmh: z.number().int().nonnegative().nullable(),
});
export type HikeScheduleHour = z.infer<typeof hikeScheduleHourSchema>;
export const hikeScheduleSchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
/** The trip's day the hike is planned on; null where only a date was given. */
day_id: idSchema.nullable(),
start: clockTimeSchema.nullable(),
arrive: clockTimeSchema.nullable(),
/** The arrival falls on the day after the start. */
next_day: z.boolean(),
sunrise: clockTimeSchema.nullable(),
sunset: clockTimeSchema.nullable(),
/** Without a start: the latest one that ends half an hour before sunset; null where none does. */
latest_start: clockTimeSchema.nullable(),
/** A forecast, last year's weather for a day further out (climate), or none to be had. */
weather: z.enum(['forecast', 'climate']).nullable(),
hours: z.array(hikeScheduleHourSchema).max(HIKE_SCHEDULE_HOURS),
});
export type HikeSchedule = z.infer<typeof hikeScheduleSchema>;
export const hikeWarningSchema = z.object({
kind: z.enum(HIKE_WARNING_KINDS),
/** The clock it is about: the first such hour, or the sunrise or sunset passed. */
at: clockTimeSchema.nullable(),
});
export type HikeWarning = z.infer<typeof hikeWarningSchema>;
export interface HikeScheduleInput {
date: string;
dayId: number | null;
/** HH:MM, or null where the hike has no start yet. */
start: string | null;
/** The walking time. */
minutes: number;
/** The day's detailed weather, or null where it could not be had. */
weather: WeatherResult | null;
}
/** Minutes after midnight of a clock, null where it is no clock. */
function minutesOf(clock: string | null | undefined): number | null {
if (!clock || !clockTimeSchema.safeParse(clock).success) return null;
return Number(clock.slice(0, 2)) * 60 + Number(clock.slice(3, 5));
}
/** A time of day as a clock, past midnight wrapped onto the next day. */
function clockOf(minutes: number): string {
const within = ((Math.round(minutes) % MINUTES_A_DAY) + MINUTES_A_DAY) % MINUTES_A_DAY;
return `${String(Math.floor(within / 60)).padStart(2, '0')}:${String(within % 60).padStart(2, '0')}`;
}
/** The kind of weather a day was answered with, or null for no weather. */
function weatherKindOf(weather: WeatherResult | null): HikeSchedule['weather'] {
if (!weather || weather.error) return null;
return weather.type === 'forecast' || weather.type === 'climate' ? weather.type : null;
}
function hourOf(entry: NonNullable<WeatherResult['hourly']>[number], likelihood: boolean): HikeScheduleHour {
return {
hour: entry.hour,
main: entry.main,
temp_c: Math.round(entry.temp),
rain_pct: likelihood ? Math.max(0, Math.min(100, Math.round(entry.precipitation_probability))) : null,
wind_kmh: Number.isFinite(entry.wind) ? Math.max(0, Math.round(entry.wind)) : null,
};
}
/** The hours from `first` to `last` the weather has, every `step`, at most `most`. */
function hoursBetween(hourly: readonly HikeScheduleHour[], first: number, last: number, step: number, most: number) {
return hourly
.filter((entry) => entry.hour >= first && entry.hour <= last && (entry.hour - first) % step === 0)
.slice(0, most);
}
/** The first hour walked whose weather matches, as the clock it starts at (never before the start). */
function firstWalked(
hourly: readonly HikeScheduleHour[],
start: number,
arrive: number,
matches: (hour: HikeScheduleHour) => boolean,
) {
const first = Math.floor(start / 60);
const last = Math.max(first, Math.ceil(arrive / 60) - 1);
const hit = hourly.find((entry) => entry.hour >= first && entry.hour <= last && matches(entry));
return hit ? clockOf(Math.max(hit.hour * 60, start)) : null;
}
/** The warnings of a hike with a start, in the order they matter most: the dark first. */
function warningsOf(
start: number,
arrive: number,
sun: { rise: number | null; set: number | null },
hourly: readonly HikeScheduleHour[],
kind: HikeSchedule['weather'],
): HikeWarning[] {
const warnings: HikeWarning[] = [];
if (sun.rise !== null && start < sun.rise) warnings.push({ kind: 'dark_start', at: clockOf(sun.rise) });
if (sun.set !== null && arrive > sun.set) warnings.push({ kind: 'dark_end', at: clockOf(sun.set) });
else if (sun.set !== null && sun.set - arrive <= HIKE_DUSK_MINUTES)
warnings.push({ kind: 'dusk', at: clockOf(sun.set) });
if (kind !== 'forecast') return warnings;
const thunder = firstWalked(hourly, start, arrive, (hour) => hour.main === 'Thunderstorm');
if (thunder) warnings.push({ kind: 'thunder', at: thunder });
const rain = firstWalked(hourly, start, arrive, (hour) => (hour.rain_pct ?? 0) >= HIKE_RAIN_PCT);
if (rain) warnings.push({ kind: 'rain', at: rain });
return warnings;
}
/** A hike's day by the rules above: its schedule, and what to look out for. */
export function hikeScheduleOf(input: HikeScheduleInput): { schedule: HikeSchedule; warnings: HikeWarning[] } {
const kind = weatherKindOf(input.weather);
const known = kind === null ? null : input.weather;
// A forecast says how likely rain is, with the day's highest likelihood beside its hours. Last year's
// weather does not, nor a past day as observed, which is answered as a forecast without it.
const likelihood = known?.type === 'forecast' && known.precipitation_probability_max !== undefined;
const hourly = (known?.hourly ?? []).map((entry) => hourOf(entry, likelihood));
const sun = { rise: minutesOf(known?.sunrise), set: minutesOf(known?.sunset) };
const start = minutesOf(input.start);
const minutes = Math.max(0, Math.round(input.minutes));
const base = {
date: input.date,
day_id: input.dayId,
sunrise: sun.rise === null ? null : clockOf(sun.rise),
sunset: sun.set === null ? null : clockOf(sun.set),
weather: kind,
};
if (start === null) {
const latest = sun.set === null ? null : sun.set - HIKE_SUNSET_MARGIN_MINUTES - minutes;
const daylight =
sun.rise === null || sun.set === null
? []
: hoursBetween(hourly, Math.ceil(sun.rise / 60), Math.ceil(sun.set / 60) - 1, 2, HIKE_DAYLIGHT_HOURS);
return {
schedule: {
...base,
start: null,
arrive: null,
next_day: false,
latest_start: latest !== null && sun.rise !== null && latest >= sun.rise ? clockOf(latest) : null,
hours: daylight,
},
warnings: [],
};
}
const arrive = start + minutes;
const first = Math.floor(start / 60) - 1;
const last = Math.floor(arrive / 60) + 1;
return {
schedule: {
...base,
start: clockOf(start),
arrive: clockOf(arrive),
next_day: arrive >= MINUTES_A_DAY,
latest_start: null,
hours: hoursBetween(hourly, first, last, 1, HIKE_SCHEDULE_HOURS),
},
warnings: warningsOf(start, arrive, sun, hourly, kind),
};
}
/** The clock of each point reached `minutes` after a start, null for every point without one. */
export function arrivalsOf(start: string | null, minutes: ReadonlyArray<number | null>): Array<string | null> {
const from = minutesOf(start);
return minutes.map((walked) => (from === null || walked === null ? null : clockOf(from + walked)));
}
+99
View File
@@ -0,0 +1,99 @@
import { haversineMetres } from '../geo/haversine';
import {
cumulativeMetres,
legMinutes,
nearestAlongM,
pointsPerLeg,
pointsPerLine,
type AlongPoint,
} from './line-along';
import { describe, expect, it } from 'vitest';
/** A point `k` steps of a ten-thousandth of a degree north of 47° N on 11° E, about 11 m each, at `height`. */
const north = (k: number, height?: number): AlongPoint =>
height === undefined ? [47 + k / 1e4, 11] : [47 + k / 1e4, 11, height];
const STEP_M = haversineMetres(47, 11, 47.0001, 11);
describe('metres along a line', () => {
it('LINE-ALONG-001: measures each point from the start, the first at nothing', () => {
const along = cumulativeMetres([north(0), north(1), north(3)]);
expect(along[0]).toBe(0);
expect(along[1]).toBeCloseTo(STEP_M, 6);
expect(along[2]).toBeCloseTo(3 * STEP_M, 6);
expect(cumulativeMetres([])).toEqual([]);
});
it('LINE-ALONG-002: finds the spot nearest a point beside the line, and holds it to the line ends', () => {
const line = [north(0), north(10), north(20)];
expect(nearestAlongM(line, 47.0005, 11.0003)).toBeCloseTo(5 * STEP_M, 0);
expect(nearestAlongM(line, 47.0015, 10.9998)).toBeCloseTo(15 * STEP_M, 0);
expect(nearestAlongM(line, 46.99, 11)).toBe(0);
expect(nearestAlongM(line, 47.01, 11)).toBeCloseTo(20 * STEP_M, 6);
expect(nearestAlongM([north(0)], 47, 11)).toBe(0);
expect(nearestAlongM([north(0), north(0)], 47, 11)).toBe(0);
});
it('LINE-ALONG-003: on a loop back to its start, places the start on the first pass and the far side halfway', () => {
const loop: AlongPoint[] = [
[47, 11],
[47.001, 11],
[47.001, 11.0015],
[47, 11.0015],
[47, 11],
];
const along = cumulativeMetres(loop);
const total = along[along.length - 1] ?? 0;
expect(nearestAlongM(loop, 47.00001, 11.00001)).toBeLessThan(5);
expect(nearestAlongM(loop, 47.0011, 11.0016)).toBeCloseTo(along[2] ?? 0, 0);
expect(along[2]).toBeCloseTo(total / 2, 0);
});
});
describe('the walking minutes of each leg', () => {
it('LINE-ALONG-004: splits the whole time over the legs, a climbing leg longer than a flat one, summing to the total', () => {
// Flat for 10 steps, then 10 steps climbing 30 m each: 300 m up.
const line = [north(0, 500), north(10, 500), ...Array.from({ length: 10 }, (_, k) => north(11 + k, 530 + 30 * k))];
const along = cumulativeMetres(line);
const atMs = [0, along[1] ?? 0, along[along.length - 1] ?? 0];
const legs = legMinutes(line, atMs, 75);
expect(legs).toHaveLength(2);
expect(legs.reduce((sum, leg) => sum + leg, 0)).toBe(75);
expect(legs[1]).toBeGreaterThan(3 * (legs[0] ?? 0));
expect(legs.every(Number.isInteger)).toBe(true);
});
it('LINE-ALONG-005: weighs legs without heights by their length, and rounds a time that is not whole', () => {
const line = [north(0), north(10), north(40)];
const legs = legMinutes(line, [0, 10 * STEP_M, 40 * STEP_M], 40.4);
expect(legs).toEqual([10, 30]);
// A leg as short as a few metres keeps a share of its own rather than none.
expect(legMinutes(line, [0, 3, 40 * STEP_M], 1000)[0]).toBeGreaterThan(0);
});
it('LINE-ALONG-006: splits evenly where no leg has any length, and gives nothing for a single point', () => {
const line = [north(0, 500), north(1, 510)];
expect(legMinutes(line, [0, 0, 0], 7)).toEqual([4, 3]);
expect(legMinutes(line, [0], 7)).toEqual([]);
expect(legMinutes(line, [0, STEP_M], -3)).toEqual([0]);
});
});
describe('the points each line gets for drawing', () => {
it('LINE-ALONG-007: shares the points by length, two at least for each, never more than a line has', () => {
const shares = pointsPerLine([1000, 3000], 100);
expect(shares).toEqual([26, 74]);
expect(pointsPerLine([1000, 3000], 100, [10, 5000])).toEqual([10, 74]);
expect(pointsPerLine([0, 0, 0], 4)).toEqual([2, 2, 2]);
expect(pointsPerLine([500, 0], 10)).toEqual([8, 2]);
expect(pointsPerLine([], 300)).toEqual([]);
});
it('LINE-ALONG-008: shares the points over the planned legs in order, a leg not planned drawing none', () => {
const legs = [{ lengthM: 1000, points: 5000 }, null, { lengthM: 3000, points: 5000 }];
expect(pointsPerLeg(legs, 100)).toEqual([26, null, 74]);
expect(pointsPerLeg([null, null], 300)).toEqual([null, null]);
expect(pointsPerLeg([{ lengthM: 10, points: 3 }], 300)).toEqual([3]);
expect(pointsPerLeg([], 300)).toEqual([]);
});
});
+172
View File
@@ -0,0 +1,172 @@
import { haversineMetres } from '../geo/haversine';
import { hikingTimeMinutes } from './tour-time-estimate';
/**
* Measures along a planned line, in metres from its start, the way the
* server answers a planned way and a chat card draws it: how far along each
* point lies, where along the line a point beside it is nearest, and how a
* walking time splits over the legs between the points a way was planned
* through. Pure, for the server and the client alike.
*/
const RAD = Math.PI / 180;
/** A point of a line, `[lat, lng]`, with its height in metres where the line has one (null where it is unknown). */
export type AlongPoint = readonly [number, number, ...Array<number | null>];
/** One stretch of a line: its two ends and how far along the line each lies. */
interface Stretch {
a: AlongPoint;
b: AlongPoint;
from: number;
to: number;
}
/** Metres along the line at each of its points, the first at 0. */
export function cumulativeMetres(line: readonly AlongPoint[]): number[] {
let along = 0;
let before: AlongPoint | undefined;
return line.map((point) => {
if (before) along += haversineMetres(before[0], before[1], point[0], point[1]);
before = point;
return along;
});
}
/** The stretches of a line in order, each with where along the line it starts and ends. */
function stretchesOf(line: readonly AlongPoint[]): Stretch[] {
const along = cumulativeMetres(line);
const stretches: Stretch[] = [];
line.forEach((b, index) => {
const a = line[index - 1];
if (a) stretches.push({ a, b, from: along[index - 1] ?? 0, to: along[index] ?? 0 });
});
return stretches;
}
/**
* Metres along the line to the spot of it nearest to a point, measured as
* cumulativeMetres measures the line. Where the line passes the point twice
* (a loop back to its start, a summit walked up and down one path) the first
* pass counts. A line of fewer than two points gives 0.
*/
export function nearestAlongM(line: readonly AlongPoint[], lat: number, lng: number): number {
// Laid flat around the point: east scaled by the latitude, so the nearest spot is found as on a map.
const east = Math.cos(lat * RAD);
let best = { gap: Number.POSITIVE_INFINITY, at: 0 };
for (const { a, b, from, to } of stretchesOf(line)) {
const dx = (b[1] - a[1]) * east;
const dy = b[0] - a[0];
const px = (lng - a[1]) * east;
const py = lat - a[0];
const span = dx * dx + dy * dy;
const t = span === 0 ? 0 : Math.max(0, Math.min(1, (px * dx + py * dy) / span));
const gap = Math.hypot(px - t * dx, py - t * dy);
if (gap < best.gap) best = { gap, at: from + t * (to - from) };
}
return best.at;
}
/** The height of a point, null where it has none. */
function heightOf(point: AlongPoint): number | null {
const height = point[2];
return typeof height === 'number' && Number.isFinite(height) ? height : null;
}
/** The climb and descent between two places along the line, each stretch counted for its part between them. */
function climbBetween(stretches: readonly Stretch[], from: number, to: number) {
let climb = 0;
let descent = 0;
for (const stretch of stretches) {
const inside = Math.min(to, stretch.to) - Math.max(from, stretch.from);
const start = heightOf(stretch.a);
const end = heightOf(stretch.b);
if (inside <= 0 || stretch.to <= stretch.from || start === null || end === null) continue;
const change = ((end - start) * inside) / (stretch.to - stretch.from);
if (change > 0) climb += change;
else descent -= change;
}
return { climb, descent };
}
/**
* Whole numbers in proportion to `weights` that sum to `total` exactly: each
* share rounded down, what is left over handed to the largest remainders.
* Weights that are all nothing split the total evenly.
*/
function sharesOf(weights: readonly number[], total: number): number[] {
const sum = weights.reduce((all, weight) => all + weight, 0);
const parts = sum > 0 ? weights : weights.map(() => 1);
const whole = parts.reduce((all, weight) => all + weight, 0);
const exact = parts.map((weight) => (whole > 0 ? (weight * total) / whole : 0));
const shares = exact.map((value) => Math.floor(value));
let left = total - shares.reduce((all, share) => all + share, 0);
const order = exact
.map((value, index) => ({ index, rest: value - Math.floor(value) }))
.sort((a, b) => b.rest - a.rest);
for (const { index } of order) {
if (left <= 0) break;
shares[index] = (shares[index] ?? 0) + 1;
left -= 1;
}
return shares;
}
/**
* The walking minutes of each leg between the points a way was planned
* through (`atMs`, metres along the line in order), as the signposts' rule
* (DIN 33466, hikingTimeMinutes) weighs the leg's length, climb and descent,
* scaled so the legs sum to the way's whole time `total`. A line without
* heights weighs the legs by their length alone. One leg fewer than points.
*/
export function legMinutes(line: readonly AlongPoint[], atMs: readonly number[], total: number): number[] {
const stretches = stretchesOf(line);
const weights = atMs.slice(1).map((to, index) => {
const from = atMs[index] ?? 0;
const length = Math.max(0, to - from);
const { climb, descent } = climbBetween(stretches, from, from + length);
// The rule rounds to whole minutes and scales with the leg, so it is asked of a leg a thousand times
// as long and the answer shrunk back: a short leg keeps the seconds it walks instead of none.
return hikingTimeMinutes(length, climb * 1000, descent * 1000) / 1000;
});
return sharesOf(weights, Math.max(0, Math.round(total)));
}
/**
* How many of `most` points each of several lines gets for drawing, by its
* length: at least two each, so every line keeps its ends, and never more
* than its own points (`available`) where those are given.
*/
export function pointsPerLine(
lengths: readonly number[],
most: number,
available: readonly number[] = lengths.map(() => Number.POSITIVE_INFINITY),
): number[] {
const spare = Math.max(0, most - 2 * lengths.length);
const extra = sharesOf(
lengths.map((length) => Math.max(0, length)),
spare,
);
return extra.map((share, index) => Math.max(2, Math.min(available[index] ?? 2, 2 + share)));
}
/** A planned leg as pointsPerLeg weighs it: its length in metres and how many points its line holds. */
export interface DrawnLeg {
lengthM: number;
points: number;
}
/**
* How many of `most` points each leg of a plan draws with, shared by length
* as pointsPerLine shares them, in the order of the legs. A leg that was not
* planned (null) draws nothing and takes no share.
*/
export function pointsPerLeg(legs: ReadonlyArray<DrawnLeg | null>, most: number): Array<number | null> {
const drawn = legs.filter((leg): leg is DrawnLeg => leg !== null);
const shares = pointsPerLine(
drawn.map((leg) => leg.lengthM),
most,
drawn.map((leg) => leg.points),
).reverse();
return legs.map((leg) => (leg === null ? null : (shares.pop() ?? null)));
}
@@ -0,0 +1,14 @@
import { includeGeometryFlagSchema, TOUR_DISPLAY_LINE_POINTS } from './tour-display.schema';
import { describe, expect, it } from 'vitest';
describe('the line a planning tool adds for drawing', () => {
it('TOUR-DISPLAY-001: is asked for with a yes or a no, or left out, and says so to a model', () => {
expect(includeGeometryFlagSchema.parse(true)).toBe(true);
expect(includeGeometryFlagSchema.parse(false)).toBe(false);
expect(includeGeometryFlagSchema.parse(undefined)).toBeUndefined();
expect(includeGeometryFlagSchema.safeParse('true').success).toBe(false);
expect(includeGeometryFlagSchema.description).toContain('line_geometry');
expect(TOUR_DISPLAY_LINE_POINTS).toBe(300);
});
});
+18
View File
@@ -0,0 +1,18 @@
import { z } from 'zod';
/**
* What a planning tool answers for drawing. A tool that plans a way (a hike,
* a city walk, a paddle or a ski tour) can add the line it planned, thinned
* for display, under a key holding `geometry`, so a chat card draws it. Only
* when asked: a client that reads the answer as text has no use for a few
* hundred coordinates, and the assistant hides every such key from the model.
*/
/** The flag a planning tool takes to add its line for drawing. */
export const includeGeometryFlagSchema = z
.boolean()
.optional()
.describe('Adds the line for drawing as line_geometry; leave out');
/** The most points of a line a planning tool answers for drawing, all its legs together. */
export const TOUR_DISPLAY_LINE_POINTS = 300;
+78 -21
View File
@@ -1,5 +1,6 @@
import { HIKE_PLAN_LINE_POINTS } from './hike-plan.schema';
import {
HIKE_ALONG_MAX,
HIKE_MAX_WAYPOINTS,
HIKE_PROPOSAL_SAC_SEGMENTS,
hikeCreateRequestSchema,
@@ -29,38 +30,75 @@ const proposal = {
heights_known: true,
min_ele_m: 720,
max_ele_m: 1360,
line: [
line_geometry: [
[47.4, 11.1, 720],
[47.41, 11.1, 1360],
],
profile: [
profile_geometry: [
[0, 720],
[8200, 1360],
],
sac: { highest: 2, segments: [{ from_m: 0, to_m: 1200, sac: 2 }], unknown_share: 0.4 },
high_point: { lat: 47.41, lng: 11.1, ele_m: 1360, at_m: 8200 },
waypoints: [
{ lat: 47.4, lng: 11.1, name: 'Parking', at_m: 0 },
{ lat: 47.41, lng: 11.1, name: null, at_m: 8200 },
{
lat: 47.4,
lng: 11.1,
name: 'Parking',
poi_category: null,
at_m: 0,
snapped_m: 12,
minutes: 0,
arrival: '08:30',
},
{
lat: 47.41,
lng: 11.1,
name: null,
poi_category: 'peak',
at_m: 8200,
snapped_m: 4,
minutes: 200,
arrival: '11:50',
},
],
along: [{ name: 'Hut', category: 'shelter', lat: 47.405, lng: 11.101, at_m: 4100, off_m: 40 }],
max_hiking_difficulty: 3,
hiking_preferences: null,
round_trip: false,
day_id: 7,
date: '2027-07-10',
start_time: '08:30',
schedule: {
date: '2027-07-10',
day_id: 7,
start: '08:30',
arrive: '11:50',
next_day: false,
sunrise: '05:31',
sunset: '21:10',
latest_start: null,
weather: 'forecast',
hours: [{ hour: 8, main: 'Clear', temp_c: 14, rain_pct: 10, wind_kmh: 8 }],
},
warnings: [{ kind: 'thunder', at: '11:00' }],
};
describe('a hike proposed over MCP', () => {
it('TOUR-HIKE-SCHEMA-001: takes two points and everything a proposal can be asked with', () => {
expect(hikeProposeRequestSchema.safeParse({ waypoints: two }).success).toBe(true);
it('TOUR-HIKE-SCHEMA-001: takes a name, two points and everything a proposal can be asked with', () => {
expect(hikeProposeRequestSchema.safeParse({ name: 'Herzogstand', waypoints: two }).success).toBe(true);
const full = {
name: 'Herzogstand',
waypoints: [{ ...point(0), name: 'Parking' }, point(1), { ...point(2), name: 'Summit' }],
waypoints: [{ ...point(0), name: 'Parking' }, point(1), { ...point(2), name: 'Summit', poi_category: 'peak' }],
max_hiking_difficulty: 4,
hiking_preferences: { avoid_steps: true },
round_trip: true,
variant_label: 'by the lake',
day_id: 7,
date: '2027-07-10',
start_time: '08:30',
along: false,
include_geometry: true,
};
expect(hikeProposeRequestSchema.parse(full)).toEqual(full);
expect(hikeProposeRequestSchema.safeParse({ ...full, hiking_preferences: null }).success).toBe(true);
@@ -69,15 +107,19 @@ describe('a hike proposed over MCP', () => {
it('TOUR-HIKE-SCHEMA-002: refuses one point, too many, a long label, a date or a time it cannot read, and a key it does not know', () => {
const many = Array.from({ length: HIKE_MAX_WAYPOINTS + 1 }, (_, index) => point(index));
for (const request of [
{ waypoints: [point(0)] },
{ waypoints: many },
{ waypoints: two, variant_label: 'x'.repeat(41) },
{ waypoints: two, variant_label: ' ' },
{ waypoints: two, date: '10.07.2027' },
{ waypoints: two, start_time: '8:30' },
{ waypoints: two, start_time: '24:00' },
{ waypoints: two, max_hiking_difficulty: 7 },
{ waypoints: two, tripId: 1 },
{ waypoints: two },
{ name: 'X', waypoints: [point(0)] },
{ name: 'X', waypoints: many },
{ name: 'X', waypoints: two, variant_label: 'x'.repeat(41) },
{ name: 'X', waypoints: two, variant_label: ' ' },
{ name: 'X', waypoints: two, date: '10.07.2027' },
{ name: 'X', waypoints: two, start_time: '8:30' },
{ name: 'X', waypoints: two, start_time: '24:00' },
{ name: 'X', waypoints: two, max_hiking_difficulty: 7 },
{ name: 'X', waypoints: two, day_id: 0 },
{ name: 'X', waypoints: two, include_geometry: 'yes' },
{ name: 'X', waypoints: [point(0), { ...point(1), poi_category: 'volcano' }] },
{ name: 'X', waypoints: two, tripId: 1 },
]) {
expect(hikeProposeRequestSchema.safeParse(request).success, JSON.stringify(request).slice(0, 80)).toBe(false);
}
@@ -97,17 +139,29 @@ describe('a hike proposed over MCP', () => {
heights_known: false,
min_ele_m: null,
max_ele_m: null,
line: [],
profile: [],
line_geometry: undefined,
profile_geometry: undefined,
sac: null,
waypoints: proposal.waypoints.map((waypoint) => ({ ...waypoint, at_m: null })),
high_point: null,
along: null,
waypoints: proposal.waypoints.map((waypoint) => ({ ...waypoint, at_m: null, snapped_m: null, minutes: null })),
schedule: null,
warnings: [],
};
expect(hikeProposalSchema.safeParse(unrouted).success).toBe(true);
const { line_geometry: _line, profile_geometry: _profile, ...withoutGeometry } = proposal;
expect(hikeProposalSchema.safeParse(withoutGeometry).success).toBe(true);
});
it('TOUR-HIKE-SCHEMA-004: keeps a proposal within what a card draws', () => {
const long = Array.from({ length: HIKE_PLAN_LINE_POINTS + 1 }, () => [47.4, 11.1]);
expect(hikeProposalSchema.safeParse({ ...proposal, line: long }).success).toBe(false);
expect(hikeProposalSchema.safeParse({ ...proposal, line_geometry: long }).success).toBe(false);
const crowded = Array.from({ length: HIKE_ALONG_MAX + 1 }, () => proposal.along[0]);
expect(hikeProposalSchema.safeParse({ ...proposal, along: crowded }).success).toBe(false);
expect(hikeProposalSchema.safeParse({ ...proposal, warnings: [{ kind: 'snow', at: null }] }).success).toBe(false);
expect(
hikeProposalSchema.safeParse({ ...proposal, waypoints: [{ ...proposal.waypoints[0], arrival: '8:30' }] }).success,
).toBe(false);
const segments = Array.from({ length: HIKE_PROPOSAL_SAC_SEGMENTS + 1 }, (_, index) => ({
from_m: index,
to_m: index + 1,
@@ -119,7 +173,7 @@ describe('a hike proposed over MCP', () => {
false,
);
expect(hikeProposalSchema.safeParse({ ...proposal, duration_basis: 'guess' }).success).toBe(false);
expect(hikeProposalSchema.safeParse({ ...proposal, line: [[47.4, 191]] }).success).toBe(false);
expect(hikeProposalSchema.safeParse({ ...proposal, line_geometry: [[47.4, 191]] }).success).toBe(false);
expect(
hikeProposalSchema.safeParse({ ...proposal, waypoints: [{ ...proposal.waypoints[0], lat: -91 }] }).success,
).toBe(false);
@@ -146,6 +200,8 @@ describe('a hike created, changed or taken in from a file over MCP', () => {
hiking_preferences: { avoid_climbs: true },
round_trip: true,
planned_duration_minutes: 360,
day_id: 3,
start_time: '08:00',
}).success,
).toBe(true);
expect(hikeCreateRequestSchema.safeParse({ waypoints: two }).success).toBe(false);
@@ -154,6 +210,7 @@ describe('a hike created, changed or taken in from a file over MCP', () => {
hikeCreateRequestSchema.safeParse({ name: 'X', waypoints: two, planned_duration_minutes: 1441 }).success,
).toBe(false);
expect(hikeCreateRequestSchema.safeParse({ name: 'X', waypoints: two, variant_label: 'a' }).success).toBe(false);
expect(hikeCreateRequestSchema.safeParse({ name: 'X', waypoints: two, start_time: '25:00' }).success).toBe(false);
});
it('TOUR-HIKE-SCHEMA-007: changes a hike only against the revision it was read at', () => {
+96 -26
View File
@@ -11,7 +11,10 @@ import {
hikeSacSegmentSchema,
hikeTrailGradesSchema,
} from './hike-plan.schema';
import { hikeScheduleSchema, hikeWarningSchema, HIKE_WARNING_KINDS } from './hike-schedule';
import { includeGeometryFlagSchema } from './tour-display.schema';
import { tourGpxImportFieldsSchema } from './tour-gpx-import.schema';
import { tourPoiCategorySchema, type TourPoiCategory } from './tour-poi-category';
import {
hikingPreferencesSchema,
plannedTourDurationMinutesSchema,
@@ -36,7 +39,18 @@ export const HIKE_MAX_WAYPOINTS = 25;
/** The longest label a variant is given, short enough for a chip that compares two ways. */
export const HIKE_VARIANT_LABEL_MAX = 40;
/** The most graded stretches a proposal shows; more are joined, the harder grade kept. */
export const HIKE_PROPOSAL_SAC_SEGMENTS = 200;
export const HIKE_PROPOSAL_SAC_SEGMENTS = 80;
/** The kinds of places a proposal looks for along its way. */
export const HIKE_ALONG_CATEGORIES = [
'peak',
'shelter',
'drinking_water',
'viewpoint',
] as const satisfies readonly TourPoiCategory[];
/** How far from the way a place along it may lie, in metres. */
export const HIKE_ALONG_CORRIDOR_M = 250;
/** The most places along the way a proposal names, the nearest to it. */
export const HIKE_ALONG_MAX = 12;
/** The largest GPX text a tool takes in, as the road trip's GPX import does. */
export const TOUR_GPX_TEXT_MAX = 1_000_000;
@@ -62,11 +76,20 @@ const plannedMinutesSchema = plannedTourDurationMinutesSchema.describe(
'How long the travellers plan to be out, in minutes, breaks included; left out the walking time stands alone',
);
const hikeDateSchema = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.describe(
'The day the hike is planned for, YYYY-MM-DD, where it is on no day of the trip; day_id takes its own date',
);
const hikeDayIdSchema = idSchema.describe('The day of the trip the hike is planned on, as list_days gives it');
const hikeStartTimeSchema = clockTimeSchema.describe('When the hike starts, HH:MM');
export const hikeProposeRequestSchema = z
.object({
name: tourNameSchema
.optional()
.describe('What the hike is called, such as "Over the Herzogstand to the Heimgarten"'),
name: tourNameSchema.describe('What the hike is called, such as "Over the Herzogstand to the Heimgarten"'),
waypoints: hikeWaypointsSchema,
max_hiking_difficulty: hikeLimitSchema.optional(),
hiking_preferences: hikePreferencesSchema.optional(),
@@ -80,12 +103,16 @@ export const hikeProposeRequestSchema = z
.describe(
'A short name for this way when several are compared, such as "short and steep" or "by the lake"; given back as it is',
),
date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
day_id: hikeDayIdSchema.optional(),
date: hikeDateSchema.optional(),
start_time: hikeStartTimeSchema.optional(),
along: z
.boolean()
.optional()
.describe('The day the hike is planned for, YYYY-MM-DD; given back as it is'),
start_time: clockTimeSchema.optional().describe('When the hike starts, HH:MM; given back as it is'),
.describe(
'Looks for summits, huts, water and viewpoints along the way; true when left out, false when comparing ways',
),
include_geometry: includeGeometryFlagSchema,
})
.strict();
export type HikeProposeRequest = z.infer<typeof hikeProposeRequestSchema>;
@@ -114,14 +141,53 @@ export const hikeProposalSacSchema = hikeTrailGradesSchema.extend({
});
export type HikeProposalSac = z.infer<typeof hikeProposalSacSchema>;
/** The highest point of a way with heights, and where along it it lies. */
export const hikeHighPointSchema = z.object({
lat,
lng,
ele_m: z.number().int(),
at_m: z.number().int().nonnegative(),
});
export type HikeHighPoint = z.infer<typeof hikeHighPointSchema>;
/** A place along a proposed way: what it is, where, how far along the way and how far off it, in metres. */
export const hikeAlongPlaceSchema = z.object({
name: z.string().nullable(),
category: tourPoiCategorySchema,
lat,
lng,
at_m: z.number().int().nonnegative(),
off_m: z.number().int().nonnegative(),
});
export type HikeAlongPlace = z.infer<typeof hikeAlongPlaceSchema>;
/** A point a hike was asked through, as a proposal shows it. */
export const hikeProposalWaypointSchema = z.object({
lat,
lng,
name: z.string().nullable(),
poi_category: tourPoiCategorySchema.nullable(),
/** Where it lies along the way; null where no way was planned. */
at_m: z.number().int().nonnegative().nullable(),
/** How far the router moved it onto a way, in metres; null where no way was planned. */
snapped_m: z.number().int().nonnegative().nullable(),
/** The walking minutes from the start to it; null where no way was planned. */
minutes: z.number().int().nonnegative().nullable(),
/** When it is reached, HH:MM; null without a start. */
arrival: clockTimeSchema.nullable(),
});
export type HikeProposalWaypoint = z.infer<typeof hikeProposalWaypointSchema>;
/**
* What propose_hike answers, and what a chat card for the hike is drawn
* from: the way, its measures, its height profile and grades, the points it
* was asked through with where each lies along the way, and what the request
* named for the schedule, given back as it came.
* from: the way, its measures, its grades and highest point, the points it
* was asked through with where and when each is reached, the places along it,
* the day's schedule and what to look out for, and the request's own words
* given back. The line and the height profile come only when asked for
* (include_geometry), under keys a model never reads.
*/
export const hikeProposalSchema = z.object({
name: z.string().nullable(),
name: z.string(),
variant_label: z.string().nullable(),
route: hikeRouteOutcomeSchema,
/** Where a point lies further from any way than a tour allows: which one, and how far it was off. */
@@ -138,25 +204,25 @@ export const hikeProposalSchema = z.object({
heights_known: z.boolean(),
min_ele_m: z.number().int().nullable(),
max_ele_m: z.number().int().nullable(),
/** The way thinned for display, `[lat, lng, ele]` where the heights are known; empty where none was planned. */
line: z.array(hikeLinePointSchema).max(HIKE_PLAN_LINE_POINTS),
/** The height profile thinned for display, `[metres along the way, height in metres]`. */
profile: z.array(hikeProfileSampleSchema).max(HIKE_PLAN_PROFILE_SAMPLES),
/** The way thinned for display, `[lat, lng, ele]` where the heights are known; only with include_geometry. */
line_geometry: z.array(hikeLinePointSchema).max(HIKE_PLAN_LINE_POINTS).optional(),
/** The height profile thinned for display, `[metres along the way, height]`; only with include_geometry. */
profile_geometry: z.array(hikeProfileSampleSchema).max(HIKE_PLAN_PROFILE_SAMPLES).optional(),
sac: hikeProposalSacSchema.nullable(),
/** The points in the order walked, a round trip's way back included; at_m null where no way was planned. */
waypoints: z.array(
z.object({
lat,
lng,
name: z.string().nullable(),
at_m: z.number().int().nonnegative().nullable(),
}),
),
high_point: hikeHighPointSchema.nullable(),
/** The points in the order walked, a round trip's way back included. */
waypoints: z.array(hikeProposalWaypointSchema),
/** Summits, huts, water and viewpoints along the way, in the order passed; null where not looked for or not found. */
along: z.array(hikeAlongPlaceSchema).max(HIKE_ALONG_MAX).nullable(),
max_hiking_difficulty: tourMaxHikingDifficultySchema,
hiking_preferences: hikingPreferencesSchema.nullable(),
round_trip: z.boolean(),
day_id: idSchema.nullable(),
date: z.string().nullable(),
start_time: z.string().nullable(),
/** The day's schedule, sun and weather; null without a date. */
schedule: hikeScheduleSchema.nullable(),
warnings: z.array(hikeWarningSchema).max(HIKE_WARNING_KINDS.length),
});
export type HikeProposal = z.infer<typeof hikeProposalSchema>;
@@ -169,6 +235,10 @@ export const hikeCreateRequestSchema = z
hiking_preferences: hikePreferencesSchema.optional(),
round_trip: roundTripSchema.optional(),
planned_duration_minutes: plannedMinutesSchema.optional(),
day_id: hikeDayIdSchema.optional().describe('The day of the trip the hike is put on, as list_days gives it'),
start_time: hikeStartTimeSchema
.optional()
.describe('When the hike starts on that day, HH:MM, kept as the start of its visit; only with day_id'),
})
.strict();
export type HikeCreateRequest = z.infer<typeof hikeCreateRequestSchema>;
@@ -37,6 +37,7 @@ describe('a city walk prepared from a briefing (CW-11)', () => {
theme_places_max: 0,
corridor_m: 500,
lang: 'de',
include_geometry: true,
};
expect(cityWalkBriefingRequestSchema.parse(full)).toEqual(full);
});
@@ -64,6 +65,7 @@ describe('a city walk prepared from a briefing (CW-11)', () => {
},
{ ...briefing, theme_places_max: CITY_WALK_BRIEFING_LIMITS.themePlaces + 1 },
{ ...briefing, corridor_m: 5000 },
{ ...briefing, include_geometry: 'yes' },
];
expect(refused.map((body) => cityWalkBriefingRequestSchema.safeParse(body).success)).toEqual(
refused.map(() => false),
@@ -1,5 +1,6 @@
import { idSchema } from '../common/primitives.schema';
import { clockTimeSchema } from '../place/place-hours';
import { includeGeometryFlagSchema } from './tour-display.schema';
import { tourPoiSearchRequestSchema, walkThemeSchema } from './tour-poi-search.schema';
import { TOUR_STATION_NAME_MAX, tourDescriptionSchema, tourNameSchema, tourWaypointSchema } from './tours.schema';
@@ -103,6 +104,7 @@ export const cityWalkBriefingRequestSchema = z
.optional()
.describe('How far from the start, the wishes and the appointments to search, in metres; 1000 when left out'),
lang: tourPoiSearchRequestSchema.shape.lang,
include_geometry: includeGeometryFlagSchema,
})
.strict();
export type CityWalkBriefingRequest = z.infer<typeof cityWalkBriefingRequestSchema>;
+5 -5
View File
@@ -273,10 +273,10 @@ The saved tours of a trip, as the Tours list and the tour detail show them, how
| `link_tour_packing` | Points a tour at the packing list of its trip: `category` `null` for the whole list, or the name of one of its categories exactly as its items carry it (one the user can see an item of). Replaces what the tour pointed at; nothing in the packing list is copied or changed. Needs `place_edit` on the trip | `trips:write`, `packing:read` |
| `unlink_tour_packing` | Lets a tour stop pointing at its packing list. The list, its categories and its items stay exactly as they are. Needs `place_edit` on the trip | `trips:write` |
| `preview_google_maps_walk` | Reads the stops of a Google Maps walking route shared as a link (a `maps.app.goo.gl` short link or a `google.com/maps/dir` link), in order, for a city walk: each stop's name and position, or no position when the link gives none (nothing is looked up or guessed), and the long link that was read. Only a route Google Maps marks as on foot is read; a car, bike or transit route, or a link that does not say, is refused. A short link is followed only to Google Maps itself. Nothing is saved, and the line drawn in Google Maps is not taken over: TREK plans the way on foot between the stops again. Needs `place_edit` on the trip | `trips:read` |
| `propose_city_walk` | Proposes a city walk from a briefing without saving anything: start (and end), the time frame on a day, the themes (`culture`, `parks`, `food`, `mixed`), the stations the traveller wished for, the places of the trip it may use and fixed appointments. Places for the themes come from the same search as `search_tour_pois`, up to 8, each with its source, theme and category; nothing is made up. Answers the stations in order with the walking distance and time from the one before, the arrival and leave times, the stay where the traveller gave it (unknown otherwise, which makes later arrivals the earliest possible), and the opening hours checked against the arrival on that day. The order is chosen on estimates; the walk in that order is then planned on foot by the router TREK uses for road trips (the traveller's own routing server where they set one), whose legs give the distances and times. Where it gives no way, because the router cannot be reached or a station lies more than 300 m off every way, the times are estimates over the straight distance with an allowance for streets at 4.5 km/h, and the answer says so and why. Walking and stays are kept apart, a complete total only when every stay is known, with the minutes the frame leaves for the stays not known. Lists free time, the wishes and appointments that cannot be kept as asked, what was left out and why, and `stations_for_create` for `create_city_walk` | `trips:read` |
| `propose_city_walk` | Proposes a city walk from a briefing without saving anything: start (and end), the time frame on a day, the themes (`culture`, `parks`, `food`, `mixed`), the stations the traveller wished for, the places of the trip it may use and fixed appointments. Places for the themes come from the same search as `search_tour_pois`, up to 8, each with its source, theme and category; nothing is made up. Answers the stations in order with the walking distance and time from the one before, the arrival and leave times, the stay where the traveller gave it (unknown otherwise, which makes later arrivals the earliest possible), and the opening hours checked against the arrival on that day. The order is chosen on estimates; the walk in that order is then planned on foot by the router TREK uses for road trips (the traveller's own routing server where they set one), whose legs give the distances and times. Where it gives no way, because the router cannot be reached or a station lies more than 300 m off every way, the times are estimates over the straight distance with an allowance for streets at 4.5 km/h, and the answer says so and why. Walking and stays are kept apart, a complete total only when every stay is known, with the minutes the frame leaves for the stays not known. Lists free time, the wishes and appointments that cannot be kept as asked, what was left out and why, and `stations_for_create` for `create_city_walk`. With `include_geometry` it adds the way on foot for drawing (`line_geometry`, up to 300 points) where the router planned one | `trips:read` |
| `create_city_walk` | Creates a city walk from stations the traveller confirmed (for instance `stations_for_create` of `propose_city_walk`), through the same creation the planner's save uses: a key for every station, its name, stay, importance, protection, opening hours and the place of the trip it stands for. The way on foot is planned by the same router as for the proposal, and the walk is saved with it, its distance and its walking time. Where the router gives no way, the walk is saved with its stations only (`route_pending`), says why, and has no line, distance or walking time until someone opens it in the planner, where TREK plans the way on foot and the walk can be saved with it. Needs `place_edit` on the trip | `trips:write` |
| `propose_hike` | Plans a hike for the traveller to look at, without saving anything: the way on foot through its points in order (start, points on the way, end; `round_trip` comes back to the start), on paths no harder than `max_hiking_difficulty` on the SAC scale (T1 to T6, T2 when left out) and avoiding steep climbs, steps or ferries where there is a way round (`hiking_preferences`). The way is planned by TREK's Valhalla router with the planner's hiking costing (the instance an admin set, else the public FOSSGIS one), through the same paced queue as the road trip. Answers the distance, ascent and descent, the walking time without breaks (by DIN 33466 from the heights where every point has one, else the router's), the lowest and highest point, the SAC grades OpenStreetMap records along the way (the hardest, the graded stretches and how much is unknown, which is never read as easy), where each point lies along the way, and the line (up to 300 points) and height profile (up to 200 samples) for display. Where no way is planned it says why: no Valhalla (a routing server of the traveller's own knows no hiking limit), the router not answering, no way within the limit, a point more than 300 m from any way, or a way too long for one hike (past the router's own distance limit, or more than 10,000 points, whose heights would hold the router's queue up for everyone). A line's heights are kept for five minutes, so a hike created from the proposal does not ask for them again. `variant_label`, `date` and `start_time` are given back, so several ways can be compared | `trips:read` |
| `create_hike` | Creates a hike from points the traveller confirmed (usually those of `propose_hike`), through the same creation the planner's save uses: its points in order as start, points on the way and end, the way planned by the same router and saved whole with the height of every point, its distance, ascent, descent, walking time, hiking limit, preferences and planned time. Where the router does not answer, for the way or for its heights, the hike is saved with its points only (`route_pending`), says so, and TREK plans the way when someone opens it in the planner. Any other reason the proposal names (no Valhalla, no way within the limit, a point off every way, a way too long) refuses the hike with why, and nothing is saved, since the planner could not plan it either. Needs `place_edit` on the trip | `trips:write` |
| `propose_hike` | Plans a named hike for the traveller to look at, without saving anything: the way on foot through its points in order (start, points on the way, each with its kind of place where it is one, end; `round_trip` comes back to the start), on paths no harder than `max_hiking_difficulty` on the SAC scale (T1 to T6, T2 when left out) and avoiding steep climbs, steps or ferries where there is a way round (`hiking_preferences`). The way is planned by TREK's Valhalla router with the planner's hiking costing (the instance an admin set, else the public FOSSGIS one), through the same paced queue as the road trip. Answers the distance, ascent and descent, the walking time without breaks (by DIN 33466 from the heights where every point has one, else the router's), the lowest and highest point (with where it lies along the way), the SAC grades OpenStreetMap records along the way (the hardest, up to 80 graded stretches, neighbours joined with the harder grade kept, and how much is unknown, which is never read as easy), each point with where it lies along the way, how far it was moved onto a way, the walking minutes from the start and, with `start_time`, its arrival, and up to 12 summits, huts, drinking water and viewpoints within 250 m of the way (`along`; left out with `along: false`, and none where they could not be searched). With `day_id` (whose date it takes) or `date` it plans the day: the arrival (on the next day past midnight), sunrise and sunset and the weather by the hour at the highest point (to about a kilometre), from the forecast, as observed for a past day (which knows no likelihood of rain), or, further out, from last year's day; without a start the latest one that is back half an hour before sunset. It warns of a start before sunrise, an arrival after sunset or within the hour before it, and of the first walking hour with thunderstorms or rain more likely than not, these two on a forecast only; without the weather the day still answers, without sun, hours or warnings. With `include_geometry` it adds the line (up to 300 points) and height profile (up to 200 samples) for drawing (`line_geometry`, `profile_geometry`). Where no way is planned it says why: no Valhalla (a routing server of the traveller's own knows no hiking limit), the router not answering, no way within the limit, a point more than 300 m from any way, or a way too long for one hike (past the router's own distance limit, or more than 10,000 points, whose heights would hold the router's queue up for everyone). A planned way with every height is kept for 15 minutes (the last 64 per instance, up to 200,000 line points together), so a hike created or changed from the proposal saves the line it showed. `variant_label` is given back, so several ways can be compared | `trips:read` |
| `create_hike` | Creates a hike from points the traveller confirmed (usually those of `propose_hike`), through the same creation the planner's save uses: its points in order as start, points on the way and end with their names and kinds of place, the way planned by the same router (the line a proposal of the last 15 minutes showed) and saved whole with the height of every point, its distance, ascent, descent, walking time, hiking limit, preferences and planned time. Where the router does not answer, for the way or for its heights, the hike is saved with its points only (`route_pending`), says so, and TREK plans the way when someone opens it in the planner. Any other reason the proposal names (no Valhalla, no way within the limit, a point off every way, a way too long) refuses the hike with why, and nothing is saved, since the planner could not plan it either. With `day_id` it is put on that day of the trip in the same write, `start_time` kept as the start of that visit, and the trip is told of the tour and the visit once both are saved (`assignment` names the visit; `day_edit` is needed as well). Needs `place_edit` on the trip | `trips:write` |
| `update_tour` | Changes a saved hike against the `revision` it was read at: a new name, description or planned time keeps its way; new points, a new hiking limit or new preferences plan the way anew as `propose_hike` does, and so does a hike still waiting for its way. A hike changed since that revision is refused as a conflict before anything is planned, and a new way that cannot be planned, or not with its heights, is refused with the reason, leaving the hike as it was. A new point standing where a saved one stood (within 25 m) keeps that point's link to a place of the trip and its name. A hike with a section drawn by hand (a via ferrata, a free line, a ferry) or split into stages takes new points only in the planner, the hand-drawn one a new limit or new preferences too. A recorded track without points of its own takes only new points, which plan it over and keep the recording; every other tour type is refused (a city walk changes through `prepare_tour_change` and `save_tour_change`). The hike stays on its days. Needs `place_edit` on the trip | `trips:write` |
| `import_tour_gpx` | Takes in the tracks and routes of a GPX file, passed as its text, as tours of the trip, one each, through the same import as the planner's file import: a hike unless another type is named (a bike import with its profile, a run with its profile, a paddle import with its boat and water, a ski import with its discipline). A track already in the trip under the same name and start is skipped and counted; a file without a track or route is refused. Needs `place_edit` on the trip | `trips:write` |
| `prepare_tour_change` | Prepares a change to a saved city walk for the traveller to review, without saving anything: no tour, station, visit or day plan changes, and turning it down keeps the plan as it was. The changes apply in order and name the saved stations by their keys: `add` (a new station after a station; a spontaneous stop on the way is an add after the station the traveller is at), `remove`, `replace` and `move`. A protected station, or one visited or skipped on any day of the walk, is never removed, replaced or moved, nothing is put before the last station already done, and a leg drawn by hand stays as drawn, the closing leg of a loop included; such a change is refused, and with it the whole proposal, naming every change and why. Another type of tour, or a walk that is a recorded track without stations of its own, is refused as well: so far only a city walk takes prepared changes. The answer names the tour and the `revision` it was prepared on (a save has to name it, and a tour changed since is refused rather than overwritten), the stations after the change with what changed each and where it stood before, what was removed, the way on foot before and after planned by the same router TREK uses for city walks and its change in metres and minutes (the distance and walking time stored with the walk given apart), the stays as far as known before and after, every leg after the change with the new ones marked, `data_gaps` for what could not be worked out (no way from the router, stays not known, no arrival times, shaping points dropped because the leg they steered changed), a warning for a station of importance must removed or replaced, each new station that is not yet a place of the trip as `create_place` takes it to keep it as a place instead (a station linked to a place of the trip is left out), whether the traveller may change the trip's places at all (`can_save`) and the `proposal_id` a save of it names | `trips:read` |
@@ -284,8 +284,8 @@ The saved tours of a trip, as the Tours list and the tour detail show them, how
| `list_tour_change_steps` | Lists the changes saved to a tour with `save_tour_change`, the latest first: each step with when it was saved, the revision it was prepared on and the one it raised the tour to, and the changes as confirmed. `standing` says whether it can be taken back: `latest` (the tour's latest change), `undone` (taken back already, with when and the revision that raised the tour to), `later_change` (something was saved on top of it since, so taking it back would lose that) or `no_earlier_state` (its record of the tour before cannot be read). Edits saved in the planner are no steps, and a draft in the planner is never seen | `trips:read` |
| `prepare_tour_undo` | Shows what taking back a saved step would do, without changing anything: the step and its changes, the tour now (distance and walking time as stored, each station with whether it was visited or skipped on a day) and the tour exactly as it was before the step (its stations, each kept or coming back with where it stands now, and the distance and walking time stored then), the stations taking back removes, and the `step_id` and `expected_revision` that `undo_tour_change` names. Only the tour's latest change can be taken back; a step something was saved on top of since is refused as a conflict, so no later edit is lost, and so is one that would remove a station visited or skipped since or bring back a station whose place of the trip is gone. Visits of stations that stay, the days and the tour's name and notes are not touched. Not the planner's undo: discarding a draft in the planner never changes a saved tour. Also says whether the traveller may change the trip's places (`can_undo`) | `trips:read` |
| `undo_tour_change` | Takes back a saved step once the traveller said yes to the preview of exactly that step: `step_id` and `expected_revision` as `prepare_tour_undo` gave them, and `confirmed` set to true. Everything is checked again before anything is written, and a tour changed since the preview, a step no longer the latest change or taken back already, or a station visited or skipped meanwhile that would go, refuses it and leaves the tour as it is. Restores the tour whole or not at all, exactly as it was before the step: its line, its stations with their keys and the distance and walking time stored then. A step is taken back once, and taking it back raises the tour's revision like any other save. Needs `place_edit` on the trip | `trips:write` |
| `plan_paddle_route` | Plans the legs of a paddle tour on the water network TREK builds from the paddle regions an admin added: each leg (`from`, `to`) along rivers and canals and across lakes, carried round weirs, dams and waterfalls, through or round locks (`lock_policy`), with `boat` and `lake_style` (`direct` or `shore`). Answers per leg its length, how far its ends were moved onto the water and its stretches (water, lake, whitewater, boat pass, portage with its source, lock, gap); a leg that cannot be planned says why (`not_on_water`, `no_region`, `cross_region`, `no_water_path`, `busy`, `timeout`). Nothing is saved; a planning aid, never a safety or legal clearance | `trips:read` |
| `plan_ski_route` | Plans the legs of a ski and winter tour on the runs, lifts, cross-country trails, ski touring and winter paths of OpenStreetMap, which the Places host serves as one graph per ski area: for a `discipline` (`alpine`, `snowboard`, `cross_country` with a `technique`, `touring`, `snowshoe`, `winter_hike`) and its `options` (difficulty limit, freeride, lift classes to avoid, riding down, priority, pace, groomed or lit trails only, ascent along pistes, lifts at all). Answers per leg its length, climb and descent, its time split into skiing, lifts, waiting and walking, how far its ends were moved onto the network, its stretches and notes on what they rest on; a leg that cannot be planned says why (`not_on_piste`, `no_area`, `cross_area`, `disconnected`, `blocked_by_difficulty`, `blocked_by_lift_avoid`, `blocked_by_technique`, `needs_ride_down`, `uphill_only`, `loading`, `places_unavailable`, `disabled`, `busy`, `timeout`) and, where one helps, the one change that would plan it. No clicked point leaves the server. Times are estimates, nothing says what is open, groomed or safe today; nothing is saved | `trips:read` |
| `plan_paddle_route` | Plans the legs of a paddle tour on the water network TREK builds from the paddle regions an admin added: each leg (`from`, `to`) along rivers and canals and across lakes, carried round weirs, dams and waterfalls, through or round locks (`lock_policy`), with `boat` and `lake_style` (`direct` or `shore`). Answers per leg its length, how far its ends were moved onto the water and its stretches (water, lake, whitewater, boat pass, portage with its source, lock, gap) with where each starts and ends in metres along the leg; a leg that cannot be planned says why (`not_on_water`, `no_region`, `cross_region`, `no_water_path`, `busy`, `timeout`). Nothing is saved; a planning aid, never a safety or legal clearance. With `include_geometry` each planned leg adds its line for drawing (`line_geometry`), all legs together up to 300 points shared by length | `trips:read` |
| `plan_ski_route` | Plans the legs of a ski and winter tour on the runs, lifts, cross-country trails, ski touring and winter paths of OpenStreetMap, which the Places host serves as one graph per ski area: for a `discipline` (`alpine`, `snowboard`, `cross_country` with a `technique`, `touring`, `snowshoe`, `winter_hike`) and its `options` (difficulty limit, freeride, lift classes to avoid, riding down, priority, pace, groomed or lit trails only, ascent along pistes, lifts at all). Answers per leg its length, climb and descent, its time split into skiing, lifts, waiting and walking, how far its ends were moved onto the network, its stretches with where each starts and ends in metres along the leg, and notes on what they rest on; a leg that cannot be planned says why (`not_on_piste`, `no_area`, `cross_area`, `disconnected`, `blocked_by_difficulty`, `blocked_by_lift_avoid`, `blocked_by_technique`, `needs_ride_down`, `uphill_only`, `loading`, `places_unavailable`, `disabled`, `busy`, `timeout`) and, where one helps, the one change that would plan it. No clicked point leaves the server. Times are estimates, nothing says what is open, groomed or safe today; nothing is saved. With `include_geometry` each planned leg adds its line for drawing (`line_geometry`, with its heights), all legs together up to 300 points shared by length | `trips:read` |
### Road trip _(Road trip addon required)_