mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
fix(tours): match a saved ski route by its ski choice, and keep the planner under the lint limits
The saved ski route compared against an undefined skiKey, so opening a saved ski tour threw. The held seed now lives in useSavedSkiSeed and is matched by skiRouteKey(skiChoice). The ski route facts move to skiRouteFacts.ts so the summary and leg rows only export components, the rail renders SkiPlannerSummary, and openTour, useTourRoute and useAddonManager shed the branches and lines the new ski code had added.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import type { AdminFeatureToggleResponse } from '@trek/shared';
|
||||
import { Bike, Footprints, MountainSnow } from 'lucide-react';
|
||||
import { useEffect, useEffectEvent, useState } from 'react';
|
||||
|
||||
@@ -9,6 +10,18 @@ import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
import { type Addon, type ProviderOption, isPhotosAddon } from './addonModel';
|
||||
|
||||
/** A switch under Tours as the server keeps it, read into `set`; `onError` tells the admin it could not be read. */
|
||||
function loadToursFlag(
|
||||
api: { get: () => Promise<AdminFeatureToggleResponse> },
|
||||
set: (enabled: boolean) => void,
|
||||
onError: () => void
|
||||
) {
|
||||
api
|
||||
.get()
|
||||
.then((flag) => set(flag.enabled))
|
||||
.catch(onError);
|
||||
}
|
||||
|
||||
/**
|
||||
* The addon switches behind both admin shells: the addon list loaded on mount, an
|
||||
* optimistic toggle with a per row rollback, and the lists each shell groups its tiles
|
||||
@@ -37,22 +50,11 @@ export function useAddonManager() {
|
||||
|
||||
const loadOnMount = useEffectEvent(() => {
|
||||
void loadAddons().finally(() => setLoading(false));
|
||||
wandererAdminApi
|
||||
.get()
|
||||
.then((flag) => setToursWanderer(flag.enabled))
|
||||
.catch(() => toast.error(t('admin.addons.toast.error')));
|
||||
hikingMapAdminApi
|
||||
.get()
|
||||
.then((flag) => setToursHikingMap(flag.enabled))
|
||||
.catch(() => toast.error(t('admin.addons.toast.error')));
|
||||
cycleMapAdminApi
|
||||
.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')));
|
||||
const unreadable = () => toast.error(t('admin.addons.toast.error'));
|
||||
loadToursFlag(wandererAdminApi, setToursWanderer, unreadable);
|
||||
loadToursFlag(hikingMapAdminApi, setToursHikingMap, unreadable);
|
||||
loadToursFlag(cycleMapAdminApi, setToursCycleMap, unreadable);
|
||||
loadToursFlag(skiRoutingAdminApi, setToursSkiRouting, unreadable);
|
||||
});
|
||||
useEffect(() => {
|
||||
loadOnMount();
|
||||
|
||||
@@ -63,7 +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 { SkiPlannerSummary } from '../ski/SkiRouteSummary'
|
||||
import { walkTimeFields } from '../walk/walkTimeFields'
|
||||
|
||||
const STATUS_KEY: Partial<Record<TourPlannerStatus, string>> = {
|
||||
@@ -498,7 +498,7 @@ export function TourPlannerRail({ planner,
|
||||
|
||||
<HorseRoutesCard planner={planner} />
|
||||
|
||||
{planner.isSki && <SkiRouteSummary route={planner.skiRoute} />}
|
||||
<SkiPlannerSummary planner={planner} />
|
||||
|
||||
{planner.tourType === 'hike' && (
|
||||
<HikeWaysCard tripId={planner.tripId} route={planner.route} routeCurrent={planner.routeCurrent}
|
||||
|
||||
@@ -88,6 +88,11 @@ function derivedFromField(placeId: number | null, derivedFrom: number | null): {
|
||||
return placeId === null && derivedFrom !== null ? { derived_from_place_id: derivedFrom } : {}
|
||||
}
|
||||
|
||||
/** SKI-02: the line a ski tour was saved with, as this device keeps it; a tour of another type has none to read. */
|
||||
async function savedSkiLineOf(tripId: number | string, tour: TourListItem): Promise<string | null> {
|
||||
return tour.tour_type === 'ski' ? await tourRepo.savedLine(tripId, tour.place_id) : null
|
||||
}
|
||||
|
||||
function trimmedOrNull(value: string): string | null {
|
||||
return value.trim() || null
|
||||
}
|
||||
@@ -704,19 +709,20 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
&& draftRevisionRef.current === submittedRevision
|
||||
&& editingPlaceIdRef.current === submittedEditingPlaceId
|
||||
&& (readOnlyGpxTourRef.current?.tour.place_id ?? null) === submittedGpxPlaceId
|
||||
const superseded = () => !requestIsCurrent() || saveInFlightRef.current
|
||||
setRouteProfileFocus(null)
|
||||
setOpeningTourId(tour.place_id)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await tourRepo.detail(tripId, tour.place_id, controller.signal)
|
||||
if (!requestIsCurrent() || saveInFlightRef.current) return false
|
||||
if (superseded()) return false
|
||||
if (result.waypoints.length < 2) {
|
||||
setError('open')
|
||||
return false
|
||||
}
|
||||
// SKI-02: a saved ski tour opens with the line it was saved with, read from this device.
|
||||
const skiLine = result.tour.tour_type === 'ski' ? await tourRepo.savedLine(tripId, result.tour.place_id) : null
|
||||
if (!requestIsCurrent() || saveInFlightRef.current) return false
|
||||
const skiLine = await savedSkiLineOf(tripId, result.tour)
|
||||
if (superseded()) return false
|
||||
|
||||
interruptRouting()
|
||||
const opened = openedTourFields(result.tour)
|
||||
@@ -743,8 +749,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setStageLoss(0)
|
||||
setStatus('dirty')
|
||||
clearRoute()
|
||||
const savedSki = savedSkiSeed(result.tour, skiLine, nextWaypoints, opened.ski)
|
||||
if (savedSki) openSavedSkiRoute(savedSki)
|
||||
openSavedSkiRoute(savedSkiSeed(result.tour, skiLine, nextWaypoints, opened.ski))
|
||||
// CW-11: a walk without its way yet is planned on opening and can be saved with it at once.
|
||||
const pending = result.tour.route_pending === true
|
||||
setRoutePending(pending)
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type RefObject,
|
||||
type SetStateAction,
|
||||
} from 'react';
|
||||
import { randomId } from '../../../utils/randomId';
|
||||
@@ -112,6 +113,65 @@ export interface SavedSkiSeed {
|
||||
skiKey: string;
|
||||
}
|
||||
|
||||
/** SKI-02: a ski tour's route as the planner shows it, planned in this run or opened as it was saved. */
|
||||
function skiPlanned(route: SkiTourRoute, plannedKey: string, runKey: string): PlannedRoute {
|
||||
return {
|
||||
route: route.coordinates,
|
||||
routeParts: route.parts,
|
||||
paddleRoute: null,
|
||||
skiRoute: route,
|
||||
enrichedGeometry: route.elevations,
|
||||
distanceMeters: route.distanceMeters,
|
||||
durationSeconds: route.durationSeconds,
|
||||
forKey: plannedKey,
|
||||
forRun: runKey,
|
||||
};
|
||||
}
|
||||
|
||||
/** SKI-02: what a saved ski tour's route must match to be shown as it was saved. */
|
||||
interface SavedSkiMatch {
|
||||
tourType: PlannableTourType;
|
||||
waypointKey: string;
|
||||
skiChoice: SkiChoice;
|
||||
}
|
||||
|
||||
/**
|
||||
* SKI-02: a saved ski tour's route held for the run that plans its points
|
||||
* (none for a tour that opens to be planned afresh). That run takes it whether
|
||||
* it fits or not; it gets the route only for the points and the ski choice the
|
||||
* tour was saved with.
|
||||
*/
|
||||
function useSavedSkiSeed() {
|
||||
const savedSkiRef = useRef<SavedSkiSeed | null>(null);
|
||||
const openSavedSkiRoute = useCallback((seed: SavedSkiSeed | null) => {
|
||||
savedSkiRef.current = seed;
|
||||
}, []);
|
||||
const takeSavedSki = useCallback(({ tourType, waypointKey, skiChoice }: SavedSkiMatch) => {
|
||||
const seed = savedSkiRef.current;
|
||||
savedSkiRef.current = null;
|
||||
if (!seed || tourType !== 'ski' || seed.waypointKey !== waypointKey) return null;
|
||||
return seed.skiKey === skiRouteKey(skiChoice) ? seed.route : null;
|
||||
}, []);
|
||||
return { openSavedSkiRoute, takeSavedSki };
|
||||
}
|
||||
|
||||
type RunRefs = [generationRef: RefObject<number>, abortRef: RefObject<AbortController | null>];
|
||||
|
||||
/** Any answer still on its way is stale from here on; `next` is the run that takes over, if one does. */
|
||||
function interruptRuns([generationRef, abortRef]: RunRefs, next: AbortController | null = null): number {
|
||||
generationRef.current += 1;
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = next;
|
||||
return generationRef.current;
|
||||
}
|
||||
|
||||
/** A new run, stale once it is called off or another one starts. */
|
||||
function startRun(refs: RunRefs) {
|
||||
const controller = new AbortController();
|
||||
const generation = interruptRuns(refs, controller);
|
||||
return { controller, stale: () => controller.signal.aborted || generation !== refs[0].current };
|
||||
}
|
||||
|
||||
/** One planning run: what it plans from, whether it went stale, and where it writes. */
|
||||
interface RouteRun {
|
||||
inputs: Omit<TourRouteInputs, 'retryToken' | 'error' | 'onCleared'>;
|
||||
@@ -231,17 +291,7 @@ async function routeSki({ inputs, plannedKey, runKey, signal, stale, setPlanned
|
||||
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,
|
||||
});
|
||||
setPlanned(skiPlanned(planned, plannedKey, runKey));
|
||||
if (!planned.elevations) {
|
||||
setStatus('enriching-elevation');
|
||||
const enriched = await enrichTourElevations(planned.coordinates, signal);
|
||||
@@ -331,24 +381,14 @@ export function useTourRoute(inputs: TourRouteInputs) {
|
||||
const [unreachable, setUnreachable] = useState<TourRouteUnreachable | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const generationRef = useRef(0);
|
||||
// SKI-02: a saved ski tour's route waiting for the run that plans its points.
|
||||
const savedSkiRef = useRef<SavedSkiSeed | null>(null);
|
||||
const { openSavedSkiRoute, takeSavedSki } = useSavedSkiSeed();
|
||||
|
||||
/** Any answer still on its way is stale from here on. */
|
||||
const interruptRouting = useCallback(() => {
|
||||
generationRef.current += 1;
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
}, []);
|
||||
const interruptRouting = useCallback(() => void interruptRuns([generationRef, abortRef]), []);
|
||||
|
||||
/** No route at all, as for an empty draft. */
|
||||
const clearRoute = useCallback(() => setPlanned(NO_ROUTE), []);
|
||||
|
||||
/** SKI-02: the next run for these points and this ski choice takes the saved route instead of asking the router. */
|
||||
const openSavedSkiRoute = useCallback((seed: SavedSkiSeed) => {
|
||||
savedSkiRef.current = seed;
|
||||
}, []);
|
||||
|
||||
const waypointKey = useMemo(() => tourWaypointKey(waypoints), [waypoints]);
|
||||
const plannedKey = plannedKeyOf(waypointKey, inputs);
|
||||
// CW-02, CW-03: a station's own fields and the keys a save answered are no part of what a route is planned from,
|
||||
@@ -361,22 +401,12 @@ export function useTourRoute(inputs: TourRouteInputs) {
|
||||
const routeStands = useEffectEvent(() => planned.forRun === runKey && routeComplete(planned));
|
||||
// SKI-02: a saved ski tour opened as it was saved, its line, time and build shown until something of it changes.
|
||||
const takeSavedSkiRoute = useEffectEvent(() => {
|
||||
const seed = savedSkiRef.current;
|
||||
savedSkiRef.current = null;
|
||||
if (!seed || tourType !== 'ski' || seed.waypointKey !== waypointKey || seed.skiKey !== skiKey) return false;
|
||||
const { route } = seed;
|
||||
const route = takeSavedSki({ tourType, waypointKey, skiChoice });
|
||||
if (!route) return false;
|
||||
setUnreachable(null);
|
||||
setPlanned({
|
||||
route: route.coordinates,
|
||||
routeParts: route.parts,
|
||||
paddleRoute: null,
|
||||
skiRoute: route,
|
||||
enrichedGeometry: route.elevations,
|
||||
distanceMeters: route.distanceMeters,
|
||||
durationSeconds: route.durationSeconds,
|
||||
forKey: plannedKey,
|
||||
forRun: runKey,
|
||||
});
|
||||
setPlanned(skiPlanned(route, plannedKey, runKey));
|
||||
setStatus('ready');
|
||||
setError(null);
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -390,21 +420,13 @@ export function useTourRoute(inputs: TourRouteInputs) {
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
if (takeSavedSkiRoute()) {
|
||||
setStatus('ready');
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
if (takeSavedSkiRoute()) return;
|
||||
if (routeStands()) {
|
||||
setStatus((current) => (current === 'dirty' ? 'ready' : current));
|
||||
return;
|
||||
}
|
||||
|
||||
const generation = ++generationRef.current;
|
||||
const controller = new AbortController();
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = controller;
|
||||
const stale = () => controller.signal.aborted || generation !== generationRef.current;
|
||||
const { controller, stale } = startRun([generationRef, abortRef]);
|
||||
const run: RouteRun = {
|
||||
inputs: {
|
||||
tripId,
|
||||
|
||||
@@ -1,78 +1,16 @@
|
||||
import {
|
||||
SKI_DIFFICULTIES,
|
||||
skiLiftClassSchema,
|
||||
type SkiColourScheme,
|
||||
type SkiDifficulty,
|
||||
type SkiRouteOkLeg,
|
||||
type SkiRouteOptionsInput,
|
||||
type SkiRouteSegment,
|
||||
} from '@trek/shared';
|
||||
import type { SkiColourScheme, SkiRouteOkLeg, 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 { skiFixesOf } from './skiRouteFacts';
|
||||
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
|
||||
@@ -176,7 +114,7 @@ export function SkiLegSteps({ leg, scheme }: { leg: SkiRouteOkLeg; scheme: SkiCo
|
||||
{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"
|
||||
className="inline-flex min-w-0 max-w-full items-center gap-1 rounded-full bg-surface-secondary py-0.5 pe-2 ps-1.5 text-content-secondary"
|
||||
style={fs(10.5, 'caption')}
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { SkiFlag, SkiGeometryPoint, SkiRouteOkLeg, SkiSegmentKind, SkiWhy } from '@trek/shared';
|
||||
import { Info, MountainSnow } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
@@ -8,71 +7,7 @@ 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',
|
||||
]);
|
||||
|
||||
/** Stretches ridden on a lift, a funicular or rack railway, or a gondola down. */
|
||||
const RIDES: ReadonlySet<SkiSegmentKind> = new Set(['lift', 'rail', 'lift_down']);
|
||||
|
||||
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 height lost along a stretch of a leg's line, counted where its points have a height. */
|
||||
function descentAlong(geometry: readonly SkiGeometryPoint[], from: number, to: number): number {
|
||||
let down = 0;
|
||||
let last: number | null = null;
|
||||
for (const [, , height] of geometry.slice(from, to + 1)) {
|
||||
if (height === null) continue;
|
||||
if (last !== null && height < last) down += last - height;
|
||||
last = height;
|
||||
}
|
||||
return down;
|
||||
}
|
||||
|
||||
/** The metres of runs, the height skied down on them, and the lift rides of every planned leg. */
|
||||
export function skiTallyOf(route: SkiTourRoute): { runsM: number; downM: number; rides: number } {
|
||||
const tally = { runsM: 0, downM: 0, rides: 0 };
|
||||
for (const leg of route.legs) {
|
||||
if (leg?.status !== 'ok') continue;
|
||||
for (const segment of leg.segments) {
|
||||
if (RIDES.has(segment.kind)) tally.rides += 1;
|
||||
if (segment.kind !== 'run') continue;
|
||||
tally.runsM += segment.length_m;
|
||||
tally.downM += descentAlong(leg.geometry, segment.from, segment.to);
|
||||
}
|
||||
}
|
||||
return tally;
|
||||
}
|
||||
|
||||
/** The harder runs the route kept clear of, and whether any stretch rests on a guess. */
|
||||
function skiWhyOf(route: SkiTourRoute): { avoided: Avoided[]; guessed: boolean } {
|
||||
const why = route.legs.flatMap((leg) => (leg?.status === 'ok' ? leg.why : []));
|
||||
return {
|
||||
avoided: why.flatMap((entry) => (entry.kind === 'avoided_difficulty' ? [entry] : [])),
|
||||
guessed: why.some((entry) => entry.kind === 'flag' && GUESSED.has(entry.flag)),
|
||||
};
|
||||
}
|
||||
import { skiSecondsOf, skiTallyOf, skiWhyOf, type SkiAvoided } from './skiRouteFacts';
|
||||
|
||||
const minutes = (seconds: number) => formatTourDuration(Math.round(seconds / 60));
|
||||
|
||||
@@ -132,7 +67,7 @@ export function SkiRouteSummary({ route }: { route: SkiTourRoute | null }) {
|
||||
const seconds = skiSecondsOf(route);
|
||||
const tally = skiTallyOf(route);
|
||||
const { avoided, guessed } = skiWhyOf(route);
|
||||
const level = (entry: Avoided) => t(`tours.ski.difficulty.${entry.level}`);
|
||||
const level = (entry: SkiAvoided) => 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) },
|
||||
@@ -169,3 +104,8 @@ export function SkiRouteSummary({ route }: { route: SkiTourRoute | null }) {
|
||||
</SummaryCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** SKI-02: the summary in the planner's rail, for a ski tour only. */
|
||||
export function SkiPlannerSummary({ planner }: { planner: { isSki: boolean; skiRoute: SkiTourRoute | null } }) {
|
||||
return planner.isSki ? <SkiRouteSummary route={planner.skiRoute} /> : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
SKI_DIFFICULTIES,
|
||||
skiLiftClassSchema,
|
||||
type SkiDifficulty,
|
||||
type SkiFlag,
|
||||
type SkiGeometryPoint,
|
||||
type SkiRouteOkLeg,
|
||||
type SkiRouteOptionsInput,
|
||||
type SkiSegmentKind,
|
||||
type SkiWhy,
|
||||
} from '@trek/shared';
|
||||
import type { SkiLegProblem, SkiTourRoute } from '../planner/skiRouting';
|
||||
import type { SkiChoice } from './skiChoice';
|
||||
|
||||
/** 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',
|
||||
]);
|
||||
|
||||
/** Stretches ridden on a lift, a funicular or rack railway, or a gondola down. */
|
||||
const RIDES: ReadonlySet<SkiSegmentKind> = new Set(['lift', 'rail', 'lift_down']);
|
||||
|
||||
type Seconds = SkiRouteOkLeg['seconds'];
|
||||
|
||||
/** A harder run the piste router kept the route clear of. */
|
||||
export type SkiAvoided = 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 height lost along a stretch of a leg's line, counted where its points have a height. */
|
||||
function descentAlong(geometry: readonly SkiGeometryPoint[], from: number, to: number): number {
|
||||
let down = 0;
|
||||
let last: number | null = null;
|
||||
for (const [, , height] of geometry.slice(from, to + 1)) {
|
||||
if (height === null) continue;
|
||||
if (last !== null && height < last) down += last - height;
|
||||
last = height;
|
||||
}
|
||||
return down;
|
||||
}
|
||||
|
||||
/** The metres of runs, the height skied down on them, and the lift rides of every planned leg. */
|
||||
export function skiTallyOf(route: SkiTourRoute): { runsM: number; downM: number; rides: number } {
|
||||
const tally = { runsM: 0, downM: 0, rides: 0 };
|
||||
for (const leg of route.legs) {
|
||||
if (leg?.status !== 'ok') continue;
|
||||
for (const segment of leg.segments) {
|
||||
if (RIDES.has(segment.kind)) tally.rides += 1;
|
||||
if (segment.kind !== 'run') continue;
|
||||
tally.runsM += segment.length_m;
|
||||
tally.downM += descentAlong(leg.geometry, segment.from, segment.to);
|
||||
}
|
||||
}
|
||||
return tally;
|
||||
}
|
||||
|
||||
/** The harder runs the route kept clear of, and whether any stretch rests on a guess. */
|
||||
export function skiWhyOf(route: SkiTourRoute): { avoided: SkiAvoided[]; 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)),
|
||||
};
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { SkiLiftClass, SkiRouteOkLeg, SkiRouteSegment } from '@trek/shared';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { SkiLiftClass, SkiRouteOkLeg, SkiRouteSegment } from '@trek/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { SkiLegProblem, SkiTourRoute } from '../planner/skiRouting';
|
||||
import { SkiLegFailureRow, SkiLegSteps, skiFixesOf } from './SkiLegRows';
|
||||
import { SkiLegFailureRow, SkiLegSteps } from './SkiLegRows';
|
||||
import { skiFixesOf, skiSecondsOf, skiTallyOf } from './skiRouteFacts';
|
||||
import { SkiRouteOptions } from './SkiRouteOptions';
|
||||
import { skiPaintOf } from './skiRouteStyle';
|
||||
import { SkiRouteSummary, skiSecondsOf, skiTallyOf } from './SkiRouteSummary';
|
||||
import { SkiPlannerSummary, SkiRouteSummary } from './SkiRouteSummary';
|
||||
|
||||
// FE-TOUR-SKI-UI-001 to FE-TOUR-SKI-UI-012
|
||||
// FE-TOUR-SKI-UI-001 to FE-TOUR-SKI-UI-013
|
||||
|
||||
function segment(kind: SkiRouteSegment['kind'], extra: Partial<SkiRouteSegment> = {}): SkiRouteSegment {
|
||||
return {
|
||||
@@ -259,6 +260,13 @@ describe('SkiRouteSummary (SKI-02)', () => {
|
||||
render(<SkiRouteSummary route={route({ saved: true, legs: [], build: null })} />);
|
||||
expect(screen.getByTestId('ski-route-summary')).not.toHaveTextContent('tours.ski.summary.build');
|
||||
});
|
||||
|
||||
it('FE-TOUR-SKI-UI-013: the planner rail shows the summary for a ski tour only', () => {
|
||||
const planned = route({ legs: [okLeg()] });
|
||||
expect(render(<SkiPlannerSummary planner={{ isSki: false, skiRoute: planned }} />).container).toBeEmptyDOMElement();
|
||||
render(<SkiPlannerSummary planner={{ isSki: true, skiRoute: planned }} />);
|
||||
expect(screen.getByTestId('ski-route-summary')).toHaveTextContent('tours.ski.summary.ski');
|
||||
});
|
||||
});
|
||||
|
||||
describe('skiPaintOf (SKI-02)', () => {
|
||||
|
||||
Reference in New Issue
Block a user