From 2d0c0597b37771e7c540ccca8ca6b865d4edfaf4 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 20 Oct 2026 20:32:01 +0200 Subject: [PATCH] feat(tours): plan ski tours on the piste router in the planner A ski tour with a discipline now sends its legs to the piste router and takes back runs, lifts and trails: each stretch is coloured by its grade in the area's scheme, links and lift rides down are dotted, and a leg the router cannot plan stays straight and unrouted with its reason and the one change that would plan it. Snowshoe and winter hike legs off the piste data follow summer paths instead, said so. The router's time and heights are used once every leg is planned, and the options, the build and the time are saved with the tour. - skiRouteRepo and api: one request per tour, batched, remembered unless the answer may change, offline legs failed as offline - planner: router options per discipline, leg steps and failure rows with fixes, a piste route summary, draft and reopen keep the options - admin: switch for the piste router under Tours - i18n: new ski keys in all 27 locales, ski info and time texts rewritten --- client/src/api/skiRoute.ts | 10 + client/src/api/tourBaseMaps.ts | 4 +- .../Admin/addons/useAddonManager.test.ts | 15 +- .../Admin/addons/useAddonManager.ts | 19 +- .../Tours/planner/TourPlannerPanels.tsx | 5 +- .../Tours/planner/TourTypeFields.test.tsx | 17 +- .../Tours/planner/TourTypeFields.tsx | 13 +- .../Tours/planner/WaypointLegRows.tsx | 37 ++- .../Tours/planner/skiRouting.test.ts | 221 +++++++++++-- .../components/Tours/planner/skiRouting.ts | 303 ++++++++++++++++-- .../Tours/planner/tourPlannerDraft.ts | 4 +- .../Tours/planner/tourPlannerFacts.ts | 11 +- .../Tours/planner/tourPlannerParts.test.ts | 19 +- .../Tours/planner/tourRouteStyle.test.ts | 18 +- .../Tours/planner/tourRouteStyle.ts | 6 +- .../components/Tours/planner/tourRouting.ts | 12 +- .../Tours/planner/useTourPlanner.ski.test.tsx | 98 +++++- .../Tours/planner/useTourPlanner.ts | 8 +- .../planner/useTourRoute.heights.test.ts | 27 ++ .../components/Tours/planner/useTourRoute.ts | 89 ++++- .../src/components/Tours/ski/SkiLegRows.tsx | 197 ++++++++++++ .../components/Tours/ski/SkiRouteOptions.tsx | 166 ++++++++++ .../components/Tours/ski/SkiRouteSummary.tsx | 106 ++++++ .../components/Tours/ski/skiChoice.test.ts | 10 +- client/src/components/Tours/ski/skiChoice.ts | 19 +- .../src/components/Tours/ski/skiRouteStyle.ts | 59 ++++ .../components/Tours/ski/skiRouteUi.test.tsx | 225 +++++++++++++ .../components/Tours/ski/useSkiFields.test.ts | 26 +- .../src/components/Tours/ski/useSkiFields.ts | 41 ++- client/src/repo/skiRouteRepo.test.ts | 184 +++++++++++ client/src/repo/skiRouteRepo.ts | 105 ++++++ client/src/repo/tourTrailGradeRepo.test.ts | 4 +- client/tests/helpers/msw/handlers/admin.ts | 9 + client/tests/helpers/tourPlannerStub.ts | 3 +- shared/src/i18n/ar/admin.ts | 3 + shared/src/i18n/ar/tours.ts | 82 ++++- shared/src/i18n/az/admin.ts | 3 + shared/src/i18n/az/tours.ts | 84 ++++- shared/src/i18n/br/admin.ts | 3 + shared/src/i18n/br/tours.ts | 85 ++++- shared/src/i18n/ca/admin.ts | 3 + shared/src/i18n/ca/tours.ts | 85 ++++- shared/src/i18n/cs/admin.ts | 3 + shared/src/i18n/cs/tours.ts | 84 ++++- shared/src/i18n/de/admin.ts | 3 + shared/src/i18n/de/tours.ts | 84 ++++- shared/src/i18n/en/admin.ts | 3 + shared/src/i18n/en/tours.ts | 83 ++++- shared/src/i18n/es/admin.ts | 3 + shared/src/i18n/es/tours.ts | 85 ++++- shared/src/i18n/et/admin.ts | 3 + shared/src/i18n/et/tours.ts | 83 ++++- shared/src/i18n/fr/admin.ts | 3 + shared/src/i18n/fr/tours.ts | 86 ++++- shared/src/i18n/gr/admin.ts | 3 + shared/src/i18n/gr/tours.ts | 85 ++++- shared/src/i18n/hu/admin.ts | 3 + shared/src/i18n/hu/tours.ts | 83 ++++- shared/src/i18n/id/admin.ts | 3 + shared/src/i18n/id/tours.ts | 86 ++++- shared/src/i18n/it/admin.ts | 3 + shared/src/i18n/it/tours.ts | 84 ++++- shared/src/i18n/ja/admin.ts | 2 + shared/src/i18n/ja/tours.ts | 80 ++++- shared/src/i18n/ko/admin.ts | 2 + shared/src/i18n/ko/tours.ts | 81 ++++- shared/src/i18n/nl/admin.ts | 3 + shared/src/i18n/nl/tours.ts | 83 ++++- shared/src/i18n/pl/admin.ts | 3 + shared/src/i18n/pl/tours.ts | 84 ++++- shared/src/i18n/ru/admin.ts | 3 + shared/src/i18n/ru/tours.ts | 83 ++++- shared/src/i18n/sk/admin.ts | 3 + shared/src/i18n/sk/tours.ts | 84 ++++- shared/src/i18n/sv/admin.ts | 3 + shared/src/i18n/sv/tours.ts | 83 ++++- shared/src/i18n/th/admin.ts | 3 + shared/src/i18n/th/tours.ts | 81 ++++- shared/src/i18n/tours-locales.spec.ts | 2 +- shared/src/i18n/tr/admin.ts | 3 + shared/src/i18n/tr/tours.ts | 83 ++++- shared/src/i18n/uk/admin.ts | 3 + shared/src/i18n/uk/tours.ts | 83 ++++- shared/src/i18n/vi/admin.ts | 3 + shared/src/i18n/vi/tours.ts | 84 ++++- shared/src/i18n/zh-TW/admin.ts | 2 + shared/src/i18n/zh-TW/tours.ts | 81 ++++- shared/src/i18n/zh/admin.ts | 2 + shared/src/i18n/zh/tours.ts | 81 ++++- shared/src/tours/ski.schema.spec.ts | 16 +- shared/src/tours/ski.schema.ts | 5 + 91 files changed, 4183 insertions(+), 257 deletions(-) create mode 100644 client/src/api/skiRoute.ts create mode 100644 client/src/components/Tours/planner/useTourRoute.heights.test.ts create mode 100644 client/src/components/Tours/ski/SkiLegRows.tsx create mode 100644 client/src/components/Tours/ski/SkiRouteOptions.tsx create mode 100644 client/src/components/Tours/ski/SkiRouteSummary.tsx create mode 100644 client/src/components/Tours/ski/skiRouteStyle.ts create mode 100644 client/src/components/Tours/ski/skiRouteUi.test.tsx create mode 100644 client/src/repo/skiRouteRepo.test.ts create mode 100644 client/src/repo/skiRouteRepo.ts diff --git a/client/src/api/skiRoute.ts b/client/src/api/skiRoute.ts new file mode 100644 index 000000000..79b7ac770 --- /dev/null +++ b/client/src/api/skiRoute.ts @@ -0,0 +1,10 @@ +import { skiRouteResponseSchema, type SkiRouteInput, type SkiRouteResponse } from '@trek/shared'; +import apiClient from './client'; + +/** The piste router: a ski tour's legs planned on the piste data the server keeps, held to the contract. */ +export const skiRouteApi = { + plan: (tripId: number | string, request: SkiRouteInput, signal?: AbortSignal): Promise => + apiClient + .post(`/trips/${tripId}/ski/route`, request, { signal }) + .then((r) => skiRouteResponseSchema.parse(r.data)), +}; diff --git a/client/src/api/tourBaseMaps.ts b/client/src/api/tourBaseMaps.ts index 60a94cd3a..670828efb 100644 --- a/client/src/api/tourBaseMaps.ts +++ b/client/src/api/tourBaseMaps.ts @@ -1,7 +1,7 @@ import type { AdminFeatureToggleResponse } from '@trek/shared'; import apiClient from './client'; -/** An admin's switch for a map the Tours planner offers on its operator's terms. */ +/** An admin's switch for a map or a service the Tours planner offers on its operator's terms. */ function tourBaseMapAdminApi(path: string) { return { get: (): Promise => apiClient.get(path).then((r) => r.data), @@ -14,3 +14,5 @@ function tourBaseMapAdminApi(path: string) { export const hikingMapAdminApi = tourBaseMapAdminApi('/admin/tours-hiking-map'); /** BIK-06: CyclOSM for a bike tour. */ export const cycleMapAdminApi = tourBaseMapAdminApi('/admin/tours-cycle-map'); +/** SKI-02: the piste router, which plans ski tours on the Places host's piste data. */ +export const skiRoutingAdminApi = tourBaseMapAdminApi('/admin/tours-ski-routing'); diff --git a/client/src/components/Admin/addons/useAddonManager.test.ts b/client/src/components/Admin/addons/useAddonManager.test.ts index 0ce0d4a5c..b150d5e3f 100644 --- a/client/src/components/Admin/addons/useAddonManager.test.ts +++ b/client/src/components/Admin/addons/useAddonManager.test.ts @@ -2,7 +2,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { adminApi } from '../../../api/client'; -import { cycleMapAdminApi, hikingMapAdminApi } from '../../../api/tourBaseMaps'; +import { cycleMapAdminApi, hikingMapAdminApi, skiRoutingAdminApi } from '../../../api/tourBaseMaps'; import { wandererAdminApi } from '../../../api/wanderer'; import { useAddonStore } from '../../../store/addonStore'; import type { Addon } from './addonModel'; @@ -40,6 +40,7 @@ beforeEach(() => { vi.spyOn(wandererAdminApi, 'get').mockResolvedValue({ enabled: false }); vi.spyOn(hikingMapAdminApi, 'get').mockResolvedValue({ enabled: true }); vi.spyOn(cycleMapAdminApi, 'get').mockResolvedValue({ enabled: true }); + vi.spyOn(skiRoutingAdminApi, 'get').mockResolvedValue({ enabled: true }); }); afterEach(() => vi.restoreAllMocks()); @@ -52,7 +53,7 @@ async function loaded() { const enabledOf = (addons: Addon[], id: string) => addons.find((a) => a.id === id)?.enabled; /** One of the map switches under Tours, by its key. */ -function mapSwitch(manager: ReturnType, key: 'hiking' | 'cycle') { +function mapSwitch(manager: ReturnType, key: 'hiking' | 'cycle' | 'ski') { const found = manager.mapSwitches.find((entry) => entry.key === key); if (!found) throw new Error(`no ${key} map switch`); return found; @@ -149,6 +150,16 @@ describe('useAddonManager', () => { expect(mapSwitch(result.current, 'hiking').enabled).toBe(true); }); + it('FE-HOOK-ADDONMGR-010: the piste router switch under Tours loads on, flips and saves on its own (SKI-02)', async () => { + const update = vi.spyOn(skiRoutingAdminApi, 'update').mockResolvedValue({ enabled: false }); + const { result } = await loaded(); + await waitFor(() => expect(mapSwitch(result.current, 'ski').enabled).toBe(true)); + await act(() => mapSwitch(result.current, 'ski').toggle()); + expect(update).toHaveBeenCalledWith(false); + expect(mapSwitch(result.current, 'ski').enabled).toBe(false); + expect(mapSwitch(result.current, 'cycle').enabled).toBe(true); + }); + it('FE-HOOK-ADDONMGR-008: a failed save puts the switch under Tours back and says so', async () => { vi.spyOn(wandererAdminApi, 'update').mockRejectedValue(new Error('500')); const { result } = await loaded(); diff --git a/client/src/components/Admin/addons/useAddonManager.ts b/client/src/components/Admin/addons/useAddonManager.ts index c76405ce2..24547a747 100644 --- a/client/src/components/Admin/addons/useAddonManager.ts +++ b/client/src/components/Admin/addons/useAddonManager.ts @@ -1,8 +1,8 @@ -import { Bike, Footprints } from 'lucide-react'; +import { Bike, Footprints, MountainSnow } from 'lucide-react'; import { useEffect, useEffectEvent, useState } from 'react'; import { adminApi } from '../../../api/client'; -import { cycleMapAdminApi, hikingMapAdminApi } from '../../../api/tourBaseMaps'; +import { cycleMapAdminApi, hikingMapAdminApi, skiRoutingAdminApi } from '../../../api/tourBaseMaps'; import { wandererAdminApi } from '../../../api/wanderer'; import { useTranslation } from '../../../i18n'; import { useAddonStore } from '../../../store/addonStore'; @@ -24,6 +24,7 @@ export function useAddonManager() { const [toursWanderer, setToursWanderer] = useState(false); const [toursHikingMap, setToursHikingMap] = useState(false); const [toursCycleMap, setToursCycleMap] = useState(false); + const [toursSkiRouting, setToursSkiRouting] = useState(false); const loadAddons = async () => { try { @@ -48,6 +49,10 @@ export function useAddonManager() { .get() .then((flag) => setToursCycleMap(flag.enabled)) .catch(() => toast.error(t('admin.addons.toast.error'))); + skiRoutingAdminApi + .get() + .then((flag) => setToursSkiRouting(flag.enabled)) + .catch(() => toast.error(t('admin.addons.toast.error'))); }); useEffect(() => { loadOnMount(); @@ -94,7 +99,7 @@ export function useAddonManager() { toast.success(t('admin.addons.toast.updated')); }; const toggleToursWanderer = () => flipToursSwitch(toursWanderer, setToursWanderer, wandererAdminApi.update); - /** The maps the Tours planner offers on their operators' terms, one switch each (MAP-01, BIK-06). */ + /** The maps and the piste router Tours offers on their operators' terms, one switch each (MAP-01, BIK-06, SKI-02). */ const mapSwitches = [ { key: 'hiking', @@ -112,6 +117,14 @@ export function useAddonManager() { enabled: toursCycleMap, toggle: () => flipToursSwitch(toursCycleMap, setToursCycleMap, cycleMapAdminApi.update), }, + { + key: 'ski', + Icon: MountainSnow, + titleKey: 'admin.toursSkiRouting.title', + subtitleKey: 'admin.toursSkiRouting.subtitle', + enabled: toursSkiRouting, + toggle: () => flipToursSwitch(toursSkiRouting, setToursSkiRouting, skiRoutingAdminApi.update), + }, ] as const; const asOption = (provider: Addon): ProviderOption => ({ diff --git a/client/src/components/Tours/planner/TourPlannerPanels.tsx b/client/src/components/Tours/planner/TourPlannerPanels.tsx index e58c66caa..2e8f5326d 100644 --- a/client/src/components/Tours/planner/TourPlannerPanels.tsx +++ b/client/src/components/Tours/planner/TourPlannerPanels.tsx @@ -63,6 +63,7 @@ import { RunWaysCard } from '../run/RunWaysCard' import { HorseRoutesCard } from '../horse/HorseRoutesCard' import { RecordedRideCard } from '../horse/RecordedRideFacts' import { HikeWaysCard } from '../hike/HikeWaysCard' +import { SkiRouteSummary } from '../ski/SkiRouteSummary' import { walkTimeFields } from '../walk/walkTimeFields' const STATUS_KEY: Partial> = { @@ -497,6 +498,8 @@ export function TourPlannerRail({ planner, + {planner.isSki && } + {planner.tourType === 'hike' && ( @@ -670,7 +673,7 @@ const ESTIMATE_KEYS = { run: { labelKey: 'tours.run.timeEstimated', shortKey: 'tours.run.timeEstimatedShort', infoLabelKey: 'tours.run.timeEstimatedInfoLabel', infoKey: 'tours.run.timeEstimatedInfo' }, // HOR-01: a ride has no estimate; the field says why, and the planned total is the rider's own. horse: { labelKey: 'tours.horse.time', shortKey: 'tours.horse.time', infoLabelKey: 'tours.horse.timeInfoLabel', infoKey: 'tours.horse.timeInfo' }, - // SKI-02: nor has a ski tour, whose legs are drawn; the field says so. + // SKI-02: a ski tour's time is the piste router's, given once every leg is planned; the field says so. ski: { labelKey: 'tours.ski.time', shortKey: 'tours.ski.time', infoLabelKey: 'tours.ski.timeInfoLabel', infoKey: 'tours.ski.timeInfo' }, } as const diff --git a/client/src/components/Tours/planner/TourTypeFields.test.tsx b/client/src/components/Tours/planner/TourTypeFields.test.tsx index 519ab1cad..0f90ce4b7 100644 --- a/client/src/components/Tours/planner/TourTypeFields.test.tsx +++ b/client/src/components/Tours/planner/TourTypeFields.test.tsx @@ -136,7 +136,9 @@ describe('TourTypeFields ski and winter (SKI-02)', () => { expect(screen.getByRole('button', { name })).toHaveAttribute('aria-pressed', 'false'); } expect(fields).toHaveTextContent('Choose a discipline to save the tour.'); - expect(fields).toHaveTextContent('Each leg is drawn straight between your points.'); + expect(fields).toHaveTextContent('Legs follow runs, lifts and trails from OpenStreetMap'); + // No router options before a discipline is chosen. + expect(screen.queryByTestId('tour-planner-ski-options')).not.toBeInTheDocument(); expect(screen.queryByTestId('tour-planner-paddle-fields')).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Snowshoeing' })); expect(ski.ski.change).toHaveBeenCalledWith({ discipline: 'snowshoe', technique: null }); @@ -144,7 +146,12 @@ describe('TourTypeFields ski and winter (SKI-02)', () => { it('asks a cross-country tour for its technique and keeps it dimmed for any other discipline', () => { const crossCountry = skiPlanner({ - ski: { choice: { discipline: 'cross_country', technique: null }, change: vi.fn(), load: vi.fn() }, + ski: { + choice: { discipline: 'cross_country', technique: null }, + change: vi.fn(), + setOption: vi.fn(), + load: vi.fn(), + }, skiMissing: 'technique', }); const { unmount } = render(); @@ -155,13 +162,17 @@ describe('TourTypeFields ski and winter (SKI-02)', () => { unmount(); const alpine = skiPlanner({ - ski: { choice: { discipline: 'alpine', technique: null }, change: vi.fn(), load: vi.fn() }, + ski: { choice: { discipline: 'alpine', technique: null }, change: vi.fn(), setOption: vi.fn(), load: vi.fn() }, skiMissing: null, }); render(); expect(screen.getByTestId('tour-ski-technique')).toBeDisabled(); expect(screen.getByRole('button', { name: 'Alpine' })).toHaveAttribute('aria-pressed', 'true'); expect(screen.queryByRole('status')).not.toBeInTheDocument(); + // The piste router's options for the discipline chosen, each change handed to the planner. + expect(screen.getByTestId('tour-planner-ski-options')).toHaveTextContent('Hardest runs'); + fireEvent.click(screen.getByRole('button', { name: 'Sporty' })); + expect(alpine.ski.setOption).toHaveBeenCalledWith({ pace: 'sporty' }); }); it('shows no ski fields on any other tour', () => { diff --git a/client/src/components/Tours/planner/TourTypeFields.tsx b/client/src/components/Tours/planner/TourTypeFields.tsx index 7bf675ac0..c6e530d7c 100644 --- a/client/src/components/Tours/planner/TourTypeFields.tsx +++ b/client/src/components/Tours/planner/TourTypeFields.tsx @@ -15,6 +15,7 @@ import { crossesLake } from '../paddle/lakeStyleAdvice'; import { PaddleLakeStyleField } from '../paddle/PaddleLakeStyleField'; import { RunFields } from '../run/RunFields'; import { SkiClassificationFields } from '../ski/SkiClassificationFields'; +import { SkiRouteOptions } from '../ski/SkiRouteOptions'; import { TourNotice } from '../tourParts'; import { TourTypeSelect } from './TourTypeSelect'; import type { TourPlannerController } from './useTourPlanner'; @@ -107,17 +108,21 @@ function BikeProfileField({ planner }: { planner: TourPlannerController }) { /** * SKI-02: what a ski tour is, chosen on purpose and nothing preselected: the - * discipline and, for cross-country, the technique. A short line says how its - * legs are laid: straight between the points, nothing checked against pistes. + * discipline and, for cross-country, the technique, then what the piste router + * is asked for. A short line says how its legs are laid. */ function SkiFields({ planner }: { planner: TourPlannerController }) { const { t } = useTranslation(); + const { choice } = planner.ski; return (
- + - {t('tours.ski.drawnFreely')} + {t('tours.ski.plannedHint')} + {choice.discipline !== null && ( + + )}
); } diff --git a/client/src/components/Tours/planner/WaypointLegRows.tsx b/client/src/components/Tours/planner/WaypointLegRows.tsx index e1cc0377c..b629e9dbf 100644 --- a/client/src/components/Tours/planner/WaypointLegRows.tsx +++ b/client/src/components/Tours/planner/WaypointLegRows.tsx @@ -15,6 +15,7 @@ import { LockRow, PendingPortageRow, PortageLegRow } from '../paddle/PaddleWaypo import type { usePaddleRegionLoad } from '../paddle/usePaddleRegionLoad'; import { usePaddleWaypointActions } from '../paddle/usePaddleWaypointActions'; import { usePortageWay, type usePortageRouting } from '../paddle/usePortageRouting'; +import { SkiLegFailureRow, SkiLegSteps } from '../ski/SkiLegRows'; import type { PaddleLegFailure } from './paddleRouting'; import { lineLengthM } from './tourFixedLegs'; import type { TourPlannerController } from './useTourPlanner'; @@ -175,11 +176,44 @@ function LegFailure({ ); } +/** + * SKI-02: what a ski tour shows under a waypoint for its leg: the steps the + * piste router planned, or why it could not plan it and what would. + */ +function SkiLegExtras({ + planner, + point, + index, + editable, +}: { + planner: TourPlannerController; + point: WaypointPoint; + index: number; + editable: boolean; +}) { + const route = planner.skiRoute; + if (!route) return null; + const problem = route.problems.find((candidate) => candidate.index === index); + if (problem) { + return ( + planner.toggleWaypointFreeLeg(point.id)} + /> + ); + } + const leg = route.legs[index]; + return leg?.status === 'ok' ? : null; +} + /** * The rows under a waypoint for the leg that starts there: the via ferrata it * crosses, a line drawn by hand, a ride's horse route section (HOR-01), and * on a paddle tour what the router found on the way (PAD-11) or why it could - * not plan it, plus the portage and lock rows. + * not plan it, plus the portage and lock rows, and a ski tour's planned steps. */ export function WaypointLegRows({ planner, @@ -211,6 +245,7 @@ export function WaypointLegRows({ {point.fixedLeg?.kind === 'free' && } {point.fixedLeg?.kind === 'horse_route' && } {routedLegs && } + {planner.isSki && } {failure && ( ({ + ...(await importOriginal()), + routeWalkingTour: vi.fn(), +})); + +// FE-TOUR-SKI-ROUTE-001 to FE-TOUR-SKI-ROUTE-010 const start = { lat: 47, lng: 11 }; const col = { lat: 47.01, lng: 11 }; const hut = { lat: 47.01, lng: 11.01 }; -const touring = { discipline: 'touring' as const, technique: null }; +const alpine = { discipline: 'alpine' as const, technique: null }; +const snowshoe = { discipline: 'snowshoe' as const, technique: null }; + +function segment(kind: SkiRouteSegment['kind'], from: number, to: number, extra: Partial = {}) { + return { + kind, + from, + to, + name: null, + ref: null, + difficulty: null, + lift_type: null, + grooming: [], + seconds: 60, + wait_seconds: 0, + length_m: 100, + flags: [], + osm_way: null, + ...extra, + } satisfies SkiRouteSegment; +} + +/** A planned leg from one point to the next: a lift up, then a red run down. */ +function okLeg(from: { lat: number; lng: number }, to: { lat: number; lng: number }): SkiRouteLeg { + return { + status: 'ok', + geometry: [ + [from.lat, from.lng, 1500], + [(from.lat + to.lat) / 2, (from.lng + to.lng) / 2, 2000], + [to.lat, to.lng, 1600], + ], + segments: [ + segment('lift', 0, 1, { name: 'Gipfelbahn' }), + segment('run', 1, 2, { name: 'Talabfahrt', difficulty: 'intermediate' }), + ], + distance_m: 1800, + climb_m: 500, + descent_m: 400, + seconds: { ski: 300, lift: 420, wait: 60, walk: 20 }, + snapped_m: { from: 0, to: 0 }, + area_ids: ['w:1'], + why: [], + alternatives: [], + }; +} + +function failed(code: Extract['code']): SkiLegAnswer { + const hint = { raise_difficulty: 'advanced' as const }; + return { status: 'failed', code, at: null, hint, detail: { name: 'Schwarze' } } as SkiLegAnswer; +} + +const answer = (legs: SkiLegAnswer[], extra: Partial = {}): SkiPlanAnswer => ({ + legs, + build: '2026-11-02', + stale: false, + areaNames: ['Gletscherwelt'], + colourScheme: 'europe', + ...extra, +}); + +const planSpy = () => vi.spyOn(skiRouteRepo, 'plan'); +let plan: ReturnType; + +beforeEach(() => { + plan = planSpy(); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); describe('skiRouting (SKI-02)', () => { - it('FE-TOUR-SKI-ROUTE-001: lays a straight free leg between every two points, and keeps a leg the traveller drew', async () => { + it('FE-TOUR-SKI-ROUTE-001: lays a straight free leg between every two points, and keeps a leg the traveller drew', () => { const drawn = { kind: 'free' as const, geometry: [ @@ -19,8 +96,7 @@ describe('skiRouting (SKI-02)', () => { [47.01, 11.01], ] as [number, number][], }; - const legs = await skiLegs([start, { ...col, fixedLeg: drawn }, hut], touring); - expect(legs).toEqual([ + expect(skiLegs([start, { ...col, fixedLeg: drawn }, hut])).toEqual([ { kind: 'free', geometry: [ @@ -30,20 +106,17 @@ describe('skiRouting (SKI-02)', () => { }, drawn, ]); - expect(await skiLegs([start], touring)).toEqual([]); + expect(skiLegs([start])).toEqual([]); }); - it('FE-TOUR-SKI-ROUTE-002: the line is drawn whole, sampled along each leg for its heights, with no time worked out', async () => { - const route = await routeSkiTour([start, col, hut], touring); - expect(route).not.toBeNull(); + it('FE-TOUR-SKI-ROUTE-002: before a discipline is chosen every leg is drawn straight, nothing asked and no time', async () => { + const route = await routeSkiTour([start, col, hut], { discipline: null, technique: null }, 7); + expect(plan).not.toHaveBeenCalled(); expect(route?.durationSeconds).toBeNull(); - expect(route?.parts).toHaveLength(1); - expect(route?.parts[0]?.free).toBe(true); - const coordinates = route?.coordinates ?? []; - expect(coordinates[0]).toEqual([47, 11]); - expect(coordinates[coordinates.length - 1]).toEqual([47.01, 11.01]); + expect(route?.parts.every((part) => part.free)).toBe(true); + expect(route?.legs).toEqual([null, null]); // Every 25 m a point, so the heights follow the ground between the two ends of each leg. - expect(coordinates.length).toBeGreaterThan(40); + expect(route?.coordinates.length ?? 0).toBeGreaterThan(40); const straight = lineLengthM([ [47, 11], [47.01, 11], @@ -53,18 +126,120 @@ describe('skiRouting (SKI-02)', () => { }); it('FE-TOUR-SKI-ROUTE-003: fewer than two points, or a request called off, draw nothing', async () => { - expect(await routeSkiTour([start], touring)).toBeNull(); + expect(await routeSkiTour([start], alpine, 7)).toBeNull(); const controller = new AbortController(); controller.abort(); - expect(await routeSkiTour([start, col], touring, controller.signal)).toBeNull(); + plan.mockResolvedValue(answer([okLeg(start, col)])); + expect(await routeSkiTour([start, col], alpine, 7, controller.signal)).toBeNull(); }); - it('FE-TOUR-SKI-ROUTE-004: no choice of the discipline changes the legs, so none plans the tour again', () => { + it('FE-TOUR-SKI-ROUTE-004: the discipline, a cross-country technique and the options plan the legs; nothing before', () => { + expect(skiRouteKey({ discipline: null, technique: null })).toBe(''); const keys = [ - skiRouteKey({ discipline: null, technique: null }), - skiRouteKey({ discipline: 'alpine', technique: null }), + skiRouteKey(alpine), + skiRouteKey({ ...alpine, options: { pace: 'sporty' } }), skiRouteKey({ discipline: 'cross_country', technique: 'skating' }), + skiRouteKey({ discipline: 'cross_country', technique: 'classic' }), ]; - expect(new Set(keys).size).toBe(1); + expect(new Set(keys).size).toBe(4); + // A technique left over from cross-country plans an alpine tour the same way. + expect(skiRouteKey({ discipline: 'alpine', technique: 'classic' })).toBe(skiRouteKey(alpine)); + }); + + it('FE-TOUR-SKI-ROUTE-005: planned legs follow the router, styled by kind and grade, with its time and heights', async () => { + plan.mockResolvedValue(answer([okLeg(start, col), okLeg(col, hut)])); + const route = await routeSkiTour([start, col, hut], { ...alpine, options: { pace: 'relaxed' } }, 7); + expect(plan).toHaveBeenCalledWith( + 7, + [ + { from: start, to: col }, + { from: col, to: hut }, + ], + { discipline: 'alpine', technique: null, options: { pace: 'relaxed' } }, + undefined + ); + expect(route?.durationSeconds).toBe(2 * 800); + expect(route?.distanceMeters).toBe(3600); + expect(route?.problems).toEqual([]); + expect(route?.parts.map((part) => part.ski?.kind)).toEqual(['lift', 'run', 'lift', 'run']); + expect(route?.parts[1]?.ski).toEqual({ kind: 'run', difficulty: 'intermediate', scheme: 'europe' }); + expect(route?.elevations).toHaveLength(5); + expect(route?.elevations?.[0]).toEqual([47, 11, 1500]); + expect(route?.build).toBe('2026-11-02'); + expect(route?.areaNames).toEqual(['Gletscherwelt']); + }); + + it('FE-TOUR-SKI-ROUTE-006: a leg it cannot plan stays straight and unrouted, with its reason, hint and name', async () => { + plan.mockResolvedValue(answer([okLeg(start, col), failed('blocked_by_difficulty')])); + const route = await routeSkiTour([start, col, hut], alpine, 7); + expect(route?.durationSeconds).toBeNull(); + expect(route?.elevations).toBeNull(); + expect(route?.problems).toEqual([ + { + index: 1, + code: 'blocked_by_difficulty', + hint: { raise_difficulty: 'advanced' }, + name: 'Schwarze', + summer: false, + }, + ]); + const last = route?.parts[route.parts.length - 1]; + expect(last?.mark).toBe('unrouted'); + expect(last?.coordinates).toEqual([ + [47.01, 11], + [47.01, 11.01], + ]); + }); + + it('FE-TOUR-SKI-ROUTE-007: a snowshoer off the piste data takes summer paths, said so', async () => { + plan.mockResolvedValue(answer([failed('not_on_piste')])); + const summer = vi.mocked(routeWalkingTour).mockResolvedValueOnce({ + coordinates: [ + [47, 11], + [47.005, 11.001], + [47.01, 11], + ], + parts: [], + distanceMeters: 1200, + durationSeconds: 900, + }); + const route = await routeSkiTour([start, col], snowshoe, 7); + expect(summer).toHaveBeenCalled(); + expect(route?.problems[0]).toMatchObject({ code: 'not_on_piste', summer: true }); + expect(route?.parts[0]).toMatchObject({ mark: 'gap', ski: { kind: 'summer' } }); + expect(route?.durationSeconds).toBeNull(); + }); + + it('FE-TOUR-SKI-ROUTE-008: an area still downloading is asked again until it is in', async () => { + vi.useFakeTimers(); + plan.mockResolvedValueOnce(answer([failed('loading')])).mockResolvedValueOnce(answer([okLeg(start, col)])); + const pending = routeSkiTour([start, col], alpine, 7); + await vi.advanceTimersByTimeAsync(2_000); + const route = await pending; + expect(plan).toHaveBeenCalledTimes(2); + expect(route?.problems).toEqual([]); + }); + + it('FE-TOUR-SKI-ROUTE-009: a request that fails outright leaves every asked leg unrouted, a drawn one kept', async () => { + plan.mockRejectedValue(new Error('502')); + const drawn = { + kind: 'free' as const, + geometry: [ + [47.01, 11], + [47.01, 11.01], + ] as [number, number][], + }; + const route = await routeSkiTour([start, { ...col, fixedLeg: drawn }, hut], alpine, 7); + expect(plan).toHaveBeenCalledWith(7, [{ from: start, to: col }], expect.anything(), undefined); + expect(route?.problems).toEqual([ + { index: 0, code: 'places_unavailable', hint: { free_draw: true }, name: null, summer: false }, + ]); + expect(route?.parts.some((part) => part.free)).toBe(true); + }); + + it('FE-TOUR-SKI-ROUTE-010: a leg takes its time on the move and waiting together', () => { + const leg = okLeg(start, col); + if (leg.status !== 'ok') throw new Error('planned leg expected'); + expect(legSeconds(leg)).toBe(800); }); }); diff --git a/client/src/components/Tours/planner/skiRouting.ts b/client/src/components/Tours/planner/skiRouting.ts index aad7a01bc..ad5d4d337 100644 --- a/client/src/components/Tours/planner/skiRouting.ts +++ b/client/src/components/Tours/planner/skiRouting.ts @@ -1,19 +1,21 @@ -import type { TourFixedLeg } from '@trek/shared'; +import type { SkiColourScheme, SkiFailCode, SkiFailHint, SkiRouteLeg, SkiRoutePoint, TourFixedLeg } from '@trek/shared'; +import { skiRouteRepo, type SkiLegAnswer, type SkiPlanAnswer } from '../../../repo/skiRouteRepo'; import type { SkiChoice } from '../ski/skiChoice'; +import { densifyLine, lineLengthM } from './tourFixedLegs'; +import { isTourRoute } from './tourRouteOutcome'; import { freeLegBetween } from './tourRouteActions'; -import { drawnTour, type TourRouteResult } from './tourRouting'; +import { drawnTour, routeWalkingTour, type TourRoutePart, type TourRouteResult } from './tourRouting'; /** - * SKI-02: how the legs of a planned ski tour are laid. There is no piste - * router yet, so every leg is drawn straight between its two points, as a leg - * drawn by hand is, and its heights are read off the ground like any free - * leg's. No ski time is worked out from it. - * - * This file is the one place the piste router plugs in: `skiLegs` will ask it - * for the legs between the fixed ones and keep a straight leg wherever it - * finds none, and `skiRouteKey` will name what of the discipline, technique - * and options the legs were planned for, so a change of them plans the tour - * again. Nothing else of the planner has to change for it. + * SKI-02: how the legs of a planned ski tour are laid. With a discipline + * chosen, the legs without a line of their own go to the piste router in one + * request, each going on from the one before, and come back along runs, lifts + * and trails. A leg the router cannot plan stays a straight stretch marked as + * unrouted, with the reason and the one change that would plan it; a + * snowshoe or winter hike leg off the piste data follows summer paths + * instead, said so. Before a discipline is chosen every leg is drawn straight + * and nothing is asked. A time is given only when the router planned every + * leg; heights come from the piste data where it has them for every point. */ interface SkiLegPoint { @@ -22,39 +24,276 @@ interface SkiLegPoint { fixedLeg?: TourFixedLeg | null; } -/** - * What of the ski choice the legs are laid for. A straight leg depends on its - * two points only, so nothing of the choice yet: a discipline changed plans - * nothing again. - */ -export function skiRouteKey(_choice: SkiChoice): string { - return ''; +type SkiLegFailure = SkiFailCode | 'offline'; + +export interface SkiLegProblem { + /** The leg's place in the tour: the index of the point it starts at. */ + index: number; + code: SkiLegFailure; + hint: SkiFailHint | null; + name: string | null; + /** The leg follows summer paths instead (snowshoe and winter hike). */ + summer: boolean; } +export interface SkiTourRoute extends TourRouteResult { + /** Per leg: the router's answer, or null for a leg with a line of its own or none asked. */ + legs: Array; + problems: SkiLegProblem[]; + /** The line with the piste data's heights, when it has one for every point; else null. */ + elevations: [number, number, number][] | null; + build: string | null; + stale: boolean; + areaNames: string[]; + colourScheme: SkiColourScheme; +} + +/** How long a leg waits for its ski area to download, asked again every two seconds. */ +const LOADING_POLL_MS = 2000; +const LOADING_TRIES = 15; +/** Failures a snowshoer or winter hiker takes summer paths around. */ +const SUMMER_FALLBACK: ReadonlySet = new Set(['not_on_piste', 'no_area', 'places_unavailable', 'disabled']); +const ON_FOOT: ReadonlySet = new Set(['snowshoe', 'winter_hike']); +/** Segments walked or ridden down: drawn dotted. */ +const DOTTED: ReadonlySet = new Set(['link', 'crossing', 'walk', 'lift_down']); + /** - * One leg for each point but the last: the line the point carries (a leg the - * traveller drew), else a straight line to the next point. + * What of the ski choice the legs are laid for: the discipline, the technique + * where it counts, and the options. Nothing while no discipline is chosen, so + * picking the first one is the change that plans the tour. */ -export async function skiLegs( - points: readonly SkiLegPoint[], - _choice: SkiChoice, - _signal?: AbortSignal -): Promise { +export function skiRouteKey(choice: SkiChoice): string { + if (choice.discipline === null) return ''; + const technique = choice.discipline === 'cross_country' ? choice.technique : null; + return JSON.stringify([choice.discipline, technique, choice.options ?? {}]); +} + +/** One leg for each point but the last: the line the point carries, else a straight line to the next point. */ +export function skiLegs(points: readonly SkiLegPoint[]): TourFixedLeg[] { return points.slice(0, -1).map((point, index) => point.fixedLeg ?? freeLegBetween(point, points[index + 1])); } +const sleep = (ms: number, signal?: AbortSignal) => + new Promise((resolve) => { + const timer = window.setTimeout(resolve, ms); + signal?.addEventListener('abort', () => { + window.clearTimeout(timer); + resolve(); + }); + }); + +/** The answers for the legs asked, a leg whose area still downloads asked again until it is in or the wait is over. */ +async function askRouter( + tripId: number | string, + legs: Array<{ from: SkiRoutePoint; to: SkiRoutePoint }>, + choice: SkiChoice, + signal?: AbortSignal +): Promise { + if (choice.discipline === null) throw new Error('no discipline'); + const ask = { + discipline: choice.discipline, + technique: choice.discipline === 'cross_country' ? choice.technique : null, + options: choice.options ?? {}, + }; + let answer = await skiRouteRepo.plan(tripId, legs, ask, signal); + for (let tries = 1; tries < LOADING_TRIES && answer.legs.some(isLoading) && !signal?.aborted; tries += 1) { + await sleep(LOADING_POLL_MS, signal); + answer = await skiRouteRepo.plan(tripId, legs, ask, signal); + } + // Waited long enough: the area is not to be had now. + const legsNow = answer.legs.map((leg): SkiLegAnswer => (isLoading(leg) ? notReachable : leg)); + return { ...answer, legs: legsNow }; +} + +const isLoading = (leg: SkiLegAnswer) => leg.status === 'failed' && leg.code === 'loading'; +const notReachable: SkiLegAnswer = { + status: 'failed', + code: 'places_unavailable', + at: null, + hint: { free_draw: true }, + detail: null, +}; + +/** The parts of a planned leg, one per stretch, styled by what it is. */ +function routedParts(leg: Extract, scheme: SkiColourScheme): TourRoutePart[] { + return leg.segments.map((segment) => ({ + coordinates: leg.geometry.slice(segment.from, segment.to + 1).map(([lat, lng]): [number, number] => [lat, lng]), + free: false, + ...(DOTTED.has(segment.kind) ? { mark: 'gap' as const } : {}), + ski: { kind: segment.kind, difficulty: segment.difficulty, scheme }, + })); +} + +const straight = (from: SkiLegPoint, to: SkiLegPoint): [number, number][] => [ + [from.lat, from.lng], + [to.lat, to.lng], +]; + +/** A leg off the piste data on summer paths, for a snowshoer or winter hiker; null when there is none either. */ +async function summerParts(from: SkiLegPoint, to: SkiLegPoint, signal?: AbortSignal): Promise { + try { + const routed = await routeWalkingTour([from, to], signal); + if (!isTourRoute(routed)) return null; + return { coordinates: routed.coordinates, free: false, mark: 'gap', ski: { kind: 'summer', difficulty: null } }; + } catch { + // No summer path either: the leg stays straight and unrouted, which says as much. + return null; + } +} + +/** Neighbouring parts drawn alike as one line. */ +function mergeParts(parts: TourRoutePart[]): TourRoutePart[] { + const merged: TourRoutePart[] = []; + for (const part of parts) { + const previous = merged[merged.length - 1]; + const alike = + previous && + previous.free === part.free && + previous.mark === part.mark && + JSON.stringify(previous.ski ?? null) === JSON.stringify(part.ski ?? null); + if (alike) previous.coordinates = [...previous.coordinates, ...part.coordinates.slice(1)]; + else merged.push({ ...part, coordinates: [...part.coordinates] }); + } + return merged; +} + +/** The heights of the whole line when the piste data has one for every point of every leg; else null. */ +function graphHeights(answers: Array): [number, number, number][] | null { + const line: [number, number, number][] = []; + for (const leg of answers) { + if (leg?.status !== 'ok') return null; + const points = line.length === 0 ? leg.geometry : leg.geometry.slice(1); + for (const [lat, lng, ele] of points) { + if (ele === null) return null; + line.push([lat, lng, ele]); + } + } + return line.length > 1 ? line : null; +} + +interface LegOutcome { + parts: TourRoutePart[]; + length: number; + answer: SkiRouteLeg | null; + problem: SkiLegProblem | null; +} + +/** One leg of the tour from the router's answer: planned, on summer paths, or straight and unrouted. */ +async function legOutcome( + index: number, + points: readonly SkiLegPoint[], + answer: SkiLegAnswer, + context: { onFoot: boolean; scheme: SkiColourScheme; signal?: AbortSignal } +): Promise { + const from = points[index]; + const to = points[index + 1]; + if (answer.status === 'ok') { + return { parts: routedParts(answer, context.scheme), length: answer.distance_m, answer, problem: null }; + } + const name = answer.detail?.name ?? null; + const summerFirst = context.onFoot && SUMMER_FALLBACK.has(answer.code); + const summer = summerFirst ? await summerParts(from, to, context.signal) : null; + const problem = { index, code: answer.code, hint: answer.hint, name, summer: summer !== null }; + if (summer) return { parts: [summer], length: lineLengthM(summer.coordinates), answer: null, problem }; + const line = straight(from, to); + const unrouted: TourRoutePart = { coordinates: line, free: false, mark: 'unrouted' }; + return { parts: [unrouted], length: lineLengthM(line), answer: null, problem }; +} + +/** Asks the router for the legs that need it; a server that cannot be asked fails them all, drawn straight. */ +async function plannedAnswers( + points: readonly SkiLegPoint[], + choice: SkiChoice, + tripId: number | string, + signal?: AbortSignal +): Promise<{ asked: number[]; plan: SkiPlanAnswer }> { + const asked = points.slice(0, -1).flatMap((point, index) => (point.fixedLeg ? [] : [index])); + const at = (point: SkiLegPoint): SkiRoutePoint => ({ lat: point.lat, lng: point.lng }); + const legs = asked.map((index) => ({ from: at(points[index]), to: at(points[index + 1]) })); + const none = (answers: SkiLegAnswer[]): SkiPlanAnswer => ({ + legs: answers, + build: null, + stale: false, + areaNames: [], + colourScheme: 'europe', + }); + if (legs.length === 0) return { asked, plan: none([]) }; + try { + return { asked, plan: await askRouter(tripId, legs, choice, signal) }; + } catch { + // The route request failed outright (the server, the network): every asked leg is said to be unrouted. + return { asked, plan: none(legs.map(() => notReachable)) }; + } +} + /** - * A ski tour's line from its legs, drawn and never routed: each leg is sampled - * along its length so the heights read for it follow the ground. Null for - * fewer than two points or a request called off. + * A ski tour's line: each leg planned by the piste router, kept as drawn, or + * straight where neither is possible. Null for fewer than two points or a + * request called off. */ export async function routeSkiTour( points: readonly SkiLegPoint[], choice: SkiChoice, + tripId: number | string, signal?: AbortSignal -): Promise { +): Promise { if (points.length < 2) return null; - const legs = await skiLegs(points, choice, signal); + if (choice.discipline === null) return drawnSkiTour(points); + const { asked, plan } = await plannedAnswers(points, choice, tripId, signal); if (signal?.aborted) return null; - return drawnTour(points.map((point, index) => ({ ...point, fixedLeg: legs[index] ?? null }))); + const byLeg = new Map(asked.map((index, at) => [index, plan.legs[at]])); + const onFoot = ON_FOOT.has(choice.discipline); + const outcomes = await Promise.all( + points.slice(0, -1).map(async (point, index): Promise => { + const answer = byLeg.get(index); + if (point.fixedLeg || !answer) { + // A leg drawn by hand, sampled along its length so its heights follow the ground. + const drawn = (point.fixedLeg ?? freeLegBetween(point, points[index + 1])).geometry; + const line = densifyLine(drawn.map(([lat, lng]): [number, number] => [lat, lng])); + return { parts: [{ coordinates: line, free: true }], length: lineLengthM(line), answer: null, problem: null }; + } + return legOutcome(index, points, answer, { onFoot, scheme: plan.colourScheme, signal }); + }) + ); + if (signal?.aborted) return null; + const parts = mergeParts(outcomes.flatMap((outcome) => outcome.parts)); + const coordinates = parts.flatMap((part, index) => (index === 0 ? part.coordinates : part.coordinates.slice(1))); + const legs = outcomes.map((outcome) => outcome.answer); + const everyLegPlanned = legs.every((leg) => leg?.status === 'ok'); + const seconds = legs.reduce((sum, leg) => sum + (leg?.status === 'ok' ? legSeconds(leg) : 0), 0); + return { + coordinates, + parts, + distanceMeters: outcomes.reduce((sum, outcome) => sum + outcome.length, 0), + durationSeconds: everyLegPlanned ? Math.round(seconds) : null, + legs, + problems: outcomes.flatMap((outcome) => (outcome.problem ? [outcome.problem] : [])), + elevations: everyLegPlanned ? graphHeights(legs) : null, + build: plan.build, + stale: plan.stale, + areaNames: plan.areaNames, + colourScheme: plan.colourScheme, + }; +} + +/** A leg's time on the move and waiting, in seconds. */ +export function legSeconds(leg: Extract): number { + return leg.seconds.ski + leg.seconds.lift + leg.seconds.wait + leg.seconds.walk; +} + +/** Every leg drawn straight (or as the traveller drew it), sampled for its heights, nothing asked and no time. */ +function drawnSkiTour(points: readonly SkiLegPoint[]): SkiTourRoute | null { + const legs = skiLegs(points); + const drawn = drawnTour(points.map((point, index) => ({ ...point, fixedLeg: legs[index] ?? null }))); + if (!drawn) return null; + return { + ...drawn, + legs: legs.map(() => null), + problems: [], + elevations: null, + build: null, + stale: false, + areaNames: [], + colourScheme: 'europe', + }; } diff --git a/client/src/components/Tours/planner/tourPlannerDraft.ts b/client/src/components/Tours/planner/tourPlannerDraft.ts index 6da9706d0..53de72992 100644 --- a/client/src/components/Tours/planner/tourPlannerDraft.ts +++ b/client/src/components/Tours/planner/tourPlannerDraft.ts @@ -6,6 +6,7 @@ import { plannableTourTypeSchema, runProfileSchema, skiDisciplineSchema, + skiRouteOptionsSchema, skiTechniqueSchema, type BikeProfile, type HikingPreferences, @@ -108,7 +109,8 @@ function optionalSki(value: SkiChoice | undefined): boolean { if (!value || typeof value !== 'object') return false; return ( (value.discipline === null || skiDisciplineSchema.safeParse(value.discipline).success) && - (value.technique === null || skiTechniqueSchema.safeParse(value.technique).success) + (value.technique === null || skiTechniqueSchema.safeParse(value.technique).success) && + (value.options == null || skiRouteOptionsSchema.safeParse(value.options).success) ); } diff --git a/client/src/components/Tours/planner/tourPlannerFacts.ts b/client/src/components/Tours/planner/tourPlannerFacts.ts index 198d6c0ca..cbb2dda90 100644 --- a/client/src/components/Tours/planner/tourPlannerFacts.ts +++ b/client/src/components/Tours/planner/tourPlannerFacts.ts @@ -4,6 +4,7 @@ import { maxTourMinutesFor, plannableTourTypeSchema, runMinutesAtPace, + skiOptionsForDiscipline, tourWebsiteSchema, type BikeProfile, type HikingPreferences, @@ -50,6 +51,8 @@ export interface TourPlannerFactsInput { bikeProfile: BikeProfile; /** SKI-02: a ski tour's discipline and cross-country technique, nothing chosen until the traveller picks. */ ski?: SkiChoice; + /** The piste data build a ski tour's legs were planned on; null where the piste router planned none. */ + skiRouteBuild?: string | 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; @@ -98,11 +101,12 @@ function runMinutes({ runPaceSecondsPerKm: pace, routeAnalysis }: TourPlannerFac * weighs the climbs, a hike by the signposts' rule over distance, climb and * descent once the route has its heights, and by the router's own time before that. * A ride (HOR-01) has none: its pace is the horse's and the rider's, so nothing is made up. - * Nor has a ski tour (SKI-02): its legs are drawn, not planned along pistes and lifts. + * A ski tour (SKI-02) has the piste router's, which it gives only when it planned every leg. */ function movingMinutes(input: TourPlannerFactsInput, routerMinutes: number | null): number | null { const { paddleEstimate, routeAnalysis } = input; - if (input.tourType === 'horse' || input.tourType === 'ski') return null; + if (input.tourType === 'horse') return null; + if (input.tourType === 'ski') return routerMinutes; if (paddleEstimate) return paddleEstimate.waterMinutes === null ? null : paddleEstimate.estimateMinutes; if (input.tourType === 'run') return runMinutes(input); // A bike tour is timed by the router for its profile, a city walk by the router on foot. @@ -164,6 +168,9 @@ function typeFieldsOf(input: TourPlannerFactsInput) { hiking_preferences: isHike ? input.hikingPreferences : null, ski_discipline: ski?.ski_discipline ?? null, ski_technique: ski?.ski_technique ?? null, + // 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, }; } diff --git a/client/src/components/Tours/planner/tourPlannerParts.test.ts b/client/src/components/Tours/planner/tourPlannerParts.test.ts index 79b0f2788..819bf6bbb 100644 --- a/client/src/components/Tours/planner/tourPlannerParts.test.ts +++ b/client/src/components/Tours/planner/tourPlannerParts.test.ts @@ -1,4 +1,5 @@ import { act, renderHook } from '@testing-library/react'; +import { DEFAULT_SKI_ROUTE_OPTIONS } from '@trek/shared'; import { describe, expect, it } from 'vitest'; import { openedTourFields, tourPlannerFacts, type TourPlannerFactsInput } from './tourPlannerFacts'; import { usePlannerHistory } from './usePlannerHistory'; @@ -58,7 +59,7 @@ describe('tourPlannerFacts', () => { expect(facts.typeFields).toMatchObject({ tour_type: 'hike', max_hiking_difficulty: 3, boat_type: null }); }); - it('SKI-02: saves a ski tour with its discipline, untimed, and asks for a discipline and a cross-country technique', () => { + it('SKI-02: saves a ski tour with its discipline and router time, and asks for a discipline and a technique', () => { const analysis = { distanceKm: 8, gain: 600, loss: 600 } as TourPlannerFactsInput['routeAnalysis']; const snowshoe = tourPlannerFacts( hike({ @@ -76,11 +77,22 @@ describe('tourPlannerFacts', () => { hiking_preferences: null, }); expect(snowshoe.skiMissing).toBeNull(); - // No ski time is worked out: no signposts' rule, no router time, nothing saved. + // A ski time is the piste router's, never the signposts' rule: without one, nothing is saved. expect(snowshoe.walkingDurationMinutes).toBeNull(); expect(snowshoe.plannedTotalMinutes).toBeNull(); expect(snowshoe.savedDurationSeconds).toBeNull(); - expect(tourPlannerFacts(hike({ tourType: 'ski', durationSeconds: 3_600 })).savedDurationSeconds).toBeNull(); + expect(snowshoe.typeFields).toMatchObject({ ski_options: DEFAULT_SKI_ROUTE_OPTIONS, ski_route_build: null }); + const routed = tourPlannerFacts( + hike({ + tourType: 'ski', + ski: { discipline: 'alpine', technique: null }, + durationSeconds: 3_600, + skiRouteBuild: '2026-11-02', + }) + ); + expect(routed.walkingDurationMinutes).toBe(60); + expect(routed.savedDurationSeconds).toBe(3_600); + expect(routed.typeFields).toMatchObject({ ski_route_build: '2026-11-02' }); expect(tourPlannerFacts(hike({ tourType: 'ski' })).skiMissing).toBe('discipline'); const crossCountry = hike({ tourType: 'ski', ski: { discipline: 'cross_country', technique: null } }); @@ -90,6 +102,7 @@ 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 }); expect(other.skiMissing).toBeNull(); } }); diff --git a/client/src/components/Tours/planner/tourRouteStyle.test.ts b/client/src/components/Tours/planner/tourRouteStyle.test.ts index 6ddd797ee..6eee9a7da 100644 --- a/client/src/components/Tours/planner/tourRouteStyle.test.ts +++ b/client/src/components/Tours/planner/tourRouteStyle.test.ts @@ -1,4 +1,4 @@ -// FE-TOUR-ROUTE-STYLE-001 to FE-TOUR-ROUTE-STYLE-003: how the planner map draws the parts of a tour (PAD-03, PAD-11). +// FE-TOUR-ROUTE-STYLE-001 to FE-TOUR-ROUTE-STYLE-004: how the planner map draws the parts of a tour (PAD-03, PAD-11, SKI-02). import { describe, expect, it } from 'vitest'; import { tourRouteStyle } from './tourRouteStyle'; import type { TourRoutePart } from './tourRouting'; @@ -44,4 +44,20 @@ describe('tourRouteStyle', () => { const style = tourRouteStyle([part(), part({ free: true })], false, PAINT); expect(style).toEqual({ walking: [false, true], colors: null }); }); + + it('FE-TOUR-ROUTE-STYLE-004: a ski tour colours each stretch by its grade or kind, and dots the links between them', () => { + const style = tourRouteStyle( + [ + part({ ski: { kind: 'run', difficulty: 'intermediate', scheme: 'europe' } }), + part({ mark: 'gap', ski: { kind: 'link', difficulty: null } }), + part({ ski: { kind: 'lift', difficulty: null } }), + part({ mark: 'unrouted' }), + ], + false, + PAINT + ); + expect(style.walking).toEqual([false, true, false, true]); + expect(style.colors?.map((color) => color?.line)).toEqual(['#d51e1e', '#6b7280', '#1f2937', undefined]); + expect(style.colors?.[0]?.casing).toBe('white'); + }); }); diff --git a/client/src/components/Tours/planner/tourRouteStyle.ts b/client/src/components/Tours/planner/tourRouteStyle.ts index cc9a31d03..30506421c 100644 --- a/client/src/components/Tours/planner/tourRouteStyle.ts +++ b/client/src/components/Tours/planner/tourRouteStyle.ts @@ -1,3 +1,4 @@ +import { skiPaintOf } from '../ski/skiRouteStyle'; import type { TourRoutePart } from './tourRouting'; /** @@ -41,7 +42,10 @@ export function tourRouteStyle( ): TourRouteStyle { if (!parts?.length) return { walking: null, colors: null }; const walking = parts.map((part) => part.free || !!part.land || part.mark === 'gap' || part.mark === 'unrouted'); - const colors = parts.map((part) => (isPaddle ? paddleColor(part, paint) : undefined)); + // SKI-02: a ski tour's stretches in the colours of their grades and kinds, over the same rim. + const colors = parts.map((part) => + part.ski ? { line: skiPaintOf(part.ski), casing: paint.casing } : isPaddle ? paddleColor(part, paint) : undefined + ); return { walking: walking.some(Boolean) ? walking : null, colors: colors.some(Boolean) ? colors : null, diff --git a/client/src/components/Tours/planner/tourRouting.ts b/client/src/components/Tours/planner/tourRouting.ts index 3da0e58bd..81a349bee 100644 --- a/client/src/components/Tours/planner/tourRouting.ts +++ b/client/src/components/Tours/planner/tourRouting.ts @@ -1,4 +1,12 @@ -import type { BikeProfile, HikingPreferences, RunProfile, TourFixedLeg } from '@trek/shared' +import type { + BikeProfile, + HikingPreferences, + RunProfile, + SkiColourScheme, + SkiRunDifficulty, + SkiSegmentKind, + TourFixedLeg, +} from '@trek/shared' import type { Waypoint } from '../../../types' import { valhallaBase, valhallaRun, valhallaTurn, type CostingOptions } from '../../Map/valhallaRoute' import { hikingCosting, TOURS_MAX_HIKING_DIFFICULTY } from './hikingCosting' @@ -17,6 +25,8 @@ export interface TourRoutePart { * it could not plan (drawn straight, and nothing checked it). */ mark?: 'rapids' | 'gap' | 'unrouted' + /** SKI-02: a stretch of a ski tour the piste router planned, coloured by what it is; `summer` a summer path. */ + ski?: { kind: SkiSegmentKind | 'summer'; difficulty: SkiRunDifficulty | null; scheme?: SkiColourScheme } } export interface TourRouteResult { diff --git a/client/src/components/Tours/planner/useTourPlanner.ski.test.tsx b/client/src/components/Tours/planner/useTourPlanner.ski.test.tsx index 244a11af4..af23e94a8 100644 --- a/client/src/components/Tours/planner/useTourPlanner.ski.test.tsx +++ b/client/src/components/Tours/planner/useTourPlanner.ski.test.tsx @@ -1,7 +1,9 @@ import { act, renderHook } from '@testing-library/react'; +import { DEFAULT_SKI_ROUTE_OPTIONS } from '@trek/shared'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const { routeWalkingTour, enrichTourElevations, createTour, detailTour, updateTour } = vi.hoisted(() => ({ +const { routeWalkingTour, enrichTourElevations, createTour, detailTour, updateTour, planSki } = vi.hoisted(() => ({ + planSki: vi.fn(), routeWalkingTour: vi.fn(), enrichTourElevations: vi.fn(), createTour: vi.fn(), @@ -9,18 +11,19 @@ const { routeWalkingTour, enrichTourElevations, createTour, detailTour, updateTo updateTour: vi.fn(), })); -// The ski legs are laid by the planner's own drawing; only the routers and the heights are stood in for. +// The ski legs are laid by the planner; only the routers, the piste router and the heights are stood in for. vi.mock('./tourRouting', async (importOriginal) => ({ ...(await importOriginal()), routeWalkingTour, enrichTourElevations, })); vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } })); +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-006 +// FE-TOUR-SKI-PLAN-001 to FE-TOUR-SKI-PLAN-007 const savedSki = { place_id: 95, @@ -50,6 +53,50 @@ async function heightsAlong(line: [number, number][]): Promise<[number, number, return line.map(([lat, lng], index): [number, number, number] => [lat, lng, 1600 - index]); } +type Point = { lat: number; lng: number }; + +/** The piste router's answer for every leg asked: a lift up and a run down, with the graph's heights. */ +async function pisteLegs(_tripId: number, legs: Array<{ from: Point; to: Point }>) { + return { + legs: legs.map(({ from, to }) => ({ + status: 'ok' as const, + geometry: [ + [from.lat, from.lng, 1500], + [to.lat, to.lng, 1400], + ], + segments: [ + { + kind: 'run' as const, + from: 0, + to: 1, + name: 'Talabfahrt', + ref: null, + difficulty: 'easy' as const, + lift_type: null, + grooming: [], + seconds: 600, + wait_seconds: 0, + length_m: 1900, + flags: [], + osm_way: null, + }, + ], + distance_m: 1900, + climb_m: 0, + descent_m: 100, + seconds: { ski: 600, lift: 0, wait: 0, walk: 0 }, + snapped_m: { from: 0, to: 0 }, + area_ids: ['w:1'], + why: [], + alternatives: [], + })), + build: '2026-11-02', + stale: false, + areaNames: ['Gletscherwelt'], + colourScheme: 'europe' as const, + }; +} + async function settle() { await act(async () => { await vi.advanceTimersByTimeAsync(450); @@ -73,6 +120,7 @@ describe('useTourPlanner, a ski and winter tour (SKI-02)', () => { vi.useFakeTimers(); localStorage.clear(); routeWalkingTour.mockReset(); + planSki.mockReset().mockImplementation(pisteLegs); enrichTourElevations.mockReset().mockImplementation(heightsAlong); createTour.mockReset(); detailTour.mockReset(); @@ -81,10 +129,11 @@ describe('useTourPlanner, a ski and winter tour (SKI-02)', () => { afterEach(() => vi.useRealTimers()); - it('FE-TOUR-SKI-PLAN-001: draws every leg straight, asks no router, reads the heights, and works out no time', async () => { + it('FE-TOUR-SKI-PLAN-001: before a discipline every leg is drawn straight, nothing asked and no time', async () => { const { result } = await plannedSki(81); expect(result.current.isSki).toBe(true); expect(routeWalkingTour).not.toHaveBeenCalled(); + expect(planSki).not.toHaveBeenCalled(); expect(enrichTourElevations).toHaveBeenCalledTimes(1); expect(result.current.status).toBe('ready'); expect(result.current.routeParts?.every((part) => part.free)).toBe(true); @@ -108,18 +157,47 @@ describe('useTourPlanner, a ski and winter tour (SKI-02)', () => { expect(result.current.canSave).toBe(true); }); - it('FE-TOUR-SKI-PLAN-003: a discipline changed lays no leg again and reads no height again', async () => { + it('FE-TOUR-SKI-PLAN-003: a discipline asks the piste router, whose legs, heights and time the tour takes', async () => { const { result } = await plannedSki(83); act(() => result.current.ski.change({ discipline: 'alpine', technique: null })); await settle(); - act(() => result.current.ski.change({ discipline: 'snowshoe', technique: null })); - await settle(); + expect(planSki).toHaveBeenCalledWith( + 83, + [{ from: { lat: 47, lng: 11 }, to: { lat: 47.01, lng: 11.02 } }], + { discipline: 'alpine', technique: null, options: {} }, + expect.anything() + ); + // The piste data has a height for every point: none is read from the terrain again. expect(enrichTourElevations).toHaveBeenCalledTimes(1); expect(result.current.status).toBe('ready'); + expect(result.current.durationSeconds).toBe(600); + expect(result.current.walkingDurationMinutes).toBe(10); + expect(result.current.skiRoute?.areaNames).toEqual(['Gletscherwelt']); + expect(result.current.routeParts?.[0]?.ski).toEqual({ kind: 'run', difficulty: 'easy', scheme: 'europe' }); + + act(() => result.current.ski.change({ discipline: 'snowshoe', technique: null })); + await settle(); + expect(planSki).toHaveBeenCalledTimes(2); expect(result.current.routeCurrent).toBe(true); }); - it('FE-TOUR-SKI-PLAN-004: saves the type, the discipline and no time, nothing of a hike', async () => { + it('FE-TOUR-SKI-PLAN-007: an option changed plans the legs again, and a switch to snowboard leaves out surface lifts', async () => { + const { result } = await plannedSki(87); + act(() => result.current.ski.change({ discipline: 'snowboard', technique: null })); + await settle(); + expect(result.current.ski.choice.options).toEqual({ avoid_lifts: ['drag'] }); + act(() => result.current.ski.setOption({ pace: 'sporty' })); + await settle(); + expect(planSki).toHaveBeenLastCalledWith( + 87, + expect.anything(), + { discipline: 'snowboard', technique: null, options: { avoid_lifts: ['drag'], pace: 'sporty' } }, + expect.anything() + ); + expect(planSki).toHaveBeenCalledTimes(2); + }); + + it('FE-TOUR-SKI-PLAN-004: saves the type, the discipline, the router time, options and build, nothing of a hike', async () => { const saved = { ...savedSki, ski_discipline: 'winter_hike', ski_technique: null }; createTour.mockResolvedValue({ tour: saved, waypoints: savedPoints }); const { result } = await plannedSki(84); @@ -135,7 +213,9 @@ describe('useTourPlanner, a ski and winter tour (SKI-02)', () => { ski_technique: null, max_hiking_difficulty: null, hiking_preferences: null, - duration_seconds: null, + duration_seconds: 600, + ski_options: DEFAULT_SKI_ROUTE_OPTIONS, + ski_route_build: '2026-11-02', }) ); expect(result.current.status).toBe('saved'); diff --git a/client/src/components/Tours/planner/useTourPlanner.ts b/client/src/components/Tours/planner/useTourPlanner.ts index 5c1e3dc83..6bdeecafb 100644 --- a/client/src/components/Tours/planner/useTourPlanner.ts +++ b/client/src/components/Tours/planner/useTourPlanner.ts @@ -253,7 +253,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe tripId, waypoints, tourType, maxHikingDifficulty, hikingPreferences, bikeProfile, runProfile, paddleBoat, paddleLakeStyle, skiChoice: ski.choice, retryToken, error, setStatus, setError, onCleared: forgetFocus, patchWaypoints, }) - const { route, routeParts, paddleRoute, enrichedGeometry, routeCurrent, distanceMeters, durationSeconds, + const { route, routeParts, paddleRoute, skiRoute, enrichedGeometry, routeCurrent, distanceMeters, durationSeconds, unreachable, interruptRouting, clearRoute } = routing const commitWaypoints = useCallback((nextValue: TourPlannerWaypoint[] | ((current: TourPlannerWaypoint[]) => TourPlannerWaypoint[])) => { @@ -535,12 +535,12 @@ 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, - plannedDurationMinutes, breakAdditionalMinutes, + skiRouteBuild: skiRoute?.build ?? null, plannedDurationMinutes, breakAdditionalMinutes, paddle: { boatType, waterContext, speedKmh, portageSpeedKmh, lakeStyle }, paddleSpeedInvalid, paddleEstimate, routeAnalysis, durationSeconds, stays, stageCount, }), [bikeProfile, boatType, breakAdditionalMinutes, durationSeconds, hikingPreferences, lakeStyle, maxHikingDifficulty, paddleEstimate, paddleSpeedInvalid, plannedDurationMinutes, portageSpeedKmh, routeAnalysis, runPaceSecondsPerKm, runProfile, speedKmh, stageCount, stays, tourType, waterContext, - website, ski.choice]) + website, ski.choice, skiRoute]) // CW-02: a city walk's times as the overview says them, from the same derivation as its planned total. const { websiteInvalid, plannedDurationInvalid, breakAdditionalInvalid, paddleIncomplete, walkingDurationMinutes, plannedTotalMinutes, plannedTotalInvalid, savedDurationSeconds, savedBreakAdditionalMinutes, typeFields, stationStays, @@ -920,7 +920,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe waypoints, selectedWaypointId, setSelectedWaypointId, addWaypoint, appendFixedLeg, setWaypointPosition, removeWaypoint, moveWaypoint, reorderWaypoint, editWaypoints: commitWaypoints, reverse, returnToStart, isLoop: isClosedLoop(waypoints), toggleWaypointFreeLeg, editPaddlePoints, amendWaypoints, paddleEstimate, - ...shaping, legVariants, roundHikes, paddleRoute, routeCurrent, stages, + ...shaping, legVariants, roundHikes, paddleRoute, skiRoute, routeCurrent, stages, undo, redo, canUndo: history.canUndo, canRedo: history.canRedo, status, error, retry, isSaving, route, routeParts, enrichedGeometry, routeAnalysis, distanceMeters, durationSeconds, unreachable, diff --git a/client/src/components/Tours/planner/useTourRoute.heights.test.ts b/client/src/components/Tours/planner/useTourRoute.heights.test.ts new file mode 100644 index 000000000..6aabc7727 --- /dev/null +++ b/client/src/components/Tours/planner/useTourRoute.heights.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { interpolatedHeights } from './useTourRoute'; + +// FE-TOUR-ROUTE-HEIGHTS-001 to FE-TOUR-ROUTE-HEIGHTS-002 + +describe('interpolatedHeights (SKI-02)', () => { + it('FE-TOUR-ROUTE-HEIGHTS-001: fills the points the elevation source missed straight between the known ones', () => { + const line: Array<[number, number] | [number, number, number]> = [ + [47, 11], + [47.001, 11, 1000], + [47.002, 11], + [47.003, 11], + [47.004, 11, 1300], + [47.005, 11], + ]; + expect(interpolatedHeights(line)?.map((point) => point[2])).toEqual([1000, 1000, 1100, 1200, 1300, 1300]); + }); + + it('FE-TOUR-ROUTE-HEIGHTS-002: a line without one known height has none to estimate from', () => { + expect( + interpolatedHeights([ + [47, 11], + [47.001, 11], + ]) + ).toBeNull(); + }); +}); diff --git a/client/src/components/Tours/planner/useTourRoute.ts b/client/src/components/Tours/planner/useTourRoute.ts index 46dffa835..ca832a7cb 100644 --- a/client/src/components/Tours/planner/useTourRoute.ts +++ b/client/src/components/Tours/planner/useTourRoute.ts @@ -20,7 +20,7 @@ import { import { randomId } from '../../../utils/randomId'; import type { SkiChoice } from '../ski/skiChoice'; import { planPaddleStep, type PaddleTourRoute } from './paddleRouting'; -import { routeSkiTour, skiRouteKey } from './skiRouting'; +import { routeSkiTour, skiRouteKey, type SkiTourRoute } from './skiRouting'; import { normalizePlannerWaypoints, routeInputsKey, @@ -54,7 +54,7 @@ export interface TourRouteInputs { runProfile: RunProfile; paddleBoat: PaddleBoatType | null; paddleLakeStyle: PaddleLakeStyle; - /** SKI-02: a ski tour's discipline and technique, what its legs are laid for. */ + /** SKI-02: a ski tour's discipline, technique and piste router options, what its legs are laid for. */ skiChoice: SkiChoice; /** Raised to plan the same points again. */ retryToken: number; @@ -72,6 +72,8 @@ interface PlannedRoute { route: [number, number][] | null; routeParts: TourRoutePart[] | null; paddleRoute: PaddleTourRoute | null; + /** SKI-02: a ski tour's legs as the piste router answered them. */ + skiRoute: SkiTourRoute | null; enrichedGeometry: TourElevationPoint[] | null; distanceMeters: number | null; durationSeconds: number | null; @@ -84,6 +86,7 @@ const NO_ROUTE: PlannedRoute = { route: null, routeParts: null, paddleRoute: null, + skiRoute: null, enrichedGeometry: null, distanceMeters: null, durationSeconds: null, @@ -91,11 +94,11 @@ const NO_ROUTE: PlannedRoute = { forRun: null, }; -/** A route planned whole: a paddle route without a failed leg, a route on land with its heights. */ +/** A route planned whole: a paddle route without a failed leg, a ski tour too, a route on land with its heights. */ function routeComplete(planned: PlannedRoute): boolean { - return planned.paddleRoute - ? planned.paddleRoute.failures.length === 0 - : hasCompleteElevation(planned.enrichedGeometry); + if (planned.paddleRoute) return planned.paddleRoute.failures.length === 0; + if (planned.skiRoute && planned.skiRoute.problems.length > 0) return false; + return hasCompleteElevation(planned.enrichedGeometry); } /** One planning run: what it plans from, whether it went stale, and where it writes. */ @@ -122,6 +125,7 @@ async function routePaddle({ inputs, plannedKey, runKey, signal, stale, setPlann route: planned.coordinates, routeParts: planned.parts, paddleRoute: planned, + skiRoute: null, enrichedGeometry: planned.coordinates.map(([lat, lng]): [number, number, number] => [lat, lng, 0]), distanceMeters: planned.distanceMeters, durationSeconds: null, @@ -135,21 +139,19 @@ async function routePaddle({ inputs, plannedKey, runKey, signal, stale, setPlann /** * A route on land for the type of the draft: a bike tour and a run for their - * profile, a city walk, a hike within its limit, a ride (HOR-01) put together - * from its sections, never routed, and a ski tour (SKI-02) laid by its own - * routing, which draws every leg straight until the piste router comes. + * profile, a city walk, a hike within its limit, and a ride (HOR-01) put + * together from its sections, never routed. */ async function routeLand(inputs: RouteRun['inputs'], signal: AbortSignal) { const { waypoints, tourType, bikeProfile, runProfile, maxHikingDifficulty, hikingPreferences } = inputs; if (tourType === 'horse') return rideTour(waypoints); - if (tourType === 'ski') return await routeSkiTour(waypoints, inputs.skiChoice, signal); if (tourType === 'bike') return await routeBikeTour(waypoints, bikeProfile, signal); if (tourType === 'run') return await routeRun(waypoints, runProfile, signal); if (tourType === 'walk') return await routeCityWalk(waypoints, signal); return await routeWalkingTour(waypoints, signal, maxHikingDifficulty, hikingPreferences); } -// A hike, a run, a city walk, a bike tour, a ride or a ski tour on land, then the heights along it. +// A hike, a run, a city walk, a bike tour or a ride on land, then the heights along it. async function routeOnLand({ inputs, plannedKey, runKey, signal, stale, setPlanned, setUnreachable }: RouteRun) { const { setStatus, setError } = inputs; setStatus('routing'); @@ -166,6 +168,7 @@ async function routeOnLand({ inputs, plannedKey, runKey, signal, stale, setPlann route: routed.coordinates, routeParts: routed.parts, paddleRoute: null, + skiRoute: null, enrichedGeometry: null, distanceMeters: routed.distanceMeters, durationSeconds: routed.durationSeconds, @@ -186,14 +189,68 @@ 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. */ +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]; + 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; + const share = after === before ? 0 : (index - before) / (after - before); + return [point[0], point[1], height(before) + (height(after) - height(before)) * share]; + }); +} + +// SKI-02: a ski tour on the piste router's legs; heights from the piste data where it has them all, else the terrain. +async function routeSki({ inputs, plannedKey, runKey, signal, stale, setPlanned }: RouteRun) { + const { waypoints, tripId, skiChoice, setStatus, setError } = inputs; + setStatus('routing'); + const planned = await routeSkiTour(waypoints, skiChoice, tripId, signal); + if (stale()) return; + if (!planned) { + setStatus('routing-failed'); + setError('routing'); + return; + } + setPlanned({ + route: planned.coordinates, + routeParts: planned.parts, + paddleRoute: null, + skiRoute: planned, + enrichedGeometry: planned.elevations, + distanceMeters: planned.distanceMeters, + durationSeconds: planned.durationSeconds, + forKey: plannedKey, + forRun: runKey, + }); + if (!planned.elevations) { + setStatus('enriching-elevation'); + const enriched = await enrichTourElevations(planned.coordinates, signal); + if (stale()) return; + // The terrain answered for some points or none: the rest are estimated between the ones known. + const heights = hasCompleteElevation(enriched) ? enriched : interpolatedHeights(enriched ?? planned.coordinates); + if (!heights) { + setStatus('elevation-failed'); + setError('elevation'); + return; + } + setPlanned((current) => ({ ...current, enrichedGeometry: heights })); + } + setStatus('ready'); + setError(null); +} + /** - * Plans a run a moment from now, on the water or on land; a failure that is - * still the current run says the route could not be planned. Returns the way - * to call it off before it starts. + * Plans a run a moment from now, on the water, on the pistes or on land; a + * failure that is still the current run says the route could not be planned. + * Returns the way to call it off before it starts. */ function scheduleRoute(run: RouteRun): () => void { const timer = window.setTimeout(() => { - (run.inputs.tourType === 'kayak' ? routePaddle(run) : routeOnLand(run)).catch(() => { + const { tourType } = run.inputs; + const plan = tourType === 'kayak' ? routePaddle(run) : tourType === 'ski' ? routeSki(run) : routeOnLand(run); + plan.catch(() => { if (run.stale()) return; run.inputs.setStatus('routing-failed'); run.inputs.setError('routing'); @@ -227,7 +284,7 @@ function plannedKeyOf(waypointKey: string, inputs: TourRouteInputs): string { * or its choices change: a hike on foot within its limit, a bike tour and a * run for their profile (BIK-01, RUN-01), a paddle tour on the water by the * server's router, a ride put together from its horse route sections and - * manual legs (HOR-01), a ski tour with its legs drawn (SKI-02). Every tour on + * manual legs (HOR-01), a ski tour on the piste router (SKI-02). Every tour on * land then gets its heights; a later * change makes an answer still on its way stale, and a failure keeps the last * route that was planned. diff --git a/client/src/components/Tours/ski/SkiLegRows.tsx b/client/src/components/Tours/ski/SkiLegRows.tsx new file mode 100644 index 000000000..6c49ca6f9 --- /dev/null +++ b/client/src/components/Tours/ski/SkiLegRows.tsx @@ -0,0 +1,197 @@ +import { + SKI_DIFFICULTIES, + skiLiftClassSchema, + type SkiColourScheme, + type SkiDifficulty, + type SkiRouteOkLeg, + type SkiRouteOptionsInput, + type SkiRouteSegment, +} from '@trek/shared'; +import { AlertTriangle, CableCar, Footprints, MountainSnow, Spline, Sun } from 'lucide-react'; +import { useTranslation } from '../../../i18n'; +import { fs } from '../../shared/DialogShell'; +import type { SkiLegProblem } from '../planner/skiRouting'; +import { formatTourDuration } from '../planner/tourDurationInput'; +import type { SkiChoice } from './skiChoice'; +import { skiPaintOf } from './skiRouteStyle'; + +const ACTION = + 'inline-flex h-[22px] flex-none items-center gap-1 rounded-full bg-surface-card px-2 font-semibold text-content shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover disabled:opacity-40'; + +/** One change a failed leg's hint suggests, as a change of the ski choice. */ +interface SkiFix { + labelKey: string; + apply: (choice: SkiChoice) => SkiChoice; +} + +const withOptions = (choice: SkiChoice, patch: Partial): SkiChoice => ({ + ...choice, + options: { ...choice.options, ...patch }, +}); + +/** The fixes a failed leg's hint offers, each a change of the ski choice that would plan it. */ +export function skiFixesOf(problem: SkiLegProblem): SkiFix[] { + const hint = problem.hint; + if (!hint) return []; + const fixes: SkiFix[] = []; + const level = hint.raise_difficulty; + if (level) { + const graded = (SKI_DIFFICULTIES as readonly string[]).includes(level); + fixes.push({ + labelKey: 'tours.ski.fix.raiseDifficulty', + apply: (choice) => + withOptions(choice, graded ? { max_difficulty: level as SkiDifficulty } : { allow_freeride: true }), + }); + } + const lift = skiLiftClassSchema.safeParse(hint.allow_lift); + if (lift.success) { + fixes.push({ + labelKey: 'tours.ski.fix.allowLift', + apply: (choice) => + withOptions(choice, { avoid_lifts: (choice.options?.avoid_lifts ?? []).filter((c) => c !== lift.data) }), + }); + } + if (hint.allow_lift_down) { + fixes.push({ + labelKey: 'tours.ski.fix.liftDown', + apply: (choice) => withOptions(choice, { allow_lift_down: true }), + }); + } + const technique = hint.technique; + if (technique) fixes.push({ labelKey: 'tours.ski.fix.technique', apply: (choice) => ({ ...choice, technique }) }); + const discipline = hint.discipline; + if (discipline) { + fixes.push({ + labelKey: 'tours.ski.fix.discipline', + apply: (choice) => ({ + ...choice, + discipline, + technique: discipline === 'cross_country' ? choice.technique : null, + }), + }); + } + return fixes; +} + +/** + * A ski leg the piste router could not plan: why, the run or lift it is about, + * and the change that would plan it, applied with one press. A snowshoer's or + * winter hiker's leg taken on summer paths instead says so calmly. + */ +export function SkiLegFailureRow({ + problem, + choice, + disabled, + onChoice, + onDrawStraight, +}: { + problem: SkiLegProblem; + choice: SkiChoice; + disabled: boolean; + onChoice: (next: SkiChoice) => void; + onDrawStraight: () => void; +}) { + const { t } = useTranslation(); + const tone = problem.summer ? 'bg-surface-secondary text-content-secondary' : 'bg-danger-soft text-danger'; + return ( +
  • +
    + + {problem.summer ? ( + + {problem.name && ( + + {problem.name} + + )} + + {skiFixesOf(problem).map((fix) => ( + + ))} + + +
    +
  • + ); +} + +/** Neighbouring stretches of one way (the same kind, name and grade) as one step. */ +function stepsOf(segments: readonly SkiRouteSegment[]): SkiRouteSegment[] { + const steps: SkiRouteSegment[] = []; + for (const segment of segments) { + const last = steps[steps.length - 1]; + const same = + last && last.kind === segment.kind && last.name === segment.name && last.difficulty === segment.difficulty; + if (same) steps[steps.length - 1] = { ...last, to: segment.to, seconds: last.seconds + segment.seconds }; + else steps.push(segment); + } + return steps; +} + +const LIFT_KINDS = new Set(['lift', 'rail', 'lift_down']); +const FOOT_KINDS = new Set(['walk', 'hike', 'link', 'crossing']); + +/** The icon of a step: a lift, a stretch on foot, or skiing. */ +function StepIcon({ kind }: { kind: SkiRouteSegment['kind'] }) { + const Icon = LIFT_KINDS.has(kind) ? CableCar : FOOT_KINDS.has(kind) ? Footprints : MountainSnow; + return