mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user