From d3f574f2252d6d43733792b99b87cbab8bb50ba2 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 20 Oct 2026 21:43:46 +0200 Subject: [PATCH] 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. --- .../Admin/addons/useAddonManager.ts | 34 ++--- .../Tours/planner/TourPlannerPanels.tsx | 4 +- .../Tours/planner/useTourPlanner.ts | 15 +- .../components/Tours/planner/useTourRoute.ts | 118 ++++++++------- .../src/components/Tours/ski/SkiLegRows.tsx | 68 +-------- .../components/Tours/ski/SkiRouteSummary.tsx | 74 +--------- .../src/components/Tours/ski/skiRouteFacts.ts | 135 ++++++++++++++++++ .../components/Tours/ski/skiRouteUi.test.tsx | 16 ++- 8 files changed, 257 insertions(+), 207 deletions(-) create mode 100644 client/src/components/Tours/ski/skiRouteFacts.ts diff --git a/client/src/components/Admin/addons/useAddonManager.ts b/client/src/components/Admin/addons/useAddonManager.ts index 24547a747..19c6d5d48 100644 --- a/client/src/components/Admin/addons/useAddonManager.ts +++ b/client/src/components/Admin/addons/useAddonManager.ts @@ -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 }, + 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(); diff --git a/client/src/components/Tours/planner/TourPlannerPanels.tsx b/client/src/components/Tours/planner/TourPlannerPanels.tsx index 2e8f5326d..f3582a338 100644 --- a/client/src/components/Tours/planner/TourPlannerPanels.tsx +++ b/client/src/components/Tours/planner/TourPlannerPanels.tsx @@ -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> = { @@ -498,7 +498,7 @@ export function TourPlannerRail({ planner, - {planner.isSki && } + {planner.tourType === 'hike' && ( { + 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) diff --git a/client/src/components/Tours/planner/useTourRoute.ts b/client/src/components/Tours/planner/useTourRoute.ts index 4cd1eb46b..d4bbc934a 100644 --- a/client/src/components/Tours/planner/useTourRoute.ts +++ b/client/src/components/Tours/planner/useTourRoute.ts @@ -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(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, abortRef: RefObject]; + +/** 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; @@ -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(null); const abortRef = useRef(null); const generationRef = useRef(0); - // SKI-02: a saved ski tour's route waiting for the run that plans its points. - const savedSkiRef = useRef(null); + 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, diff --git a/client/src/components/Tours/ski/SkiLegRows.tsx b/client/src/components/Tours/ski/SkiLegRows.tsx index 6c49ca6f9..6b8b57367 100644 --- a/client/src/components/Tours/ski/SkiLegRows.tsx +++ b/client/src/components/Tours/ski/SkiLegRows.tsx @@ -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): 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) => (
  • = 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 = new Set(['lift', 'rail', 'lift_down']); - -type Seconds = SkiRouteOkLeg['seconds']; -type Avoided = Extract; - -/** 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 }) { ); } + +/** 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 ? : null; +} diff --git a/client/src/components/Tours/ski/skiRouteFacts.ts b/client/src/components/Tours/ski/skiRouteFacts.ts new file mode 100644 index 000000000..ff0ad3995 --- /dev/null +++ b/client/src/components/Tours/ski/skiRouteFacts.ts @@ -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 = 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 = 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; + +/** 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): 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; +} diff --git a/client/src/components/Tours/ski/skiRouteUi.test.tsx b/client/src/components/Tours/ski/skiRouteUi.test.tsx index 3bd97a69a..a465d272e 100644 --- a/client/src/components/Tours/ski/skiRouteUi.test.tsx +++ b/client/src/components/Tours/ski/skiRouteUi.test.tsx @@ -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 { return { @@ -259,6 +260,13 @@ describe('SkiRouteSummary (SKI-02)', () => { render(); 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().container).toBeEmptyDOMElement(); + render(); + expect(screen.getByTestId('ski-route-summary')).toHaveTextContent('tours.ski.summary.ski'); + }); }); describe('skiPaintOf (SKI-02)', () => {