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
This commit is contained in:
Maurice
2026-10-20 20:32:01 +02:00
parent a85a50e186
commit 2d0c0597b3
91 changed files with 4183 additions and 257 deletions
@@ -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<typeof useAddonManager>, key: 'hiking' | 'cycle') {
function mapSwitch(manager: ReturnType<typeof useAddonManager>, 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();
@@ -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 => ({
@@ -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<Record<TourPlannerStatus, string>> = {
@@ -497,6 +498,8 @@ export function TourPlannerRail({ planner,
<HorseRoutesCard planner={planner} />
{planner.isSki && <SkiRouteSummary route={planner.skiRoute} />}
{planner.tourType === 'hike' && (
<HikeWaysCard tripId={planner.tripId} route={planner.route} routeCurrent={planner.routeCurrent}
routingLimit={planner.maxHikingDifficulty} />
@@ -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
@@ -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(<TourTypeFields planner={crossCountry} locked={false} />);
@@ -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(<TourTypeFields planner={alpine} locked={false} />);
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', () => {
@@ -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 (
<div data-testid="tour-planner-ski-fields" className="flex min-w-0 flex-col gap-1.5">
<SkiClassificationFields choice={planner.ski.choice} onChange={planner.ski.change} planned />
<SkiClassificationFields choice={choice} onChange={planner.ski.change} planned />
<span className="block text-content-faint" style={fs(11, 'body')}>
{t('tours.ski.drawnFreely')}
{t('tours.ski.plannedHint')}
</span>
{choice.discipline !== null && (
<SkiRouteOptions discipline={choice.discipline} options={choice.options} onChange={planner.ski.setOption} />
)}
</div>
);
}
@@ -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 (
<SkiLegFailureRow
problem={problem}
choice={planner.ski.choice}
disabled={!editable}
onChoice={planner.ski.change}
onDrawStraight={() => planner.toggleWaypointFreeLeg(point.id)}
/>
);
}
const leg = route.legs[index];
return leg?.status === 'ok' ? <SkiLegSteps leg={leg} scheme={route.colourScheme} /> : 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' && <FreeLegRow leg={point.fixedLeg} ride={planner.isHorse} />}
{point.fixedLeg?.kind === 'horse_route' && <HorseLegRow leg={point.fixedLeg} />}
{routedLegs && <PaddleLegNotes leg={routedLegs.legs[index]} />}
{planner.isSki && <SkiLegExtras planner={planner} point={point} index={index} editable={editable} />}
{failure && (
<LegFailure
code={failure.code}
@@ -1,16 +1,93 @@
import { describe, expect, it } from 'vitest';
import { routeSkiTour, skiLegs, skiRouteKey } from './skiRouting';
import type { SkiRouteLeg, SkiRouteSegment } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { skiRouteRepo, type SkiLegAnswer, type SkiPlanAnswer } from '../../../repo/skiRouteRepo';
import { legSeconds, routeSkiTour, skiLegs, skiRouteKey } from './skiRouting';
import { lineLengthM } from './tourFixedLegs';
import { routeWalkingTour } from './tourRouting';
// FE-TOUR-SKI-ROUTE-001 to FE-TOUR-SKI-ROUTE-004
vi.mock('./tourRouting', async (importOriginal) => ({
...(await importOriginal<typeof import('./tourRouting')>()),
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<SkiRouteSegment> = {}) {
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<SkiLegAnswer, { status: 'failed' }>['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> = {}): SkiPlanAnswer => ({
legs,
build: '2026-11-02',
stale: false,
areaNames: ['Gletscherwelt'],
colourScheme: 'europe',
...extra,
});
const planSpy = () => vi.spyOn(skiRouteRepo, 'plan');
let plan: ReturnType<typeof planSpy>;
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);
});
});
+271 -32
View File
@@ -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<SkiRouteLeg | null>;
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<string> = new Set(['not_on_piste', 'no_area', 'places_unavailable', 'disabled']);
const ON_FOOT: ReadonlySet<string> = new Set(['snowshoe', 'winter_hike']);
/** Segments walked or ridden down: drawn dotted. */
const DOTTED: ReadonlySet<string> = 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<TourFixedLeg[]> {
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<void>((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<SkiPlanAnswer> {
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<SkiRouteLeg, { status: 'ok' }>, 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<TourRoutePart | null> {
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<SkiRouteLeg | null>): [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<LegOutcome> {
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<TourRouteResult | null> {
): Promise<SkiTourRoute | null> {
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<LegOutcome> => {
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<SkiRouteLeg, { status: 'ok' }>): 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',
};
}
@@ -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)
);
}
@@ -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,
};
}
@@ -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();
}
});
@@ -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');
});
});
@@ -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,
@@ -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 {
@@ -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<typeof import('./tourRouting')>()),
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');
@@ -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,
@@ -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();
});
});
@@ -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.
@@ -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<SkiRouteOptionsInput>): 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 (
<li data-testid="tour-planner-ski-leg-failure" className="flex min-w-0 py-0.5 ps-[34px]">
<div
role={problem.summer ? 'status' : 'alert'}
className={`flex w-full min-w-0 flex-col gap-1.5 rounded-[10px] ${tone} px-2 py-1.5`}
>
<span className="flex min-w-0 items-center gap-1.5 font-semibold" style={fs(11.5, 'body')}>
{problem.summer ? (
<Sun size={12} strokeWidth={2.2} className="flex-none" aria-hidden="true" />
) : (
<AlertTriangle size={12} strokeWidth={2.2} className="flex-none" aria-hidden="true" />
)}
<span className="min-w-0">
{problem.summer ? t('tours.ski.summerPath') : t(`tours.ski.fail.${problem.code}`)}
</span>
</span>
{problem.name && (
<span
className="w-fit max-w-full truncate rounded-full bg-surface-card px-2 py-0.5 text-content"
style={fs(10.5, 'caption')}
>
{problem.name}
</span>
)}
<span className="flex flex-wrap gap-1">
{skiFixesOf(problem).map((fix) => (
<button
key={fix.labelKey}
type="button"
disabled={disabled}
onClick={() => onChoice(fix.apply(choice))}
className={ACTION}
style={fs(10.5)}
>
{t(fix.labelKey)}
</button>
))}
<button type="button" disabled={disabled} onClick={onDrawStraight} className={ACTION} style={fs(10.5)}>
<Spline size={11} strokeWidth={2.2} aria-hidden="true" />
{t('tours.paddle.drawStraight')}
</button>
</span>
</div>
</li>
);
}
/** 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 <Icon size={11} strokeWidth={2.2} className="flex-none" aria-hidden="true" />;
}
/**
* The steps of a planned ski leg, in order: each run in the colour of its
* grade, each lift and stretch on foot, with the time on it. A step the map
* data names no way for says what kind of way it is.
*/
export function SkiLegSteps({ leg, scheme }: { leg: SkiRouteOkLeg; scheme: SkiColourScheme }) {
const { t } = useTranslation();
const steps = stepsOf(leg.segments);
return (
<li data-testid="tour-planner-ski-leg-steps" className="flex min-w-0 py-0.5 ps-[34px]">
<ol className="m-0 flex min-w-0 list-none flex-wrap gap-1 p-0">
{steps.map((step, at) => (
<li
key={`${step.from}-${at}`}
className="inline-flex min-w-0 max-w-full items-center gap-1 rounded-full bg-surface-secondary py-0.5 ps-1.5 pe-2 text-content-secondary"
style={fs(10.5, 'caption')}
>
<span
aria-hidden="true"
className="h-2 w-2 flex-none rounded-full"
style={{ background: skiPaintOf({ kind: step.kind, difficulty: step.difficulty, scheme }) }}
/>
<StepIcon kind={step.kind} />
<span className="min-w-0 truncate">{step.name ?? t(`tours.ski.kind.${step.kind}`)}</span>
{step.seconds >= 60 && (
<span className="flex-none tabular-nums">{formatTourDuration(Math.round(step.seconds / 60))}</span>
)}
</li>
))}
</ol>
</li>
);
}
@@ -0,0 +1,166 @@
import {
DEFAULT_SKI_ROUTE_OPTIONS,
SKI_DIFFICULTIES,
SKI_LIFT_CLASSES,
SKI_PACES,
SKI_ROUTE_PRIORITIES,
skiOptionsReadBy,
skiUsesLifts,
type SkiDifficulty,
type SkiDiscipline,
type SkiLiftClass,
type SkiPace,
type SkiRouteOptions as Options,
type SkiRouteOptionsInput,
type SkiRoutePriority,
} from '@trek/shared';
import { useTranslation } from '../../../i18n';
import ToggleSwitch from '../../Settings/ToggleSwitch';
import CustomSelect from '../../shared/CustomSelect';
import { LABEL, Segmented } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
/** The switches of the piste router, each with the key of its label, in the order they are read. */
const SWITCHES = [
['use_lifts', 'tours.ski.options.useLifts'],
['allow_freeride', 'tours.ski.options.freeride'],
['allow_lift_down', 'tours.ski.options.liftDown'],
['groomed_only', 'tours.ski.options.groomedOnly'],
['lit_only', 'tours.ski.options.litOnly'],
['touring_uphill_on_pistes', 'tours.ski.options.uphillOnPistes'],
['avoid_inferred_links', 'tours.ski.options.avoidLinks'],
] as const;
type SwitchKey = (typeof SWITCHES)[number][0];
/** One switch set, the rest of the options kept. */
function switchPatch(key: SwitchKey, on: boolean): Partial<SkiRouteOptionsInput> {
const patch: Partial<SkiRouteOptionsInput> = {};
patch[key] = on;
return patch;
}
/** Switches that say something only while lifts are ridden. */
const LIFT_SWITCHES: ReadonlySet<SwitchKey> = new Set(['allow_lift_down']);
interface SkiRouteOptionsProps {
discipline: SkiDiscipline;
options: SkiRouteOptionsInput | null | undefined;
onChange: (patch: Partial<SkiRouteOptionsInput>) => void;
}
/** One switch of the piste router on its own row, its label beside it. */
function OptionSwitch({ label, on, onToggle }: { label: string; on: boolean; onToggle: () => void }) {
return (
<div className="flex items-center gap-3">
<span aria-hidden="true" className="min-w-0 flex-1 text-content-secondary" style={fs(12, 'body')}>
{label}
</span>
<ToggleSwitch on={on} onToggle={onToggle} label={label} />
</div>
);
}
/**
* The lift classes a skier leaves out, as pressed badges: a pressed one is
* not ridden. Shown only while lifts are ridden at all.
*/
function LiftClasses({ avoided, onChange }: { avoided: SkiLiftClass[]; onChange: (next: SkiLiftClass[]) => void }) {
const { t } = useTranslation();
const flip = (lift: SkiLiftClass) =>
onChange(avoided.includes(lift) ? avoided.filter((entry) => entry !== lift) : [...avoided, lift]);
return (
<div className="min-w-0">
<span className={LABEL}>{t('tours.ski.options.avoidLifts')}</span>
<div role="group" aria-label={t('tours.ski.options.avoidLifts')} className="flex flex-wrap gap-1.5">
{SKI_LIFT_CLASSES.map((lift) => {
const left = avoided.includes(lift);
const look = left
? 'border-edge bg-surface-tertiary text-content-muted line-through'
: 'border-edge bg-surface-card text-content-secondary';
return (
<button
key={lift}
type="button"
aria-pressed={left}
onClick={() => flip(lift)}
className={`rounded-full border px-2.5 py-1 font-medium ${look}`}
style={fs(11.5, 'caption')}
>
{t(`tours.ski.lift.${lift}`)}
</button>
);
})}
</div>
</div>
);
}
/**
* SKI-02: what the piste router is asked for besides the discipline, only the
* options the chosen discipline reads: the hardest runs, the pace and what to
* weigh most for a downhill skier, groomed and lit trails for cross-country,
* the lifts for anyone who rides them. Every change plans the tour again.
*/
export function SkiRouteOptions({ discipline, options, onChange }: SkiRouteOptionsProps) {
const { t } = useTranslation();
const read = new Set(skiOptionsReadBy(discipline));
const current: Options = { ...DEFAULT_SKI_ROUTE_OPTIONS, ...options };
const usesLifts = skiUsesLifts(discipline, current);
const switchOn = (key: SwitchKey) => (key === 'use_lifts' ? usesLifts : current[key]);
const switches = SWITCHES.filter(([key]) => read.has(key) && (usesLifts || !LIFT_SWITCHES.has(key)));
return (
<div data-testid="tour-planner-ski-options" className="flex min-w-0 flex-col gap-2.5">
{read.has('max_difficulty') && (
<div className="min-w-0">
<span className={LABEL} id="tour-planner-ski-difficulty-label">
{t('tours.ski.options.maxDifficulty')}
</span>
<CustomSelect
id="tour-planner-ski-difficulty"
ariaLabel={t('tours.ski.options.maxDifficulty')}
size="sm"
value={current.max_difficulty}
onChange={(value) => onChange({ max_difficulty: value as SkiDifficulty })}
options={SKI_DIFFICULTIES.map((level) => ({ value: level, label: t(`tours.ski.difficulty.${level}`) }))}
/>
</div>
)}
{read.has('priority') && (
<div className="min-w-0">
<span className={LABEL}>{t('tours.ski.options.priority')}</span>
<Segmented<SkiRoutePriority>
value={current.priority}
onChange={(priority) => onChange({ priority })}
label={t('tours.ski.options.priority')}
fill
options={SKI_ROUTE_PRIORITIES.map((value) => ({ value, label: t(`tours.ski.priority.${value}`) }))}
/>
</div>
)}
{read.has('pace') && (
<div className="min-w-0">
<span className={LABEL}>{t('tours.ski.options.pace')}</span>
<Segmented<SkiPace>
value={current.pace}
onChange={(pace) => onChange({ pace })}
label={t('tours.ski.options.pace')}
fill
options={SKI_PACES.map((value) => ({ value, label: t(`tours.ski.pace.${value}`) }))}
/>
</div>
)}
{switches.map(([key, labelKey]) => (
<OptionSwitch
key={key}
label={t(labelKey)}
on={switchOn(key)}
onToggle={() => onChange(switchPatch(key, !switchOn(key)))}
/>
))}
{usesLifts && read.has('avoid_lifts') && (
<LiftClasses avoided={current.avoid_lifts} onChange={(avoid_lifts) => onChange({ avoid_lifts })} />
)}
</div>
);
}
@@ -0,0 +1,106 @@
import type { SkiFlag, SkiRouteOkLeg, SkiWhy } from '@trek/shared';
import { Info, MountainSnow } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { fs } from '../../shared/DialogShell';
import type { SkiTourRoute } from '../planner/skiRouting';
import { formatTourDuration } from '../planner/tourDurationInput';
import { RailCard, RailFacts } from '../tourParts';
/** Flags that say a stretch rests on a guess of the map data, not on what is mapped. */
const GUESSED: ReadonlySet<SkiFlag> = new Set([
'inferred_link',
'inferred_crossing',
'dir_from_dem',
'technique_assumed',
'area_derived',
'difficulty_unknown',
]);
type Seconds = SkiRouteOkLeg['seconds'];
type Avoided = Extract<SkiWhy, { kind: 'avoided_difficulty' }>;
/** The time of every planned leg, by what it is spent on. */
export function skiSecondsOf(route: SkiTourRoute): Seconds {
const total: Seconds = { ski: 0, lift: 0, wait: 0, walk: 0 };
for (const leg of route.legs) {
if (leg?.status !== 'ok') continue;
total.ski += leg.seconds.ski;
total.lift += leg.seconds.lift;
total.wait += leg.seconds.wait;
total.walk += leg.seconds.walk;
}
return total;
}
/** The harder runs the route kept clear of, and whether any stretch rests on a guess. */
function skiWhyOf(route: SkiTourRoute): { avoided: Avoided[]; guessed: boolean } {
const why = route.legs.flatMap((leg) => (leg?.status === 'ok' ? leg.why : []));
return {
avoided: why.flatMap((entry) => (entry.kind === 'avoided_difficulty' ? [entry] : [])),
guessed: why.some((entry) => entry.kind === 'flag' && GUESSED.has(entry.flag)),
};
}
const minutes = (seconds: number) => formatTourDuration(Math.round(seconds / 60));
/** A calm line of the card: what the reader should know, never a warning about snow or safety. */
function Note({ children }: { children: string }) {
return (
<p className="m-0 flex min-w-0 items-start gap-1.5 text-content-muted" style={fs(11, 'body')}>
<Info size={12} strokeWidth={2} className="mt-0.5 flex-none" aria-hidden="true" />
<span className="min-w-0">{children}</span>
</p>
);
}
/**
* SKI-02: what the piste router made of a planned ski tour: the time on runs,
* on lifts, waiting and on foot, the harder runs it kept clear of, and what
* the reader should weigh: legs it could not plan, stretches that rest on a
* guess, an older copy of the piste data. The ski areas and the source of the
* data close it.
*/
export function SkiRouteSummary({ route }: { route: SkiTourRoute | null }) {
const { t } = useTranslation();
if (!route || route.legs.every((leg) => leg === null)) return null;
const seconds = skiSecondsOf(route);
const { avoided, guessed } = skiWhyOf(route);
const level = (entry: Avoided) => t(`tours.ski.difficulty.${entry.level}`);
const facts = [
{ label: t('tours.ski.summary.ski'), value: minutes(seconds.ski) },
{ label: t('tours.ski.summary.lift'), value: minutes(seconds.lift) },
{ label: t('tours.ski.summary.wait'), value: minutes(seconds.wait) },
{ label: t('tours.ski.summary.walk'), value: minutes(seconds.walk) },
];
return (
<RailCard icon={<MountainSnow size={14} strokeWidth={2} />} title={t('tours.ski.summary.title')} foldId="ski-route">
<div data-testid="ski-route-summary" className="flex min-w-0 flex-col gap-3">
<RailFacts facts={facts} />
{avoided.length > 0 && (
<div className="flex min-w-0 flex-col gap-1">
<span className="text-content-faint" style={fs(10.5, 'body')}>
{t('tours.ski.summary.avoided')}
</span>
<span className="flex flex-wrap gap-1">
{avoided.map((entry, at) => (
<span
key={`${entry.level}-${entry.name ?? at}`}
className="rounded-full bg-surface-secondary px-2 py-0.5 text-content-secondary"
style={fs(10.5, 'caption')}
>
{entry.name ? `${level(entry)}: ${entry.name}` : level(entry)}
</span>
))}
</span>
</div>
)}
{route.problems.length > 0 && <Note>{t('tours.ski.summary.unplanned')}</Note>}
{guessed && <Note>{t('tours.ski.summary.guessed')}</Note>}
{route.stale && <Note>{t('tours.ski.summary.stale')}</Note>}
<span className="text-content-faint" style={fs(10.5, 'caption')}>
{[route.areaNames.join(', '), t('tours.ski.summary.source')].filter(Boolean).join('. ')}
</span>
</div>
</RailCard>
);
}
@@ -1,7 +1,8 @@
import { DEFAULT_SKI_ROUTE_OPTIONS } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { NO_SKI_CHOICE, skiChoiceChanged, skiChoiceMissing, skiChoiceOf, skiFieldsOf } from './skiChoice';
// FE-TOUR-SKI-CHOICE-001 to FE-TOUR-SKI-CHOICE-003
// FE-TOUR-SKI-CHOICE-001 to FE-TOUR-SKI-CHOICE-004
describe('ski choice (SKI-02)', () => {
it('FE-TOUR-SKI-CHOICE-001: asks for a discipline first, then for a cross-country technique, Unknown counting as one', () => {
@@ -32,4 +33,11 @@ describe('ski choice (SKI-02)', () => {
const alpine = { discipline: 'alpine' as const, technique: null };
expect(skiChoiceChanged({ discipline: 'alpine', technique: 'classic' }, alpine)).toBe(false);
});
it('FE-TOUR-SKI-CHOICE-004: opens with the router options a planned tour kept, and without any where none were kept', () => {
const options = { ...DEFAULT_SKI_ROUTE_OPTIONS, pace: 'sporty' as const };
const planned = skiChoiceOf({ ski_discipline: 'alpine', ski_technique: null, ski_options: options });
expect(planned).toEqual({ discipline: 'alpine', technique: null, options });
expect(skiChoiceOf({ ski_discipline: 'alpine', ski_options: null })).not.toHaveProperty('options');
});
});
+15 -4
View File
@@ -1,4 +1,10 @@
import type { SkiDiscipline, SkiTechnique, TourListItem, TourSkiClassificationRequest } from '@trek/shared';
import type {
SkiDiscipline,
SkiRouteOptionsInput,
SkiTechnique,
TourListItem,
TourSkiClassificationRequest,
} from '@trek/shared';
/**
* SKI-02: a ski classification as a form holds it: nothing chosen until the
@@ -8,6 +14,8 @@ import type { SkiDiscipline, SkiTechnique, TourListItem, TourSkiClassificationRe
export interface SkiChoice {
discipline: SkiDiscipline | null;
technique: SkiTechnique | null;
/** What the piste router is asked for besides the discipline; absent means its defaults. */
options?: SkiRouteOptionsInput | null;
}
export const NO_SKI_CHOICE: SkiChoice = { discipline: null, technique: null };
@@ -28,9 +36,12 @@ export function skiFieldsOf(
: { ski_discipline: choice.discipline };
}
/** The choice a saved ski track opens with. */
export function skiChoiceOf(tour: Partial<Pick<TourListItem, 'ski_discipline' | 'ski_technique'>>): SkiChoice {
return { discipline: tour.ski_discipline ?? null, technique: tour.ski_technique ?? null };
/** The choice a saved ski track opens with, with the router options it was planned with where it keeps them. */
export function skiChoiceOf(
tour: Partial<Pick<TourListItem, 'ski_discipline' | 'ski_technique' | 'ski_options'>>
): SkiChoice {
const choice: SkiChoice = { discipline: tour.ski_discipline ?? null, technique: tour.ski_technique ?? null };
return tour.ski_options ? { ...choice, options: tour.ski_options } : choice;
}
/** Whether a choice says something else than the saved track does; the technique counts for cross-country only. */
@@ -0,0 +1,59 @@
import type { SkiColourScheme, SkiRunDifficulty, SkiSegmentKind } from '@trek/shared';
/**
* SKI-02: the paint of a ski tour's stretches on the planner map. Runs carry
* the colour of their grade as the slopes are marked where they are: in
* Europe novice green, easy blue, intermediate red, advanced black, expert
* orange and freeride yellow; in North America green for novice and easy,
* blue for intermediate, black for advanced and the double black beyond.
* Lifts are drawn dark, cross-country trails violet, links and crossings
* grey, summer paths amber. Data colours, the same in every theme, as the map
* under them is.
*/
const EUROPE: Record<SkiRunDifficulty, string> = {
novice: '#2e9e3b',
easy: '#2949ca',
intermediate: '#d51e1e',
advanced: '#202020',
expert: '#f37300',
freeride: '#e0b800',
extreme: '#e0b800',
};
const NORTH_AMERICA: Record<SkiRunDifficulty, string> = {
novice: '#2e9e3b',
easy: '#2e9e3b',
intermediate: '#2949ca',
advanced: '#202020',
expert: '#000000',
freeride: '#000000',
extreme: '#000000',
};
const WAYS: Partial<Record<SkiSegmentKind | 'summer', string>> = {
lift: '#1f2937',
rail: '#1f2937',
lift_down: '#1f2937',
nordic: '#7c3aed',
skitour: '#b45309',
hike: '#db2777',
link: '#6b7280',
crossing: '#6b7280',
walk: '#6b7280',
summer: '#d97706',
};
/** A run of no known grade, and anything else not named above. */
const UNGRADED = '#475569';
/** The colour a stretch of a ski tour is drawn in. */
export function skiPaintOf(ski: {
kind: SkiSegmentKind | 'summer';
difficulty: SkiRunDifficulty | null;
scheme?: SkiColourScheme;
}): string {
const graded = ski.kind === 'run' || ski.kind === 'area' || ski.kind === 'connection';
if (graded && ski.difficulty) return (ski.scheme === 'north_america' ? NORTH_AMERICA : EUROPE)[ski.difficulty];
return WAYS[ski.kind] ?? UNGRADED;
}
@@ -0,0 +1,225 @@
import type { SkiLiftClass, SkiRouteOkLeg, SkiRouteSegment } from '@trek/shared';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { SkiLegProblem, SkiTourRoute } from '../planner/skiRouting';
import { SkiLegFailureRow, SkiLegSteps, skiFixesOf } from './SkiLegRows';
import { SkiRouteOptions } from './SkiRouteOptions';
import { skiPaintOf } from './skiRouteStyle';
import { SkiRouteSummary, skiSecondsOf } from './SkiRouteSummary';
// FE-TOUR-SKI-UI-001 to FE-TOUR-SKI-UI-009
function segment(kind: SkiRouteSegment['kind'], extra: Partial<SkiRouteSegment> = {}): SkiRouteSegment {
return {
kind,
from: 0,
to: 1,
name: null,
ref: null,
difficulty: null,
lift_type: null,
grooming: [],
seconds: 600,
wait_seconds: 0,
length_m: 1000,
flags: [],
osm_way: null,
...extra,
};
}
function okLeg(extra: Partial<SkiRouteOkLeg> = {}): SkiRouteOkLeg {
return {
status: 'ok',
geometry: [
[47, 11, 1500],
[47.01, 11, 2000],
[47.02, 11, 1400],
],
segments: [
{ ...segment('lift', { name: 'Gipfelbahn' }), from: 0, to: 1 },
{ ...segment('run', { difficulty: 'intermediate' }), from: 1, to: 2 },
],
distance_m: 3000,
climb_m: 500,
descent_m: 600,
seconds: { ski: 600, lift: 480, wait: 120, walk: 0 },
snapped_m: { from: 0, to: 0 },
area_ids: ['w:1'],
why: [],
alternatives: [],
...extra,
};
}
function route(extra: Partial<SkiTourRoute> = {}): SkiTourRoute {
return {
coordinates: [
[47, 11],
[47.02, 11],
],
parts: [],
distanceMeters: 3000,
durationSeconds: 1200,
legs: [okLeg()],
problems: [],
elevations: null,
build: '2026-11-02',
stale: false,
areaNames: ['Gletscherwelt'],
colourScheme: 'europe',
...extra,
};
}
const problem = (extra: Partial<SkiLegProblem> = {}): SkiLegProblem => ({
index: 0,
code: 'blocked_by_difficulty',
hint: { raise_difficulty: 'advanced' },
name: 'Schwarze Wand',
summer: false,
...extra,
});
describe('SkiRouteOptions (SKI-02)', () => {
it('FE-TOUR-SKI-UI-001: a downhill skier sets the hardest runs, the pace, the switches and the lifts to leave out', () => {
const onChange = vi.fn();
render(<SkiRouteOptions discipline="alpine" options={{ avoid_lifts: ['gondola'] }} onChange={onChange} />);
expect(screen.getByRole('button', { name: 'tours.ski.options.maxDifficulty' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'tours.ski.pace.sporty' }));
expect(onChange).toHaveBeenLastCalledWith({ pace: 'sporty' });
fireEvent.click(screen.getByRole('button', { name: 'tours.ski.priority.easiest' }));
expect(onChange).toHaveBeenLastCalledWith({ priority: 'easiest' });
fireEvent.click(screen.getByRole('button', { name: 'tours.ski.options.freeride' }));
expect(onChange).toHaveBeenLastCalledWith({ allow_freeride: true });
fireEvent.click(screen.getByRole('button', { name: 'tours.ski.options.useLifts' }));
expect(onChange).toHaveBeenLastCalledWith({ use_lifts: false });
expect(screen.getByRole('button', { name: 'tours.ski.lift.gondola' })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: 'tours.ski.lift.drag' }));
expect(onChange).toHaveBeenLastCalledWith({ avoid_lifts: ['gondola', 'drag'] });
fireEvent.click(screen.getByRole('button', { name: 'tours.ski.lift.gondola' }));
expect(onChange).toHaveBeenLastCalledWith({ avoid_lifts: [] });
});
it('FE-TOUR-SKI-UI-002: a cross-country skier sees the trail switches only, and no lift classes while riding none', () => {
render(<SkiRouteOptions discipline="cross_country" options={null} onChange={vi.fn()} />);
expect(screen.getByRole('button', { name: 'tours.ski.options.groomedOnly' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'tours.ski.options.litOnly' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'tours.ski.options.maxDifficulty' })).toBeNull();
expect(screen.queryByRole('button', { name: 'tours.ski.options.liftDown' })).toBeNull();
expect(screen.queryByRole('group', { name: 'tours.ski.options.avoidLifts' })).toBeNull();
});
});
describe('ski leg rows (SKI-02)', () => {
it('FE-TOUR-SKI-UI-003: each hint becomes the change of the choice that would plan the leg', () => {
const avoided: SkiLiftClass[] = ['drag', 'gondola'];
const alpine = { discipline: 'alpine' as const, technique: null, options: { avoid_lifts: avoided } };
const [raise, lift] = skiFixesOf(problem({ hint: { raise_difficulty: 'advanced', allow_lift: 'drag' } }));
expect(raise?.apply(alpine).options).toMatchObject({ max_difficulty: 'advanced' });
expect(lift?.apply(alpine).options).toMatchObject({ avoid_lifts: ['gondola'] });
const [freeride] = skiFixesOf(problem({ hint: { raise_difficulty: 'freeride' } }));
expect(freeride?.apply(alpine).options).toMatchObject({ allow_freeride: true });
const [down, technique, discipline] = skiFixesOf(
problem({ hint: { allow_lift_down: true, technique: 'skating', discipline: 'touring' } })
);
expect(down?.apply(alpine).options).toMatchObject({ allow_lift_down: true });
expect(technique?.apply(alpine).technique).toBe('skating');
expect(discipline?.apply({ discipline: 'cross_country', technique: 'classic' })).toEqual({
discipline: 'touring',
technique: null,
});
expect(skiFixesOf(problem({ hint: null }))).toEqual([]);
expect(skiFixesOf(problem({ hint: { allow_lift: 'teleporter' } }))).toEqual([]);
});
it('FE-TOUR-SKI-UI-004: a failed leg says why, names the run, applies its fix and offers to draw it', () => {
const onChoice = vi.fn();
const onDrawStraight = vi.fn();
const choice = { discipline: 'alpine' as const, technique: null };
render(
<ul>
<SkiLegFailureRow
problem={problem()}
choice={choice}
disabled={false}
onChoice={onChoice}
onDrawStraight={onDrawStraight}
/>
</ul>
);
expect(screen.getByRole('alert')).toHaveTextContent('tours.ski.fail.blocked_by_difficulty');
expect(screen.getByText('Schwarze Wand')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'tours.ski.fix.raiseDifficulty' }));
expect(onChoice).toHaveBeenCalledWith({ ...choice, options: { max_difficulty: 'advanced' } });
fireEvent.click(screen.getByRole('button', { name: 'tours.paddle.drawStraight' }));
expect(onDrawStraight).toHaveBeenCalled();
});
it('FE-TOUR-SKI-UI-005: a leg on summer paths is said calmly, not as a failure', () => {
render(
<ul>
<SkiLegFailureRow
problem={problem({ code: 'not_on_piste', hint: null, name: null, summer: true })}
choice={{ discipline: 'snowshoe', technique: null }}
disabled
onChoice={vi.fn()}
onDrawStraight={vi.fn()}
/>
</ul>
);
expect(screen.getByRole('status')).toHaveTextContent('tours.ski.summerPath');
expect(screen.queryByRole('alert')).toBeNull();
});
it('FE-TOUR-SKI-UI-006: a planned leg lists its steps, named or by their kind, with the time on each', () => {
render(
<ul>
<SkiLegSteps leg={okLeg()} scheme="europe" />
</ul>
);
const steps = screen.getAllByRole('listitem').filter((item) => item.closest('ol'));
expect(steps).toHaveLength(2);
expect(steps[0]).toHaveTextContent('Gipfelbahn');
expect(steps[1]).toHaveTextContent('tours.ski.kind.run');
expect(steps[1]).toHaveTextContent('10');
});
});
describe('SkiRouteSummary (SKI-02)', () => {
it('FE-TOUR-SKI-UI-007: sums the time by what it is spent on, and says what the reader should weigh', () => {
const avoided = { kind: 'avoided_difficulty', level: 'advanced', name: 'Schwarze Wand', saved_seconds: 0 } as const;
const guessed = { kind: 'flag', flag: 'inferred_link', count: 1 } as const;
const planned = route({
legs: [okLeg({ why: [avoided, guessed] }), null],
problems: [problem({ index: 1 })],
stale: true,
});
expect(skiSecondsOf(planned)).toEqual({ ski: 600, lift: 480, wait: 120, walk: 0 });
render(<SkiRouteSummary route={planned} />);
const card = screen.getByTestId('ski-route-summary');
expect(card).toHaveTextContent('tours.ski.summary.ski');
expect(card).toHaveTextContent('tours.ski.difficulty.advanced: Schwarze Wand');
expect(card).toHaveTextContent('tours.ski.summary.unplanned');
expect(card).toHaveTextContent('tours.ski.summary.guessed');
expect(card).toHaveTextContent('tours.ski.summary.stale');
expect(card).toHaveTextContent('Gletscherwelt. tours.ski.summary.source');
});
it('FE-TOUR-SKI-UI-008: nothing while no leg was asked', () => {
const { container } = render(<SkiRouteSummary route={route({ legs: [null] })} />);
expect(container).toBeEmptyDOMElement();
expect(render(<SkiRouteSummary route={null} />).container).toBeEmptyDOMElement();
});
});
describe('skiPaintOf (SKI-02)', () => {
it('FE-TOUR-SKI-UI-009: runs in the colours of their grade where they are, lifts and the rest by kind', () => {
expect(skiPaintOf({ kind: 'run', difficulty: 'intermediate', scheme: 'europe' })).toBe('#d51e1e');
expect(skiPaintOf({ kind: 'run', difficulty: 'intermediate', scheme: 'north_america' })).toBe('#2949ca');
expect(skiPaintOf({ kind: 'run', difficulty: 'easy' })).toBe('#2949ca');
expect(skiPaintOf({ kind: 'lift', difficulty: null })).toBe('#1f2937');
expect(skiPaintOf({ kind: 'summer', difficulty: null })).toBe('#d97706');
expect(skiPaintOf({ kind: 'run', difficulty: null })).toBe('#475569');
});
});
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
import { NO_SKI_CHOICE } from './skiChoice';
import { useSkiFields } from './useSkiFields';
// FE-TOUR-SKI-FIELDS-001 to FE-TOUR-SKI-FIELDS-002
// FE-TOUR-SKI-FIELDS-001 to FE-TOUR-SKI-FIELDS-004
describe('useSkiFields (SKI-02)', () => {
it('FE-TOUR-SKI-FIELDS-001: a change is an edit only when it says something else and the planner allows it', () => {
@@ -27,4 +27,28 @@ describe('useSkiFields (SKI-02)', () => {
expect(result.current.choice).toEqual({ discipline: 'cross_country', technique: 'classic' });
expect(onEdit).not.toHaveBeenCalled();
});
it('FE-TOUR-SKI-FIELDS-003: an option changed is an edit that keeps the rest, the same option again none', () => {
const onEdit = vi.fn(() => true);
const { result } = renderHook(() => useSkiFields({ discipline: 'alpine', technique: null }, onEdit));
act(() => result.current.setOption({ pace: 'sporty' }));
act(() => result.current.setOption({ max_difficulty: 'advanced' }));
expect(result.current.choice.options).toEqual({ pace: 'sporty', max_difficulty: 'advanced' });
act(() => result.current.setOption({ pace: 'sporty' }));
expect(onEdit).toHaveBeenCalledTimes(2);
// Another discipline keeps the options chosen.
act(() => result.current.change({ discipline: 'touring', technique: null }));
expect(result.current.choice.options).toEqual({ pace: 'sporty', max_difficulty: 'advanced' });
});
it('FE-TOUR-SKI-FIELDS-004: a switch to snowboard leaves out surface lifts, unless lift classes were chosen', () => {
const onEdit = vi.fn(() => true);
const { result } = renderHook(() => useSkiFields({ discipline: 'alpine', technique: null }, onEdit));
act(() => result.current.change({ discipline: 'snowboard', technique: null }));
expect(result.current.choice.options).toEqual({ avoid_lifts: ['drag'] });
act(() => result.current.setOption({ avoid_lifts: [] }));
act(() => result.current.change({ discipline: 'alpine', technique: null }));
act(() => result.current.change({ discipline: 'snowboard', technique: null }));
expect(result.current.choice.options).toEqual({ avoid_lifts: [] });
});
});
@@ -1,25 +1,52 @@
import type { SkiRouteOptionsInput } from '@trek/shared';
import { useCallback, useState } from 'react';
import type { SkiChoice } from './skiChoice';
/**
* SKI-02: a planned ski tour's discipline and cross-country technique, fields
* of the draft like a paddle tour's boat: nothing chosen until the traveller
* picks, and no step of the history. A technique stays remembered while
* another discipline is tried. `onEdit` tells the planner the draft changed,
* and says whether it may.
* The options a choice carries on to the next discipline. A snowboarder is
* spared surface lifts unless they said otherwise: switching to snowboard
* leaves them out where no lift class was chosen yet.
*/
function carriedOptions(previous: SkiChoice, next: SkiChoice): SkiRouteOptionsInput | null | undefined {
const options = next.options !== undefined ? next.options : previous.options;
const toBoard = next.discipline === 'snowboard' && previous.discipline !== 'snowboard';
if (toBoard && options?.avoid_lifts === undefined) return { ...options, avoid_lifts: ['drag'] };
return options;
}
const sameChoice = (a: SkiChoice, b: SkiChoice) =>
a.discipline === b.discipline &&
a.technique === b.technique &&
JSON.stringify(a.options ?? null) === JSON.stringify(b.options ?? null);
/**
* SKI-02: a planned ski tour's discipline, cross-country technique and piste
* router options, fields of the draft like a paddle tour's boat: nothing
* chosen until the traveller picks, and no step of the history. A technique
* stays remembered while another discipline is tried, and so do the options.
* `onEdit` tells the planner the draft changed, and says whether it may.
*/
export function useSkiFields(initial: SkiChoice, onEdit: () => boolean) {
const [choice, setChoice] = useState<SkiChoice>(initial);
const change = useCallback(
(next: SkiChoice) => {
if (next.discipline === choice.discipline && next.technique === choice.technique) return;
if (onEdit()) setChoice(next);
const options = carriedOptions(choice, next);
const merged: SkiChoice = { discipline: next.discipline, technique: next.technique };
if (options !== undefined) merged.options = options;
if (sameChoice(merged, choice)) return;
if (onEdit()) setChoice(merged);
},
[choice, onEdit]
);
/** One option of the piste router changed, the rest kept. */
const setOption = useCallback(
(patch: Partial<SkiRouteOptionsInput>) => change({ ...choice, options: { ...choice.options, ...patch } }),
[change, choice]
);
return {
choice,
change,
setOption,
/** Loaded from a tour, a draft or an import, or cleared; not an edit. */
load: setChoice,
};