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