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)', () => {