feat(tours): keep a ski tour's piste router options, build and time

A ski tour now saves the options its legs were planned with and the piste
data build they ran on, as one JSON column (a new migration, since the
ski migrations have shipped on this branch); the server keeps only the
options its discipline reads. Both stay off every other type. The refusal
of a ski time is lifted: the piste router works one out, and the planner
sends it only when every leg followed the piste data. get_tour_plan names
the legs of a tour the router planned as routed, and list_tours says what
the new fields hold.
This commit is contained in:
Maurice
2026-10-20 20:02:52 +02:00
parent c8eb510bd4
commit a85a50e186
15 changed files with 469 additions and 20 deletions
+2
View File
@@ -50,6 +50,7 @@ export class Tours {
track_started_at?: string | null;
track_ended_at?: string | null;
ski_discipline?: TToursSkiDiscipline | null;
ski_options?: string | null;
tourTypeRef!: Ref<TourTypes>;
tour_activities_collection = new Collection<TourActivities>(this);
tour_activities_collection1 = new Collection<TourActivities>(this);
@@ -181,6 +182,7 @@ export const ToursSchema = defineEntity({
track_started_at: p.type(DbTimestampType).nullable(),
track_ended_at: p.type(DbTimestampType).nullable(),
ski_discipline: p.enum(() => ToursSkiDiscipline).nullable(),
ski_options: p.text().nullable(),
tourTypeRef: () => p.manyToOne(TourTypes).ref().joinColumn('tour_type').hidden().index('idx_tours_tour_type'),
tour_activities_collection: () => p.oneToMany(TourActivities).mappedBy('place').hidden(),
tour_activities_collection1: () => p.oneToMany(TourActivities).mappedBy('trackPlace').hidden(),
+15
View File
@@ -8,6 +8,7 @@ import {
paddleLockSchema,
paddleRegionBoundarySchema,
paddleRegionGraphStatsSchema,
skiRouteOptionsSchema,
tourChangeSchema,
tourFixedLegSchema,
tourSourceHintChangeSchema,
@@ -21,6 +22,7 @@ import {
type PaddleLock,
type PaddleRegionBoundary,
type PaddleRegionGraphStats,
type SkiRouteOptions,
type TourChange,
type TourFixedLeg,
type TourSourceHintChange,
@@ -191,6 +193,19 @@ export const TOUR_HIKING_PREFERENCES: JsonColumn<HikingPreferences | null> = {
fallback: () => null,
};
/** What a ski tour's legs were planned with: the piste router's options and the piste data build they ran on. */
export interface TourSkiRoute {
options: SkiRouteOptions;
build: string | null;
}
/** A ski tour's piste router options. A broken row reads as none, and the planner offers the defaults. */
export const TOUR_SKI_ROUTE: JsonColumn<TourSkiRoute | null> = {
column: 'tours.ski_options',
schema: z.object({ options: skiRouteOptionsSchema, build: z.string().nullable() }).nullable(),
fallback: () => null,
};
/** A paddle object's own geometry (PAD-05A). A broken row has none, and the object is left out rather than drawn anywhere. */
export const PADDLE_FEATURE_GEOMETRY: JsonColumn<PaddleGeometry | null> = {
column: 'paddle_features.geometry',
+1
View File
@@ -37,4 +37,5 @@ export interface ToursTable {
track_started_at: InsertOptional<string | null>;
track_ended_at: InsertOptional<string | null>;
ski_discipline: InsertOptional<string | null>;
ski_options: InsertOptional<string | null>;
}
@@ -0,0 +1,18 @@
import { Migration } from '@mikro-orm/migrations';
/**
* A ski tour keeps what its legs were planned for: the piste router's options
* (difficulty limit, lifts to avoid, pace and the rest) and the build of the
* piste data they were planned on, as one JSON text written by the server
* (`TOUR_SKI_ROUTE` in json-columns.ts). NULL for every other type, for an
* imported track and for a ski tour saved before the router. Reopened, the
* tour is planned with the same options, and a newer build of the piste data
* can be offered instead of applied.
*/
export class Migration20200101046140_a_ski_tour_keeps_its_piste_router_options extends Migration {
override name = 'Migration20200101046140_a_ski_tour_keeps_its_piste_router_options';
override up(): void {
this.addSql('ALTER TABLE tours ADD COLUMN ski_options TEXT');
}
}
@@ -45,6 +45,8 @@ export interface TourListRow {
/** SKI-02: a ski tour's discipline and, for cross-country, its technique; NULL on every other type. */
ski_discipline: string | null;
ski_technique: string | null;
/** A ski tour's piste router options and build, still JSON text; absent where a read does not take it. */
ski_options?: string | null;
boat_type: string | null;
water_context: string | null;
paddle_speed_kmh: number | null;
@@ -123,6 +125,7 @@ export interface TourRow {
ski_technique: string | null;
track_started_at: string | null;
track_ended_at: string | null;
ski_options: string | null;
}
const _tourRowKeys: AssertRowKeys<TourRow, Tours> = true;
@@ -146,6 +149,8 @@ export interface TourInsert {
/** SKI-02: a ski tour's discipline and technique; left out, NULL. */
ski_discipline?: SkiDiscipline | null;
ski_technique?: SkiTechnique | null;
/** A ski tour's piste router options and build as JSON text; left out, NULL. */
ski_options?: string | null;
/** A paddle tour's boat, water and chosen speed; left out, a hike's NULLs. */
boat_type?: PaddleBoatType | null;
water_context?: PaddleWaterContext | null;
@@ -305,6 +310,7 @@ export class ToursRepository extends TrekRepository<Tours> {
't.run_pace_seconds_per_km as run_pace_seconds_per_km',
't.ski_discipline as ski_discipline',
't.ski_technique as ski_technique',
't.ski_options as ski_options',
't.boat_type as boat_type',
't.water_context as water_context',
't.paddle_speed_kmh as paddle_speed_kmh',
@@ -771,6 +777,7 @@ export class ToursRepository extends TrekRepository<Tours> {
run_pace_seconds_per_km: input.run_pace_seconds_per_km ?? null,
ski_discipline: input.ski_discipline ?? null,
ski_technique: input.ski_technique ?? null,
ski_options: input.ski_options ?? null,
boat_type: input.boat_type ?? null,
water_context: input.water_context ?? null,
paddle_speed_kmh: input.paddle_speed_kmh ?? null,
@@ -862,6 +869,7 @@ export class ToursRepository extends TrekRepository<Tours> {
run_pace_seconds_per_km: input.run_pace_seconds_per_km,
ski_discipline: input.ski_discipline,
ski_technique: input.ski_technique,
...(input.ski_options !== undefined ? { ski_options: input.ski_options } : {}),
boat_type: input.boat_type,
water_context: input.water_context,
paddle_speed_kmh: input.paddle_speed_kmh,
+9 -1
View File
@@ -2,7 +2,7 @@
* The read half of ToursService that needs no database: a tour as it is
* listed, and where it came from as its provenance names it.
*/
import { TOUR_HIKING_PREFERENCES, TOUR_TRACK_MARKS } from '../../db/json-columns';
import { TOUR_HIKING_PREFERENCES, TOUR_SKI_ROUTE, TOUR_TRACK_MARKS } from '../../db/json-columns';
import type { TourActivityRefRow } from '../../db/repositories/TourActivities.repository';
import type { TourWaypointRow } from '../../db/repositories/TourWaypoints.repository';
import type { TourListRow, TourProvenanceRow } from '../../db/repositories/Tours.repository';
@@ -32,6 +32,13 @@ function stageStopsOrNull(points: Parameters<typeof tourStageStopsOf>[0]): TourL
return stops.length > 0 ? stops : null;
}
/** A ski tour's piste router options and the build its legs ran on, where it keeps them; nothing for any other tour. */
function skiRouteFacts(r: TourListRow): Pick<TourListItem, 'ski_options' | 'ski_route_build'> {
if (r.tour_type !== 'ski' || !r.ski_options) return {};
const route = decodeJson(TOUR_SKI_ROUTE, r.ski_options, 'tour');
return route ? { ski_options: route.options, ski_route_build: route.build } : {};
}
/**
* A tour as it is listed; a city walk with the stations its points make and their stays (CW-02, CW-03),
* every tour with the recordings assigned to it and the tour it documents itself (ACT-01).
@@ -64,6 +71,7 @@ export function tourItemOf(
run_pace_seconds_per_km: r.run_pace_seconds_per_km,
ski_discipline: r.ski_discipline as SkiDiscipline | null,
ski_technique: r.ski_technique as SkiTechnique | null,
...skiRouteFacts(r),
boat_type: r.boat_type as PaddleBoatType | null,
water_context: r.water_context as PaddleWaterContext | null,
paddle_speed_kmh: r.paddle_speed_kmh,
+14 -1
View File
@@ -3,7 +3,7 @@
* or an update stores, the checks a write passes before its transaction, and
* the keys its points keep.
*/
import { TOUR_HIKING_PREFERENCES } from '../../db/json-columns';
import { TOUR_HIKING_PREFERENCES, TOUR_SKI_ROUTE } from '../../db/json-columns';
import type { TourUpdate } from '../../db/repositories/Tours.repository';
import { encodeJson } from '../../utils/json-column';
import { badRequest } from '../common/domain-error';
@@ -14,6 +14,7 @@ import {
maxTourMinutesFor,
plannedTimeLimitMessage,
plannedTourDurationMinutesSchema,
skiOptionsForDiscipline,
tourStageCountOf,
tourStageIssues,
tourWebsiteSchema,
@@ -41,6 +42,17 @@ function skiFields(input: TourCreateCommand): Pick<TourUpdate, 'ski_discipline'
};
}
/**
* A save's piste router options as stored, with the build its legs ran on:
* only a ski tour keeps them, and only the options its discipline reads.
* Without options (an older client, a type without them) none are kept.
*/
function skiRouteField(input: TourCreateCommand): string | null {
if (input.tour_type !== 'ski' || !input.ski_discipline || !input.ski_options) return null;
const options = skiOptionsForDiscipline(input.ski_discipline, input.ski_options);
return encodeJson(TOUR_SKI_ROUTE, { options, build: input.ski_route_build ?? null });
}
/**
* RUN-01: a save's run profile and pace as stored. Only a run keeps them;
* another type takes them off. A run without a pace keeps none: its time stays
@@ -92,6 +104,7 @@ export function tourRouteFields(input: TourCreateCommand): TourUpdate {
bike_profile: input.tour_type === 'bike' ? (input.bike_profile ?? null) : null,
...runFields(input),
...skiFields(input),
ski_options: skiRouteField(input),
boat_type: input.boat_type ?? null,
water_context: input.water_context ?? null,
paddle_speed_kmh: input.paddle_speed_kmh ?? null,
+6 -3
View File
@@ -61,8 +61,11 @@ function planOf({ detail, days, line, names }: PlanRead, run: WalkProgress | nul
waypoints: planPoints(detail.tour.tour_type, named, run),
// ACT-01: the recorded tracks assigned to the tour, with their values and without their lines.
activities: (detail.activities ?? []).map(withoutLine),
// SKI-02: a planned ski tour's legs are drawn straight between its points; no router planned them.
legs: planLegs(detail.waypoints, { recorded, drawn: detail.tour.tour_type === 'ski' }),
// SKI-02: a ski tour saved before the piste router, or with none of its legs planned by it, was drawn straight.
legs: planLegs(detail.waypoints, {
recorded,
drawn: detail.tour.tour_type === 'ski' && !detail.tour.ski_route_build,
}),
line: saved,
...(isWalk ? { run: run ? planRun(run) : null } : {}),
derived: derivedFields(detail, { controlsFromTrack, line: saved, run }),
@@ -102,7 +105,7 @@ export class ToursMcp {
@Tool({
name: 'list_tours',
description:
'List the saved tours of a trip (hikes, city walks, runs, bike tours, paddle tours, rides on horseback and ski and winter tours planned or imported in the Tours addon): name, type (hike, walk for a city walk, run, bike, kayak for a paddle tour, horse for a ride, or ski for a ski and winter tour; a ride and a ski tour have no time worked out, so their duration is null), where it came from (origin: trek, gpx for an imported GPX, KML or KMZ file, or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_climbs for gentler ways, avoid_steps, avoid_ferries; weightings, never a grade of the path), the profile a bike tour is routed for (bike_profile: touring for paved cycle paths and quiet roads, road for asphalt, gravel for gravel and farm tracks, mtb for rough tracks; a routing preference, never a statement that a way is rideable or safe), the profile and pace of a run (run_profile: running for runnable, mostly paved ways in towns and parks, trail for forest tracks and natural paths up to mountain paths (T2); a routing preference, never a statement that a way can be run; run_pace_seconds_per_km, the pace the runners planned with, null while they entered none; its duration is the distance at that pace and nothing else, no climb, descent or difficulty is in it, and it is a plan, never a recorded time), the discipline of a ski tour (ski_discipline: alpine, snowboard, cross_country, touring for ski touring, snowshoe or winter_hike; snowshoeing and winter hiking are ski tours, a hike is summer only; ski_technique for cross-country only: classic, skating or unknown when the travellers did not know; a planned ski tour has every leg drawn straight between its points, never routed along pistes, lifts or trails, an imported one is kept as recorded; its distance and heights come from that line or the file, it has no ski time worked out, and nothing in it says anything about snow, avalanches or whether a run or trail is open), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), for a city walk the number of its stations (station_count: its points without the shaping points and the way back of a loop), their stays as far as entered (station_stay_minutes) and how many have none (station_stays_unknown), whether a tour still waits for its way to be planned (route_pending: saved with its points only, so its distance and walking time stay null until someone opens it in the planner and saves it), for a multi-day tour the number of stages its route is split into (stage_count: one route planned whole, each stage ending at a stop marked as its end; null for a tour of one day) and the stops they run between (stage_stops, in route order with key and coordinates: stage n runs from stop n-1 to stop n, and is known by the key of the stop it starts at), the revision (raised by every saved change of the tour), the recorded tracks assigned to a tour (activity_tracks) and, for a recorded track, the tour it documents (activity_of), when an imported track was recorded (track_started_at, track_ended_at; null when the file carried no times), the time a tour without a planned time (an import) is estimated to take (time_estimate: minutes and basis, din33466 for a hike by the hiking signpost rule DIN 33466 from its distance, ascent and descent, run_pace for a run at the pace entered; worked out on reading and without breaks, never a recorded or planned time; minutes null when the track has no heights, and time_estimate null where nothing estimates it, a tour the planner timed among them, whose duration is its time), and the days of the trip each tour is on (tour_stage_key names the stage a day holds by its starting stop, null where the day holds the whole tour; a stage may also be on no day yet).',
'List the saved tours of a trip (hikes, city walks, runs, bike tours, paddle tours, rides on horseback and ski and winter tours planned or imported in the Tours addon): name, type (hike, walk for a city walk, run, bike, kayak for a paddle tour, horse for a ride, or ski for a ski and winter tour; a ride has no time worked out, so its duration is null), where it came from (origin: trek, gpx for an imported GPX, KML or KMZ file, or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_climbs for gentler ways, avoid_steps, avoid_ferries; weightings, never a grade of the path), the profile a bike tour is routed for (bike_profile: touring for paved cycle paths and quiet roads, road for asphalt, gravel for gravel and farm tracks, mtb for rough tracks; a routing preference, never a statement that a way is rideable or safe), the profile and pace of a run (run_profile: running for runnable, mostly paved ways in towns and parks, trail for forest tracks and natural paths up to mountain paths (T2); a routing preference, never a statement that a way can be run; run_pace_seconds_per_km, the pace the runners planned with, null while they entered none; its duration is the distance at that pace and nothing else, no climb, descent or difficulty is in it, and it is a plan, never a recorded time), the discipline of a ski tour (ski_discipline: alpine, snowboard, cross_country, touring for ski touring, snowshoe or winter_hike; snowshoeing and winter hiking are ski tours, a hike is summer only; ski_technique for cross-country only: classic, skating or unknown when the travellers did not know; a planned ski tour has its legs planned by the piste router along the runs, lifts and trails of OpenStreetMap where that data reaches (ski_options: the router options it was planned with, ski_route_build: the piste data build) and drawn straight elsewhere, an imported one is kept as recorded; its duration is the estimate of the router only when every leg was planned by it, else null, and nothing in it says anything about snow, avalanches or whether a run or trail is open), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), for a city walk the number of its stations (station_count: its points without the shaping points and the way back of a loop), their stays as far as entered (station_stay_minutes) and how many have none (station_stays_unknown), whether a tour still waits for its way to be planned (route_pending: saved with its points only, so its distance and walking time stay null until someone opens it in the planner and saves it), for a multi-day tour the number of stages its route is split into (stage_count: one route planned whole, each stage ending at a stop marked as its end; null for a tour of one day) and the stops they run between (stage_stops, in route order with key and coordinates: stage n runs from stop n-1 to stop n, and is known by the key of the stop it starts at), the revision (raised by every saved change of the tour), the recorded tracks assigned to a tour (activity_tracks) and, for a recorded track, the tour it documents (activity_of), when an imported track was recorded (track_started_at, track_ended_at; null when the file carried no times), the time a tour without a planned time (an import) is estimated to take (time_estimate: minutes and basis, din33466 for a hike by the hiking signpost rule DIN 33466 from its distance, ascent and descent, run_pace for a run at the pace entered; worked out on reading and without breaks, never a recorded or planned time; minutes null when the track has no heights, and time_estimate null where nothing estimates it, a tour the planner timed among them, whose duration is its time), and the days of the trip each tour is on (tour_stage_key names the stage a day holds by its starting stop, null where the day holds the whole tour; a stage may also be on no day yet).',
inputSchema: { tripId: idSchema },
annotations: TOOL_ANNOTATIONS_READONLY,
when: addonGate(ADDON_IDS.TOURS),
+37 -2
View File
@@ -231,12 +231,11 @@ describe('Ski tracks e2e (real guards + temp SQLite)', () => {
{ ...planned, duration_seconds: null },
{ ...planned, duration_seconds: null, ski_discipline: 'cross_country' },
{ ...planned, duration_seconds: null, ski_discipline: 'alpine', ski_technique: 'classic' },
{ ...planned, ski_discipline: 'snowshoe' },
{ ...planned, duration_seconds: null, ski_discipline: 'snowshoe', max_hiking_difficulty: 3 },
],
(body) => request(server).post('/api/trips/5/tours').set('Cookie', sessionCookie(OWNER)).send(body),
);
expect(refused.map((res) => res.status)).toEqual([400, 400, 400, 400, 400]);
expect(refused.map((res) => res.status)).toEqual([400, 400, 400, 400]);
expect(await countRows(orm, Tours)).toBe(0);
const res = await request(server)
@@ -286,6 +285,42 @@ describe('Ski tracks e2e (real guards + temp SQLite)', () => {
expect(await findRow(orm, Tours, { place: placeId })).toMatchObject({ ski_discipline: null, ski_technique: null });
});
it('TOURS-SKI-E2E-011: a ski tour the piste router planned keeps its time, its options and the build', async () => {
const planned = { ...hikeBody, tour_type: 'ski', max_hiking_difficulty: null, ski_discipline: 'alpine' };
const created = await request(server)
.post('/api/trips/5/tours')
.set('Cookie', sessionCookie(OWNER))
.send({
...planned,
duration_seconds: 3600,
ski_options: { max_difficulty: 'advanced', lit_only: true },
ski_route_build: '2026-11-02',
});
expect(created.status).toBe(201);
expect(created.body.tour).toMatchObject({
duration: 60,
ski_route_build: '2026-11-02',
ski_options: { max_difficulty: 'advanced', lit_only: false, priority: 'fastest' },
});
const placeId = created.body.tour.place_id as number;
const stored = await findRow(orm, Tours, { place: placeId });
expect(JSON.parse(String(stored?.ski_options))).toMatchObject({ build: '2026-11-02' });
const refused = await request(server)
.post('/api/trips/5/tours')
.set('Cookie', sessionCookie(OWNER))
.send({ ...hikeBody, ski_options: {} });
expect(refused.status).toBe(400);
const asHike = await request(server)
.put(`/api/trips/5/tours/${placeId}`)
.set('Cookie', sessionCookie(OWNER))
.send({ ...hikeBody, expected_revision: created.body.tour.revision });
expect(asHike.status).toBe(200);
expect(asHike.body.tour).not.toHaveProperty('ski_options');
expect(await findRow(orm, Tours, { place: placeId })).toMatchObject({ ski_options: null });
});
it('TOURS-SKI-E2E-005: classified again, only the classification changes: track, measures, days and provenance stay', async () => {
const placeId = await importSki({ ski_discipline: 'cross_country', ski_technique: 'unknown' });
await insertRows(orm, DayAssignments, [{ day: 3, place: placeId, order_index: 0 }]);
@@ -81,6 +81,7 @@ const UNNUMBERED = [
'Migration20200101046120_a_wanderer_connection_can_watch_its_sources',
'Migration20200101046130_snowshoeing_and_winter_hiking_are_ski_disciplines',
'Migration20200101046140_tours_are_on_for_every_install',
'Migration20200101046140_a_ski_tour_keeps_its_piste_router_options',
];
// The last step the positional runner ever had; planLegacyBaseline refuses a
// schema_version past it.
@@ -0,0 +1,38 @@
/**
* `Migration20200101046140_a_ski_tour_keeps_its_piste_router_options` gives a
* tour one more column, `ski_options`: the JSON text of the piste router's
* options and the build its legs ran on. Every tour that was there keeps its
* columns and reads NULL in the new one; the column takes the server's JSON.
*/
import { createMigrationOrm, migrateTo, pendingNames, rawExec, rawQuery } from '../../helpers/migration-step';
import { describe, expect, it } from 'vitest';
const TARGET = 'Migration20200101046140_a_ski_tour_keeps_its_piste_router_options';
describe('the ski options column', () => {
it('TOURSKIOPT-001: adds a nullable column, leaves every tour as it was and takes the JSON the server writes', async () => {
const orm = await createMigrationOrm();
try {
const names = await pendingNames(orm);
const idx = names.indexOf(TARGET);
expect(idx).toBeGreaterThan(0);
await migrateTo(orm, names[idx - 1] as string);
await rawExec(orm, "INSERT INTO users (id, username, email, password_hash) VALUES (1, 'owner', 'owner@test', 'x')");
await rawExec(orm, "INSERT INTO trips (id, user_id, title) VALUES (10, 1, 'Alps')");
await rawExec(orm, "INSERT INTO places (id, trip_id, name) VALUES (100, 10, 'Valley run')");
await rawExec(orm, "INSERT INTO tours (place_id, tour_type, ski_discipline) VALUES (100, 'ski', 'alpine')");
await migrateTo(orm, TARGET);
expect(await rawQuery(orm, 'SELECT tour_type, ski_discipline, ski_options FROM tours')).toEqual([
{ tour_type: 'ski', ski_discipline: 'alpine', ski_options: null },
]);
const stored = JSON.stringify({ options: { max_difficulty: 'advanced' }, build: '2026-11-02' });
await rawExec(orm, 'UPDATE tours SET ski_options = ? WHERE place_id = 100', [stored]);
expect(await rawQuery(orm, 'SELECT ski_options FROM tours')).toEqual([{ ski_options: stored }]);
} finally {
await orm.close(true);
}
}, 30000);
});
+25 -2
View File
@@ -34,6 +34,7 @@ import { resetTestDb } from '../../helpers/test-db';
import { createTestModule, type TestModule } from '../../helpers/test-module';
import { createTestOrm } from '../../helpers/test-orm';
import { createTour } from '../../helpers/tours-repos';
import { DEFAULT_SKI_ROUTE_OPTIONS } from '@trek/shared';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -650,9 +651,10 @@ describe('ToursMcp', () => {
const description = entry?.kind === 'tool' ? (entry.options.description ?? '') : '';
expect(description).toContain('ski_discipline: alpine, snowboard, cross_country, touring for ski touring');
expect(description).toContain('snowshoe or winter_hike');
expect(description).toContain('drawn straight between its points, never routed');
expect(description).toContain('planned by the piste router along the runs, lifts and trails');
expect(description).toContain('and drawn straight elsewhere');
expect(description).toContain('an imported one is kept as recorded');
expect(description).toContain('it has no ski time worked out');
expect(description).toContain("the router's estimate only when every leg was planned by it");
expect(description).toContain('nothing in it says anything about snow, avalanches');
});
@@ -702,4 +704,25 @@ describe('ToursMcp', () => {
'never present them as saved facts. tour.time_estimate is one of them where present',
);
});
it('TOURS-MCP-027: a ski tour the piste router planned names its options and build, and its legs as routed', async () => {
const { member, trip } = fixture();
const orm = await createTestOrm(db);
const ski = await makePlace(orm, trip.id, { name: 'Down to the valley' });
const stored = { options: { ...DEFAULT_SKI_ROUTE_OPTIONS, max_difficulty: 'advanced' }, build: '2026-11-02' };
await makeTour(orm, ski.id, {
tourTypeRef: 'ski',
max_hiking_difficulty: null,
ski_discipline: 'alpine',
ski_options: JSON.stringify(stored),
});
await addTourWaypoints(orm, ski.id, [
{ lat: 46, lng: 7, station_key: 'pk-top' },
{ lat: 46.01, lng: 7, station_key: 'pk-valley' },
]);
await orm.close();
const plan = read(await mcp.getTourPlan({ tripId: trip.id, placeId: ski.id }, ctx(member.id)));
expect(plan.tour).toMatchObject({ ski_options: stored.options, ski_route_build: '2026-11-02' });
expect(plan.legs).toEqual([expect.objectContaining({ from_key: 'pk-top', to_key: 'pk-valley', kind: 'routed' })]);
});
});
+269
View File
@@ -0,0 +1,269 @@
import {
DEFAULT_SKI_ROUTE_OPTIONS,
SKI_LIFT_CLASSES,
skiOptionsForDiscipline,
skiRouteOptionsSchema,
skiUsesLifts,
} from '../tours/ski.schema';
import { tourCreateRequestSchema, tourListItemSchema } from '../tours/tours.schema';
import {
MAX_SKI_ROUTE_LEGS,
skiAreaIdSchema,
skiAreaNearQuerySchema,
skiNetworkResponseSchema,
skiRouteRequestSchema,
skiRouteResponseSchema,
} from './ski-route.schema';
import { winterIndexSchema, winterMetaSchema } from './winter-index.schema';
import { describe, expect, it } from 'vitest';
const A = { lat: 45.98, lng: 7.75 };
const B = { lat: 45.93, lng: 7.71 };
const SHA = 'a'.repeat(64);
describe('piste router options (SKI-ROUTE)', () => {
it('SKI-ROUTE-SCHEMA-001: an empty object is a complete answer, use_lifts left to the discipline', () => {
expect(DEFAULT_SKI_ROUTE_OPTIONS).toEqual({
max_difficulty: 'intermediate',
allow_freeride: false,
avoid_lifts: [],
allow_lift_down: true,
priority: 'fastest',
pace: 'normal',
groomed_only: false,
lit_only: false,
touring_uphill_on_pistes: false,
avoid_inferred_links: false,
});
expect(skiUsesLifts('alpine', {})).toBe(true);
expect(skiUsesLifts('snowboard', {})).toBe(true);
expect(skiUsesLifts('touring', {})).toBe(false);
expect(skiUsesLifts('cross_country', { use_lifts: true })).toBe(true);
expect(skiUsesLifts('alpine', { use_lifts: false })).toBe(false);
});
it('SKI-ROUTE-SCHEMA-002: refuses unknown keys, unknown lift classes and difficulties off the scale', () => {
expect(skiRouteOptionsSchema.safeParse({ colour: 'red' }).success).toBe(false);
expect(skiRouteOptionsSchema.safeParse({ avoid_lifts: ['zipline'] }).success).toBe(false);
expect(skiRouteOptionsSchema.safeParse({ max_difficulty: 'freeride' }).success).toBe(false);
expect(skiRouteOptionsSchema.safeParse({ avoid_lifts: [...SKI_LIFT_CLASSES, 'drag'] }).success).toBe(false);
});
it('SKI-ROUTE-SCHEMA-003: keeps the options a discipline reads and puts the others back to their defaults', () => {
const asked = {
max_difficulty: 'expert' as const,
avoid_lifts: ['drag' as const],
groomed_only: true,
lit_only: true,
touring_uphill_on_pistes: true,
use_lifts: true,
};
expect(skiOptionsForDiscipline('alpine', asked)).toEqual({
...DEFAULT_SKI_ROUTE_OPTIONS,
max_difficulty: 'expert',
avoid_lifts: ['drag'],
use_lifts: true,
});
expect(skiOptionsForDiscipline('cross_country', asked)).toEqual({
...DEFAULT_SKI_ROUTE_OPTIONS,
groomed_only: true,
lit_only: true,
use_lifts: true,
});
expect(skiOptionsForDiscipline('touring', asked)).toEqual({
...DEFAULT_SKI_ROUTE_OPTIONS,
max_difficulty: 'expert',
avoid_lifts: ['drag'],
touring_uphill_on_pistes: true,
use_lifts: true,
});
expect(skiOptionsForDiscipline('snowshoe', { priority: 'easiest' })).toEqual(DEFAULT_SKI_ROUTE_OPTIONS);
});
it('SKI-ROUTE-SCHEMA-004: a saved ski tour keeps its options, another type may not carry them', () => {
const planned = {
name: 'Valley run',
tour_type: 'ski' as const,
ski_discipline: 'alpine' as const,
route_geometry: [
[A.lat, A.lng, 2500],
[B.lat, B.lng, 1600],
],
waypoints: [
{ lat: A.lat, lng: A.lng, role: 'start', sequence: 0 },
{ lat: B.lat, lng: B.lng, role: 'end', sequence: 1 },
],
};
const saved = tourCreateRequestSchema.parse({
...planned,
ski_options: { max_difficulty: 'advanced', lit_only: true },
ski_route_build: '2026-11-02',
});
expect(saved.ski_options).toEqual({ ...DEFAULT_SKI_ROUTE_OPTIONS, max_difficulty: 'advanced', lit_only: true });
expect(saved.ski_route_build).toBe('2026-11-02');
expect(tourCreateRequestSchema.parse(planned).ski_options).toBeUndefined();
expect(tourCreateRequestSchema.safeParse({ ...planned, ski_route_build: 'x y' }).success).toBe(false);
const hike = { ...planned, tour_type: 'hike' as const, ski_discipline: null };
expect(() => tourCreateRequestSchema.parse({ ...hike, ski_options: {} })).toThrow(/ski_options belongs/);
expect(() => tourCreateRequestSchema.parse({ ...hike, ski_route_build: '2026-11-02' })).toThrow(
/ski_route_build belongs/,
);
});
it('SKI-ROUTE-SCHEMA-005: a listed tour may carry its options and build, or neither', () => {
const listed = {
place_id: 4,
tour_type: 'ski',
distance: 8,
elevation_gain: 100,
elevation_loss: 900,
duration: 3600,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: null,
name: 'Valley run',
planned: true,
caution: false,
};
expect(tourListItemSchema.parse({ ...listed, ski_options: {}, ski_route_build: '2026-11-02' })).toMatchObject({
ski_options: DEFAULT_SKI_ROUTE_OPTIONS,
ski_route_build: '2026-11-02',
});
expect(tourListItemSchema.parse(listed).ski_options).toBeUndefined();
});
});
describe('piste router request and answer (SKI-ROUTE)', () => {
it('SKI-ROUTE-SCHEMA-006: fills a request with no technique, default options and no alternatives', () => {
expect(skiRouteRequestSchema.parse({ legs: [{ from: A, to: B }], discipline: 'alpine' })).toEqual({
legs: [{ from: A, to: B, shaping_to: false }],
discipline: 'alpine',
technique: null,
options: DEFAULT_SKI_ROUTE_OPTIONS,
alternatives: 0,
});
});
it('SKI-ROUTE-SCHEMA-007: refuses no legs, too many, a point off the globe, three alternatives', () => {
const many = Array.from({ length: MAX_SKI_ROUTE_LEGS + 1 }, () => ({ from: A, to: B }));
const base = { discipline: 'alpine' };
expect(skiRouteRequestSchema.safeParse({ ...base, legs: [] }).success).toBe(false);
expect(skiRouteRequestSchema.safeParse({ ...base, legs: many }).success).toBe(false);
const offGlobe = [{ from: { lat: 91, lng: 0 }, to: B }];
expect(skiRouteRequestSchema.safeParse({ ...base, legs: offGlobe }).success).toBe(false);
const three = { ...base, legs: [{ from: A, to: B }], alternatives: 3 };
expect(skiRouteRequestSchema.safeParse(three).success).toBe(false);
expect(skiRouteRequestSchema.safeParse({ legs: [{ from: A, to: B }], discipline: 'sledge' }).success).toBe(false);
});
it('SKI-ROUTE-SCHEMA-008: answers a planned leg, heights possibly unknown, and a failed one with its fix', () => {
const answer = {
build: '2026-11-02',
stale: false,
area_names: ['Zermatt'],
colour_scheme: 'europe',
attribution: '© OpenStreetMap contributors, ODbL',
legs: [
{
status: 'ok',
geometry: [
[A.lat, A.lng, 2500],
[B.lat, B.lng, null],
],
segments: [
{
kind: 'run',
from: 0,
to: 1,
name: 'Rote Eins',
ref: '1',
difficulty: 'intermediate',
lift_type: null,
grooming: [],
seconds: 300,
wait_seconds: 0,
length_m: 2500,
flags: ['dir_from_dem'],
osm_way: 12,
},
],
distance_m: 2500,
climb_m: 0,
descent_m: 900,
seconds: { ski: 300, lift: 0, wait: 0, walk: 0 },
snapped_m: { from: 3, to: 0 },
area_ids: ['w:100'],
why: [{ kind: 'flag', flag: 'dir_from_dem', count: 1 }],
alternatives: [],
},
{
status: 'failed',
code: 'blocked_by_difficulty',
at: null,
hint: { raise_difficulty: 'advanced' },
detail: { name: 'Schwarze Wand' },
},
],
};
expect(skiRouteResponseSchema.parse(answer)).toEqual(answer);
const badFlag = structuredClone(answer);
(badFlag.legs[0] as { segments: Array<{ flags: string[] }> }).segments[0]!.flags = ['avalanche'];
expect(skiRouteResponseSchema.safeParse(badFlag).success).toBe(false);
});
it('SKI-ROUTE-SCHEMA-009: area ids, the area-near query and the network answer', () => {
expect(skiAreaIdSchema.safeParse('w:100').success).toBe(true);
expect(skiAreaIdSchema.safeParse('r:42-1a2b').success).toBe(true);
expect(skiAreaIdSchema.safeParse('../etc').success).toBe(false);
expect(skiAreaNearQuerySchema.parse({ lat: '46.1', lng: '7.2' })).toEqual({ lat: 46.1, lng: 7.2 });
expect(skiAreaNearQuerySchema.safeParse({ lat: '95', lng: '7' }).success).toBe(false);
const network = {
area_id: 'w:100',
build: '2026-11-02',
colour_scheme: 'europe',
simplified_m: 5,
features: [
{
kind: 'lift',
difficulty: null,
name: 'Sonnenbahn',
lift_type: 'chair_detachable',
oneway: true,
line: '_p~iF~ps|U',
},
],
};
expect(skiNetworkResponseSchema.parse(network)).toEqual(network);
});
});
describe('the winter layer of the Places host (SKI-ROUTE)', () => {
it('SKI-ROUTE-SCHEMA-010: reads the meta and an index row as the host sends them', () => {
const meta = {
build: '2026-11-02',
format: 1,
areas: 1,
indexSha: SHA,
sourceTimestamp: null,
attribution: { data: '© OpenStreetMap contributors, ODbL', terrain: 'Terrain: Mapzen Terrarium', url: 'x' },
};
expect(winterMetaSchema.parse(meta)).toMatchObject({ build: '2026-11-02', indexSha: SHA });
expect(winterMetaSchema.safeParse({ ...meta, indexSha: 'abc' }).success).toBe(false);
const row = {
id: 'w:100',
name: 'Sonnenbahn',
kind: 'resort',
country: null,
colourScheme: 'europe',
bbox: [7.0, 45.999, 7.005, 46.0052],
counts: { runsKm: 1.19, nordicKm: 0.53, lifts: 1, skitourKm: 0, hikeKm: 0 },
bytes: 900,
sha256: SHA,
};
const index = { build: '2026-11-02', format: 1, areas: [row] };
expect(winterIndexSchema.parse(index).areas[0]).toEqual(row);
expect(winterIndexSchema.safeParse({ ...index, areas: [{ ...row, id: 'x' }] }).success).toBe(false);
});
});
+4 -4
View File
@@ -150,10 +150,10 @@ describe('ski and winter tours (SKI-02)', () => {
);
});
it('SKI-SCHEMA-008: works out no ski time and takes nothing of another type, a free leg allowed', () => {
expect(() => tourCreateRequestSchema.parse({ ...planned, duration_seconds: 3600 })).toThrow(
/a ski tour has no ski time worked out/,
);
it('SKI-SCHEMA-008: takes the time the piste router worked out and nothing of another type, a free leg allowed', () => {
expect(tourCreateRequestSchema.parse({ ...planned, duration_seconds: 3600 })).toMatchObject({
duration_seconds: 3600,
});
expect(tourCreateRequestSchema.safeParse({ ...planned, duration_seconds: null }).success).toBe(true);
expect(() => tourCreateRequestSchema.parse({ ...planned, max_hiking_difficulty: 3 })).toThrow(
/a ski tour has no hiking difficulty/,
+22 -7
View File
@@ -10,6 +10,8 @@ import { runPaceSecondsPerKmSchema } from './run-pace';
import {
skiDisciplineSchema,
skiIssues,
skiRouteBuildSchema,
skiRouteOptionsSchema,
skiTechniqueSchema,
type SkiDiscipline,
type SkiTechnique,
@@ -48,8 +50,8 @@ export type TourTypeKey = z.infer<typeof tourTypeKeySchema>;
* run (RUN-01), planned on foot with the runner's own pace. `horse` is a
* ride (HOR-01): sections of signposted horse routes and stretches the rider
* draws by hand, never routed. `ski` is a ski and winter tour (SKI-02): a
* discipline from alpine skiing to winter hiking, every leg drawn between
* its points until the piste router plans them, no ski time worked out.
* discipline from alpine skiing to winter hiking, its legs planned by the
* piste router where the piste data reaches and drawn straight elsewhere.
*/
export const plannableTourTypeSchema = z.enum(['hike', 'walk', 'run', 'bike', 'kayak', 'horse', 'ski']);
export type PlannableTourType = z.infer<typeof plannableTourTypeSchema>;
@@ -221,6 +223,10 @@ export const tourSchema = z.object({
ski_discipline: skiDisciplineSchema.nullable().optional(),
/** SKI-02: how a cross-country tour is skied; null for every other discipline and type. */
ski_technique: skiTechniqueSchema.nullable().optional(),
/** The piste router's options a ski tour was planned with; null for every other type and an imported track. */
ski_options: skiRouteOptionsSchema.nullable().optional(),
/** The piste data build a ski tour's legs were planned on; null where the router planned none. */
ski_route_build: z.string().nullable().optional(),
/** A paddle tour's boat and water; null for every other type. Absent in a list cached before they existed. */
boat_type: paddleBoatTypeSchema.nullable().optional(),
water_context: paddleWaterContextSchema.nullable().optional(),
@@ -521,6 +527,10 @@ export const tourCreateRequestSchema = z
/** SKI-02, a ski tour's only, and its one requirement: the discipline, for cross-country the technique too. */
ski_discipline: skiDisciplineSchema.nullable().optional(),
ski_technique: skiTechniqueSchema.nullable().optional(),
/** A ski tour's only: what the piste router was asked for (the server keeps what its discipline reads). */
ski_options: skiRouteOptionsSchema.nullable().optional(),
/** A ski tour's only: the piste data build its legs were planned on, so newer data can be offered later. */
ski_route_build: skiRouteBuildSchema.nullable().optional(),
boat_type: paddleBoatTypeSchema.nullable().optional(),
water_context: paddleWaterContextSchema.nullable().optional(),
paddle_speed_kmh: paddleSpeedKmhSchema.nullable().optional(),
@@ -632,8 +642,8 @@ const PADDLE_FIELDS = [
* paddler's, a run also its pace; a paddle tour exactly one boat and its
* water, and no hiking limit; a horse tour (HOR-01) only legs taken from a
* horse route or drawn by hand, and no riding time worked out for it; a ski
* tour (SKI-02) its discipline (and a cross-country technique), no via
* ferrata, and no ski time worked out for it.
* tour (SKI-02) its discipline (and a cross-country technique), its piste
* router options, and no via ferrata.
*/
function tourTypeIssues(value: {
tour_type: PlannableTourType;
@@ -645,6 +655,8 @@ function tourTypeIssues(value: {
run_pace_seconds_per_km?: number | null;
ski_discipline?: SkiDiscipline | null;
ski_technique?: SkiTechnique | null;
ski_options?: object | null;
ski_route_build?: string | null;
boat_type?: PaddleBoatType | null;
water_context?: PaddleWaterContext | null;
paddle_speed_kmh?: number | null;
@@ -685,12 +697,15 @@ function tourTypeIssues(value: {
issues.push({ path: ['bike_profile'], message: 'bike_profile belongs to a bike tour' });
issues.push(...runIssues(value));
issues.push(...skiIssues(type === 'ski', value));
if (type !== 'ski') {
for (const field of ['ski_options', 'ski_route_build'] as const) {
if (value[field] != null) issues.push({ path: [field], message: `${field} belongs to a ski tour` });
}
}
// No router plans a ride, so no time is worked out for it; a planned total the rider enters is theirs.
// A ski tour's time is the piste router's, sent only when it planned every leg.
if (type === 'horse' && value.duration_seconds != null)
issues.push({ path: ['duration_seconds'], message: 'a horse tour has no riding time worked out' });
// SKI-02: no ski time is worked out until the piste router plans the legs; a planned total stays the traveller's.
if (type === 'ski' && value.duration_seconds != null)
issues.push({ path: ['duration_seconds'], message: 'a ski tour has no ski time worked out' });
const points = value.waypoints ?? [];
// A portage and a lock are a paddler's; a via ferrata is climbed, never ridden.
points.forEach((point, index) => {