From ae699187f7d3817ea018943a58574cfacaa85988 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 20 Oct 2026 21:07:06 +0200 Subject: [PATCH] fix(tours): open a saved ski tour as saved and keep what its router planned A saved ski tour now opens with the line, heights, time, build and planned legs it was saved with, read from the place this device keeps, and nothing is asked of the piste router until a point, an option or the discipline changes. Opened offline, while TREK Places is down or with piste routing switched off, it no longer turns every leg straight, and a rename saves the planned tour as it was. A kept line whose length is not the tour's is another save's, and the tour is planned afresh. - the save names the legs the router planned (ski_planned_legs, checked against the tour's own legs); get_tour_plan reports each leg routed or drawn from them instead of from the build alone, a tour that names none counting as planned only with the router's time - copies keep ski_options in every path: in the trip, taken over, kept in a collection and made from one (collection_tours gets the column in the same unshipped migration) - the route summary shows the data build, the km of runs, the height skied down on them and the lift rides, and the credit the server sends in place of a fixed one; a tour opened as saved says so - answers refused while piste routing is off, or planned on an older kept copy, are not remembered, so they are asked again later - terrain heights between known ones are filled in one walk along the line - i18n: five summary keys in all 27 locales --- .../Tours/planner/skiRouting.test.ts | 7 + .../components/Tours/planner/skiRouting.ts | 13 ++ .../Tours/planner/tourPlannerFacts.ts | 3 + .../Tours/planner/tourPlannerParts.test.ts | 13 +- .../Tours/planner/useTourPlanner.ski.test.tsx | 89 +++++++++++- .../Tours/planner/useTourPlanner.ts | 13 +- .../planner/useTourRoute.heights.test.ts | 20 ++- .../components/Tours/planner/useTourRoute.ts | 59 +++++++- .../components/Tours/ski/SkiRouteSummary.tsx | 133 +++++++++++++----- .../Tours/ski/savedSkiRoute.test.ts | 81 +++++++++++ .../src/components/Tours/ski/savedSkiRoute.ts | 98 +++++++++++++ .../components/Tours/ski/skiRouteUi.test.tsx | 52 ++++++- client/src/repo/skiRouteRepo.test.ts | 23 ++- client/src/repo/skiRouteRepo.ts | 29 +++- client/src/repo/tourRepo.test.ts | 16 ++- client/src/repo/tourRepo.ts | 10 ++ .../src/db/entities/CollectionTours.entity.ts | 8 +- server/src/db/json-columns.ts | 11 +- server/src/db/kysely/CollectionTours.table.ts | 1 + ...ski_tour_keeps_its_piste_router_options.ts | 4 +- .../CollectionTours.repository.ts | 2 + server/src/nest/tours/tour-copy.helpers.ts | 8 +- server/src/nest/tours/tour-item.helpers.ts | 5 +- server/src/nest/tours/tour-plan-read.ts | 16 ++- server/src/nest/tours/tour-write.helpers.ts | 3 +- server/src/nest/tours/tours.mcp.ts | 28 +++- server/tests/e2e/tours-ski.e2e.test.ts | 29 ++++ .../CollectionTours.repository.test.ts | 1 + .../unit/db/tour-ski-options-column.test.ts | 4 + .../nest/collection-tours.helpers.test.ts | 15 ++ server/tests/unit/nest/tour-plan-read.test.ts | 9 +- server/tests/unit/nest/tours.mcp.test.ts | 71 +++++++++- shared/src/i18n/ar/tours.ts | 5 + shared/src/i18n/az/tours.ts | 5 + shared/src/i18n/br/tours.ts | 5 + shared/src/i18n/ca/tours.ts | 6 + shared/src/i18n/cs/tours.ts | 5 + shared/src/i18n/de/tours.ts | 6 + shared/src/i18n/en/tours.ts | 5 + shared/src/i18n/es/tours.ts | 6 + shared/src/i18n/et/tours.ts | 5 + shared/src/i18n/fr/tours.ts | 5 + shared/src/i18n/gr/tours.ts | 6 + shared/src/i18n/hu/tours.ts | 6 + shared/src/i18n/id/tours.ts | 6 + shared/src/i18n/it/tours.ts | 5 + shared/src/i18n/ja/tours.ts | 5 + shared/src/i18n/ko/tours.ts | 5 + shared/src/i18n/nl/tours.ts | 5 + shared/src/i18n/pl/tours.ts | 5 + shared/src/i18n/ru/tours.ts | 6 + shared/src/i18n/sk/tours.ts | 5 + shared/src/i18n/sv/tours.ts | 5 + shared/src/i18n/th/tours.ts | 5 + shared/src/i18n/tr/tours.ts | 6 + shared/src/i18n/uk/tours.ts | 5 + shared/src/i18n/vi/tours.ts | 5 + shared/src/i18n/zh-TW/tours.ts | 5 + shared/src/i18n/zh/tours.ts | 5 + shared/src/ski/ski-route.schema.spec.ts | 59 +++++--- shared/src/tours/ski.schema.ts | 6 + shared/src/tours/tours.schema.ts | 14 +- wiki/MCP-Addon-Tools.md | 4 +- 63 files changed, 1002 insertions(+), 98 deletions(-) create mode 100644 client/src/components/Tours/ski/savedSkiRoute.test.ts create mode 100644 client/src/components/Tours/ski/savedSkiRoute.ts diff --git a/client/src/components/Tours/planner/skiRouting.test.ts b/client/src/components/Tours/planner/skiRouting.test.ts index e042b582f..efe7e524c 100644 --- a/client/src/components/Tours/planner/skiRouting.test.ts +++ b/client/src/components/Tours/planner/skiRouting.test.ts @@ -72,6 +72,7 @@ const answer = (legs: SkiLegAnswer[], extra: Partial = {}): SkiPl stale: false, areaNames: ['Gletscherwelt'], colourScheme: 'europe', + attribution: '© OpenStreetMap contributors. Terrain: Mapzen Terrarium', ...extra, }); @@ -115,6 +116,7 @@ describe('skiRouting (SKI-02)', () => { expect(route?.durationSeconds).toBeNull(); expect(route?.parts.every((part) => part.free)).toBe(true); expect(route?.legs).toEqual([null, null]); + expect(route).toMatchObject({ plannedLegs: [], saved: false, attribution: null }); // Every 25 m a point, so the heights follow the ground between the two ends of each leg. expect(route?.coordinates.length ?? 0).toBeGreaterThan(40); const straight = lineLengthM([ @@ -167,6 +169,8 @@ describe('skiRouting (SKI-02)', () => { expect(route?.elevations?.[0]).toEqual([47, 11, 1500]); expect(route?.build).toBe('2026-11-02'); expect(route?.areaNames).toEqual(['Gletscherwelt']); + expect(route).toMatchObject({ plannedLegs: [0, 1], saved: false }); + expect(route?.attribution).toBe('© OpenStreetMap contributors. Terrain: Mapzen Terrarium'); }); it('FE-TOUR-SKI-ROUTE-006: a leg it cannot plan stays straight and unrouted, with its reason, hint and name', async () => { @@ -174,6 +178,7 @@ describe('skiRouting (SKI-02)', () => { const route = await routeSkiTour([start, col, hut], alpine, 7); expect(route?.durationSeconds).toBeNull(); expect(route?.elevations).toBeNull(); + expect(route?.plannedLegs).toEqual([0]); expect(route?.problems).toEqual([ { index: 1, @@ -235,6 +240,8 @@ describe('skiRouting (SKI-02)', () => { { index: 0, code: 'places_unavailable', hint: { free_draw: true }, name: null, summer: false }, ]); expect(route?.parts.some((part) => part.free)).toBe(true); + // The drawn leg is no leg the router planned, and nothing was answered to credit. + expect(route).toMatchObject({ plannedLegs: [], attribution: null }); }); it('FE-TOUR-SKI-ROUTE-010: a leg takes its time on the move and waiting together', () => { diff --git a/client/src/components/Tours/planner/skiRouting.ts b/client/src/components/Tours/planner/skiRouting.ts index ad5d4d337..00b0187e1 100644 --- a/client/src/components/Tours/planner/skiRouting.ts +++ b/client/src/components/Tours/planner/skiRouting.ts @@ -46,6 +46,12 @@ export interface SkiTourRoute extends TourRouteResult { stale: boolean; areaNames: string[]; colourScheme: SkiColourScheme; + /** The credit of the piste and terrain data, as the server words it; null where nothing was asked. */ + attribution: string | null; + /** The legs the piste router planned, by the index of the point each starts at. */ + plannedLegs: number[]; + /** The line as the tour was saved, opened without asking the router again; false for one planned now. */ + saved: boolean; } /** How long a leg waits for its ski area to download, asked again every two seconds. */ @@ -216,6 +222,7 @@ async function plannedAnswers( stale: false, areaNames: [], colourScheme: 'europe', + attribution: null, }); if (legs.length === 0) return { asked, plan: none([]) }; try { @@ -273,6 +280,9 @@ export async function routeSkiTour( stale: plan.stale, areaNames: plan.areaNames, colourScheme: plan.colourScheme, + attribution: plan.attribution, + plannedLegs: legs.flatMap((leg, index) => (leg?.status === 'ok' ? [index] : [])), + saved: false, }; } @@ -295,5 +305,8 @@ function drawnSkiTour(points: readonly SkiLegPoint[]): SkiTourRoute | null { stale: false, areaNames: [], colourScheme: 'europe', + attribution: null, + plannedLegs: [], + saved: false, }; } diff --git a/client/src/components/Tours/planner/tourPlannerFacts.ts b/client/src/components/Tours/planner/tourPlannerFacts.ts index cbb2dda90..d0fe866d7 100644 --- a/client/src/components/Tours/planner/tourPlannerFacts.ts +++ b/client/src/components/Tours/planner/tourPlannerFacts.ts @@ -53,6 +53,8 @@ export interface TourPlannerFactsInput { ski?: SkiChoice; /** The piste data build a ski tour's legs were planned on; null where the piste router planned none. */ skiRouteBuild?: string | null; + /** The ski tour's legs the piste router planned, by the index of the point each starts at; null if unknown. */ + skiPlannedLegs?: number[] | null; /** RUN-01: the profile a run is routed for, and the pace it is planned at (null while none is entered). */ runProfile: RunProfile; runPaceSecondsPerKm: number | null; @@ -171,6 +173,7 @@ function typeFieldsOf(input: TourPlannerFactsInput) { // What the piste router was asked for and the data it planned on, so the tour reopens as it was planned. ski_options: ski ? skiOptionsForDiscipline(ski.ski_discipline, input.ski?.options ?? {}) : null, ski_route_build: ski ? (input.skiRouteBuild ?? null) : null, + ski_planned_legs: ski ? (input.skiPlannedLegs ?? null) : null, }; } diff --git a/client/src/components/Tours/planner/tourPlannerParts.test.ts b/client/src/components/Tours/planner/tourPlannerParts.test.ts index 819bf6bbb..a75ac57b4 100644 --- a/client/src/components/Tours/planner/tourPlannerParts.test.ts +++ b/client/src/components/Tours/planner/tourPlannerParts.test.ts @@ -81,18 +81,23 @@ describe('tourPlannerFacts', () => { expect(snowshoe.walkingDurationMinutes).toBeNull(); expect(snowshoe.plannedTotalMinutes).toBeNull(); expect(snowshoe.savedDurationSeconds).toBeNull(); - expect(snowshoe.typeFields).toMatchObject({ ski_options: DEFAULT_SKI_ROUTE_OPTIONS, ski_route_build: null }); + expect(snowshoe.typeFields).toMatchObject({ + ski_options: DEFAULT_SKI_ROUTE_OPTIONS, + ski_route_build: null, + ski_planned_legs: null, + }); const routed = tourPlannerFacts( hike({ tourType: 'ski', ski: { discipline: 'alpine', technique: null }, durationSeconds: 3_600, skiRouteBuild: '2026-11-02', + skiPlannedLegs: [0], }) ); expect(routed.walkingDurationMinutes).toBe(60); expect(routed.savedDurationSeconds).toBe(3_600); - expect(routed.typeFields).toMatchObject({ ski_route_build: '2026-11-02' }); + expect(routed.typeFields).toMatchObject({ ski_route_build: '2026-11-02', ski_planned_legs: [0] }); expect(tourPlannerFacts(hike({ tourType: 'ski' })).skiMissing).toBe('discipline'); const crossCountry = hike({ tourType: 'ski', ski: { discipline: 'cross_country', technique: null } }); @@ -102,7 +107,9 @@ describe('tourPlannerFacts', () => { for (const tourType of ['hike', 'bike', 'walk', 'kayak'] as const) { const other = tourPlannerFacts(hike({ tourType, ski: { discipline: 'alpine', technique: null } })); expect(other.typeFields).toMatchObject({ ski_discipline: null, ski_technique: null }); - if (tourType !== 'kayak') expect(other.typeFields).toMatchObject({ ski_options: null, ski_route_build: null }); + if (tourType !== 'kayak') { + expect(other.typeFields).toMatchObject({ ski_options: null, ski_route_build: null, ski_planned_legs: null }); + } expect(other.skiMissing).toBeNull(); } }); diff --git a/client/src/components/Tours/planner/useTourPlanner.ski.test.tsx b/client/src/components/Tours/planner/useTourPlanner.ski.test.tsx index af23e94a8..ea6ad7cf7 100644 --- a/client/src/components/Tours/planner/useTourPlanner.ski.test.tsx +++ b/client/src/components/Tours/planner/useTourPlanner.ski.test.tsx @@ -1,5 +1,5 @@ import { act, renderHook } from '@testing-library/react'; -import { DEFAULT_SKI_ROUTE_OPTIONS } from '@trek/shared'; +import { computeTourMetrics, DEFAULT_SKI_ROUTE_OPTIONS } from '@trek/shared'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const { routeWalkingTour, enrichTourElevations, createTour, detailTour, updateTour, planSki } = vi.hoisted(() => ({ @@ -17,13 +17,20 @@ vi.mock('./tourRouting', async (importOriginal) => ({ routeWalkingTour, enrichTourElevations, })); -vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } })); +const { savedLine, offline } = vi.hoisted(() => ({ savedLine: vi.fn(), offline: vi.fn(() => false) })); +vi.mock('../../../sync/networkMode', async (importOriginal) => ({ + ...(await importOriginal()), + isEffectivelyOffline: offline, +})); +vi.mock('../../../repo/tourRepo', () => ({ + tourRepo: { create: createTour, detail: detailTour, update: updateTour, savedLine }, +})); vi.mock('../../../repo/skiRouteRepo', () => ({ skiRouteRepo: { plan: planSki } })); vi.mock('../useTourPermissions', () => ({ useTourPermissions: () => ({ canEdit: true, canAssign: true }) })); import { useTourPlanner } from './useTourPlanner'; -// FE-TOUR-SKI-PLAN-001 to FE-TOUR-SKI-PLAN-007 +// FE-TOUR-SKI-PLAN-001 to FE-TOUR-SKI-PLAN-009 const savedSki = { place_id: 95, @@ -125,6 +132,8 @@ describe('useTourPlanner, a ski and winter tour (SKI-02)', () => { createTour.mockReset(); detailTour.mockReset(); updateTour.mockReset(); + savedLine.mockReset().mockResolvedValue(null); + offline.mockReset().mockReturnValue(false); }); afterEach(() => vi.useRealTimers()); @@ -216,6 +225,7 @@ describe('useTourPlanner, a ski and winter tour (SKI-02)', () => { duration_seconds: 600, ski_options: DEFAULT_SKI_ROUTE_OPTIONS, ski_route_build: '2026-11-02', + ski_planned_legs: [0], }) ); expect(result.current.status).toBe('saved'); @@ -264,4 +274,77 @@ describe('useTourPlanner, a ski and winter tour (SKI-02)', () => { act(() => again.result.current.returnToNeutral()); expect(again.result.current.ski.choice).toEqual({ discipline: null, technique: null }); }); + + it('FE-TOUR-SKI-PLAN-008: a saved ski tour opens as saved, offline too: nothing asked, its line, time and build kept on a save', async () => { + const line: [number, number, number][] = [ + [47, 11, 1600], + [47.005, 11.01, 1500], + [47.01, 11.02, 1400], + ]; + const planned = { + ...savedSki, + ski_discipline: 'alpine' as const, + ski_technique: null, + distance: computeTourMetrics(line).distanceKm, + duration: 25, + ski_options: DEFAULT_SKI_ROUTE_OPTIONS, + ski_route_build: '2026-11-02', + ski_planned_legs: [0], + }; + detailTour.mockResolvedValue({ tour: planned, waypoints: savedPoints }); + savedLine.mockResolvedValue(JSON.stringify(line)); + offline.mockReturnValue(true); + updateTour.mockResolvedValue({ tour: { ...planned, name: 'Down the col', revision: 3 }, waypoints: savedPoints }); + const { result } = renderHook(() => useTourPlanner({ tripId: 88 })); + await act(async () => { + await result.current.openTour({ place_id: 95 }); + }); + await settle(); + expect(savedLine).toHaveBeenCalledWith(88, 95); + expect(planSki).not.toHaveBeenCalled(); + expect(enrichTourElevations).not.toHaveBeenCalled(); + expect(result.current.status).toBe('ready'); + expect(result.current.route).toEqual(line.map(([lat, lng]) => [lat, lng])); + expect(result.current.durationSeconds).toBe(1500); + expect(result.current.skiRoute).toMatchObject({ saved: true, build: '2026-11-02', plannedLegs: [0] }); + + // A new name changes nothing of the route: it is saved as it was planned. + act(() => result.current.setName('Down the col')); + expect(result.current.canSave).toBe(true); + await act(async () => { + await result.current.save(); + }); + expect(planSki).not.toHaveBeenCalled(); + expect(updateTour).toHaveBeenCalledWith( + 88, + 95, + expect.objectContaining({ + route_geometry: line, + duration_seconds: 1500, + ski_route_build: '2026-11-02', + ski_planned_legs: [0], + }) + ); + }); + + it('FE-TOUR-SKI-PLAN-009: a saved ski tour whose kept line is not the one saved is planned afresh', async () => { + detailTour.mockResolvedValue({ + tour: { ...savedSki, ski_discipline: 'alpine', ski_technique: null, distance: 9.5, duration: 25 }, + waypoints: savedPoints, + }); + savedLine.mockResolvedValue( + JSON.stringify([ + [47, 11, 1600], + [47.01, 11.02, 1400], + ]) + ); + const { result } = renderHook(() => useTourPlanner({ tripId: 89 })); + await act(async () => { + await result.current.openTour({ place_id: 95 }); + }); + await settle(); + expect(planSki).toHaveBeenCalledTimes(1); + expect(result.current.skiRoute?.saved).toBe(false); + expect(result.current.durationSeconds).toBe(600); + }); }); diff --git a/client/src/components/Tours/planner/useTourPlanner.ts b/client/src/components/Tours/planner/useTourPlanner.ts index 6bdeecafb..26b66de0a 100644 --- a/client/src/components/Tours/planner/useTourPlanner.ts +++ b/client/src/components/Tours/planner/useTourPlanner.ts @@ -25,6 +25,7 @@ import { NO_PADDLE_FIELDS, paddleFieldsOf, usePaddleFields, type PaddleFieldValu import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions' import { useRunPace } from '../run/useRunPace' import { NO_SKI_CHOICE, type SkiChoice } from '../ski/skiChoice' +import { savedSkiSeed } from '../ski/savedSkiRoute' import { useSkiFields } from '../ski/useSkiFields' import { lostStageEnds } from '../stages/tourStages' import { useTourStages } from '../stages/useTourStages' @@ -254,7 +255,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe error, setStatus, setError, onCleared: forgetFocus, patchWaypoints, }) const { route, routeParts, paddleRoute, skiRoute, enrichedGeometry, routeCurrent, distanceMeters, durationSeconds, - unreachable, interruptRouting, clearRoute } = routing + unreachable, interruptRouting, clearRoute, openSavedSkiRoute } = routing const commitWaypoints = useCallback((nextValue: TourPlannerWaypoint[] | ((current: TourPlannerWaypoint[]) => TourPlannerWaypoint[])) => { if (!editPermissionRef.current) return @@ -535,7 +536,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe const stageCount = Math.max(1, stages.stages.length) const facts = useMemo(() => tourPlannerFacts({ tourType, maxHikingDifficulty, hikingPreferences, bikeProfile, ski: ski.choice, runProfile, runPaceSecondsPerKm, website, - skiRouteBuild: skiRoute?.build ?? null, plannedDurationMinutes, breakAdditionalMinutes, + skiRouteBuild: skiRoute?.build ?? null, skiPlannedLegs: skiRoute?.plannedLegs ?? null, plannedDurationMinutes, breakAdditionalMinutes, paddle: { boatType, waterContext, speedKmh, portageSpeedKmh, lakeStyle }, paddleSpeedInvalid, paddleEstimate, routeAnalysis, durationSeconds, stays, stageCount, }), [bikeProfile, boatType, breakAdditionalMinutes, durationSeconds, hikingPreferences, lakeStyle, maxHikingDifficulty, paddleEstimate, paddleSpeedInvalid, @@ -713,6 +714,9 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe setError('open') return false } + // SKI-02: a saved ski tour opens with the line it was saved with, read from this device. + const skiLine = result.tour.tour_type === 'ski' ? await tourRepo.savedLine(tripId, result.tour.place_id) : null + if (!requestIsCurrent() || saveInFlightRef.current) return false interruptRouting() const opened = openedTourFields(result.tour) @@ -739,6 +743,8 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe setStageLoss(0) setStatus('dirty') clearRoute() + const savedSki = savedSkiSeed(result.tour, skiLine, nextWaypoints, opened.ski) + if (savedSki) openSavedSkiRoute(savedSki) // CW-11: a walk without its way yet is planned on opening and can be saved with it at once. const pending = result.tour.route_pending === true setRoutePending(pending) @@ -762,7 +768,8 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe setOpeningTourId(null) } } - }, [adoptRevision, clearHistory, clearRoute, interruptRouting, loadChoices, loadPaddle, loadRunPace, loadSki, forgetDraft, tripId]) + }, [adoptRevision, clearHistory, clearRoute, interruptRouting, loadChoices, loadPaddle, loadRunPace, loadSki, forgetDraft, openSavedSkiRoute, + tripId]) const retry = useCallback(() => { if (waypointsRef.current.length < 2) return diff --git a/client/src/components/Tours/planner/useTourRoute.heights.test.ts b/client/src/components/Tours/planner/useTourRoute.heights.test.ts index 6aabc7727..494840aa2 100644 --- a/client/src/components/Tours/planner/useTourRoute.heights.test.ts +++ b/client/src/components/Tours/planner/useTourRoute.heights.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { interpolatedHeights } from './useTourRoute'; -// FE-TOUR-ROUTE-HEIGHTS-001 to FE-TOUR-ROUTE-HEIGHTS-002 +// FE-TOUR-ROUTE-HEIGHTS-001 to FE-TOUR-ROUTE-HEIGHTS-003 describe('interpolatedHeights (SKI-02)', () => { it('FE-TOUR-ROUTE-HEIGHTS-001: fills the points the elevation source missed straight between the known ones', () => { @@ -16,6 +16,24 @@ describe('interpolatedHeights (SKI-02)', () => { expect(interpolatedHeights(line)?.map((point) => point[2])).toEqual([1000, 1000, 1100, 1200, 1300, 1300]); }); + it('FE-TOUR-ROUTE-HEIGHTS-003: walks a long line once, gap after gap, each filled from its own two ends', () => { + const at = (index: number): [number, number] | [number, number, number] => + index % 1000 === 0 ? [47 + index / 1e6, 11, index] : [47 + index / 1e6, 11]; + const line = Array.from({ length: 20_001 }, (_, index) => at(index)); + const heights = interpolatedHeights(line)?.map((point) => point[2]) ?? []; + expect(heights).toHaveLength(20_001); + expect(heights.every((height, index) => Math.abs(height - index) < 1e-6)).toBe(true); + const ends: Array<[number, number] | [number, number, number]> = [ + [47, 11], + [47.001, 11, 500], + [47.002, 11], + [47.003, 11, 700], + [47.004, 11, 600], + [47.005, 11], + ]; + expect(interpolatedHeights(ends)?.map((point) => point[2])).toEqual([500, 500, 600, 700, 600, 600]); + }); + it('FE-TOUR-ROUTE-HEIGHTS-002: a line without one known height has none to estimate from', () => { expect( interpolatedHeights([ diff --git a/client/src/components/Tours/planner/useTourRoute.ts b/client/src/components/Tours/planner/useTourRoute.ts index ca832a7cb..4cd1eb46b 100644 --- a/client/src/components/Tours/planner/useTourRoute.ts +++ b/client/src/components/Tours/planner/useTourRoute.ts @@ -94,13 +94,24 @@ const NO_ROUTE: PlannedRoute = { forRun: null, }; -/** A route planned whole: a paddle route without a failed leg, a ski tour too, a route on land with its heights. */ +/** + * A route planned whole: a paddle route without a failed leg, a ski tour too, + * a ski tour opened as it was saved, a route on land with its heights. + */ function routeComplete(planned: PlannedRoute): boolean { if (planned.paddleRoute) return planned.paddleRoute.failures.length === 0; + if (planned.skiRoute?.saved) return true; if (planned.skiRoute && planned.skiRoute.problems.length > 0) return false; return hasCompleteElevation(planned.enrichedGeometry); } +/** SKI-02: a saved ski tour's route, for the points and the ski choice it was saved with. */ +export interface SavedSkiSeed { + route: SkiTourRoute; + waypointKey: string; + skiKey: string; +} + /** One planning run: what it plans from, whether it went stale, and where it writes. */ interface RouteRun { inputs: Omit; @@ -189,14 +200,21 @@ async function routeOnLand({ inputs, plannedKey, runKey, signal, stale, setPlann setError(null); } -/** Heights straight between the known ones along the line, where the elevation source has none: an estimate. */ +/** + * Heights straight between the known ones along the line, where the elevation + * source has none: an estimate. Before the first known height and after the + * last, the nearest one holds. One walk along the line, however long it is. + */ export function interpolatedHeights(points: readonly TourElevationPoint[]): [number, number, number][] | null { const known = points.flatMap((point, index) => (point.length === 3 && Number.isFinite(point[2]) ? [index] : [])); if (known.length === 0) return null; const height = (k: number) => (points[k] as [number, number, number])[2]; + let next = 0; return points.map((point, index): [number, number, number] => { - const after = known.find((k) => k >= index) ?? (known[known.length - 1] as number); - const before = [...known].reverse().find((k) => k <= index) ?? after; + // The first known height at or after this point; past the last one, the last. + while (next < known.length - 1 && (known[next] as number) < index) next += 1; + const after = known[next] as number; + const before = after <= index || next === 0 ? after : (known[next - 1] as number); const share = after === before ? 0 : (index - before) / (after - before); return [point[0], point[1], height(before) + (height(after) - height(before)) * share]; }); @@ -313,6 +331,8 @@ export function useTourRoute(inputs: TourRouteInputs) { const [unreachable, setUnreachable] = useState(null); const abortRef = useRef(null); const generationRef = useRef(0); + // SKI-02: a saved ski tour's route waiting for the run that plans its points. + const savedSkiRef = useRef(null); /** Any answer still on its way is stale from here on. */ const interruptRouting = useCallback(() => { @@ -324,6 +344,11 @@ export function useTourRoute(inputs: TourRouteInputs) { /** No route at all, as for an empty draft. */ const clearRoute = useCallback(() => setPlanned(NO_ROUTE), []); + /** SKI-02: the next run for these points and this ski choice takes the saved route instead of asking the router. */ + const openSavedSkiRoute = useCallback((seed: SavedSkiSeed) => { + savedSkiRef.current = seed; + }, []); + const waypointKey = useMemo(() => tourWaypointKey(waypoints), [waypoints]); const plannedKey = plannedKeyOf(waypointKey, inputs); // CW-02, CW-03: a station's own fields and the keys a save answered are no part of what a route is planned from, @@ -334,6 +359,26 @@ export function useTourRoute(inputs: TourRouteInputs) { const currentPoints = useEffectEvent(() => waypoints); // The route shown was planned whole for exactly this run, so a change that leaves it as it is plans nothing again. const routeStands = useEffectEvent(() => planned.forRun === runKey && routeComplete(planned)); + // SKI-02: a saved ski tour opened as it was saved, its line, time and build shown until something of it changes. + const takeSavedSkiRoute = useEffectEvent(() => { + const seed = savedSkiRef.current; + savedSkiRef.current = null; + if (!seed || tourType !== 'ski' || seed.waypointKey !== waypointKey || seed.skiKey !== skiKey) return false; + const { route } = seed; + setUnreachable(null); + setPlanned({ + route: route.coordinates, + routeParts: route.parts, + paddleRoute: null, + skiRoute: route, + enrichedGeometry: route.elevations, + distanceMeters: route.distanceMeters, + durationSeconds: route.durationSeconds, + forKey: plannedKey, + forRun: runKey, + }); + return true; + }); useEffect(() => { const points = currentPoints(); @@ -345,6 +390,11 @@ export function useTourRoute(inputs: TourRouteInputs) { setError(null); return; } + if (takeSavedSkiRoute()) { + setStatus('ready'); + setError(null); + return; + } if (routeStands()) { setStatus((current) => (current === 'dirty' ? 'ready' : current)); return; @@ -414,6 +464,7 @@ export function useTourRoute(inputs: TourRouteInputs) { unreachable: error === 'unreachable' ? unreachable : null, interruptRouting, clearRoute, + openSavedSkiRoute, }; } diff --git a/client/src/components/Tours/ski/SkiRouteSummary.tsx b/client/src/components/Tours/ski/SkiRouteSummary.tsx index 451203b95..75ebcb9da 100644 --- a/client/src/components/Tours/ski/SkiRouteSummary.tsx +++ b/client/src/components/Tours/ski/SkiRouteSummary.tsx @@ -1,6 +1,9 @@ -import type { SkiFlag, SkiRouteOkLeg, SkiWhy } from '@trek/shared'; +import type { SkiFlag, SkiGeometryPoint, SkiRouteOkLeg, SkiSegmentKind, SkiWhy } from '@trek/shared'; import { Info, MountainSnow } from 'lucide-react'; +import type { ReactNode } from 'react'; import { useTranslation } from '../../../i18n'; +import { useSettingsStore } from '../../../store/settingsStore'; +import { formatDistance, formatElevation } from '../../../utils/units'; import { fs } from '../../shared/DialogShell'; import type { SkiTourRoute } from '../planner/skiRouting'; import { formatTourDuration } from '../planner/tourDurationInput'; @@ -16,6 +19,9 @@ const GUESSED: ReadonlySet = new Set([ 'difficulty_unknown', ]); +/** Stretches ridden on a lift, a funicular or rack railway, or a gondola down. */ +const RIDES: ReadonlySet = new Set(['lift', 'rail', 'lift_down']); + type Seconds = SkiRouteOkLeg['seconds']; type Avoided = Extract; @@ -32,6 +38,33 @@ export function skiSecondsOf(route: SkiTourRoute): Seconds { return total; } +/** The height lost along a stretch of a leg's line, counted where its points have a height. */ +function descentAlong(geometry: readonly SkiGeometryPoint[], from: number, to: number): number { + let down = 0; + let last: number | null = null; + for (const [, , height] of geometry.slice(from, to + 1)) { + if (height === null) continue; + if (last !== null && height < last) down += last - height; + last = height; + } + return down; +} + +/** The metres of runs, the height skied down on them, and the lift rides of every planned leg. */ +export function skiTallyOf(route: SkiTourRoute): { runsM: number; downM: number; rides: number } { + const tally = { runsM: 0, downM: 0, rides: 0 }; + for (const leg of route.legs) { + if (leg?.status !== 'ok') continue; + for (const segment of leg.segments) { + if (RIDES.has(segment.kind)) tally.rides += 1; + if (segment.kind !== 'run') continue; + tally.runsM += segment.length_m; + tally.downM += descentAlong(leg.geometry, segment.from, segment.to); + } + } + return tally; +} + /** The harder runs the route kept clear of, and whether any stretch rests on a guess. */ function skiWhyOf(route: SkiTourRoute): { avoided: Avoided[]; guessed: boolean } { const why = route.legs.flatMap((leg) => (leg?.status === 'ok' ? leg.why : [])); @@ -53,17 +86,51 @@ function Note({ children }: { children: string }) { ); } +/** The card the summary sits in, with the piste data build and the credit of the data closing it. */ +function SummaryCard({ route, children }: { route: SkiTourRoute; children: ReactNode }) { + const { t } = useTranslation(); + const credit = route.attribution ?? t('tours.ski.summary.source'); + return ( + } title={t('tours.ski.summary.title')} foldId="ski-route"> +
+ {children} + {route.build && ( + + {t('tours.ski.summary.build', { build: route.build })} + + )} + + {[route.areaNames.join(', '), credit].filter(Boolean).join('. ')} + +
+
+ ); +} + /** * SKI-02: what the piste router made of a planned ski tour: the time on runs, - * on lifts, waiting and on foot, the harder runs it kept clear of, and what + * on lifts, waiting and on foot, the length of the runs, the height skied + * down on them and the lift rides, the harder runs it kept clear of, and what * the reader should weigh: legs it could not plan, stretches that rest on a - * guess, an older copy of the piste data. The ski areas and the source of the - * data close it. + * guess, an older copy of the piste data. The build of the data, the ski + * areas and the credit of the data close it. A tour opened as it was saved + * says so, and keeps its line until something of it changes. */ export function SkiRouteSummary({ route }: { route: SkiTourRoute | null }) { const { t } = useTranslation(); - if (!route || route.legs.every((leg) => leg === null)) return null; + const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric'; + if (!route) return null; + if (route.saved) { + if (route.plannedLegs.length === 0 && !route.build) return null; + return ( + + {t('tours.ski.summary.saved')} + + ); + } + if (route.legs.every((leg) => leg === null)) return null; const seconds = skiSecondsOf(route); + const tally = skiTallyOf(route); const { avoided, guessed } = skiWhyOf(route); const level = (entry: Avoided) => t(`tours.ski.difficulty.${entry.level}`); const facts = [ @@ -71,36 +138,34 @@ export function SkiRouteSummary({ route }: { route: SkiTourRoute | null }) { { label: t('tours.ski.summary.lift'), value: minutes(seconds.lift) }, { label: t('tours.ski.summary.wait'), value: minutes(seconds.wait) }, { label: t('tours.ski.summary.walk'), value: minutes(seconds.walk) }, + { label: t('tours.ski.summary.runs'), value: formatDistance(tally.runsM / 1000, unit) }, + { label: t('tours.ski.summary.descent'), value: formatElevation(tally.downM, unit) }, + { label: t('tours.ski.summary.lifts'), value: String(tally.rides) }, ]; return ( - } title={t('tours.ski.summary.title')} foldId="ski-route"> -
- - {avoided.length > 0 && ( -
- - {t('tours.ski.summary.avoided')} - - - {avoided.map((entry, at) => ( - - {entry.name ? `${level(entry)}: ${entry.name}` : level(entry)} - - ))} - -
- )} - {route.problems.length > 0 && {t('tours.ski.summary.unplanned')}} - {guessed && {t('tours.ski.summary.guessed')}} - {route.stale && {t('tours.ski.summary.stale')}} - - {[route.areaNames.join(', '), t('tours.ski.summary.source')].filter(Boolean).join('. ')} - -
-
+ + + {avoided.length > 0 && ( +
+ + {t('tours.ski.summary.avoided')} + + + {avoided.map((entry, at) => ( + + {entry.name ? `${level(entry)}: ${entry.name}` : level(entry)} + + ))} + +
+ )} + {route.problems.length > 0 && {t('tours.ski.summary.unplanned')}} + {guessed && {t('tours.ski.summary.guessed')}} + {route.stale && {t('tours.ski.summary.stale')}} +
); } diff --git a/client/src/components/Tours/ski/savedSkiRoute.test.ts b/client/src/components/Tours/ski/savedSkiRoute.test.ts new file mode 100644 index 000000000..994d651b8 --- /dev/null +++ b/client/src/components/Tours/ski/savedSkiRoute.test.ts @@ -0,0 +1,81 @@ +import { computeTourMetrics } from '@trek/shared'; +import { describe, expect, it } from 'vitest'; +import { skiRouteKey } from '../planner/skiRouting'; +import { tourWaypointKey, type TourPlannerWaypoint } from '../planner/tourPlannerWaypoints'; +import { savedSkiRoute, savedSkiSeed } from './savedSkiRoute'; + +// FE-TOUR-SKI-SAVED-001 to FE-TOUR-SKI-SAVED-004 + +const line: [number, number, number][] = [ + [47, 11, 1600], + [47.005, 11.01, 1500], + [47.01, 11.02, 1400], +]; + +const tour = { + tour_type: 'ski' as const, + ski_discipline: 'alpine' as const, + distance: computeTourMetrics(line).distanceKm, + duration: 25, + ski_route_build: '2026-11-02', + ski_planned_legs: [1], +}; + +describe('savedSkiRoute (SKI-02)', () => { + it('FE-TOUR-SKI-SAVED-001: opens a saved ski tour with its line, heights, time, build and planned legs', () => { + const route = savedSkiRoute(tour, JSON.stringify(line), 2); + expect(route).toMatchObject({ + coordinates: line.map(([lat, lng]) => [lat, lng]), + elevations: line, + durationSeconds: 1500, + build: '2026-11-02', + plannedLegs: [1], + saved: true, + problems: [], + attribution: null, + }); + expect(route?.distanceMeters).toBeCloseTo(tour.distance * 1000, 6); + expect(route?.parts).toEqual([{ coordinates: route?.coordinates, free: false }]); + // The line may come as the place keeps it, or already read. + expect(savedSkiRoute(tour, line, 2)?.saved).toBe(true); + expect(savedSkiRoute({ ...tour, duration: null }, line, 2)?.durationSeconds).toBeNull(); + }); + + it('FE-TOUR-SKI-SAVED-002: a tour that does not name its planned legs counts them all only with a kept time and build', () => { + const unnamed = { ...tour, ski_planned_legs: null }; + expect(savedSkiRoute(unnamed, line, 2)?.plannedLegs).toEqual([0, 1]); + expect(savedSkiRoute({ ...unnamed, duration: null }, line, 2)?.plannedLegs).toEqual([]); + expect(savedSkiRoute({ ...unnamed, ski_route_build: null }, line, 2)?.plannedLegs).toEqual([]); + }); + + it('FE-TOUR-SKI-SAVED-003: nothing to open with: another type, no discipline or length, no line, a broken one, another save', () => { + const json = JSON.stringify(line); + expect(savedSkiRoute({ ...tour, tour_type: 'hike' }, json, 2)).toBeNull(); + expect(savedSkiRoute({ ...tour, ski_discipline: null }, json, 2)).toBeNull(); + expect(savedSkiRoute({ ...tour, distance: null }, json, 2)).toBeNull(); + expect(savedSkiRoute(tour, null, 2)).toBeNull(); + expect(savedSkiRoute(tour, '[[47, 11', 2)).toBeNull(); + expect(savedSkiRoute(tour, JSON.stringify({ line }), 2)).toBeNull(); + expect(savedSkiRoute(tour, JSON.stringify([line[0]]), 2)).toBeNull(); + expect(savedSkiRoute(tour, JSON.stringify([line[0], [47.01, 11.02]]), 2)).toBeNull(); + expect(savedSkiRoute(tour, JSON.stringify([line[0], 'x']), 2)).toBeNull(); + expect(savedSkiRoute(tour, JSON.stringify([line[0], [47.01, 11.02, null]]), 2)).toBeNull(); + // A line of another length is another save's. + expect(savedSkiRoute({ ...tour, distance: tour.distance + 0.1 }, json, 2)).toBeNull(); + }); + + it('FE-TOUR-SKI-SAVED-004: hands the route on for exactly the points and the ski choice it opens with', () => { + const points: TourPlannerWaypoint[] = [ + { id: 'a', lat: 47, lng: 11, role: 'start' }, + { id: 'b', lat: 47.01, lng: 11.02, role: 'end' }, + ]; + const choice = { discipline: 'alpine' as const, technique: null }; + const seed = savedSkiSeed(tour, JSON.stringify(line), points, choice); + expect(seed?.waypointKey).toBe(tourWaypointKey(points)); + expect(seed?.skiKey).toBe(skiRouteKey(choice)); + expect(seed?.route.plannedLegs).toEqual([1]); + expect(savedSkiSeed({ ...tour, ski_planned_legs: null }, line, points, choice)?.route.plannedLegs).toEqual([0]); + expect(savedSkiSeed(tour, null, points, choice)).toBeNull(); + expect(savedSkiSeed({ ...tour, ski_planned_legs: null }, line, [], choice)?.route.plannedLegs).toEqual([]); + }); +}); diff --git a/client/src/components/Tours/ski/savedSkiRoute.ts b/client/src/components/Tours/ski/savedSkiRoute.ts new file mode 100644 index 000000000..a799fc59c --- /dev/null +++ b/client/src/components/Tours/ski/savedSkiRoute.ts @@ -0,0 +1,98 @@ +import { computeTourMetrics, type TourListItem } from '@trek/shared'; +import { skiRouteKey, type SkiTourRoute } from '../planner/skiRouting'; +import { tourWaypointKey, type TourPlannerWaypoint } from '../planner/tourPlannerWaypoints'; +import type { SavedSkiSeed } from '../planner/useTourRoute'; +import type { SkiChoice } from './skiChoice'; + +/** + * How far in km the length of the line read may stray from the length the + * tour was saved with. Both are worked out the same way from the same points, + * so a line that strays further is another save's. + */ +const SAME_LINE_KM = 0.005; + +/** A line as its place keeps it: every point with its height, or nothing. */ +function savedLineOf(routeGeometry: unknown): [number, number, number][] | null { + let points: unknown = routeGeometry; + if (typeof routeGeometry === 'string') { + try { + points = JSON.parse(routeGeometry); + } catch { + // Not a line at all: the tour is planned afresh. + return null; + } + } + if (!Array.isArray(points) || points.length < 2) return null; + const line: [number, number, number][] = []; + for (const point of points) { + if (!Array.isArray(point) || point.length < 3) return null; + const [lat, lng, height] = point as unknown[]; + if (![lat, lng, height].every((value) => typeof value === 'number' && Number.isFinite(value))) return null; + line.push([lat as number, lng as number, height as number]); + } + return line; +} + +type SavedSkiTour = Pick< + TourListItem, + 'tour_type' | 'ski_discipline' | 'distance' | 'duration' | 'ski_route_build' | 'ski_planned_legs' +>; + +/** + * The legs the router planned, as the tour names them. A tour that does not + * name them was planned throughout only when it kept the router's time. + */ +function plannedLegsOf(tour: SavedSkiTour, legCount: number): number[] { + if (tour.ski_planned_legs) return [...tour.ski_planned_legs]; + if (!tour.ski_route_build || tour.duration == null) return []; + return Array.from({ length: legCount }, (_, index) => index); +} + +/** + * SKI-02: a saved ski tour as the planner opens it: the line, the time and + * the build it was saved with, so nothing is asked of the piste router until + * a point or an option changes, and a tour opened offline or while the piste + * data cannot be reached shows what was planned instead of straight lines. + * Null when there is nothing to open it with: a tour of another type or + * without a discipline, no line kept on this device, a line without every + * height, or a line of another save than the tour's. + */ +export function savedSkiRoute(tour: SavedSkiTour, routeGeometry: unknown, legCount: number): SkiTourRoute | null { + if (tour.tour_type !== 'ski' || !tour.ski_discipline || tour.distance == null) return null; + const line = savedLineOf(routeGeometry); + if (!line) return null; + const { distanceKm } = computeTourMetrics(line); + if (Math.abs(distanceKm - tour.distance) > SAME_LINE_KM) return null; + const coordinates = line.map(([lat, lng]): [number, number] => [lat, lng]); + return { + coordinates, + parts: [{ coordinates, free: false }], + distanceMeters: distanceKm * 1000, + durationSeconds: tour.duration == null ? null : tour.duration * 60, + legs: [], + problems: [], + elevations: line, + build: tour.ski_route_build ?? null, + stale: false, + areaNames: [], + colourScheme: 'europe', + attribution: null, + plannedLegs: plannedLegsOf(tour, legCount), + saved: true, + }; +} + +/** + * What the planner hands its route when it opens a saved ski tour: the route + * as saved, for exactly the points and the ski choice it opens with. Null + * when the tour opens to be planned afresh. + */ +export function savedSkiSeed( + tour: SavedSkiTour, + routeGeometry: unknown, + waypoints: TourPlannerWaypoint[], + choice: SkiChoice +): SavedSkiSeed | null { + const route = savedSkiRoute(tour, routeGeometry, Math.max(0, waypoints.length - 1)); + return route ? { route, waypointKey: tourWaypointKey(waypoints), skiKey: skiRouteKey(choice) } : null; +} diff --git a/client/src/components/Tours/ski/skiRouteUi.test.tsx b/client/src/components/Tours/ski/skiRouteUi.test.tsx index 95b0409a7..3bd97a69a 100644 --- a/client/src/components/Tours/ski/skiRouteUi.test.tsx +++ b/client/src/components/Tours/ski/skiRouteUi.test.tsx @@ -5,9 +5,9 @@ import type { SkiLegProblem, SkiTourRoute } from '../planner/skiRouting'; import { SkiLegFailureRow, SkiLegSteps, skiFixesOf } from './SkiLegRows'; import { SkiRouteOptions } from './SkiRouteOptions'; import { skiPaintOf } from './skiRouteStyle'; -import { SkiRouteSummary, skiSecondsOf } from './SkiRouteSummary'; +import { SkiRouteSummary, skiSecondsOf, skiTallyOf } from './SkiRouteSummary'; -// FE-TOUR-SKI-UI-001 to FE-TOUR-SKI-UI-009 +// FE-TOUR-SKI-UI-001 to FE-TOUR-SKI-UI-012 function segment(kind: SkiRouteSegment['kind'], extra: Partial = {}): SkiRouteSegment { return { @@ -68,6 +68,9 @@ function route(extra: Partial = {}): SkiTourRoute { stale: false, areaNames: ['Gletscherwelt'], colourScheme: 'europe', + attribution: null, + plannedLegs: [0], + saved: false, ...extra, }; } @@ -211,6 +214,51 @@ describe('SkiRouteSummary (SKI-02)', () => { expect(container).toBeEmptyDOMElement(); expect(render().container).toBeEmptyDOMElement(); }); + + it('FE-TOUR-SKI-UI-010: counts the runs, the height skied down on them and the lift rides, and names the build', () => { + const leg = okLeg({ + geometry: [ + [47, 11, 1500], + [47.01, 11, 2000], + [47.015, 11, null], + [47.02, 11, 1400], + [47.021, 11, 1450], + [47.03, 11, 1200], + ], + segments: [ + { ...segment('lift'), from: 0, to: 1 }, + { ...segment('run', { length_m: 1800 }), from: 1, to: 5 }, + { ...segment('rail'), from: 5, to: 5 }, + ], + }); + // 2000 down to 1400 past a point without a height, up 50 and down 250: 850 m skied down. + expect(skiTallyOf(route({ legs: [leg, null] }))).toEqual({ runsM: 1800, downM: 850, rides: 2 }); + const credited = route({ legs: [leg], attribution: '© OpenStreetMap contributors. Terrain: Terrarium' }); + render(); + const card = screen.getByTestId('ski-route-summary'); + expect(card).toHaveTextContent('tours.ski.summary.runs1.8 km'); + expect(card).toHaveTextContent('tours.ski.summary.descent850 m'); + expect(card).toHaveTextContent('tours.ski.summary.lifts2'); + expect(card).toHaveTextContent('tours.ski.summary.build'); + expect(card).toHaveTextContent('Gletscherwelt. © OpenStreetMap contributors. Terrain: Terrarium'); + expect(card).not.toHaveTextContent('tours.ski.summary.source'); + }); + + it('FE-TOUR-SKI-UI-011: a tour opened as saved says so with its build, and the fixed credit where none was sent', () => { + render(); + const card = screen.getByTestId('ski-route-summary'); + expect(card).toHaveTextContent('tours.ski.summary.saved'); + expect(card).toHaveTextContent('tours.ski.summary.build'); + expect(card).toHaveTextContent('tours.ski.summary.source'); + expect(card).not.toHaveTextContent('tours.ski.summary.ski'); + }); + + it('FE-TOUR-SKI-UI-012: nothing for a tour saved without a leg the router planned, and no build line without a build', () => { + const drawn = route({ legs: [], saved: true, plannedLegs: [], build: null }); + expect(render().container).toBeEmptyDOMElement(); + render(); + expect(screen.getByTestId('ski-route-summary')).not.toHaveTextContent('tours.ski.summary.build'); + }); }); describe('skiPaintOf (SKI-02)', () => { diff --git a/client/src/repo/skiRouteRepo.test.ts b/client/src/repo/skiRouteRepo.test.ts index dcd381034..22725c560 100644 --- a/client/src/repo/skiRouteRepo.test.ts +++ b/client/src/repo/skiRouteRepo.test.ts @@ -1,4 +1,4 @@ -// FE-REPO-SKIROUTE-001 to FE-REPO-SKIROUTE-005: ski legs planned on the piste data, online only, remembered per request. +// FE-REPO-SKIROUTE-001 to FE-REPO-SKIROUTE-007: ski legs planned on the piste data, online only, remembered per request. // FE-API-SKIROUTE-001 to FE-API-SKIROUTE-002: the call behind them, held to the @trek/shared contract. import { MAX_SKI_ROUTE_LEGS, @@ -108,6 +108,7 @@ describe('skiRouteRepo (SKI-02)', () => { expect(answer.legs.map((leg) => leg.status)).toEqual(['ok', 'ok']); expect(answer).toMatchObject({ build: '2026-11-02', stale: false, areaNames: ['Gletscherwelt'] }); expect(answer.colourScheme).toBe('europe'); + expect(answer.attribution).toBe('© OpenStreetMap contributors'); }); it('FE-REPO-SKIROUTE-002: the same request again comes from memory, another option asks again', async () => { @@ -134,6 +135,7 @@ describe('skiRouteRepo (SKI-02)', () => { const answer = await skiRouteRepo.plan(204, [{ from: A, to: B }], ALPINE); expect(answer.legs).toEqual([{ status: 'failed', code: 'offline', at: null, hint: null, detail: null }]); expect(answer.build).toBeNull(); + expect(answer.attribution).toBeNull(); expect(skiRouteApi.plan).not.toHaveBeenCalled(); }); @@ -152,6 +154,25 @@ describe('skiRouteRepo (SKI-02)', () => { expect(answer.stale).toBe(true); expect(answer.areaNames).toEqual([`Area ${MAX_SKI_ROUTE_LEGS}`, 'Area 2']); }); + + it('FE-REPO-SKIROUTE-006: legs refused while piste routing is switched off are asked again once it is back on', async () => { + vi.mocked(skiRouteApi.plan).mockResolvedValueOnce( + response([{ status: 'failed', code: 'disabled', at: null, hint: null, detail: null }], { build: null }) + ); + const off = await skiRouteRepo.plan(206, [{ from: A, to: B }], ALPINE); + expect(off.legs[0]).toMatchObject({ status: 'failed', code: 'disabled' }); + const on = await skiRouteRepo.plan(206, [{ from: A, to: B }], ALPINE); + expect(on.legs[0]?.status).toBe('ok'); + expect(skiRouteApi.plan).toHaveBeenCalledTimes(2); + }); + + it('FE-REPO-SKIROUTE-007: a plan on an older copy of the piste data is asked again, a current one comes from memory', async () => { + vi.mocked(skiRouteApi.plan).mockResolvedValueOnce(response([okLeg(A, B)], { stale: true })); + expect((await skiRouteRepo.plan(207, [{ from: A, to: B }], ALPINE)).stale).toBe(true); + expect((await skiRouteRepo.plan(207, [{ from: A, to: B }], ALPINE)).stale).toBe(false); + await skiRouteRepo.plan(207, [{ from: A, to: B }], ALPINE); + expect(skiRouteApi.plan).toHaveBeenCalledTimes(2); + }); }); /** The real call; the repo tests above run against a mock of this module. */ diff --git a/client/src/repo/skiRouteRepo.ts b/client/src/repo/skiRouteRepo.ts index 2d9f2371f..33f7c240e 100644 --- a/client/src/repo/skiRouteRepo.ts +++ b/client/src/repo/skiRouteRepo.ts @@ -27,6 +27,8 @@ export interface SkiPlanAnswer { stale: boolean; areaNames: string[]; colourScheme: SkiColourScheme; + /** The credit of the piste and terrain data the legs were planned on; null where nothing was asked. */ + attribution: string | null; } const OFFLINE: SkiLegAnswer = { status: 'failed', code: 'offline', at: null, hint: null, detail: null }; @@ -35,8 +37,19 @@ const OFFLINE: SkiLegAnswer = { status: 'failed', code: 'offline', at: null, hin const REMEMBERED = 200; const remembered = new Map(); -/** Answers that may change by asking again a moment later are not remembered. */ -const PASSING: ReadonlySet = new Set(['loading', 'busy', 'timeout', 'places_unavailable', 'offline']); +/** + * Answers that may change by asking again a moment later are not remembered: + * an area still downloading, a busy or slow router, the piste data out of + * reach, no connection, or piste routing switched off by the admin. + */ +const PASSING: ReadonlySet = new Set([ + 'loading', + 'busy', + 'timeout', + 'places_unavailable', + 'offline', + 'disabled', +]); function keyOf( tripId: number | string, @@ -48,6 +61,8 @@ function keyOf( } function remember(key: string, answer: SkiPlanAnswer): void { + // A plan on an older kept copy of the piste data is asked again once the current one is in reach. + if (answer.stale) return; if (answer.legs.some((leg) => leg.status === 'failed' && PASSING.has(leg.code))) return; remembered.delete(key); remembered.set(key, answer); @@ -61,6 +76,7 @@ function merged(answers: SkiRouteResponse[]): SkiPlanAnswer { stale: answers.some((answer) => answer.stale), areaNames: [...new Set(answers.flatMap((answer) => answer.area_names))], colourScheme: answers[0]?.colour_scheme ?? 'europe', + attribution: answers.find((answer) => answer.attribution)?.attribution ?? null, }; } @@ -83,7 +99,14 @@ export const skiRouteRepo = { const known = remembered.get(key); if (known) return known; if (isEffectivelyOffline()) { - return { legs: legs.map(() => OFFLINE), build: null, stale: false, areaNames: [], colourScheme: 'europe' }; + return { + legs: legs.map(() => OFFLINE), + build: null, + stale: false, + areaNames: [], + colourScheme: 'europe', + attribution: null, + }; } const batches: Array> = []; for (let start = 0; start < legs.length; start += MAX_SKI_ROUTE_LEGS) { diff --git a/client/src/repo/tourRepo.test.ts b/client/src/repo/tourRepo.test.ts index 2b5d68645..22e3d4014 100644 --- a/client/src/repo/tourRepo.test.ts +++ b/client/src/repo/tourRepo.test.ts @@ -1,4 +1,4 @@ -// FE-REPO-TOUR-001 to FE-REPO-TOUR-014, FE-REPO-TOUR-042 to FE-REPO-TOUR-045 +// FE-REPO-TOUR-001 to FE-REPO-TOUR-014, FE-REPO-TOUR-042 to FE-REPO-TOUR-047 // Tours through the offline core: read-through to Dexie, offline create and // edit written there and queued, and the online paths keeping the copy fresh. import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' @@ -129,6 +129,20 @@ describe('tourRepo.detail', () => { }) }) +describe('tourRepo.savedLine', () => { + it('FE-REPO-TOUR-047: reads the line a tour was saved with from its cached place, never one of another trip (SKI-02)', async () => { + setOnline(false) + const line = JSON.stringify([[47, 11, 2000], [47.01, 11, 1500]]) + await offlineDb.places.put(buildPlace({ id: 40, trip_id: 3, route_geometry: line })) + await offlineDb.places.put(buildPlace({ id: 41, trip_id: 3, route_geometry: null })) + + expect(await tourRepo.savedLine(3, 40)).toBe(line) + expect(await tourRepo.savedLine(4, 40)).toBeNull() + expect(await tourRepo.savedLine(3, 41)).toBeNull() + expect(await tourRepo.savedLine(3, 99)).toBeNull() + }) +}) + describe('tourRepo.create', () => { it('FE-REPO-TOUR-008: offline, place and facet share one temp id, the metrics match the server\'s, and a POST is queued', async () => { setOnline(false) diff --git a/client/src/repo/tourRepo.ts b/client/src/repo/tourRepo.ts index 1767157ac..289e2e8ff 100644 --- a/client/src/repo/tourRepo.ts +++ b/client/src/repo/tourRepo.ts @@ -202,6 +202,16 @@ export const tourRepo = { ) }, + /** + * SKI-02: the line a tour was saved with, as this device keeps its place + * (the trip's places are read into it whenever the trip loads); null when + * it keeps none for that trip. Read only, so it answers offline the same. + */ + async savedLine(tripId: number | string, placeId: number): Promise { + const place = await offlineDb.places.get(placeId) + return place && place.trip_id === Number(tripId) ? (place.route_geometry ?? null) : null + }, + async create(tripId: number | string, body: TourCreateRequest): Promise { if (isEffectivelyOffline()) { const tid = Number(tripId) diff --git a/server/src/db/entities/CollectionTours.entity.ts b/server/src/db/entities/CollectionTours.entity.ts index 4fca82ef0..f77c15c57 100644 --- a/server/src/db/entities/CollectionTours.entity.ts +++ b/server/src/db/entities/CollectionTours.entity.ts @@ -46,6 +46,7 @@ export class CollectionTours { source_trip_title!: string; source_tour_name!: string; created_at!: string & Opt; + ski_options?: string | null; tourTypeRef!: Ref; savedByRef?: Ref | null; } @@ -93,7 +94,10 @@ export const CollectionToursSchema = defineEntity({ source_trip_title: p.text(), source_tour_name: p.text(), created_at: p.type(DbTimestampType).defaultRaw(`CURRENT_TIMESTAMP`), - tourTypeRef: () => p.manyToOne(TourTypes).ref().joinColumn('tour_type').hidden().index('idx_collection_tours_tour_type'), - savedByRef: () => p.manyToOne(Users).ref().joinColumn('saved_by').nullable().hidden().index('idx_collection_tours_saved_by'), + ski_options: p.text().nullable(), + tourTypeRef: () => + p.manyToOne(TourTypes).ref().joinColumn('tour_type').hidden().index('idx_collection_tours_tour_type'), + savedByRef: () => + p.manyToOne(Users).ref().joinColumn('saved_by').nullable().hidden().index('idx_collection_tours_saved_by'), }, }); diff --git a/server/src/db/json-columns.ts b/server/src/db/json-columns.ts index f5a5e9fdc..c0978ecbe 100644 --- a/server/src/db/json-columns.ts +++ b/server/src/db/json-columns.ts @@ -8,6 +8,7 @@ import { paddleLockSchema, paddleRegionBoundarySchema, paddleRegionGraphStatsSchema, + skiPlannedLegsSchema, skiRouteOptionsSchema, tourChangeSchema, tourFixedLegSchema, @@ -197,12 +198,20 @@ export const TOUR_HIKING_PREFERENCES: JsonColumn = { export interface TourSkiRoute { options: SkiRouteOptions; build: string | null; + /** The legs the piste router planned, by the index of the point each starts at; absent where the save did not say. */ + planned_legs?: number[]; } +const tourSkiRouteSchema = z.object({ + options: skiRouteOptionsSchema, + build: z.string().nullable(), + planned_legs: skiPlannedLegsSchema.optional(), +}); + /** 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(), + schema: tourSkiRouteSchema.nullable(), fallback: () => null, }; diff --git a/server/src/db/kysely/CollectionTours.table.ts b/server/src/db/kysely/CollectionTours.table.ts index 9c13f54e5..f797a9948 100644 --- a/server/src/db/kysely/CollectionTours.table.ts +++ b/server/src/db/kysely/CollectionTours.table.ts @@ -41,4 +41,5 @@ export interface CollectionToursTable { source_trip_title: string; source_tour_name: string; created_at: 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 index b9ade4710..396c9119b 100644 --- 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 @@ -7,12 +7,14 @@ import { Migration } from '@mikro-orm/migrations'; * (`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. + * can be offered instead of applied. A tour kept in a collection keeps the + * same column, so a copy made from it is planned as the tour it was saved from. */ 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'); + this.addSql('ALTER TABLE collection_tours ADD COLUMN ski_options TEXT'); } } diff --git a/server/src/db/repositories/CollectionTours.repository.ts b/server/src/db/repositories/CollectionTours.repository.ts index 245112da9..16eda4f60 100644 --- a/server/src/db/repositories/CollectionTours.repository.ts +++ b/server/src/db/repositories/CollectionTours.repository.ts @@ -36,6 +36,8 @@ export interface CollectionTourRow { run_pace_seconds_per_km: number | null; ski_discipline: string | null; ski_technique: string | null; + /** A ski tour's piste router options and build as JSON text, as the tour it was saved from kept them. */ + ski_options: string | null; boat_type: string | null; water_context: string | null; paddle_speed_kmh: number | null; diff --git a/server/src/nest/tours/tour-copy.helpers.ts b/server/src/nest/tours/tour-copy.helpers.ts index c966d5c6b..6c7dbc256 100644 --- a/server/src/nest/tours/tour-copy.helpers.ts +++ b/server/src/nest/tours/tour-copy.helpers.ts @@ -30,6 +30,8 @@ interface TourCopiedColumns { run_pace_seconds_per_km: number | null; ski_discipline: string | null; ski_technique: string | null; + /** A ski tour's piste router options and build as 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; @@ -50,8 +52,11 @@ interface TourCopyFacet extends TourCopiedColumns { taken_over_from_tour_name: string | null; } +/** The columns as a copy writes them: a ski tour's options are always said, null where there are none. */ +type CopiedColumns = Omit & { ski_options: string | null }; + /** The columns a copy carries over, read off any row that has them, and nothing else of it. */ -export function copiedColumns(row: TourCopiedColumns): TourCopiedColumns { +export function copiedColumns(row: TourCopiedColumns): CopiedColumns { return { tour_type: row.tour_type, distance: row.distance, @@ -69,6 +74,7 @@ export function copiedColumns(row: TourCopiedColumns): TourCopiedColumns { run_pace_seconds_per_km: row.run_pace_seconds_per_km, ski_discipline: row.ski_discipline, ski_technique: row.ski_technique, + ski_options: row.ski_options ?? null, boat_type: row.boat_type, water_context: row.water_context, paddle_speed_kmh: row.paddle_speed_kmh, diff --git a/server/src/nest/tours/tour-item.helpers.ts b/server/src/nest/tours/tour-item.helpers.ts index 7f5d5e392..4c213cdc0 100644 --- a/server/src/nest/tours/tour-item.helpers.ts +++ b/server/src/nest/tours/tour-item.helpers.ts @@ -33,10 +33,11 @@ function stageStopsOrNull(points: Parameters[0]): TourL } /** 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 { +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 } : {}; + if (!route) return {}; + return { ski_options: route.options, ski_route_build: route.build, ski_planned_legs: route.planned_legs ?? null }; } /** diff --git a/server/src/nest/tours/tour-plan-read.ts b/server/src/nest/tours/tour-plan-read.ts index 0d4708c49..971628059 100644 --- a/server/src/nest/tours/tour-plan-read.ts +++ b/server/src/nest/tours/tour-plan-read.ts @@ -93,8 +93,11 @@ function roundKm(km: number): number { interface PlanLegSource { /** The line is a recorded track (the tour stores no control points of its own), so no router planned it. */ recorded: boolean; - /** SKI-02: the planner drew every leg straight between its points (a planned ski tour), so no router planned one. */ - drawn?: boolean; + /** + * SKI-02: whether the planner drew the leg starting at this point straight + * (a ski tour's leg the piste router did not plan); left out, no leg was drawn. + */ + drawn?: (leg: number) => boolean; } /** A fixed leg's line as points of a line: latitude and longitude, no height. */ @@ -112,14 +115,15 @@ function straightLine(from: TourWaypoint, to: TourWaypoint): GeometryPoint[] { /** * The legs between consecutive control points, each saying whether the router - * planned it, it follows a recorded track, or it is a fixed line. A planned - * ski tour's leg is a straight line between its points, as the planner drew it. + * planned it, it follows a recorded track, or it is a fixed line. A ski tour's + * leg the piste router did not plan is a straight line between its points, as + * the planner drew it. */ -export function planLegs(points: readonly TourWaypoint[], { recorded, drawn = false }: PlanLegSource): PlanLeg[] { - const unfixed = recorded ? 'track' : drawn ? 'free' : 'routed'; +export function planLegs(points: readonly TourWaypoint[], { recorded, drawn }: PlanLegSource): PlanLeg[] { return points.slice(1).map((to, index) => { const from = points[index] as TourWaypoint; const leg = from.fixed_leg ?? null; + const unfixed = recorded ? 'track' : drawn?.(index) ? 'free' : 'routed'; const line = leg ? legLine(leg.geometry) : unfixed === 'free' ? straightLine(from, to) : null; return { from_sequence: from.sequence, diff --git a/server/src/nest/tours/tour-write.helpers.ts b/server/src/nest/tours/tour-write.helpers.ts index 1182d3704..699e26225 100644 --- a/server/src/nest/tours/tour-write.helpers.ts +++ b/server/src/nest/tours/tour-write.helpers.ts @@ -50,7 +50,8 @@ function skiFields(input: TourCreateCommand): Pick) { return { ...tour, days: days.get(tour.place_id) ?? [] }; } +/** + * SKI-02: which legs of a planned ski tour the planner drew straight: those + * the piste router did not plan. A tour saved without naming its planned legs + * counts as planned throughout only when it kept the router's time, which the + * planner sends only when the router planned every leg. + */ +function skiDrawnLegs( + tour: Pick, +): ((leg: number) => boolean) | undefined { + if (tour.tour_type !== 'ski') return undefined; + const plannedLegs = tour.ski_planned_legs; + if (plannedLegs) return (leg) => !plannedLegs.includes(leg); + const everyLeg = Boolean(tour.ski_route_build) && tour.duration != null; + return () => !everyLeg; +} + /** What get_tour_plan reads of one saved tour, before a day's visits are added. */ interface PlanRead { detail: TourDetailResponse; @@ -61,11 +77,7 @@ 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 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, - }), + legs: planLegs(detail.waypoints, { recorded, drawn: skiDrawnLegs(detail.tour) }), line: saved, ...(isWalk ? { run: run ? planRun(run) : null } : {}), derived: derivedFields(detail, { controlsFromTrack, line: saved, run }), @@ -105,7 +117,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 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).', + '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, ski_planned_legs: the legs it planned, each by the index of the control point it starts at, null when the save did not say) 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), @@ -120,7 +132,11 @@ export class ToursMcp { @Tool({ name: 'get_tour_plan', description: +<<<<<<< HEAD 'Read one saved tour of a trip as the travellers saved it: its facts (tour: place_id, name, tour_type, distance, heights and walking time as stored, null when not known; the routing options as list_tours describes them; revision, raised by every saved change; the days it is on), its ordered control points (start, via and end, each with its sequence and its key, the same across saves; an import without its own shows the two ends of its track), where it came from (provenance) and its legs. Drafts in a browser are never visible: only saved tours exist here, and reading changes nothing, not the tour, its revision nor any visit. A control point with linked_place_id stands for that place of the trip (linked_place_name names it); it keeps its own coordinates. A control point with stage_end true ends a stage of a multi-day tour, and the next stage begins there; the stages run in the order of the route, the first from the start, the last to the end, and a tour without such a point is a tour of one day. legs lists every step from one control point to the next (from_sequence, to_sequence, from_key, to_key) with its kind: routed (planned by the router), track (part of a recorded line, an import without control points of its own, which no router planned), ferrata (follows a via ferrata, name gives it), free (drawn by hand and checked by nothing; every leg of a planned ski tour is free, a straight line between its points), portage (a paddle tour carried over land) or horse_route (a ride only: a section of a signposted horse route from OpenStreetMap in the direction the rider chose; the fixed_leg of its control point says signposted, with or against the direction the route is signposted in, null where it is not signposted one way, and access, the metres of each horse=* value its ways carried when it was taken, unknown where untagged; being on a horse route is no permission to ride it); length_km is the length of the line a fixed leg keeps (for the leg of a ski tour the straight line) and null for a routed or track leg, whose length is not stored apart. line gives the saved line in brief: point_count, has_heights and a preview thinned to 200 points; null while the tour waits for its way to be planned. A paddle tour also carries its obstacle checklist (obstacle_check): the obstacles of the OpenStreetMap data along its line, the decisions the travellers made about them and their own portages and locks; a planning aid, never a safety or legal clearance. A city walk (tour_type walk) has stations for control points, shaping points and the way back of a loop aside, each with every field spelled out: key (the visits of a day are kept by it), name, stay_minutes (the stay in minutes; 0 for a point only passed through, null when not known), importance (optional or must; null for a normal station), protected (true for a station any change you propose has to keep where it is: neither removed, replaced nor moved; the travellers can still edit it themselves in the app), opening_hours (as OpenStreetMap writes them; a hint to check, never a promise; null when none is known) and visit_status. Visits belong to one day of the walk: name that day with dayId (one of its days) to read the status of each station that day (planned, visited or skipped) and run (the day, its assignment and what is still open); without dayId visit_status and run are null. A station visited or skipped that day is done: do not ask about it again or move it to another day. dayId is for a city walk only. The recorded tracks assigned to the tour (activities, ACT-01) each name their execution (assignment_id and day_id, null for none), the recording (track: its tour in the trip, origin, file name, when it came in) and what the recording measures (metrics: distance_km, elevation_gain_m and elevation_loss_m, null without heights; started_at, ended_at and elapsed_minutes from the first to the last time on its points, pauses included, null when the file carried no times, never estimated); these are the values of the recording, the planned values of the tour stay apart and unchanged. derived names the fields worked out on reading rather than stored with the tour: never present them as saved facts. tour.time_estimate is one of them where present: an estimate from distance and heights (or the pace of a run), never a time the travellers planned or recorded.', +======= + 'Read one saved tour of a trip as the travellers saved it: its facts (tour: place_id, name, tour_type, distance, heights and walking time as stored, null when not known; the routing options as list_tours describes them; revision, raised by every saved change; the days it is on), its ordered control points (start, via and end, each with its sequence and its key, the same across saves; an import without its own shows the two ends of its track), where it came from (provenance) and its legs. Drafts in a browser are never visible: only saved tours exist here, and reading changes nothing, not the tour, its revision nor any visit. A control point with linked_place_id stands for that place of the trip (linked_place_name names it); it keeps its own coordinates. A control point with stage_end true ends a stage of a multi-day tour, and the next stage begins there; the stages run in the order of the route, the first from the start, the last to the end, and a tour without such a point is a tour of one day. legs lists every step from one control point to the next (from_sequence, to_sequence, from_key, to_key) with its kind: routed (planned by the router), track (part of a recorded line, an import without control points of its own, which no router planned), ferrata (follows a via ferrata, name gives it), free (drawn by hand and checked by nothing; a leg of a planned ski tour is routed where the piste router planned it and free, a straight line between its points, where it did not), portage (a paddle tour carried over land) or horse_route (a ride only: a section of a signposted horse route from OpenStreetMap in the direction the rider chose; the fixed_leg of its control point says signposted, with or against the direction the route is signposted in, null where it is not signposted one way, and access, the metres of each horse=* value its ways carried when it was taken, unknown where untagged; being on a horse route is no permission to ride it); length_km is the length of the line a fixed leg keeps (for the leg of a ski tour the straight line) and null for a routed or track leg, whose length is not stored apart. line gives the saved line in brief: point_count, has_heights and a preview thinned to 200 points; null while the tour waits for its way to be planned. A paddle tour also carries its obstacle checklist (obstacle_check): the obstacles of the OpenStreetMap data along its line, the decisions the travellers made about them and their own portages and locks; a planning aid, never a safety or legal clearance. A city walk (tour_type walk) has stations for control points, shaping points and the way back of a loop aside, each with every field spelled out: key (the visits of a day are kept by it), name, stay_minutes (the stay in minutes; 0 for a point only passed through, null when not known), importance (optional or must; null for a normal station), protected (true for a station any change you propose has to keep where it is: neither removed, replaced nor moved; the travellers can still edit it themselves in the app), opening_hours (as OpenStreetMap writes them; a hint to check, never a promise; null when none is known) and visit_status. Visits belong to one day of the walk: name that day with dayId (one of its days) to read the status of each station that day (planned, visited or skipped) and run (the day, its assignment and what is still open); without dayId visit_status and run are null. A station visited or skipped that day is done: do not ask about it again or move it to another day. dayId is for a city walk only. The recorded tracks assigned to the tour (activities, ACT-01) each name their execution (assignment_id and day_id, null for none), the recording (track: its tour in the trip, origin, file name, when it came in) and what the recording measures (metrics: distance_km, elevation_gain_m and elevation_loss_m, null without heights; started_at, ended_at and elapsed_minutes from the first to the last time on its points, pauses included, null when the file carried no times, never estimated); these are the values of the recording, the planned values of the tour stay apart and unchanged. derived names the fields worked out on reading rather than stored with the tour: never present them as saved facts.', +>>>>>>> 44601ec9c (fix(tours): open a saved ski tour as saved and keep what its router planned) inputSchema: { tripId: idSchema, placeId: idSchema.describe('The place id of the tour, as list_tours gives it'), diff --git a/server/tests/e2e/tours-ski.e2e.test.ts b/server/tests/e2e/tours-ski.e2e.test.ts index f8c021d14..ddba97c41 100644 --- a/server/tests/e2e/tours-ski.e2e.test.ts +++ b/server/tests/e2e/tours-ski.e2e.test.ts @@ -321,6 +321,35 @@ describe('Ski tracks e2e (real guards + temp SQLite)', () => { expect(await findRow(orm, Tours, { place: placeId })).toMatchObject({ ski_options: null }); }); + it('TOURS-SKI-E2E-012: a copy of a ski tour the piste router planned keeps its options, build and planned legs', async () => { + const created = await request(server) + .post('/api/trips/5/tours') + .set('Cookie', sessionCookie(OWNER)) + .send({ + ...hikeBody, + tour_type: 'ski', + max_hiking_difficulty: null, + ski_discipline: 'alpine', + ski_options: { max_difficulty: 'advanced', lit_only: false }, + ski_route_build: '2026-11-02', + ski_planned_legs: [0], + }); + expect(created.status).toBe(201); + const copy = await request(server) + .post(`/api/trips/5/tours/${created.body.tour.place_id as number}/copy`) + .set('Cookie', sessionCookie(OWNER)) + .send({ name: 'Again' }); + expect(copy.status).toBe(201); + expect(copy.body.tour).toMatchObject({ + tour_type: 'ski', + ski_route_build: '2026-11-02', + ski_options: { max_difficulty: 'advanced' }, + ski_planned_legs: [0], + }); + const stored = await findRow(orm, Tours, { place: copy.body.tour.place_id as number }); + expect(JSON.parse(String(stored?.ski_options))).toMatchObject({ build: '2026-11-02' }); + }); + 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/repositories/CollectionTours.repository.test.ts b/server/tests/unit/db/repositories/CollectionTours.repository.test.ts index af40474c7..4bf71fe47 100644 --- a/server/tests/unit/db/repositories/CollectionTours.repository.test.ts +++ b/server/tests/unit/db/repositories/CollectionTours.repository.test.ts @@ -70,6 +70,7 @@ function snapshot(collectionId: number, savedBy: number | null, overrides: Parti run_pace_seconds_per_km: null, ski_discipline: null, ski_technique: null, + ski_options: null, boat_type: null, water_context: null, paddle_speed_kmh: null, diff --git a/server/tests/unit/db/tour-ski-options-column.test.ts b/server/tests/unit/db/tour-ski-options-column.test.ts index 9024c68a5..2960d25cb 100644 --- a/server/tests/unit/db/tour-ski-options-column.test.ts +++ b/server/tests/unit/db/tour-ski-options-column.test.ts @@ -3,6 +3,7 @@ * 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. + * A tour kept in a collection gets the same column. */ import { createMigrationOrm, migrateTo, pendingNames, rawExec, rawQuery } from '../../helpers/migration-step'; @@ -31,6 +32,9 @@ describe('the ski options column', () => { 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 }]); + // A tour kept in a collection keeps the same column, so its copies keep the options. + const kept = "SELECT name FROM pragma_table_info('collection_tours') WHERE name = 'ski_options'"; + expect(await rawQuery(orm, kept)).toEqual([{ name: 'ski_options' }]); } finally { await orm.close(true); } diff --git a/server/tests/unit/nest/collection-tours.helpers.test.ts b/server/tests/unit/nest/collection-tours.helpers.test.ts index d5b2ef2ce..3bb08ea0e 100644 --- a/server/tests/unit/nest/collection-tours.helpers.test.ts +++ b/server/tests/unit/nest/collection-tours.helpers.test.ts @@ -45,6 +45,7 @@ const row: CollectionTourRow = { run_pace_seconds_per_km: null, ski_discipline: null, ski_technique: null, + ski_options: null, boat_type: null, water_context: null, paddle_speed_kmh: null, @@ -186,6 +187,20 @@ describe('collection tour helpers (LIB-03)', () => { expect(kept.map((point) => point.linked_place_id)).toEqual([null, null]); }); + it('LIB3-HLP-007: a kept ski tour keeps its piste router options and build, and a copy of it takes them along', () => { + const skiOptions = JSON.stringify({ options: { max_difficulty: 'advanced' }, build: '2026-11-02' }); + const ski = { ...row, tour_type: 'ski', bike_profile: null, ski_discipline: 'alpine', ski_options: skiOptions }; + const snapshot = keptTourSnapshot(keptTourCopySource(ski, true), { + collectionId: 9, + userId: 1, + name: 'Kept', + tripTitle: 'Alps', + }); + expect(snapshot).toMatchObject({ tour_type: 'ski', ski_discipline: 'alpine', ski_options: skiOptions }); + expect(keptTourCopySource(ski, false).facet.ski_options).toBe(skiOptions); + expect(keptTourCopySource(row, false).facet.ski_options).toBeNull(); + }); + it('LIB3-HLP-006: a point that is no control point refuses the whole snapshot', () => { const broken = keptTourCopySource({ ...row, waypoints: [{ lat: 147, lng: 11, role: 'start', sequence: 0 }] }, true); expect(() => keptTourSnapshot(broken, { collectionId: 9, userId: 1, name: 'Kept', tripTitle: 'Coast' })).toThrow(); diff --git a/server/tests/unit/nest/tour-plan-read.test.ts b/server/tests/unit/nest/tour-plan-read.test.ts index 8228adb67..139e29dcd 100644 --- a/server/tests/unit/nest/tour-plan-read.test.ts +++ b/server/tests/unit/nest/tour-plan-read.test.ts @@ -133,12 +133,17 @@ describe('tour plan read (MCP-T01)', () => { ], }, }); - expect(planLegs([point(0), free, point(2)], { recorded: false, drawn: true })).toEqual([ + expect(planLegs([point(0), free, point(2)], { recorded: false, drawn: () => true })).toEqual([ expect.objectContaining({ kind: 'free', name: null, length_km: 1.11 }), expect.objectContaining({ kind: 'free', name: null, length_km: 1.11 }), ]); + // A leg the piste router planned is routed, with no length of its own; the leg drawn straight keeps its line. + expect(planLegs([point(0), point(1), point(2)], { recorded: false, drawn: (leg) => leg === 1 })).toEqual([ + expect.objectContaining({ kind: 'routed', length_km: null }), + expect.objectContaining({ kind: 'free', length_km: 1.11 }), + ]); // A ski track kept as recorded stays a track. - expect(planLegs([point(0), point(1)], { recorded: true, drawn: true })).toEqual([ + expect(planLegs([point(0), point(1)], { recorded: true, drawn: () => true })).toEqual([ expect.objectContaining({ kind: 'track', length_km: null }), ]); }); diff --git a/server/tests/unit/nest/tours.mcp.test.ts b/server/tests/unit/nest/tours.mcp.test.ts index 4a40734da..1f71bac83 100644 --- a/server/tests/unit/nest/tours.mcp.test.ts +++ b/server/tests/unit/nest/tours.mcp.test.ts @@ -709,7 +709,11 @@ describe('ToursMcp', () => { 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' }; + const stored = { + options: { ...DEFAULT_SKI_ROUTE_OPTIONS, max_difficulty: 'advanced' }, + build: '2026-11-02', + planned_legs: [0], + }; await makeTour(orm, ski.id, { tourTypeRef: 'ski', max_hiking_difficulty: null, @@ -722,7 +726,70 @@ describe('ToursMcp', () => { ]); 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.tour).toMatchObject({ + ski_options: stored.options, + ski_route_build: '2026-11-02', + ski_planned_legs: [0], + }); expect(plan.legs).toEqual([expect.objectContaining({ from_key: 'pk-top', to_key: 'pk-valley', kind: 'routed' })]); }); + + it('TOURS-MCP-027: a ski tour planned in part names only the legs the router planned as routed, the rest drawn straight', async () => { + const { member, trip } = fixture(); + const orm = await createTestOrm(db); + const partly = await makePlace(orm, trip.id, { name: 'Across the valley' }); + const options = DEFAULT_SKI_ROUTE_OPTIONS; + await makeTour(orm, partly.id, { + tourTypeRef: 'ski', + max_hiking_difficulty: null, + ski_discipline: 'alpine', + ski_options: JSON.stringify({ options, build: '2026-11-02', planned_legs: [1] }), + }); + await addTourWaypoints(orm, partly.id, [ + { lat: 46, lng: 7, station_key: 'pp-a' }, + { lat: 46.01, lng: 7, station_key: 'pp-b' }, + { lat: 46.02, lng: 7, station_key: 'pp-c' }, + ]); + // Saved without naming its legs: planned throughout only with the router's time kept. + const timed = await makePlace(orm, trip.id, { name: 'Timed' }); + await makeTour(orm, timed.id, { + tourTypeRef: 'ski', + max_hiking_difficulty: null, + ski_discipline: 'alpine', + duration: 25, + ski_options: JSON.stringify({ options, build: '2026-11-02' }), + }); + const untimed = await makePlace(orm, trip.id, { name: 'Untimed' }); + await makeTour(orm, untimed.id, { + tourTypeRef: 'ski', + max_hiking_difficulty: null, + ski_discipline: 'alpine', + ski_options: JSON.stringify({ options, build: '2026-11-02' }), + }); + for (const place of [timed, untimed]) { + await addTourWaypoints(orm, place.id, [ + { lat: 46, lng: 7, station_key: `${place.id}-a` }, + { lat: 46.01, lng: 7, station_key: `${place.id}-b` }, + ]); + } + await orm.close(); + const plan = read(await mcp.getTourPlan({ tripId: trip.id, placeId: partly.id }, ctx(member.id))); + expect(plan.legs.map((leg: { kind: string }) => leg.kind)).toEqual(['free', 'routed']); + expect(plan.legs[0].length_km).toBeCloseTo(1.11, 1); + expect(plan.legs[1].length_km).toBeNull(); + const kinds = async (placeId: number): Promise => { + const other = read(await mcp.getTourPlan({ tripId: trip.id, placeId }, ctx(member.id))); + return other.legs.map((leg: { kind: string }) => leg.kind); + }; + expect(await kinds(timed.id)).toEqual(['routed']); + expect(await kinds(untimed.id)).toEqual(['free']); + const listEntry = getEntry(ToursMcp as unknown as ClassRef, 'listTours'); + expect(listEntry?.kind === 'tool' ? listEntry.options.description : '').toContain( + 'ski_planned_legs: the legs it planned', + ); + const planEntry = getEntry(ToursMcp as unknown as ClassRef, 'getTourPlan'); + expect(planEntry?.kind === 'tool' ? planEntry.options.description : '').toContain( + 'routed where the piste router planned it and free', + ); + }); }); diff --git a/shared/src/i18n/ar/tours.ts b/shared/src/i18n/ar/tours.ts index 0bb8edd11..739a1d9f3 100644 --- a/shared/src/i18n/ar/tours.ts +++ b/shared/src/i18n/ar/tours.ts @@ -496,6 +496,11 @@ const tours: TranslationStrings = { 'tours.ski.summary.guessed': 'تعتمد بعض الأجزاء على تخمين من بيانات الخريطة، مثل وصلة أو اتجاه غير مرسوم.', 'tours.ski.summary.stale': 'خُطط على نسخة أقدم من بيانات المنحدرات، لأن النسخة الحالية غير متاحة.', 'tours.ski.summary.source': 'بيانات المنحدرات © مساهمو OpenStreetMap', + 'tours.ski.summary.runs': 'المنحدرات', + 'tours.ski.summary.descent': 'النزول على المنحدرات', + 'tours.ski.summary.lifts': 'رحلات المصاعد', + 'tours.ski.summary.build': 'بيانات الخريطة بتاريخ {build}', + 'tours.ski.summary.saved': 'معروض كما حُفظ. حرّك نقطة أو غيّر خيارًا لإعادة التخطيط.', 'tours.ski.chooseTechnique': 'اختر الأسلوب، أو غير معروف.', 'tours.ski.infoLabel': 'ما يفعله TREK بجولات ومسارات التزلج', 'tours.ski.info': diff --git a/shared/src/i18n/az/tours.ts b/shared/src/i18n/az/tours.ts index 44cecb43d..88f892531 100644 --- a/shared/src/i18n/az/tours.ts +++ b/shared/src/i18n/az/tours.ts @@ -487,6 +487,11 @@ const tours: TranslationStrings = { 'tours.ski.summary.stale': 'Cari nüsxəyə çatmaq mümkün olmadığı üçün pist məlumatlarının köhnə nüsxəsi ilə planlaşdırılıb.', 'tours.ski.summary.source': 'Pist məlumatları © OpenStreetMap iştirakçıları', + 'tours.ski.summary.runs': 'Pistlər', + 'tours.ski.summary.descent': 'Pistlərdə eniş', + 'tours.ski.summary.lifts': 'Lift gedişləri', + 'tours.ski.summary.build': 'Xəritə məlumatları: {build}', + 'tours.ski.summary.saved': 'Saxlandığı kimi göstərilir. Yenidən planlamaq üçün nöqtəni tərpət və ya seçimi dəyiş.', 'tours.ski.chooseTechnique': 'Texnikanı və ya Naməlum seç.', 'tours.ski.infoLabel': 'TREK xizək turları və izləri ilə nə edir', 'tours.ski.info': diff --git a/shared/src/i18n/br/tours.ts b/shared/src/i18n/br/tours.ts index f6b5bb80e..f29bd35f9 100644 --- a/shared/src/i18n/br/tours.ts +++ b/shared/src/i18n/br/tours.ts @@ -494,6 +494,11 @@ const tours: TranslationStrings = { 'tours.ski.summary.stale': 'Planejado com uma cópia mais antiga dos dados de pistas, pois a atual não está acessível.', 'tours.ski.summary.source': 'Dados de pistas © colaboradores do OpenStreetMap', + 'tours.ski.summary.runs': 'Pistas', + 'tours.ski.summary.descent': 'Descida nas pistas', + 'tours.ski.summary.lifts': 'Viagens de teleférico', + 'tours.ski.summary.build': 'Dados do mapa de {build}', + 'tours.ski.summary.saved': 'Exibido como foi salvo. Mova um ponto ou mude uma opção para planejar de novo.', 'tours.ski.chooseTechnique': 'Escolha a técnica, ou Desconhecida.', 'tours.ski.infoLabel': 'O que o TREK faz com passeios e trilhas de esqui', 'tours.ski.info': diff --git a/shared/src/i18n/ca/tours.ts b/shared/src/i18n/ca/tours.ts index afe5757ea..837142697 100644 --- a/shared/src/i18n/ca/tours.ts +++ b/shared/src/i18n/ca/tours.ts @@ -495,6 +495,12 @@ const tours: TranslationStrings = { 'tours.ski.summary.stale': 'Planificat amb una còpia anterior de les dades de pistes, perquè l’actual no és accessible.', 'tours.ski.summary.source': 'Dades de pistes © col·laboradors d’OpenStreetMap', + 'tours.ski.summary.runs': 'Pistes', + 'tours.ski.summary.descent': 'Baixada per pistes', + 'tours.ski.summary.lifts': 'Viatges en remuntador', + 'tours.ski.summary.build': 'Dades del mapa de {build}', + 'tours.ski.summary.saved': + 'Es mostra tal com es va desar. Mou un punt o canvia una opció per tornar-ho a planificar.', 'tours.ski.chooseTechnique': 'Tria la tècnica, o Desconeguda.', 'tours.ski.infoLabel': 'Què fa TREK amb les rutes i traces d’esquí', 'tours.ski.info': diff --git a/shared/src/i18n/cs/tours.ts b/shared/src/i18n/cs/tours.ts index 15221d7d6..c57bc7ef2 100644 --- a/shared/src/i18n/cs/tours.ts +++ b/shared/src/i18n/cs/tours.ts @@ -486,6 +486,11 @@ const tours: TranslationStrings = { 'Některé části stojí na odhadu z mapových dat, například na nezmapované spojce nebo směru.', 'tours.ski.summary.stale': 'Naplánováno na starší kopii dat sjezdovek, protože aktuální není dostupná.', 'tours.ski.summary.source': 'Data sjezdovek © přispěvatelé OpenStreetMap', + 'tours.ski.summary.runs': 'Sjezdovky', + 'tours.ski.summary.descent': 'Sjezd po sjezdovkách', + 'tours.ski.summary.lifts': 'Jízdy vlekem', + 'tours.ski.summary.build': 'Mapová data z {build}', + 'tours.ski.summary.saved': 'Zobrazeno, jak bylo uloženo. Posuňte bod nebo změňte volbu a naplánujte znovu.', 'tours.ski.chooseTechnique': 'Vyberte techniku, nebo Neznámá.', 'tours.ski.infoLabel': 'Co TREK dělá s lyžařskými túrami a trasami', 'tours.ski.info': diff --git a/shared/src/i18n/de/tours.ts b/shared/src/i18n/de/tours.ts index 1e44a8242..d96965d8c 100644 --- a/shared/src/i18n/de/tours.ts +++ b/shared/src/i18n/de/tours.ts @@ -496,6 +496,12 @@ const tours: TranslationStrings = { 'Einige Stücke beruhen auf einer Vermutung aus den Kartendaten, etwa einer Verbindung oder Richtung, die nicht kartiert ist.', 'tours.ski.summary.stale': 'Auf einer älteren Kopie der Pistendaten geplant, weil die aktuelle nicht erreichbar ist.', 'tours.ski.summary.source': 'Pistendaten © OpenStreetMap-Mitwirkende', + 'tours.ski.summary.runs': 'Pisten', + 'tours.ski.summary.descent': 'Abfahrt auf Pisten', + 'tours.ski.summary.lifts': 'Liftfahrten', + 'tours.ski.summary.build': 'Kartendaten vom {build}', + 'tours.ski.summary.saved': + 'So angezeigt, wie gespeichert. Verschiebe einen Punkt oder ändere eine Option, um neu zu planen.', 'tours.ski.chooseTechnique': 'Wähle die Technik oder Unbekannt.', 'tours.ski.infoLabel': 'Was TREK mit Wintertouren und Skistrecken macht', 'tours.ski.info': diff --git a/shared/src/i18n/en/tours.ts b/shared/src/i18n/en/tours.ts index a28ad15dc..41e9ac101 100644 --- a/shared/src/i18n/en/tours.ts +++ b/shared/src/i18n/en/tours.ts @@ -485,6 +485,11 @@ const tours: TranslationStrings = { 'Some stretches rest on a guess of the map data, such as a link or a direction that is not mapped.', 'tours.ski.summary.stale': 'Planned on an older copy of the piste data, as the current one cannot be reached.', 'tours.ski.summary.source': 'Piste data © OpenStreetMap contributors', + 'tours.ski.summary.runs': 'Runs', + 'tours.ski.summary.descent': 'Down on runs', + 'tours.ski.summary.lifts': 'Lift rides', + 'tours.ski.summary.build': 'Map data as of {build}', + 'tours.ski.summary.saved': 'Shown as saved. Move a point or change an option to plan it again.', 'tours.ski.chooseTechnique': 'Choose the technique, or Unknown.', 'tours.ski.infoLabel': 'What TREK does with ski tours and tracks', 'tours.ski.info': diff --git a/shared/src/i18n/es/tours.ts b/shared/src/i18n/es/tours.ts index b8b341ddc..fd36b9e57 100644 --- a/shared/src/i18n/es/tours.ts +++ b/shared/src/i18n/es/tours.ts @@ -494,6 +494,12 @@ const tours: TranslationStrings = { 'tours.ski.summary.stale': 'Planificado con una copia anterior de los datos de pistas, porque la actual no está accesible.', 'tours.ski.summary.source': 'Datos de pistas © colaboradores de OpenStreetMap', + 'tours.ski.summary.runs': 'Pistas', + 'tours.ski.summary.descent': 'Descenso en pistas', + 'tours.ski.summary.lifts': 'Viajes en remonte', + 'tours.ski.summary.build': 'Datos del mapa de {build}', + 'tours.ski.summary.saved': + 'Se muestra tal como se guardó. Mueve un punto o cambia una opción para planificar de nuevo.', 'tours.ski.chooseTechnique': 'Elige la técnica, o Desconocida.', 'tours.ski.infoLabel': 'Qué hace TREK con rutas y trazas de esquí', 'tours.ski.info': diff --git a/shared/src/i18n/et/tours.ts b/shared/src/i18n/et/tours.ts index dd669c522..dec82e0b6 100644 --- a/shared/src/i18n/et/tours.ts +++ b/shared/src/i18n/et/tours.ts @@ -487,6 +487,11 @@ const tours: TranslationStrings = { 'Mõned lõigud põhinevad kaardiandmete oletusel, näiteks kaardistamata ühendusel või suunal.', 'tours.ski.summary.stale': 'Planeeritud nõlvaandmete vanemal koopial, sest praegune pole kättesaadav.', 'tours.ski.summary.source': 'Nõlvaandmed © OpenStreetMapi kaastöötajad', + 'tours.ski.summary.runs': 'Nõlvad', + 'tours.ski.summary.descent': 'Laskumine nõlvadel', + 'tours.ski.summary.lifts': 'Tõstukisõidud', + 'tours.ski.summary.build': 'Kaardiandmed seisuga {build}', + 'tours.ski.summary.saved': 'Näidatud nagu salvestatud. Uuesti planeerimiseks liiguta punkti või muuda valikut.', 'tours.ski.chooseTechnique': 'Vali tehnika või Teadmata.', 'tours.ski.infoLabel': 'Mida TREK suusamatkade ja -radadega teeb', 'tours.ski.info': diff --git a/shared/src/i18n/fr/tours.ts b/shared/src/i18n/fr/tours.ts index 247763949..eff29b3bf 100644 --- a/shared/src/i18n/fr/tours.ts +++ b/shared/src/i18n/fr/tours.ts @@ -501,6 +501,11 @@ const tours: TranslationStrings = { 'tours.ski.summary.stale': 'Planifié sur une copie plus ancienne des données de pistes, la version actuelle étant injoignable.', 'tours.ski.summary.source': 'Données de pistes © contributeurs OpenStreetMap', + 'tours.ski.summary.runs': 'Pistes', + 'tours.ski.summary.descent': 'Descente sur pistes', + 'tours.ski.summary.lifts': 'Montées en remontée', + 'tours.ski.summary.build': 'Données de carte du {build}', + 'tours.ski.summary.saved': 'Affiché tel qu’enregistré. Déplacez un point ou changez une option pour replanifier.', 'tours.ski.chooseTechnique': 'Choisissez la technique, ou Inconnue.', 'tours.ski.infoLabel': 'Ce que TREK fait des sorties et traces de ski', 'tours.ski.info': diff --git a/shared/src/i18n/gr/tours.ts b/shared/src/i18n/gr/tours.ts index b3cc0b690..bd2e2fa2a 100644 --- a/shared/src/i18n/gr/tours.ts +++ b/shared/src/i18n/gr/tours.ts @@ -502,6 +502,12 @@ const tours: TranslationStrings = { 'tours.ski.summary.stale': 'Σχεδιάστηκε σε παλαιότερο αντίγραφο των δεδομένων πιστών, επειδή το τρέχον δεν είναι διαθέσιμο.', 'tours.ski.summary.source': 'Δεδομένα πιστών © συντελεστές του OpenStreetMap', + 'tours.ski.summary.runs': 'Πίστες', + 'tours.ski.summary.descent': 'Κατάβαση σε πίστες', + 'tours.ski.summary.lifts': 'Διαδρομές με αναβατήρα', + 'tours.ski.summary.build': 'Δεδομένα χάρτη της {build}', + 'tours.ski.summary.saved': + 'Εμφανίζεται όπως αποθηκεύτηκε. Μετακινήστε ένα σημείο ή αλλάξτε μια επιλογή για νέο σχεδιασμό.', 'tours.ski.chooseTechnique': 'Επιλέξτε τεχνική ή Άγνωστη.', 'tours.ski.infoLabel': 'Τι κάνει το TREK με διαδρομές και ίχνη σκι', 'tours.ski.info': diff --git a/shared/src/i18n/hu/tours.ts b/shared/src/i18n/hu/tours.ts index dc2d7a8ff..3accb832b 100644 --- a/shared/src/i18n/hu/tours.ts +++ b/shared/src/i18n/hu/tours.ts @@ -492,6 +492,12 @@ const tours: TranslationStrings = { 'Egyes részek a térképadatok becslésén alapulnak, például nem térképezett összeköttetésen vagy irányon.', 'tours.ski.summary.stale': 'A pályaadatok egy régebbi másolatán tervezve, mert a jelenlegi nem érhető el.', 'tours.ski.summary.source': 'Pályaadatok © OpenStreetMap közreműködők', + 'tours.ski.summary.runs': 'Pályák', + 'tours.ski.summary.descent': 'Lesiklás pályán', + 'tours.ski.summary.lifts': 'Felvonós utak', + 'tours.ski.summary.build': 'Térképadatok: {build}', + 'tours.ski.summary.saved': + 'A mentett állapot látszik. Mozgass el egy pontot vagy módosíts egy beállítást az újratervezéshez.', 'tours.ski.chooseTechnique': 'Válaszd ki a technikát, vagy az Ismeretlent.', 'tours.ski.infoLabel': 'Mit kezd a TREK a sí- és téli túrákkal és a sínyomvonalakkal', 'tours.ski.info': diff --git a/shared/src/i18n/id/tours.ts b/shared/src/i18n/id/tours.ts index 67466ea7f..bfd84affa 100644 --- a/shared/src/i18n/id/tours.ts +++ b/shared/src/i18n/id/tours.ts @@ -490,6 +490,12 @@ const tours: TranslationStrings = { 'tours.ski.summary.stale': 'Direncanakan pada salinan data lintasan yang lebih lama, karena yang terbaru tidak dapat dijangkau.', 'tours.ski.summary.source': 'Data lintasan © kontributor OpenStreetMap', + 'tours.ski.summary.runs': 'Lintasan', + 'tours.ski.summary.descent': 'Turun di lintasan', + 'tours.ski.summary.lifts': 'Naik lift', + 'tours.ski.summary.build': 'Data peta per {build}', + 'tours.ski.summary.saved': + 'Ditampilkan seperti saat disimpan. Pindahkan titik atau ubah opsi untuk merencanakan ulang.', 'tours.ski.chooseTechnique': 'Pilih teknik, atau Tidak diketahui.', 'tours.ski.infoLabel': 'Apa yang TREK lakukan dengan tur dan jalur ski', 'tours.ski.info': diff --git a/shared/src/i18n/it/tours.ts b/shared/src/i18n/it/tours.ts index c737c66bc..f0be00155 100644 --- a/shared/src/i18n/it/tours.ts +++ b/shared/src/i18n/it/tours.ts @@ -496,6 +496,11 @@ const tours: TranslationStrings = { 'tours.ski.summary.stale': 'Pianificato su una copia più vecchia dei dati delle piste, perché quella attuale non è raggiungibile.', 'tours.ski.summary.source': 'Dati delle piste © contributori di OpenStreetMap', + 'tours.ski.summary.runs': 'Piste', + 'tours.ski.summary.descent': 'Discesa sulle piste', + 'tours.ski.summary.lifts': 'Corse in impianto', + 'tours.ski.summary.build': 'Dati della mappa del {build}', + 'tours.ski.summary.saved': 'Mostrato come salvato. Sposta un punto o cambia un’opzione per ripianificare.', 'tours.ski.chooseTechnique': 'Scegli la tecnica, oppure Sconosciuta.', 'tours.ski.infoLabel': 'Cosa fa TREK con tour e tracce di sci', 'tours.ski.info': diff --git a/shared/src/i18n/ja/tours.ts b/shared/src/i18n/ja/tours.ts index 8ad26ac58..b71c1b670 100644 --- a/shared/src/i18n/ja/tours.ts +++ b/shared/src/i18n/ja/tours.ts @@ -475,6 +475,11 @@ const tours: TranslationStrings = { 'tours.ski.summary.guessed': '一部の区間は、地図にない接続や方向など、地図データからの推定に基づいています。', 'tours.ski.summary.stale': '最新のゲレンデデータにアクセスできないため、古いコピーで計画しました。', 'tours.ski.summary.source': 'ゲレンデデータ © OpenStreetMap 貢献者', + 'tours.ski.summary.runs': 'ゲレンデ', + 'tours.ski.summary.descent': 'ゲレンデでの下降', + 'tours.ski.summary.lifts': 'リフト乗車', + 'tours.ski.summary.build': '地図データ: {build}', + 'tours.ski.summary.saved': '保存した状態で表示しています。地点を動かすかオプションを変えると再計画します。', 'tours.ski.chooseTechnique': '走法を選ぶか、不明を選んでください。', 'tours.ski.infoLabel': 'TREK がスキーツアーとトラックで行うこと', 'tours.ski.info': diff --git a/shared/src/i18n/ko/tours.ts b/shared/src/i18n/ko/tours.ts index e6c07ce43..f12ea5ce8 100644 --- a/shared/src/i18n/ko/tours.ts +++ b/shared/src/i18n/ko/tours.ts @@ -474,6 +474,11 @@ const tours: TranslationStrings = { 'tours.ski.summary.guessed': '일부 구간은 지도에 없는 연결이나 방향처럼 지도 데이터의 추정에 기반합니다.', 'tours.ski.summary.stale': '현재 슬로프 데이터에 연결할 수 없어 이전 사본으로 계획했습니다.', 'tours.ski.summary.source': '슬로프 데이터 © OpenStreetMap 기여자', + 'tours.ski.summary.runs': '슬로프', + 'tours.ski.summary.descent': '슬로프 하강', + 'tours.ski.summary.lifts': '리프트 탑승', + 'tours.ski.summary.build': '지도 데이터 기준: {build}', + 'tours.ski.summary.saved': '저장된 그대로 표시합니다. 지점을 옮기거나 옵션을 바꾸면 다시 계획합니다.', 'tours.ski.chooseTechnique': '주법을 선택하거나 알 수 없음을 선택하세요.', 'tours.ski.infoLabel': 'TREK이 스키 투어와 트랙으로 하는 일', 'tours.ski.info': diff --git a/shared/src/i18n/nl/tours.ts b/shared/src/i18n/nl/tours.ts index 0a522a737..b724e1a54 100644 --- a/shared/src/i18n/nl/tours.ts +++ b/shared/src/i18n/nl/tours.ts @@ -494,6 +494,11 @@ const tours: TranslationStrings = { 'Sommige stukken berusten op een gok uit de kaartdata, zoals een verbinding of richting die niet in kaart is gebracht.', 'tours.ski.summary.stale': 'Gepland op een oudere kopie van de pistedata, omdat de huidige niet bereikbaar is.', 'tours.ski.summary.source': 'Pistedata © OpenStreetMap-bijdragers', + 'tours.ski.summary.runs': 'Pistes', + 'tours.ski.summary.descent': 'Afdaling op pistes', + 'tours.ski.summary.lifts': 'Liftritten', + 'tours.ski.summary.build': 'Kaartgegevens van {build}', + 'tours.ski.summary.saved': 'Getoond zoals opgeslagen. Verplaats een punt of wijzig een optie om opnieuw te plannen.', 'tours.ski.chooseTechnique': 'Kies de techniek, of Onbekend.', 'tours.ski.infoLabel': 'Wat TREK met skitochten en skisporen doet', 'tours.ski.info': diff --git a/shared/src/i18n/pl/tours.ts b/shared/src/i18n/pl/tours.ts index 8bf498c95..c0d63e8b5 100644 --- a/shared/src/i18n/pl/tours.ts +++ b/shared/src/i18n/pl/tours.ts @@ -502,6 +502,11 @@ const tours: TranslationStrings = { 'Niektóre fragmenty opierają się na domysłach z danych mapy, np. niezmapowanym połączeniu lub kierunku.', 'tours.ski.summary.stale': 'Zaplanowano na starszej kopii danych tras, bo aktualna jest nieosiągalna.', 'tours.ski.summary.source': 'Dane tras © współtwórcy OpenStreetMap', + 'tours.ski.summary.runs': 'Trasy', + 'tours.ski.summary.descent': 'Zjazd po trasach', + 'tours.ski.summary.lifts': 'Przejazdy wyciągiem', + 'tours.ski.summary.build': 'Dane mapy z {build}', + 'tours.ski.summary.saved': 'Pokazano tak, jak zapisano. Przesuń punkt lub zmień opcję, aby zaplanować ponownie.', 'tours.ski.chooseTechnique': 'Wybierz technikę lub Nieznana.', 'tours.ski.infoLabel': 'Co TREK robi z wycieczkami i śladami narciarskimi', 'tours.ski.info': diff --git a/shared/src/i18n/ru/tours.ts b/shared/src/i18n/ru/tours.ts index bb0ffbf89..d90424146 100644 --- a/shared/src/i18n/ru/tours.ts +++ b/shared/src/i18n/ru/tours.ts @@ -505,6 +505,12 @@ const tours: TranslationStrings = { 'Некоторые отрезки опираются на догадку по картографическим данным, например на неотмеченную связку или направление.', 'tours.ski.summary.stale': 'Спланировано по более старой копии данных трасс, так как текущая недоступна.', 'tours.ski.summary.source': 'Данные трасс © участники OpenStreetMap', + 'tours.ski.summary.runs': 'Трассы', + 'tours.ski.summary.descent': 'Спуск по трассам', + 'tours.ski.summary.lifts': 'Подъёмы', + 'tours.ski.summary.build': 'Данные карты от {build}', + 'tours.ski.summary.saved': + 'Показано, как сохранено. Сдвиньте точку или измените параметр, чтобы спланировать заново.', 'tours.ski.chooseTechnique': 'Выберите технику или «Неизвестно».', 'tours.ski.infoLabel': 'Что TREK делает с лыжными маршрутами и треками', 'tours.ski.info': diff --git a/shared/src/i18n/sk/tours.ts b/shared/src/i18n/sk/tours.ts index d4ddb38c6..3b1acdb71 100644 --- a/shared/src/i18n/sk/tours.ts +++ b/shared/src/i18n/sk/tours.ts @@ -487,6 +487,11 @@ const tours: TranslationStrings = { 'Niektoré časti stoja na odhade z mapových dát, napríklad na nezmapovanej spojke alebo smere.', 'tours.ski.summary.stale': 'Naplánované na staršej kópii dát zjazdoviek, lebo aktuálna nie je dostupná.', 'tours.ski.summary.source': 'Dáta zjazdoviek © prispievatelia OpenStreetMap', + 'tours.ski.summary.runs': 'Zjazdovky', + 'tours.ski.summary.descent': 'Zjazd po zjazdovkách', + 'tours.ski.summary.lifts': 'Jazdy vlekom', + 'tours.ski.summary.build': 'Mapové dáta z {build}', + 'tours.ski.summary.saved': 'Zobrazené tak, ako bolo uložené. Posuňte bod alebo zmeňte voľbu a naplánujte znova.', 'tours.ski.chooseTechnique': 'Vyberte techniku, alebo Neznáma.', 'tours.ski.infoLabel': 'Čo TREK robí s lyžiarskymi túrami a trasami', 'tours.ski.info': diff --git a/shared/src/i18n/sv/tours.ts b/shared/src/i18n/sv/tours.ts index aa41a6c81..4db8b6a63 100644 --- a/shared/src/i18n/sv/tours.ts +++ b/shared/src/i18n/sv/tours.ts @@ -484,6 +484,11 @@ const tours: TranslationStrings = { 'Vissa delar bygger på en gissning från kartdatan, som en länk eller riktning som inte är kartlagd.', 'tours.ski.summary.stale': 'Planerad på en äldre kopia av pistdatan, eftersom den aktuella inte går att nå.', 'tours.ski.summary.source': 'Pistdata © OpenStreetMap-bidragsgivare', + 'tours.ski.summary.runs': 'Pister', + 'tours.ski.summary.descent': 'Nedfärd i pister', + 'tours.ski.summary.lifts': 'Liftturer', + 'tours.ski.summary.build': 'Kartdata från {build}', + 'tours.ski.summary.saved': 'Visas som det sparades. Flytta en punkt eller ändra ett val för att planera om.', 'tours.ski.chooseTechnique': 'Välj teknik, eller Okänd.', 'tours.ski.infoLabel': 'Vad TREK gör med skidturer och skidspår', 'tours.ski.info': diff --git a/shared/src/i18n/th/tours.ts b/shared/src/i18n/th/tours.ts index 7514fcc81..f2c6894c2 100644 --- a/shared/src/i18n/th/tours.ts +++ b/shared/src/i18n/th/tours.ts @@ -465,6 +465,11 @@ const tours: TranslationStrings = { 'tours.ski.summary.guessed': 'บางส่วนอาศัยการคาดเดาจากข้อมูลแผนที่ เช่น ทางเชื่อมหรือทิศทางที่ไม่มีในแผนที่', 'tours.ski.summary.stale': 'วางแผนบนสำเนาข้อมูลลานสกีที่เก่ากว่า เพราะเข้าถึงฉบับปัจจุบันไม่ได้', 'tours.ski.summary.source': 'ข้อมูลลานสกี © ผู้ร่วมพัฒนา OpenStreetMap', + 'tours.ski.summary.runs': 'ลานสกี', + 'tours.ski.summary.descent': 'ระยะลงบนลานสกี', + 'tours.ski.summary.lifts': 'จำนวนรอบลิฟต์', + 'tours.ski.summary.build': 'ข้อมูลแผนที่ ณ {build}', + 'tours.ski.summary.saved': 'แสดงตามที่บันทึกไว้ ย้ายจุดหรือเปลี่ยนตัวเลือกเพื่อวางแผนใหม่', 'tours.ski.chooseTechnique': 'เลือกเทคนิค หรือไม่ทราบ', 'tours.ski.infoLabel': 'TREK ทำอะไรกับทริปและเส้นทางสกี', 'tours.ski.info': diff --git a/shared/src/i18n/tr/tours.ts b/shared/src/i18n/tr/tours.ts index 0c3439875..15ffb8178 100644 --- a/shared/src/i18n/tr/tours.ts +++ b/shared/src/i18n/tr/tours.ts @@ -486,6 +486,12 @@ const tours: TranslationStrings = { 'Bazı kısımlar harita verilerinden bir tahmine dayanıyor, örneğin haritada olmayan bir bağlantı veya yön.', 'tours.ski.summary.stale': 'Güncel kopyaya ulaşılamadığı için pist verilerinin eski bir kopyasıyla planlandı.', 'tours.ski.summary.source': 'Pist verileri © OpenStreetMap katkıcıları', + 'tours.ski.summary.runs': 'Pistler', + 'tours.ski.summary.descent': 'Pistlerde iniş', + 'tours.ski.summary.lifts': 'Teleferik binişleri', + 'tours.ski.summary.build': 'Harita verisi: {build}', + 'tours.ski.summary.saved': + 'Kaydedildiği gibi gösteriliyor. Yeniden planlamak için bir noktayı taşı ya da bir seçeneği değiştir.', 'tours.ski.chooseTechnique': 'Tekniği ya da Bilinmiyor seçeneğini seç.', 'tours.ski.infoLabel': 'TREK kayak turları ve izleriyle ne yapar', 'tours.ski.info': diff --git a/shared/src/i18n/uk/tours.ts b/shared/src/i18n/uk/tours.ts index 783956f9b..a3239463b 100644 --- a/shared/src/i18n/uk/tours.ts +++ b/shared/src/i18n/uk/tours.ts @@ -500,6 +500,11 @@ const tours: TranslationStrings = { 'Деякі відрізки спираються на здогад за картографічними даними, наприклад на невідмічену зв’язку чи напрямок.', 'tours.ski.summary.stale': 'Сплановано за старішою копією даних трас, бо поточна недоступна.', 'tours.ski.summary.source': 'Дані трас © учасники OpenStreetMap', + 'tours.ski.summary.runs': 'Траси', + 'tours.ski.summary.descent': 'Спуск трасами', + 'tours.ski.summary.lifts': 'Підйоми', + 'tours.ski.summary.build': 'Дані мапи від {build}', + 'tours.ski.summary.saved': 'Показано, як збережено. Перемістіть точку або змініть параметр, щоб спланувати знову.', 'tours.ski.chooseTechnique': 'Виберіть техніку або «Невідомо».', 'tours.ski.infoLabel': 'Що TREK робить з лижними маршрутами і треками', 'tours.ski.info': diff --git a/shared/src/i18n/vi/tours.ts b/shared/src/i18n/vi/tours.ts index 0c0940f38..977e50d88 100644 --- a/shared/src/i18n/vi/tours.ts +++ b/shared/src/i18n/vi/tours.ts @@ -485,6 +485,11 @@ const tours: TranslationStrings = { 'tours.ski.summary.stale': 'Được lên kế hoạch trên bản sao cũ hơn của dữ liệu đường trượt, vì không truy cập được bản hiện tại.', 'tours.ski.summary.source': 'Dữ liệu đường trượt © cộng tác viên OpenStreetMap', + 'tours.ski.summary.runs': 'Đường trượt', + 'tours.ski.summary.descent': 'Độ xuống trên đường trượt', + 'tours.ski.summary.lifts': 'Lượt cáp treo', + 'tours.ski.summary.build': 'Dữ liệu bản đồ ngày {build}', + 'tours.ski.summary.saved': 'Hiển thị như đã lưu. Di chuyển một điểm hoặc đổi một tùy chọn để lập lại kế hoạch.', 'tours.ski.chooseTechnique': 'Chọn kỹ thuật, hoặc Không rõ.', 'tours.ski.infoLabel': 'TREK làm gì với chuyến và đường trượt tuyết', 'tours.ski.info': diff --git a/shared/src/i18n/zh-TW/tours.ts b/shared/src/i18n/zh-TW/tours.ts index bb2e07ae3..9bd73a7bd 100644 --- a/shared/src/i18n/zh-TW/tours.ts +++ b/shared/src/i18n/zh-TW/tours.ts @@ -449,6 +449,11 @@ const tours: TranslationStrings = { 'tours.ski.summary.guessed': '有些路段基於地圖資料的推測,例如未標註的連接或方向。', 'tours.ski.summary.stale': '由於無法存取目前版本,使用較早的雪道資料副本規劃。', 'tours.ski.summary.source': '雪道資料 © OpenStreetMap 貢獻者', + 'tours.ski.summary.runs': '雪道', + 'tours.ski.summary.descent': '雪道下降', + 'tours.ski.summary.lifts': '纜車次數', + 'tours.ski.summary.build': '地圖資料:{build}', + 'tours.ski.summary.saved': '依儲存時顯示。移動一個點或變更選項即可重新規劃。', 'tours.ski.chooseTechnique': '請選擇技術,或選擇未知。', 'tours.ski.infoLabel': 'TREK 如何處理滑雪路線與軌跡', 'tours.ski.info': diff --git a/shared/src/i18n/zh/tours.ts b/shared/src/i18n/zh/tours.ts index fcd3317f2..e7a0fec9a 100644 --- a/shared/src/i18n/zh/tours.ts +++ b/shared/src/i18n/zh/tours.ts @@ -450,6 +450,11 @@ const tours: TranslationStrings = { 'tours.ski.summary.guessed': '有些路段基于地图数据的推测,例如未标注的连接或方向。', 'tours.ski.summary.stale': '由于无法访问当前版本,使用较早的雪道数据副本规划。', 'tours.ski.summary.source': '雪道数据 © OpenStreetMap 贡献者', + 'tours.ski.summary.runs': '雪道', + 'tours.ski.summary.descent': '雪道下降', + 'tours.ski.summary.lifts': '缆车次数', + 'tours.ski.summary.build': '地图数据:{build}', + 'tours.ski.summary.saved': '按保存时显示。移动一个点或更改选项即可重新规划。', 'tours.ski.chooseTechnique': '请选择技术,或选择未知。', 'tours.ski.infoLabel': 'TREK 如何处理滑雪路线与轨迹', 'tours.ski.info': diff --git a/shared/src/ski/ski-route.schema.spec.ts b/shared/src/ski/ski-route.schema.spec.ts index c975c46d3..666a47faa 100644 --- a/shared/src/ski/ski-route.schema.spec.ts +++ b/shared/src/ski/ski-route.schema.spec.ts @@ -22,6 +22,23 @@ const A = { lat: 45.98, lng: 7.75 }; const B = { lat: 45.93, lng: 7.71 }; const SHA = 'a'.repeat(64); +/** A ski tour as the list carries it. */ +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, +}; + 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({ @@ -111,27 +128,37 @@ describe('piste router options (SKI-ROUTE)', () => { ); }); - 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, + it('SKI-ROUTE-SCHEMA-012: a saved ski tour names the legs the router planned, each a leg of its own, once', () => { + const planned = { name: 'Valley run', - planned: true, - caution: false, + 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: 'via', sequence: 1 }, + { lat: A.lat, lng: A.lng, role: 'end', sequence: 2 }, + ], }; - expect(tourListItemSchema.parse({ ...listed, ski_options: {}, ski_route_build: '2026-11-02' })).toMatchObject({ + expect(tourCreateRequestSchema.parse({ ...planned, ski_planned_legs: [1, 0] }).ski_planned_legs).toEqual([1, 0]); + expect(tourCreateRequestSchema.parse({ ...planned, ski_planned_legs: [] }).ski_planned_legs).toEqual([]); + for (const legs of [[2], [0, 0], [-1], [0.5]]) { + expect(tourCreateRequestSchema.safeParse({ ...planned, ski_planned_legs: legs }).success).toBe(false); + } + const hike = { ...planned, tour_type: 'hike' as const, ski_discipline: null }; + expect(() => tourCreateRequestSchema.parse({ ...hike, ski_planned_legs: [0] })).toThrow(/ski_planned_legs belongs/); + expect(tourListItemSchema.parse({ ...LISTED, ski_planned_legs: [0] }).ski_planned_legs).toEqual([0]); + }); + + it('SKI-ROUTE-SCHEMA-005: a listed tour may carry its options and build, or neither', () => { + 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(); + expect(tourListItemSchema.parse(LISTED).ski_options).toBeUndefined(); }); }); diff --git a/shared/src/tours/ski.schema.ts b/shared/src/tours/ski.schema.ts index 3476b9a96..70bd853bd 100644 --- a/shared/src/tours/ski.schema.ts +++ b/shared/src/tours/ski.schema.ts @@ -171,5 +171,11 @@ export function skiOptionsForDiscipline(discipline: SkiDiscipline, options: SkiR return result as SkiRouteOptions; } +/** + * The legs of a ski tour the piste router planned, each named by the index of + * the control point it starts at; the others were drawn straight or by hand. + */ +export const skiPlannedLegsSchema = z.array(z.number().int().nonnegative()).max(5000); + /** The name of a piste data build a ski tour's legs were planned on, as the Places host names it. */ export const skiRouteBuildSchema = z.string().trim().min(1).max(40).regex(/^[0-9A-Za-z._-]+$/); diff --git a/shared/src/tours/tours.schema.ts b/shared/src/tours/tours.schema.ts index 0b3c32c39..d583a65a5 100644 --- a/shared/src/tours/tours.schema.ts +++ b/shared/src/tours/tours.schema.ts @@ -10,6 +10,7 @@ import { runPaceSecondsPerKmSchema } from './run-pace'; import { skiDisciplineSchema, skiIssues, + skiPlannedLegsSchema, skiRouteBuildSchema, skiRouteOptionsSchema, skiTechniqueSchema, @@ -227,6 +228,8 @@ export const tourSchema = z.object({ 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(), + /** The ski tour's legs the piste router planned, by the index of the point each starts at; null if unknown. */ + ski_planned_legs: skiPlannedLegsSchema.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(), @@ -531,6 +534,8 @@ export const tourCreateRequestSchema = z 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(), + /** A ski tour's only: the legs the piste router planned, by the index of the point each starts at. */ + ski_planned_legs: skiPlannedLegsSchema.nullable().optional(), boat_type: paddleBoatTypeSchema.nullable().optional(), water_context: paddleWaterContextSchema.nullable().optional(), paddle_speed_kmh: paddleSpeedKmhSchema.nullable().optional(), @@ -657,6 +662,7 @@ function tourTypeIssues(value: { ski_technique?: SkiTechnique | null; ski_options?: object | null; ski_route_build?: string | null; + ski_planned_legs?: readonly number[] | null; boat_type?: PaddleBoatType | null; water_context?: PaddleWaterContext | null; paddle_speed_kmh?: number | null; @@ -698,10 +704,16 @@ function tourTypeIssues(value: { issues.push(...runIssues(value)); issues.push(...skiIssues(type === 'ski', value)); if (type !== 'ski') { - for (const field of ['ski_options', 'ski_route_build'] as const) { + for (const field of ['ski_options', 'ski_route_build', 'ski_planned_legs'] as const) { if (value[field] != null) issues.push({ path: [field], message: `${field} belongs to a ski tour` }); } } + // Each planned leg is one of this tour's legs, named once. + const legCount = Math.max(0, (value.waypoints?.length ?? 0) - 1); + (value.ski_planned_legs ?? []).forEach((leg, index, all) => { + if (leg >= legCount || all.indexOf(leg) !== index) + issues.push({ path: ['ski_planned_legs', index], message: 'not a leg of this tour, or named twice' }); + }); // 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) diff --git a/wiki/MCP-Addon-Tools.md b/wiki/MCP-Addon-Tools.md index 2dd5476df..ebe459ec6 100644 --- a/wiki/MCP-Addon-Tools.md +++ b/wiki/MCP-Addon-Tools.md @@ -237,8 +237,8 @@ The saved tours of a trip, as the Tours list and the tour detail show them, how | Tool | Description | Scope | |---|---|---| -| `list_tours` | The saved tours of a trip: name, type (`hike`, `walk` for a city walk, `run`, `bike`, `kayak` for a paddle tour, `horse` for a ride, which has no riding time worked out, or `ski` for a ski and winter tour, which has no ski time worked out either), origin (`trek` for planned in TREK, `gpx` for a GPX import, `wanderer` for a wanderer snapshot), distance, ascent and descent (unknown for a paddle tour drawn in the planner), the stored routing limit of a hike on the SAC scale and its route choices (`hiking_preferences`), the discipline of a ski tour (`ski_discipline`: `alpine`, `snowboard`, `cross_country`, `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`; a planned ski tour has every leg drawn straight between its points and an imported one is kept as recorded, neither is routed along pistes or trails, neither has a ski time worked out, and neither says anything about snow, avalanches or open runs), the boat, water and chosen speed of a paddle tour, the check bench of a paddle tour (`paddle_check`: how far the OpenStreetMap obstacle data covers its route and how many obstacles are open, to check again or decided; a planning status, never a safety or legal clearance), for a city walk how many stations it has (`station_count`), their stays as far as entered and how many have none, whether a tour still waits for its way to be planned (`route_pending`: saved with its points only, its distance and walking time unknown until someone opens it in the planner and saves it), for a multi-day tour how many stages its route is split into (`stage_count`, `null` for a tour of one day), the `revision` (raised by every saved change of the tour), the actual tracks linked to a tour (`activity_tracks`) and, for an actual track, the tour it belongs to (`activity_of`), when an imported track was recorded (`track_started_at`, `track_ended_at`; unknown when the file carried no times), and the days of the trip each tour is on | `trips:read` | -| `get_tour_plan` | One tour: its facts, its ordered control points, its provenance (where it came from, when, and whether it was changed in TREK since) and its days. A control point can carry a fixed leg to the next one: a via ferrata (`ferrata`), a stretch drawn by hand (`free`), which no router planned or checked, or on a ride a section of a signposted horse route (`horse_route`) in the direction chosen, with its signposting and the `horse=*` tags its ways carried; being on a route is no permission to ride it. A shaping point (`shaping: true`) only steers the route and is no stop. A stop with `stage_end: true` ends a stage of a multi-day tour and the next one begins there; the stages follow the route in its order. A paddle tour also carries its obstacle checklist (`obstacle_check`): the obstacles of the OpenStreetMap data along its route, what the travellers decided about each, and their own portages and locks. The stations of a city walk carry every field spelled out: `key` (the same across saves), `name`, `stay_minutes` (`0` for a point only passed, `null` when not known), `importance` (`optional`, `must`, or `null` for normal), `protected` (a station a proposed change has to keep where it is; the travellers can still edit it) and `opening_hours` (as OpenStreetMap writes them, `null` when none is known). Every leg from one control point to the next is listed in `legs` with its kind (`routed` for a leg the router planned, `track` for part of a recorded line no router planned, as for an import without control points of its own, `ferrata`, `free`, `portage` or `horse_route`; every leg of a planned ski tour is `free`, a straight line between its points) and, for a fixed leg or a ski tour's leg, the length of its own line; a routed or track leg has no length of its own stored. `line` gives the saved line in brief: how many points it has, whether they carry heights, and a preview thinned to 200 points. Pass `dayId` with a city walk to read the visits of that day: each station then says `visit_status` (planned, visited or skipped) and `run` what is still open; without a day no visit is told. `derived` names every field worked out on reading rather than stored with the tour. The actual tracks linked to the tour (`activities`) each name their day, the recording (its tour in the trip, origin, file name, when it came in) and what that recording measures: distance, ascent and descent (unknown without heights) and the time from its first to its last time stamp, breaks included (unknown when the file carried no times, never estimated); the planned values stay apart and unchanged. Reading changes nothing, not even the revision. The full track geometry is not included | `trips:read` | +| `list_tours` | The saved tours of a trip: name, type (`hike`, `walk` for a city walk, `run`, `bike`, `kayak` for a paddle tour, `horse` for a ride, which has no riding time worked out, or `ski` for a ski and winter tour), origin (`trek` for planned in TREK, `gpx` for a GPX import, `wanderer` for a wanderer snapshot), distance, ascent and descent (unknown for a paddle tour drawn in the planner), the stored routing limit of a hike on the SAC scale and its route choices (`hiking_preferences`), the discipline of a ski tour (`ski_discipline`: `alpine`, `snowboard`, `cross_country`, `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`; a planned ski tour has its legs planned by the piste router along the runs, lifts and trails of OpenStreetMap where that data reaches and drawn straight elsewhere (`ski_options`, `ski_route_build` and `ski_planned_legs` say what it was planned with, on which build, and which legs), with a ski time only when every leg was planned; an imported one is kept as recorded; neither says anything about snow, avalanches or open runs), the boat, water and chosen speed of a paddle tour, the check bench of a paddle tour (`paddle_check`: how far the OpenStreetMap obstacle data covers its route and how many obstacles are open, to check again or decided; a planning status, never a safety or legal clearance), for a city walk how many stations it has (`station_count`), their stays as far as entered and how many have none, whether a tour still waits for its way to be planned (`route_pending`: saved with its points only, its distance and walking time unknown until someone opens it in the planner and saves it), for a multi-day tour how many stages its route is split into (`stage_count`, `null` for a tour of one day), the `revision` (raised by every saved change of the tour), the actual tracks linked to a tour (`activity_tracks`) and, for an actual track, the tour it belongs to (`activity_of`), when an imported track was recorded (`track_started_at`, `track_ended_at`; unknown when the file carried no times), and the days of the trip each tour is on | `trips:read` | +| `get_tour_plan` | One tour: its facts, its ordered control points, its provenance (where it came from, when, and whether it was changed in TREK since) and its days. A control point can carry a fixed leg to the next one: a via ferrata (`ferrata`), a stretch drawn by hand (`free`), which no router planned or checked, or on a ride a section of a signposted horse route (`horse_route`) in the direction chosen, with its signposting and the `horse=*` tags its ways carried; being on a route is no permission to ride it. A shaping point (`shaping: true`) only steers the route and is no stop. A stop with `stage_end: true` ends a stage of a multi-day tour and the next one begins there; the stages follow the route in its order. A paddle tour also carries its obstacle checklist (`obstacle_check`): the obstacles of the OpenStreetMap data along its route, what the travellers decided about each, and their own portages and locks. The stations of a city walk carry every field spelled out: `key` (the same across saves), `name`, `stay_minutes` (`0` for a point only passed, `null` when not known), `importance` (`optional`, `must`, or `null` for normal), `protected` (a station a proposed change has to keep where it is; the travellers can still edit it) and `opening_hours` (as OpenStreetMap writes them, `null` when none is known). Every leg from one control point to the next is listed in `legs` with its kind (`routed` for a leg the router planned, `track` for part of a recorded line no router planned, as for an import without control points of its own, `ferrata`, `free`, `portage` or `horse_route`; a leg of a planned ski tour is `routed` where the piste router planned it and `free`, a straight line between its points, where it did not) and, for a fixed leg or a ski tour's leg, the length of its own line; a routed or track leg has no length of its own stored. `line` gives the saved line in brief: how many points it has, whether they carry heights, and a preview thinned to 200 points. Pass `dayId` with a city walk to read the visits of that day: each station then says `visit_status` (planned, visited or skipped) and `run` what is still open; without a day no visit is told. `derived` names every field worked out on reading rather than stored with the tour. The actual tracks linked to the tour (`activities`) each name their day, the recording (its tour in the trip, origin, file name, when it came in) and what that recording measures: distance, ascent and descent (unknown without heights) and the time from its first to its last time stamp, breaks included (unknown when the file carried no times, never estimated); the planned values stay apart and unchanged. Reading changes nothing, not even the revision. The full track geometry is not included | `trips:read` | | `find_tours_for_day` | The tours put on one day of a trip, with the same facts as `list_tours` | `trips:read` | | `get_city_walk_progress` | How far a city walk got on one day it is on: every station in order with its key, its status that day (planned, visited or skipped, as checked off in the day plan), importance, protection, stay and opening hours, what is still open, and the walking time of the whole walk. Visits belong to that day only. Reading changes nothing | `trips:read` | | `export_tour_gpx` | One saved tour as a GPX file: its whole saved line as one track, with the heights it carries and none it lacks, and a file name. A tour without a line cannot be exported | `trips:read` |