From a85a50e18669212f8795e9686c3c39c7d1574436 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 20 Oct 2026 20:02:52 +0200 Subject: [PATCH] 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. --- server/src/db/entities/Tours.entity.ts | 2 + server/src/db/json-columns.ts | 15 + server/src/db/kysely/Tours.table.ts | 1 + ...ski_tour_keeps_its_piste_router_options.ts | 18 ++ .../src/db/repositories/Tours.repository.ts | 8 + server/src/nest/tours/tour-item.helpers.ts | 10 +- server/src/nest/tours/tour-write.helpers.ts | 15 +- server/src/nest/tours/tours.mcp.ts | 9 +- server/tests/e2e/tours-ski.e2e.test.ts | 39 ++- server/tests/unit/db/legacy-baseline.test.ts | 1 + .../unit/db/tour-ski-options-column.test.ts | 38 +++ server/tests/unit/nest/tours.mcp.test.ts | 27 +- shared/src/ski/ski-route.schema.spec.ts | 269 ++++++++++++++++++ shared/src/tours/ski.schema.spec.ts | 8 +- shared/src/tours/tours.schema.ts | 29 +- 15 files changed, 469 insertions(+), 20 deletions(-) create mode 100644 server/src/db/migrations/Migration20200101046140_a_ski_tour_keeps_its_piste_router_options.ts create mode 100644 server/tests/unit/db/tour-ski-options-column.test.ts create mode 100644 shared/src/ski/ski-route.schema.spec.ts diff --git a/server/src/db/entities/Tours.entity.ts b/server/src/db/entities/Tours.entity.ts index dd4ee4041..7f42572a5 100644 --- a/server/src/db/entities/Tours.entity.ts +++ b/server/src/db/entities/Tours.entity.ts @@ -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; tour_activities_collection = new Collection(this); tour_activities_collection1 = new Collection(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(), diff --git a/server/src/db/json-columns.ts b/server/src/db/json-columns.ts index 7c00d3df5..f5a5e9fdc 100644 --- a/server/src/db/json-columns.ts +++ b/server/src/db/json-columns.ts @@ -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 = { 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 = { + 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 = { column: 'paddle_features.geometry', diff --git a/server/src/db/kysely/Tours.table.ts b/server/src/db/kysely/Tours.table.ts index b3cb34b77..5f46c12fe 100644 --- a/server/src/db/kysely/Tours.table.ts +++ b/server/src/db/kysely/Tours.table.ts @@ -37,4 +37,5 @@ export interface ToursTable { track_started_at: InsertOptional; track_ended_at: InsertOptional; ski_discipline: InsertOptional; + ski_options: InsertOptional; } diff --git a/server/src/db/migrations/Migration20200101046140_a_ski_tour_keeps_its_piste_router_options.ts b/server/src/db/migrations/Migration20200101046140_a_ski_tour_keeps_its_piste_router_options.ts new file mode 100644 index 000000000..b9ade4710 --- /dev/null +++ b/server/src/db/migrations/Migration20200101046140_a_ski_tour_keeps_its_piste_router_options.ts @@ -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'); + } +} diff --git a/server/src/db/repositories/Tours.repository.ts b/server/src/db/repositories/Tours.repository.ts index 442c7ac95..a0fcd8e12 100644 --- a/server/src/db/repositories/Tours.repository.ts +++ b/server/src/db/repositories/Tours.repository.ts @@ -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 = 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 { '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 { 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 { 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, diff --git a/server/src/nest/tours/tour-item.helpers.ts b/server/src/nest/tours/tour-item.helpers.ts index 52d214bd4..7f5d5e392 100644 --- a/server/src/nest/tours/tour-item.helpers.ts +++ b/server/src/nest/tours/tour-item.helpers.ts @@ -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[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 { + 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, diff --git a/server/src/nest/tours/tour-write.helpers.ts b/server/src/nest/tours/tour-write.helpers.ts index 007aa5ab9..1182d3704 100644 --- a/server/src/nest/tours/tour-write.helpers.ts +++ b/server/src/nest/tours/tour-write.helpers.ts @@ -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 { { ...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 }]); diff --git a/server/tests/unit/db/legacy-baseline.test.ts b/server/tests/unit/db/legacy-baseline.test.ts index b6fec4c75..97c6b4046 100644 --- a/server/tests/unit/db/legacy-baseline.test.ts +++ b/server/tests/unit/db/legacy-baseline.test.ts @@ -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. diff --git a/server/tests/unit/db/tour-ski-options-column.test.ts b/server/tests/unit/db/tour-ski-options-column.test.ts new file mode 100644 index 000000000..9024c68a5 --- /dev/null +++ b/server/tests/unit/db/tour-ski-options-column.test.ts @@ -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); +}); diff --git a/server/tests/unit/nest/tours.mcp.test.ts b/server/tests/unit/nest/tours.mcp.test.ts index de0a900bc..4a40734da 100644 --- a/server/tests/unit/nest/tours.mcp.test.ts +++ b/server/tests/unit/nest/tours.mcp.test.ts @@ -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' })]); + }); }); diff --git a/shared/src/ski/ski-route.schema.spec.ts b/shared/src/ski/ski-route.schema.spec.ts new file mode 100644 index 000000000..6fdfffabd --- /dev/null +++ b/shared/src/ski/ski-route.schema.spec.ts @@ -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); + }); +}); diff --git a/shared/src/tours/ski.schema.spec.ts b/shared/src/tours/ski.schema.spec.ts index 5c6e2bba0..b0bbbd40b 100644 --- a/shared/src/tours/ski.schema.spec.ts +++ b/shared/src/tours/ski.schema.spec.ts @@ -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/, diff --git a/shared/src/tours/tours.schema.ts b/shared/src/tours/tours.schema.ts index 6653e2471..0b3c32c39 100644 --- a/shared/src/tours/tours.schema.ts +++ b/shared/src/tours/tours.schema.ts @@ -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; * 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; @@ -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) => {