fix(tours): show a run's ways in the hike's trail details card

A run described its ways in a card of its own, with the bike's sections,
the bike's paint for the SAC grades (T5 and T6 merged) and its own words,
so the same path read differently on a hike and on a run. The run now
uses the hike's card, matched as a run, and keeps only the unpaved share
and the hardest grade in its head. The run's grade labels and the now
unused SAC paint go.
This commit is contained in:
Maurice
2026-10-20 16:42:47 +02:00
parent ae726ed370
commit a1d683d7bc
36 changed files with 144 additions and 451 deletions
+1 -20
View File
@@ -1,5 +1,5 @@
import { useMapPaint } from '../../Map/mapPaint';
import type { SacClass, SurfaceClass, TrailClass } from './routeWays';
import type { SurfaceClass, TrailClass } from './routeWays';
/**
* The colours of a bike tour's or a run's surfaces and grades (styles/bike.css),
@@ -30,25 +30,6 @@ export function trailPaintKey(key: TrailClass): BikePaintKey {
return key === 'none' || key === 'unasked' ? 'unknown' : key;
}
/**
* RUN-02: the paint of a hiking grade, on the same scale from easy to hard as
* the trail grades (T1 as S0, T5 and up as S4), so a map never shows two
* colour scales for one idea; a way without a grade in the grey of the unknown.
*/
const SAC_PAINT: Record<SacClass, BikePaintKey> = {
t1: 's0',
t2: 's1',
t3: 's2',
t4: 's3',
t5: 's4',
t6: 's4',
none: 'unknown',
};
export function sacPaintKey(key: SacClass): BikePaintKey {
return SAC_PAINT[key];
}
/** The paint of a surface class. */
export function surfacePaintKey(key: SurfaceClass): BikePaintKey {
return key;
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { encodePolyline } from '../../../../tests/helpers/polyline';
import { bikePaintCss, sacPaintKey, surfacePaintKey, trailPaintKey } from './bikePaint';
import { bikePaintCss, surfacePaintKey, trailPaintKey } from './bikePaint';
import { withRouteHighlight } from './routeHighlight';
import {
routeWaysFrom,
@@ -154,12 +154,6 @@ describe('the paints and the highlight (BIK-01 to BIK-05)', () => {
expect(bikePaintCss('s1')).toBe('var(--bike-trail-s1)');
});
it('FE-TOUR-WAYS-012: a hiking grade takes the paint of the trail grade it matches, T5 and up the hardest (RUN-02)', () => {
const grades = ['t1', 't2', 't3', 't4', 't5', 't6'] as const;
expect(grades.map(sacPaintKey)).toEqual(['s0', 's1', 's2', 's3', 's4', 's4']);
expect(sacPaintKey('none')).toBe('unknown');
});
const paint = { gravel: '#a0522d', way: '#111', casing: '#fff' } as Parameters<typeof withRouteHighlight>[4];
it('FE-TOUR-WAYS-009: lays the highlighted stretches over the route in their paint, solid', () => {
@@ -12,7 +12,7 @@ vi.mock('./useHikeWays', () => ({
},
}));
// FE-HIKE-WAYSCARD-001 to FE-HIKE-WAYSCARD-012
// FE-HIKE-WAYSCARD-001 to FE-HIKE-WAYSCARD-013
const LINE: [number, number][] = [
[47.0, 11.0],
@@ -52,6 +52,7 @@ function ways(overrides: Partial<HikeWays> = {}): HikeWays {
focus: null,
focusOn: vi.fn(),
retry: vi.fn(),
routeWays: null,
...overrides,
};
}
@@ -73,7 +74,7 @@ describe('HikeWaysCard (MAP-03, MAP-04)', () => {
it('FE-HIKE-WAYSCARD-001: without a line to describe there is no card', () => {
show(ways({ available: false, shown: false, status: 'off', rows: [] }));
expect(screen.queryByTestId('hike-ways-card')).toBeNull();
expect(hook.input).toEqual({ tripId: 4, route: LINE, current: true });
expect(hook.input).toEqual({ tripId: 4, route: LINE, current: true, matching: 'foot' });
});
it('FE-HIKE-WAYSCARD-002: switched off it only offers the switch, and the switch turns it on', () => {
@@ -202,4 +203,20 @@ describe('HikeWaysCard (MAP-03, MAP-04)', () => {
expect(screen.getByTestId('sac-row-unmatched')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /try again/i })).toBeNull();
});
it('FE-HIKE-WAYSCARD-013: a run asks for its ways matched as a run and puts its own badges in the head (RUN-02)', () => {
hook.ways = ways();
render(
<HikeWaysCard
tripId={4}
route={LINE}
routingLimit={null}
matching="run"
aside={(state) => <span data-testid="run-aside">{state.facet}</span>}
/>
);
expect(hook.input).toMatchObject({ tripId: 4, route: LINE, matching: 'run' });
expect(screen.getByTestId('run-aside')).toHaveTextContent('sac');
expect(screen.queryByTestId('sac-routing-limit')).toBeNull();
});
});
@@ -1,4 +1,5 @@
import { Footprints, Layers, MountainSnow, Signpost } from 'lucide-react';
import type { ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import ToggleSwitch from '../../Settings/ToggleSwitch';
import { Segmented, type SegmentedOption } from '../../shared/dialogParts';
@@ -7,7 +8,7 @@ import { ReadStatusNotice } from '../ReadStatusNotice';
import { RailCard } from '../tourParts';
import type { HikeFacet } from './hikeFacetSpecs';
import { HikeWaysList } from './HikeWaysList';
import { useHikeWays } from './useHikeWays';
import { useHikeWays, type HikeWays } from './useHikeWays';
const ICON = { size: 13, strokeWidth: 2 } as const;
@@ -18,13 +19,16 @@ const ICON = { size: 13, strokeWidth: 2 } as const;
* surface or the kind of way, three things kept apart, each class with its
* share; the unknown stays unknown and says why. The routing limit, where the
* hike was planned with one, sits apart as the setting it is. Nothing here
* changes the tour.
* changes the tour. A run (RUN-02) shows its ways in this same card, matched
* as a run, with what a runner reads first in the card's head.
*/
export function HikeWaysCard({
tripId,
route,
routeCurrent = true,
routingLimit,
matching = 'foot',
aside,
}: {
tripId: number | string;
/** The tour's own line, or null while there is none. */
@@ -33,9 +37,13 @@ export function HikeWaysCard({
routeCurrent?: boolean;
/** The routing limit the hike is planned with, or null where it was not routed (an import). */
routingLimit: number | null;
/** How the line is matched to the ways: on foot for a hike, as a run for a run. */
matching?: 'foot' | 'run';
/** What the card's head shows beside its title, from what has been read. */
aside?: (ways: HikeWays) => ReactNode;
}) {
const { t } = useTranslation();
const ways = useHikeWays({ tripId, route, current: routeCurrent });
const ways = useHikeWays({ tripId, route, current: routeCurrent, matching });
if (!ways.available) return null;
const facets: SegmentedOption<HikeFacet>[] = [
{ value: 'sac', label: t('tours.hike.ways.facet.sac'), icon: <MountainSnow {...ICON} /> },
@@ -43,7 +51,12 @@ export function HikeWaysCard({
{ value: 'way', label: t('tours.hike.ways.facet.way'), icon: <Signpost {...ICON} /> },
];
return (
<RailCard icon={<Footprints size={14} strokeWidth={2} />} title={t('tours.hike.ways.title')} foldId="hike-ways">
<RailCard
icon={<Footprints size={14} strokeWidth={2} />}
title={t('tours.hike.ways.title')}
foldId={matching === 'run' ? 'run-ways' : 'hike-ways'}
aside={aside?.(ways)}
>
<div data-testid="hike-ways-card" className="flex min-w-0 flex-col gap-3">
<div className="flex items-center gap-3">
<span aria-hidden="true" className="min-w-0 flex-1 text-content-secondary" style={fs(12, 'body')}>
@@ -18,7 +18,7 @@ vi.mock('../bike/useRouteWays', () => ({
},
}));
// FE-HIKE-WAYS-HOOK-001 to FE-HIKE-WAYS-HOOK-011
// FE-HIKE-WAYS-HOOK-001 to FE-HIKE-WAYS-HOOK-012
const ROUTE: [number, number][] = [
[47.0, 11.0],
@@ -228,4 +228,14 @@ describe('useHikeWays (MAP-03, MAP-04)', () => {
expect(hook.input).toEqual(asked);
expect(result.current.status).toBe('ready');
});
it('FE-HIKE-WAYS-HOOK-012: a run is read the same way, matched as a run, and hands the router answer on once read (RUN-02)', () => {
hook.state = READY;
const { result } = renderHook(() => useHikeWays({ tripId: 5, route: ROUTE, matching: 'run' }));
expect(result.current.routeWays).toBeNull();
act(() => result.current.toggle());
expect(hook.input).toMatchObject({ active: true, matching: 'run', withGrades: true });
expect(result.current.rows.map((row) => row.key)).toEqual(['t2', 't4', 'untagged']);
expect(result.current.routeWays).toBe(WAYS);
});
});
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
import type { RouteWayEdge } from '../bike/routeWays';
import type { RouteWayEdge, RouteWays } from '../bike/routeWays';
import { useRouteWays, type RouteWaysState } from '../bike/useRouteWays';
import { HIKE_FACETS, type HikeFacet } from './hikeFacetSpecs';
import { facetClassifier, facetOverlay, facetRows, isUnknownClass, knownShareOf, type FacetRow } from './hikeFacets';
@@ -30,6 +30,8 @@ export interface HikeWays {
focus: string | null;
focusOn: (key: string) => void;
retry: () => void;
/** The router's description of the ways under the line, once read; null before. */
routeWays: RouteWays | null;
}
const NO_STRAYS: ReadonlySet<RouteWayEdge> = new Set();
@@ -57,7 +59,8 @@ function mostlyOf(rows: readonly FacetRow[]): FacetRow | null {
* once; the facet picked (SAC grade, surface, kind of way) only decides how
* the stretches are sorted and drawn along the line on the planner map,
* every class at once or the one picked alone. Nothing here changes the
* tour, its places or its days; switched off, the map is as it was.
* tour, its places or its days; switched off, the map is as it was. A run
* (RUN-02) is read the same way, its line matched as a run on foot.
*/
export function useHikeWays(input: {
tripId: number | string;
@@ -65,15 +68,17 @@ export function useHikeWays(input: {
route: [number, number][] | null;
/** False while the line is being planned anew: the card stays, and waits for the new one. */
current?: boolean;
/** How the line is matched to the ways: on foot for a hike, as a run for a run. */
matching?: 'foot' | 'run';
}): HikeWays {
const { tripId, route, current = true } = input;
const { tripId, route, current = true, matching = 'foot' } = input;
const available = !!route && route.length >= 2;
const [shown, setShown] = useState(false);
const [facet, setFacet] = useState<HikeFacet>('sac');
const [picked, setPicked] = useState<string | null>(null);
const on = available && shown;
const line = current ? route : null;
const state = useRouteWays({ tripId, active: on, route: line, matching: 'foot', withGrades: true });
const state = useRouteWays({ tripId, active: on, route: line, matching, withGrades: true });
const { ways, grades } = state;
const spec = HIKE_FACETS[facet];
const strays = useMemo(() => (ways && line ? straysFrom(ways, line) : NO_STRAYS), [ways, line]);
@@ -120,5 +125,6 @@ export function useHikeWays(input: {
focus,
focusOn,
retry: state.retry,
routeWays: status === 'ready' ? ways : null,
};
}
@@ -1,5 +1,6 @@
import type { TourTrailGrade } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
import type { RouteWays } from '../bike/routeWays';
import type { RouteWaysState } from '../bike/useRouteWays';
@@ -13,7 +14,7 @@ vi.mock('../bike/useRouteWays', () => ({
},
}));
// FE-TOUR-RUNWAYS-001 to FE-TOUR-RUNWAYS-007
// FE-TOUR-RUNWAYS-001 to FE-TOUR-RUNWAYS-005
type Planner = Parameters<typeof RunWaysCard>[0]['planner'];
@@ -31,6 +32,30 @@ const WAYS: RouteWays = {
{ from: 2, to: 3, lengthM: 100, surface: 'ground', way: 'path', sac: 't2', wayId: 3 },
],
};
const grade = (id: number, values: Partial<TourTrailGrade>): TourTrailGrade => ({
id,
mtb_scale: null,
mtb_scale_uphill: null,
mtb_scale_imba: null,
sac_scale: null,
sac_scale_unclear: null,
surface: null,
highway: null,
...values,
});
const READY: RouteWaysState = {
status: 'ready',
ways: WAYS,
grades: {
status: 'ready',
byWay: new Map([
[1, grade(1, { surface: 'asphalt', highway: 'residential' })],
[2, grade(2, { sac_scale: 'hiking', surface: 'gravel', highway: 'track' })],
[3, grade(3, { sac_scale: 'mountain_hiking', surface: 'ground', highway: 'path' })],
]),
unasked: new Set(),
},
};
function planner(overrides: Partial<Planner> = {}): Planner {
return {
@@ -43,11 +68,13 @@ function planner(overrides: Partial<Planner> = {}): Planner {
} as Planner;
}
function show(state: Partial<RouteWaysState>, overrides: Partial<Planner> = {}) {
hook.state = { status: 'ready', ways: WAYS, grades: null, ...state };
function show(state: Partial<RouteWaysState> = {}, overrides: Partial<Planner> = {}) {
hook.state = { ...READY, ...state };
return render(<RunWaysCard planner={planner(overrides)} />);
}
const switchOn = () => fireEvent.click(screen.getByRole('button', { name: 'Show along the route' }));
beforeEach(() => {
localStorage.clear();
useTourRouteHighlightStore.getState().clear();
@@ -55,83 +82,50 @@ beforeEach(() => {
afterEach(() => vi.clearAllMocks());
describe('RunWaysCard (RUN-02)', () => {
it('FE-TOUR-RUNWAYS-001: shows the surfaces, the hiking grades and the kinds of way, the ungraded named as unknown', () => {
show({});
expect(within(screen.getByTestId('route-ways-row-surface-paved')).getByText('60 %')).toBeInTheDocument();
expect(screen.getByTestId('route-ways-row-surface-gravel')).toHaveTextContent('300 m');
expect(screen.getByTestId('route-ways-row-grade-t1')).toHaveTextContent('T1, hiking');
expect(screen.getByTestId('route-ways-row-grade-t2')).toHaveTextContent('10 %');
expect(screen.getByTestId('route-ways-row-grade-none')).toHaveTextContent('No grade in the data');
expect(screen.getByTestId('route-ways-row-way-track')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'About the path difficulty' })).toHaveAccessibleDescription(
/unknown, not easy/
);
it('FE-TOUR-RUNWAYS-001: the trail details a hike shows, switched on by the runner and matched as a run', () => {
show();
expect(screen.getByRole('region', { name: 'Trail details' })).toBeInTheDocument();
expect(hook.input).toMatchObject({ active: false, matching: 'run' });
expect(screen.queryByTestId('sac-row-t1')).toBeNull();
switchOn();
expect(hook.input).toMatchObject({ tripId: 4, active: true, route: LINE, matching: 'run', withGrades: true });
// The same words and the same paint as a hike's, never a raw tag in a label.
expect(screen.getByTestId('sac-row-t1')).toHaveTextContent('Hiking');
expect(screen.getByTestId('sac-row-t2')).toHaveTextContent('Mountain hiking');
expect(screen.queryByText(/sac_scale\)/)).toBeNull();
expect(useTourRouteHighlightStore.getState().highlight).toMatchObject({
id: 'sac',
paints: ['t1', 't2', 'unknown'],
});
});
it('FE-TOUR-RUNWAYS-002: the head says how much is unpaved and the hardest graded path, in words for assistive tech', () => {
show({});
it('FE-TOUR-RUNWAYS-002: once read, the head says how much is unpaved and the hardest graded path, in words for assistive tech', () => {
show();
expect(screen.queryByText(/unpaved/)).toBeNull();
switchOn();
expect(screen.getByText('40 % unpaved')).toBeInTheDocument();
const hardest = screen.getByTestId('run-ways-hardest');
expect(hardest).toHaveTextContent('T2');
expect(hardest).toHaveTextContent('Hardest graded path: T2, mountain hiking');
expect(hardest.textContent).not.toMatch(/[\u2013\u2014]/);
expect(hardest).toHaveTextContent('Hardest graded path: Mountain hiking');
expect(hardest.textContent).not.toMatch(/[–—]/);
});
it('FE-TOUR-RUNWAYS-003: a grade row points at its stretches on the map in the grade colour, and lets go', () => {
show({});
const row = screen.getByTestId('route-ways-row-grade-t2');
fireEvent.click(row);
expect(useTourRouteHighlightStore.getState().highlight).toEqual({
id: 'grade-t2',
lines: [LINE.slice(2, 4)],
paint: 's1',
});
fireEvent.click(row);
expect(useTourRouteHighlightStore.getState().highlight).toBeNull();
});
it('FE-TOUR-RUNWAYS-004: a route all paved and ungraded shows no head badges, and its grades as unknown', () => {
const paved: RouteWays = { line: LINE, edges: [WAYS.edges[0]] };
show({ ways: paved });
it('FE-TOUR-RUNWAYS-003: a route all paved and ungraded shows no head badges', () => {
show({ ways: { line: LINE, edges: [WAYS.edges[0]] } });
switchOn();
expect(screen.queryByText(/unpaved/)).toBeNull();
expect(screen.queryByTestId('run-ways-hardest')).toBeNull();
expect(screen.getByTestId('route-ways-row-grade-none')).toHaveTextContent('100 %');
});
it('FE-TOUR-RUNWAYS-005: says when it is still asking, offline, or failed with a way to ask again', () => {
const loading = show({ status: 'loading', ways: null });
expect(screen.getByTestId('run-ways-card')).toHaveTextContent(/./);
loading.unmount();
const offline = show({ status: 'offline', ways: null });
expect(screen.queryByRole('button', { name: /try again/i })).toBeNull();
offline.unmount();
show({ status: 'failed', ways: null });
fireEvent.click(screen.getByRole('button', { name: /try again/i }));
expect(hook.retry).toHaveBeenCalledTimes(1);
it('FE-TOUR-RUNWAYS-004: only for a run being edited', () => {
const other = show({}, { isRun: false });
expect(screen.queryByRole('region', { name: 'Trail details' })).toBeNull();
other.unmount();
show({}, { mode: { type: 'view-gpx' } as Planner['mode'] });
expect(screen.queryByRole('region', { name: 'Trail details' })).toBeNull();
});
it('FE-TOUR-RUNWAYS-006: matched on foot without trail grades, and only for a run being edited with its route as planned now', () => {
const run = show({});
expect(hook.input).toMatchObject({
tripId: 4,
active: true,
route: LINE,
matching: 'run',
withGrades: false,
});
run.unmount();
const idle = show({ status: 'idle', ways: null });
expect(screen.queryByTestId('run-ways-card')).toBeNull();
idle.unmount();
const bike = show({}, { isRun: false });
expect(screen.queryByTestId('run-ways-card')).toBeNull();
bike.unmount();
show({}, { routeCurrent: false, mode: { type: 'view-gpx' } as Planner['mode'] });
expect(screen.queryByTestId('run-ways-card')).toBeNull();
expect(hook.input).toMatchObject({ active: false, route: null });
});
it('FE-TOUR-RUNWAYS-007: the hardest graded step along the route, a stretch of no length left out', () => {
it('FE-TOUR-RUNWAYS-005: the hardest graded step along the route, a stretch of no length left out', () => {
expect(hardestSac(WAYS)).toBe('t2');
expect(hardestSac({ line: LINE, edges: [{ ...WAYS.edges[2], sac: 't5', lengthM: 0 }, WAYS.edges[1]] })).toBe('t1');
expect(hardestSac({ line: LINE, edges: [WAYS.edges[0]] })).toBeNull();
+22 -77
View File
@@ -1,37 +1,18 @@
import { Layers, Mountain } from 'lucide-react';
import { Mountain } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from '../../../i18n';
import { SoftPill } from '../../Planner/planParts';
import { Tooltip } from '../../shared/Tooltip';
import { sacPaintKey } from '../bike/bikePaint';
import {
percent,
RouteWaysProgress,
RouteWaysSource,
ShareBar,
ShareList,
SurfaceSection,
WaySection,
WaysSection,
} from '../bike/RouteWaySections';
import { sharesBy, unpavedShareOf, type RouteWayEdge, type RouteWays, type SacClass } from '../bike/routeWays';
import { useRouteWays } from '../bike/useRouteWays';
import { percent } from '../bike/RouteWaySections';
import { unpavedShareOf, type RouteWays, type SacClass } from '../bike/routeWays';
import { HikeWaysCard } from '../hike/HikeWaysCard';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { RailCard } from '../tourParts';
type GradedClass = Exclude<SacClass, 'none'>;
const sacOf = (edge: RouteWayEdge) => edge.sac;
/** The steps from easy to hard, so the hardest one along the route is the last one found. */
const SAC_STEPS: readonly GradedClass[] = ['t1', 't2', 't3', 't4', 't5', 't6'];
/** A hiking grade in the card's own words, a way without one as unknown. */
function useSacLabel() {
const { t } = useTranslation();
return (key: SacClass) => (key === 'none' ? t('tours.bike.trail.none') : t(`tours.run.ways.sac.${key}`));
}
/** The hardest graded step along the route, or null when no way on it carries a grade. */
export function hardestSac(ways: RouteWays): GradedClass | null {
const found = new Set(ways.edges.filter((edge) => edge.lengthM > 0).map((edge) => edge.sac));
@@ -39,34 +20,15 @@ export function hardestSac(ways: RouteWays): GradedClass | null {
}
/**
* RUN-02: the hiking grades along a run (sac_scale) as OpenStreetMap mappers
* gave them, by share, each pointing at its stretches on the map. A way
* without a grade is its own row: unknown, never easy. What the grades are
* and are not sits behind the info dot, not in a line of its own.
* The card's head: how much of the run is known to be unpaved, and the
* hardest graded path on it, named in the words the hike's trail details use.
*/
function PathGradeSection({ ways }: { ways: RouteWays }) {
const { t } = useTranslation();
const labelOf = useSacLabel();
const rows = useMemo(() => sharesBy<SacClass>(ways, sacOf), [ways]);
return (
<WaysSection
label={t('tours.run.ways.grade')}
info={{ label: t('tours.run.ways.gradeInfoLabel'), description: t('tours.run.ways.gradeInfo') }}
>
<ShareBar rows={rows} paintOf={sacPaintKey} />
<ShareList ways={ways} rows={rows} idPrefix="grade" classOf={sacOf} labelOf={labelOf} paintOf={sacPaintKey} />
</WaysSection>
);
}
/** The card's head: how much of the run is known to be unpaved, and the hardest graded path on it. */
function RunWaysBadges({ ways }: { ways: RouteWays | null }) {
const { t } = useTranslation();
const labelOf = useSacLabel();
const unpaved = useMemo(() => (ways ? unpavedShareOf(ways) : null), [ways]);
const hardest = useMemo(() => (ways ? hardestSac(ways) : null), [ways]);
if (unpaved === null && hardest === null) return null;
const hardestText = hardest ? t('tours.run.ways.gradeTop', { grade: labelOf(hardest) }) : '';
const hardestText = hardest ? t('tours.run.ways.gradeTop', { grade: t(`tours.hike.sac.${hardest}`) }) : '';
return (
<span className="flex flex-none items-center gap-1">
{unpaved !== null && <SoftPill>{t('tours.bike.ways.unpavedBadge', { share: percent(unpaved) })}</SoftPill>}
@@ -87,41 +49,24 @@ function RunWaysBadges({ ways }: { ways: RouteWays | null }) {
type RunWaysPlanner = Pick<TourPlannerController, 'tripId' | 'mode' | 'isRun' | 'route' | 'routeCurrent'>;
/**
* RUN-02: what the run being planned runs on: its surfaces, the hiking grades
* of its paths and its kinds of way, read off the router's description of the
* route matched on foot. Where the data says nothing the card says unknown,
* and nothing here grades the run, changes its time or changes the tour.
* RUN-02: what the run being planned runs on, in the same card a hike shows
* its trail details in: switched on by the runner, the SAC grade, the surface
* or the kind of way along the line, drawn on the map in the same colours and
* named in the same words, the line matched as a run. The head adds what a
* runner reads first: the unpaved share and the hardest graded path. Nothing
* here grades the run, changes its time or changes the tour.
*/
export function RunWaysCard({ planner }: { planner: RunWaysPlanner }) {
const { t } = useTranslation();
const editing = planner.mode.type === 'new-draft' || planner.mode.type === 'edit-saved';
const state = useRouteWays({
tripId: planner.tripId,
active: planner.isRun && editing,
route: planner.routeCurrent ? planner.route : null,
matching: 'run',
withGrades: false,
});
const { ways } = state;
if (!planner.isRun || !editing || state.status === 'idle') return null;
if (!planner.isRun || !editing) return null;
return (
<RailCard
icon={<Layers size={14} strokeWidth={2} />}
title={t('tours.bike.ways.title')}
foldId="run-ways"
aside={<RunWaysBadges ways={ways} />}
>
<div data-testid="run-ways-card" className="flex min-w-0 flex-col gap-3">
<RouteWaysProgress state={state} />
{ways && (
<>
<SurfaceSection ways={ways} />
<PathGradeSection ways={ways} />
<WaySection ways={ways} />
<RouteWaysSource />
</>
)}
</div>
</RailCard>
<HikeWaysCard
tripId={planner.tripId}
route={planner.route}
routeCurrent={planner.routeCurrent}
routingLimit={null}
matching="run"
aside={(ways) => <RunWaysBadges ways={ways.routeWays} />}
/>
);
}