From ea9e99733860b0d79991c23760af273d6dcedfa1 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 20 Oct 2026 12:25:20 +0200 Subject: [PATCH] feat(tours): show the known SAC grades along a hike (MAP-03) A hike in the planner, being edited or viewed as an import, now has an "SAC grades" card. Switched on, it asks the router which OpenStreetMap ways lie under the line (matched on foot, every grade of path open) and the server reads their sac_scale through the Overpass mirrors, the same trail-grades read the mountain bike grades use. The grades are drawn along the route on the planner map in one ramp of a single hue, T1 to T6, and listed by share and length; a row draws its class alone. Each row names its source in a tooltip: the SAC hiking scale and the sac_scale value OpenStreetMap wrote. What is not known stays unknown and says why, never T1: a way without a grade, a grade that names no single step (handed on as written), a stretch with no way clearly under the tour's line, or a way that could not be asked about. For imported tracks and stretches drawn by hand a matched way only counts where it keeps within 25 m of the tour's own line, so the original line is kept and nothing is guessed. The router's own reading of the scale is not asked for, since it may be normalised. The routing limit of a planned hike sits apart as a setting with its own badge, and a known step above it is marked. Nothing about the tour, its places or its days changes; switched off, the map is as it was. No regional colour categories and no grade derived from slope, height or trail markings. --- .../src/components/Map/valhallaRoute.test.ts | 17 +- client/src/components/Map/valhallaRoute.ts | 50 ++-- .../src/components/Tours/ReadStatusNotice.tsx | 40 ++++ .../Tours/bike/BikeWaysCard.test.tsx | 6 +- .../components/Tours/bike/BikeWaysCard.tsx | 2 +- .../Tours/bike/RouteWaySections.tsx | 32 +-- .../components/Tours/bike/routeHighlight.ts | 12 +- .../components/Tours/bike/routeWays.test.ts | 17 +- .../Tours/bike/useHighlightedRoute.test.ts | 17 +- .../Tours/bike/useHighlightedRoute.ts | 12 +- .../Tours/bike/useRouteWays.test.tsx | 32 ++- .../src/components/Tours/bike/useRouteWays.ts | 50 ++-- .../Tours/hike/SacGradesCard.test.tsx | 135 +++++++++++ .../components/Tours/hike/SacGradesCard.tsx | 218 ++++++++++++++++++ client/src/components/Tours/hike/hikePaint.ts | 29 +++ .../components/Tours/hike/routeMatch.test.ts | 84 +++++++ .../src/components/Tours/hike/routeMatch.ts | 98 ++++++++ .../components/Tours/hike/sacGrades.test.ts | 115 +++++++++ client/src/components/Tours/hike/sacGrades.ts | 122 ++++++++++ .../Tours/hike/useSacGrades.test.tsx | 186 +++++++++++++++ .../src/components/Tours/hike/useSacGrades.ts | 124 ++++++++++ .../Tours/planner/TourPlannerPanels.test.tsx | 38 +++ .../Tours/planner/TourPlannerPanels.tsx | 9 + .../components/Tours/run/RunWaysCard.test.tsx | 2 +- .../src/components/Tours/run/RunWaysCard.tsx | 2 +- client/src/main.tsx | 1 + .../pages/tripPlanner/useTripPlannerPage.ts | 6 +- client/src/store/tourRouteHighlightStore.ts | 12 +- client/src/styles/hike.css | 30 +++ .../nest/tours/tour-trail-grade.controller.ts | 4 +- .../nest/tours/tour-trail-grade.service.ts | 31 ++- server/tests/e2e/tours-bike.e2e.test.ts | 20 +- .../nest/tour-trail-grade.service.test.ts | 34 ++- shared/src/i18n/ar/tours.ts | 27 +++ shared/src/i18n/az/tours.ts | 28 +++ shared/src/i18n/br/tours.ts | 28 +++ shared/src/i18n/ca/tours.ts | 28 +++ shared/src/i18n/cs/tours.ts | 27 +++ shared/src/i18n/de/tours.ts | 30 +++ shared/src/i18n/en/tours.ts | 27 +++ shared/src/i18n/es/tours.ts | 29 +++ shared/src/i18n/et/tours.ts | 27 +++ shared/src/i18n/fr/tours.ts | 30 +++ shared/src/i18n/gr/tours.ts | 31 +++ shared/src/i18n/hu/tours.ts | 29 +++ shared/src/i18n/id/tours.ts | 30 +++ shared/src/i18n/it/tours.ts | 30 +++ shared/src/i18n/ja/tours.ts | 27 +++ shared/src/i18n/ko/tours.ts | 27 +++ shared/src/i18n/nl/tours.ts | 29 +++ shared/src/i18n/pl/tours.ts | 28 +++ shared/src/i18n/ru/tours.ts | 29 +++ shared/src/i18n/sk/tours.ts | 27 +++ shared/src/i18n/sv/tours.ts | 27 +++ shared/src/i18n/th/tours.ts | 27 +++ shared/src/i18n/tours-locales.spec.ts | 2 +- shared/src/i18n/tr/tours.ts | 27 +++ shared/src/i18n/uk/tours.ts | 28 +++ shared/src/i18n/vi/tours.ts | 28 +++ shared/src/i18n/zh-TW/tours.ts | 27 +++ shared/src/i18n/zh/tours.ts | 27 +++ .../src/tours/tour-trail-grade.schema.spec.ts | 49 ++++ shared/src/tours/tour-trail-grade.schema.ts | 38 ++- 63 files changed, 2327 insertions(+), 108 deletions(-) create mode 100644 client/src/components/Tours/ReadStatusNotice.tsx create mode 100644 client/src/components/Tours/hike/SacGradesCard.test.tsx create mode 100644 client/src/components/Tours/hike/SacGradesCard.tsx create mode 100644 client/src/components/Tours/hike/hikePaint.ts create mode 100644 client/src/components/Tours/hike/routeMatch.test.ts create mode 100644 client/src/components/Tours/hike/routeMatch.ts create mode 100644 client/src/components/Tours/hike/sacGrades.test.ts create mode 100644 client/src/components/Tours/hike/sacGrades.ts create mode 100644 client/src/components/Tours/hike/useSacGrades.test.tsx create mode 100644 client/src/components/Tours/hike/useSacGrades.ts create mode 100644 client/src/styles/hike.css create mode 100644 shared/src/tours/tour-trail-grade.schema.spec.ts diff --git a/client/src/components/Map/valhallaRoute.test.ts b/client/src/components/Map/valhallaRoute.test.ts index 011b5fd27..2182a8eec 100644 --- a/client/src/components/Map/valhallaRoute.test.ts +++ b/client/src/components/Map/valhallaRoute.test.ts @@ -557,7 +557,7 @@ describe('valhallaTraceAttributes (BIK-01)', () => { bodies.push(await request.json() as Record) return HttpResponse.json({ shape: SHAPE, edges: [] }) })) - await valhallaTraceAttributes(SHAPE_POINTS, undefined, 'pedestrian') + await valhallaTraceAttributes(SHAPE_POINTS, undefined, 'run') expect(bodies[0]).toMatchObject({ costing: 'pedestrian', costing_options: { pedestrian: { max_hiking_difficulty: 6 } }, @@ -589,6 +589,21 @@ describe('valhallaTraceAttributes (BIK-01)', () => { await expect(valhallaTraceAttributes(SHAPE_POINTS)).resolves.toBeNull() }) + it('VALHALLA-TRACE-006: a hike is matched on foot with every grade of path open, never by its routing limit (MAP-03)', async () => { + const bodies: Record[] = [] + server.use(http.post(TRACE, async ({ request }) => { + bodies.push(await request.json() as Record) + return HttpResponse.json({ shape: SHAPE, edges: [] }) + })) + await valhallaTraceAttributes(SHAPE_POINTS, undefined, 'foot') + expect(bodies[0]).toMatchObject({ + costing: 'pedestrian', + costing_options: { pedestrian: { max_hiking_difficulty: 6 } }, + shape_match: 'map_snap', + }) + expect(JSON.stringify(bodies[0])).not.toContain('sac_scale') + }) + it('VALHALLA-TRACE-004: a caller gone before its turn asks nothing', async () => { let asked = false server.use(http.post(TRACE, () => { asked = true; return HttpResponse.json({}) })) diff --git a/client/src/components/Map/valhallaRoute.ts b/client/src/components/Map/valhallaRoute.ts index ab970d2d2..30cf31b91 100644 --- a/client/src/components/Map/valhallaRoute.ts +++ b/client/src/components/Map/valhallaRoute.ts @@ -369,30 +369,46 @@ const TRACE_TIMEOUT_MS = 30_000 /** * How a line is matched to the ways under it: as a mountain bike rides, the - * bicycle costing that refuses the fewest ways, or on foot with no hiking - * limit, so steps and mountain paths a run takes stay on the line (matched as - * a bike, a trail run over steps was described as the street beside them). + * bicycle costing that refuses the fewest ways, so a road bike's route and a + * gravel one are described alike; or on foot with every grade of path open, + * since a hike's line (MAP-03) may run on a T6 ridge the matcher must not + * refuse, and steps and mountain paths a run takes (RUN-02) stay on the line + * (matched as a bike, a trail run over steps was described as the street + * beside them). Matching is no routing: the limit a hike was planned with + * plays no part. A hike and a run are matched alike; they differ in whether + * the router's own hiking grade is asked for. */ -export type TraceMatch = 'bicycle' | 'pedestrian' +const TRACE_COSTING = { + bike: { costing: 'bicycle', costing_options: { bicycle: { bicycle_type: 'Mountain' } } }, + foot: { costing: 'pedestrian', costing_options: { pedestrian: { max_hiking_difficulty: 6 } } }, + run: { costing: 'pedestrian', costing_options: { pedestrian: { max_hiking_difficulty: 6 } } }, +} as const -const TRACE_COSTING: Record>> = { - bicycle: { bicycle: { bicycle_type: 'Mountain' } }, - pedestrian: { pedestrian: { max_hiking_difficulty: 6 } }, +export type TraceMatching = keyof typeof TRACE_COSTING + +const TRACE_ATTRIBUTES = ['shape', 'edge.surface', 'edge.use', 'edge.road_class', 'edge.cycle_lane', 'edge.length', + 'edge.way_id', 'edge.begin_shape_index', 'edge.end_shape_index'] + +/** The router's hiking grade is asked along a bike tour's and a run's line, never a hike's (MAP-03 reads OpenStreetMap). */ +function traceAttributesFor(matching: TraceMatching): string[] { + return matching === 'foot' ? TRACE_ATTRIBUTES : [...TRACE_ATTRIBUTES, 'edge.sac_scale'] } /** - * BIK-01, RUN-02: how the router describes the ways under a line + * BIK-01, RUN-02 and MAP-03: how the router describes the ways under a line * (`trace_attributes`): the surface, the use and road class, the cycle lane, - * the hiking grade (`sac_scale`, 0 where the way has none) and the - * OpenStreetMap way of every edge, with the line as matched. A bike tour's - * line is matched as a mountain bike would ride it, so a road bike's route and - * a gravel one are described alike; a run's on foot. Null for every failure: - * the description is an addition beside a route that already exists. + * the OpenStreetMap way of every edge and, but for a hike, the router's + * hiking grade (`sac_scale`, 0 where the way has none), with the line as + * matched. A run's grade shares (RUN-02) read the router's grade; the grade a + * hike shows (MAP-03) comes from OpenStreetMap itself, since the router's + * reading may be normalised or derived, so it is not asked for there. Null for + * every failure: the description is an addition beside a route that already + * exists. */ export async function valhallaTraceAttributes( line: readonly [number, number][], signal?: AbortSignal, - matchAs: TraceMatch = 'bicycle', + matching: TraceMatching = 'bike' ): Promise { const base = valhallaBase() if (!base || line.length < 2) return null @@ -407,12 +423,10 @@ export async function valhallaTraceAttributes( headers: { 'Content-Type': 'application/json', 'X-Client-Id': 'trek' }, body: JSON.stringify({ shape, - costing: matchAs, - costing_options: TRACE_COSTING[matchAs], + ...TRACE_COSTING[matching], shape_match: 'map_snap', filters: { - attributes: ['shape', 'edge.surface', 'edge.use', 'edge.road_class', 'edge.cycle_lane', 'edge.sac_scale', 'edge.length', - 'edge.way_id', 'edge.begin_shape_index', 'edge.end_shape_index'], + attributes: traceAttributesFor(matching), action: 'include', }, }), diff --git a/client/src/components/Tours/ReadStatusNotice.tsx b/client/src/components/Tours/ReadStatusNotice.tsx new file mode 100644 index 000000000..5d26ead77 --- /dev/null +++ b/client/src/components/Tours/ReadStatusNotice.tsx @@ -0,0 +1,40 @@ +import { AlertTriangle, Loader2, RefreshCw, WifiOff } from 'lucide-react'; +import { useTranslation } from '../../i18n'; +import { fs } from '../shared/DialogShell'; +import { CardAction, TourNotice } from './tourParts'; + +/** + * How a card's online read stands while it has no answer to show (a bike + * tour's ways, a hike's SAC grades): still asking, offline, or failed with a + * way to ask again. Nothing while the answer is there, so the card shows it. + */ +export function ReadStatusNotice({ + status, + messages, + onRetry, +}: { + status: string; + messages: { loading: string; offline: string; failed: string }; + onRetry: () => void; +}) { + const { t } = useTranslation(); + if (status === 'loading') + return ( + + + {messages.loading} + + ); + if (status === 'offline') return }>{messages.offline}; + if (status !== 'failed') return null; + return ( + }> + + {messages.failed} + } onClick={onRetry}> + {t('common.errorRetry')} + + + + ); +} diff --git a/client/src/components/Tours/bike/BikeWaysCard.test.tsx b/client/src/components/Tours/bike/BikeWaysCard.test.tsx index 21fa030d0..6cbd3e92e 100644 --- a/client/src/components/Tours/bike/BikeWaysCard.test.tsx +++ b/client/src/components/Tours/bike/BikeWaysCard.test.tsx @@ -49,6 +49,8 @@ const grade = (id: number, mtb: string | null, uphill: string | null = null): To mtb_scale: mtb, mtb_scale_uphill: uphill, mtb_scale_imba: null, + sac_scale: null, + sac_scale_unclear: null, }); function show(state: Partial, overrides: Partial = {}) { @@ -147,11 +149,11 @@ describe('BikeWaysCard (BIK-01 to BIK-05)', () => { hike.unmount(); show({}, { routeCurrent: false, mode: { type: 'view-gpx' } } as Partial); expect(screen.queryByTestId('bike-ways-card')).toBeNull(); - expect(hook.input).toMatchObject({ tripId: 4, active: false, route: null, matchAs: 'bicycle', withGrades: false }); + expect(hook.input).toMatchObject({ tripId: 4, active: false, route: null, matching: 'bike', withGrades: false }); }); it('FE-TOUR-BIKECARD-009: matches the line as ridden, and asks the trail grades for a mountain bike tour only', () => { show({}, { bikeProfile: 'mtb' } as Partial); - expect(hook.input).toMatchObject({ active: true, route: LINE, matchAs: 'bicycle', withGrades: true }); + expect(hook.input).toMatchObject({ active: true, route: LINE, matching: 'bike', withGrades: true }); }); }); diff --git a/client/src/components/Tours/bike/BikeWaysCard.tsx b/client/src/components/Tours/bike/BikeWaysCard.tsx index 4cd579aa8..fc3ed0897 100644 --- a/client/src/components/Tours/bike/BikeWaysCard.tsx +++ b/client/src/components/Tours/bike/BikeWaysCard.tsx @@ -155,7 +155,7 @@ export function BikeWaysCard({ planner }: { planner: TourPlannerController }) { tripId: planner.tripId, active: planner.isBike && editing, route, - matchAs: 'bicycle', + matching: 'bike', withGrades: planner.bikeProfile === 'mtb', }); const { ways } = state; diff --git a/client/src/components/Tours/bike/RouteWaySections.tsx b/client/src/components/Tours/bike/RouteWaySections.tsx index 5cfc7d5f7..cc6174d4c 100644 --- a/client/src/components/Tours/bike/RouteWaySections.tsx +++ b/client/src/components/Tours/bike/RouteWaySections.tsx @@ -1,11 +1,12 @@ -import { AlertTriangle, Info, Loader2, RefreshCw, WifiOff } from 'lucide-react'; +import { Info } from 'lucide-react'; import { useMemo, type ReactNode } from 'react'; import { useTranslation } from '../../../i18n'; import { useSettingsStore } from '../../../store/settingsStore'; import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore'; import { formatDistance } from '../../../utils/units'; import { fs } from '../../shared/DialogShell'; -import { CardAction, InfoDot, TourNotice } from '../tourParts'; +import { ReadStatusNotice } from '../ReadStatusNotice'; +import { InfoDot } from '../tourParts'; import { bikePaintCss, surfacePaintKey, type BikePaintKey } from './bikePaint'; import { sharesBy, @@ -202,25 +203,16 @@ export function WaySection({ ways }: { ways: RouteWays }) { /** How reading the ways went while there are none to show: on its way, offline, or failed with a way to ask again. */ export function RouteWaysProgress({ state }: { state: Pick & { retry: () => void } }) { const { t } = useTranslation(); - if (state.status === 'loading') - return ( - - - {t('tours.bike.ways.loading')} - - ); - if (state.status === 'offline') - return }>{t('tours.bike.ways.offline')}; - if (state.status !== 'failed') return null; return ( - }> - - {t('tours.bike.ways.failed')} - } onClick={state.retry}> - {t('common.errorRetry')} - - - + ); } diff --git a/client/src/components/Tours/bike/routeHighlight.ts b/client/src/components/Tours/bike/routeHighlight.ts index 552bf5ebe..7d570c217 100644 --- a/client/src/components/Tours/bike/routeHighlight.ts +++ b/client/src/components/Tours/bike/routeHighlight.ts @@ -1,8 +1,9 @@ import type { RouteHighlight } from '../../../store/tourRouteHighlightStore'; -import type { BikePaintKey } from './bikePaint'; type Line = [number, number][]; type LineColor = { line: string; casing: string } | undefined; +/** The route paints resolved for the map, by key: the bike's and the hike's, with the accent and the rim. */ +type RoutePaint = Readonly> & { readonly way: string; readonly casing: string }; /** * The planner map's lines with a highlight laid over them: the route's own @@ -15,13 +16,16 @@ export function withRouteHighlight( route: Line[] | null, walking: boolean[] | null, colors: LineColor[] | null, - paint: Record + paint: RoutePaint ): { route: Line[] | null; walking: boolean[] | null; colors: LineColor[] | null } { if (!highlight?.lines.length || !route?.length) return { route, walking, colors }; - const color = { line: paint[highlight.paint as BikePaintKey] ?? paint.way, casing: paint.casing }; + const colorOf = (index: number) => ({ + line: paint[highlight.paints?.[index] ?? highlight.paint] ?? paint.way, + casing: paint.casing, + }); return { route: [...route, ...highlight.lines], walking: walking ? [...walking, ...highlight.lines.map(() => false)] : null, - colors: [...(colors ?? route.map(() => undefined)), ...highlight.lines.map(() => color)], + colors: [...(colors ?? route.map(() => undefined)), ...highlight.lines.map((_, index) => colorOf(index))], }; } diff --git a/client/src/components/Tours/bike/routeWays.test.ts b/client/src/components/Tours/bike/routeWays.test.ts index ea3a94f53..e33df96d0 100644 --- a/client/src/components/Tours/bike/routeWays.test.ts +++ b/client/src/components/Tours/bike/routeWays.test.ts @@ -13,7 +13,7 @@ import { type RouteWays, } from './routeWays'; -// FE-TOUR-WAYS-001 to FE-TOUR-WAYS-013 +// FE-TOUR-WAYS-001 to FE-TOUR-WAYS-014 const LINE: [number, number][] = [ [47.0, 11.0], @@ -186,4 +186,19 @@ describe('the paints and the highlight (BIK-01 to BIK-05)', () => { expect(withRouteHighlight({ id: 'x', lines: [], paint: 'gravel' }, route, null, null, paint).route).toBe(route); expect(withRouteHighlight({ id: 'x', lines: [LINE], paint: 'gravel' }, null, null, null, paint).route).toBeNull(); }); + + it('FE-TOUR-WAYS-014: lines of different paints each take their own, an unknown key the accent (MAP-03)', () => { + const drawn = withRouteHighlight( + { id: 'sac', lines: [LINE.slice(0, 2), LINE.slice(1, 3), LINE.slice(2)], paint: 'gravel', paints: ['way', 'nope'] }, + [LINE], + null, + null, + paint + ); + expect(drawn.colors?.slice(1)).toEqual([ + { line: '#111', casing: '#fff' }, + { line: '#111', casing: '#fff' }, + { line: '#a0522d', casing: '#fff' }, + ]); + }); }); diff --git a/client/src/components/Tours/bike/useHighlightedRoute.test.ts b/client/src/components/Tours/bike/useHighlightedRoute.test.ts index 7009828e9..9ea649366 100644 --- a/client/src/components/Tours/bike/useHighlightedRoute.test.ts +++ b/client/src/components/Tours/bike/useHighlightedRoute.test.ts @@ -8,7 +8,7 @@ const LINE: [number, number][] = [ [47.01, 11], ]; -describe('useHighlightedRoute (BIK-01 to BIK-05)', () => { +describe('useHighlightedRoute (BIK-01 to BIK-05, MAP-03)', () => { it('lays the highlight over the route while shown, and leaves the route alone otherwise', () => { act(() => useTourRouteHighlightStore.getState().show({ id: 'surface-gravel', lines: [LINE], paint: 'gravel' })); const shown = renderHook(() => useHighlightedRoute(true, [LINE], null, null)); @@ -19,4 +19,19 @@ describe('useHighlightedRoute (BIK-01 to BIK-05)', () => { act(() => useTourRouteHighlightStore.getState().clear()); expect(shown.result.current.route).toEqual([LINE]); }); + + it('draws the SAC grades of a hike in their own paints beside the bike paints (MAP-03)', () => { + act(() => + useTourRouteHighlightStore + .getState() + .show({ id: 'sac', lines: [LINE, LINE], paint: 'unknown', paints: ['t3', 'unknown'] }) + ); + const { result } = renderHook(() => useHighlightedRoute(true, [LINE], null, null)); + expect(result.current.route).toHaveLength(3); + expect(result.current.colors?.slice(1)).toEqual([ + { line: expect.any(String), casing: expect.any(String) }, + { line: expect.any(String), casing: expect.any(String) }, + ]); + act(() => useTourRouteHighlightStore.getState().clear()); + }); }); diff --git a/client/src/components/Tours/bike/useHighlightedRoute.ts b/client/src/components/Tours/bike/useHighlightedRoute.ts index 077b2667c..92325242a 100644 --- a/client/src/components/Tours/bike/useHighlightedRoute.ts +++ b/client/src/components/Tours/bike/useHighlightedRoute.ts @@ -1,4 +1,6 @@ +import { useMemo } from 'react'; import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore'; +import { useHikePaint } from '../hike/hikePaint'; import { useBikePaint } from './bikePaint'; import { withRouteHighlight } from './routeHighlight'; @@ -7,12 +9,14 @@ type Walking = Parameters[2]; type Colors = Parameters[3]; /** - * BIK-01 to BIK-05: the planner map's route with the stretches a bike tour's - * card points at laid over it, while `shown` (the route being edited is on - * the map); otherwise the lines as they came. + * BIK-01 to BIK-05 and MAP-03: the planner map's route with the stretches a + * bike tour's or a hike's card points at laid over it, while `shown` (the + * tour's own line is on the map); otherwise the lines as they came. */ export function useHighlightedRoute(shown: boolean, route: Lines, walking: Walking, colors: Colors) { const highlight = useTourRouteHighlightStore((state) => state.highlight); - const paint = useBikePaint(); + const bike = useBikePaint(); + const hike = useHikePaint(); + const paint = useMemo(() => ({ ...bike, ...hike }), [bike, hike]); return withRouteHighlight(shown ? highlight : null, route, walking, colors, paint); } diff --git a/client/src/components/Tours/bike/useRouteWays.test.tsx b/client/src/components/Tours/bike/useRouteWays.test.tsx index 4d95f85a2..b7d7f394f 100644 --- a/client/src/components/Tours/bike/useRouteWays.test.tsx +++ b/client/src/components/Tours/bike/useRouteWays.test.tsx @@ -12,7 +12,7 @@ vi.mock('../../Map/valhallaRoute', () => trace); vi.mock('../../../repo/tourTrailGradeRepo', () => ({ tourTrailGradeRepo: gradesRepo })); vi.mock('../../../hooks/useNetworkMode', () => ({ useNetworkMode: () => ({ offline: network.offline }) })); -// FE-TOUR-WAYS-HOOK-001 to FE-TOUR-WAYS-HOOK-009 +// FE-TOUR-WAYS-HOOK-001 to FE-TOUR-WAYS-HOOK-011 const ROUTE: [number, number][] = [ [47.0, 11.0], @@ -32,7 +32,7 @@ const input = (overrides: Partial = {}): Input => ({ tripId: 3, active: true, route: ROUTE, - matchAs: 'bicycle', + matching: 'bike', withGrades: false, ...overrides, }); @@ -66,7 +66,7 @@ describe('useRouteWays (BIK-01 to BIK-05, RUN-02)', () => { expect(result.current.status).toBe('loading'); expect(trace.valhallaTraceAttributes).not.toHaveBeenCalled(); await settle(); - expect(trace.valhallaTraceAttributes).toHaveBeenCalledWith(ROUTE, expect.any(AbortSignal), 'bicycle'); + expect(trace.valhallaTraceAttributes).toHaveBeenCalledWith(ROUTE, expect.any(AbortSignal), 'bike'); expect(result.current.status).toBe('ready'); expect(result.current.ways?.edges.map((edge) => edge.surface)).toEqual(['gravel', 'ground']); expect(result.current.grades).toBeNull(); @@ -75,7 +75,9 @@ describe('useRouteWays (BIK-01 to BIK-05, RUN-02)', () => { it('FE-TOUR-WAYS-HOOK-003: a mountain bike tour asks the grades of its ways, and keeps what could not be asked apart', async () => { gradesRepo.grades.mockResolvedValue({ - ways: [{ id: 11, mtb_scale: '2', mtb_scale_uphill: null, mtb_scale_imba: null }], + ways: [ + { id: 11, mtb_scale: '2', mtb_scale_uphill: null, mtb_scale_imba: null, sac_scale: null, sac_scale_unclear: null }, + ], unanswered: [12], }); const { result } = renderHook(() => useRouteWays(input({ withGrades: true }))); @@ -127,17 +129,17 @@ describe('useRouteWays (BIK-01 to BIK-05, RUN-02)', () => { expect(trace.valhallaTraceAttributes).not.toHaveBeenCalled(); }); - it('FE-TOUR-WAYS-HOOK-009: a run is matched on foot, and matched again when asked another way (RUN-02)', async () => { + it('FE-TOUR-WAYS-HOOK-011: a run is matched on foot, and matched again when asked another way (RUN-02)', async () => { const { result, rerender } = renderHook((props: Input) => useRouteWays(props), { - initialProps: input({ matchAs: 'pedestrian' }), + initialProps: input({ matching: 'run' }), }); await settle(); - expect(trace.valhallaTraceAttributes).toHaveBeenLastCalledWith(ROUTE, expect.any(AbortSignal), 'pedestrian'); + expect(trace.valhallaTraceAttributes).toHaveBeenLastCalledWith(ROUTE, expect.any(AbortSignal), 'run'); expect(result.current.status).toBe('ready'); - rerender(input({ matchAs: 'bicycle' })); + rerender(input({ matching: 'bike' })); expect(result.current.status).toBe('loading'); await settle(); - expect(trace.valhallaTraceAttributes).toHaveBeenLastCalledWith(ROUTE, expect.any(AbortSignal), 'bicycle'); + expect(trace.valhallaTraceAttributes).toHaveBeenLastCalledWith(ROUTE, expect.any(AbortSignal), 'bike'); }); it('FE-TOUR-WAYS-HOOK-008: a new route drops the highlight and the old answer, and leaving takes the highlight down', async () => { @@ -152,4 +154,16 @@ describe('useRouteWays (BIK-01 to BIK-05, RUN-02)', () => { unmount(); expect(useTourRouteHighlightStore.getState().highlight).toBeNull(); }); + + it('FE-TOUR-WAYS-HOOK-009: a hike is matched on foot, and a change of matching asks again (MAP-03)', async () => { + gradesRepo.grades.mockResolvedValue({ ways: [], unanswered: [] }); + const { result, rerender } = renderHook((props: Input) => useRouteWays(props), { + initialProps: input({ matching: 'foot', withGrades: true }), + }); + await settle(); + expect(trace.valhallaTraceAttributes).toHaveBeenCalledWith(ROUTE, expect.any(AbortSignal), 'foot'); + expect(result.current.grades?.status).toBe('ready'); + rerender(input({ matching: 'bike', withGrades: true })); + expect(result.current.status).toBe('loading'); + }); }); diff --git a/client/src/components/Tours/bike/useRouteWays.ts b/client/src/components/Tours/bike/useRouteWays.ts index 99fe3c565..e8a399174 100644 --- a/client/src/components/Tours/bike/useRouteWays.ts +++ b/client/src/components/Tours/bike/useRouteWays.ts @@ -4,7 +4,7 @@ import { useNetworkMode } from '../../../hooks/useNetworkMode'; import { OnlineOnlyError } from '../../../repo/onlineOnly'; import { tourTrailGradeRepo } from '../../../repo/tourTrailGradeRepo'; import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore'; -import { valhallaTraceAttributes, type TraceMatch } from '../../Map/valhallaRoute'; +import { valhallaTraceAttributes, type TraceMatching } from '../../Map/valhallaRoute'; import { routeWaysFrom, wayIdsOf, type RouteWays } from './routeWays'; /** How long a route has to stay before its ways are asked about, so a route being shaped asks once. */ @@ -22,7 +22,7 @@ export interface TrailGrades { export interface RouteWaysState { status: RouteWaysStatus; ways: RouteWays | null; - /** BIK-05, a mountain bike tour's only: the trail grades of its ways. */ + /** BIK-05 and MAP-03, where asked for: the trail grades of its ways. */ grades: TrailGrades | null; } @@ -30,31 +30,27 @@ interface Answer extends RouteWaysState { attempt: number; /** The route and the question it answers; another one makes it stale. */ route: [number, number][]; - matchAs: TraceMatch; + matching: TraceMatching; withGrades: boolean; } const IDLE: RouteWaysState = { status: 'idle', ways: null, grades: null }; const NO_GRADES = { byWay: new Map(), unasked: new Set() }; -interface RouteWaysInput { +interface Question { tripId: number | string; - active: boolean; - route: [number, number][] | null; - /** How the line is matched to its ways: a bike tour as ridden, a run on foot. */ - matchAs: TraceMatch; - /** BIK-05, a mountain bike tour's only: the trail grades of its ways are asked too. */ + route: [number, number][]; + matching: TraceMatching; withGrades: boolean; } -/** The ways of a route, and for a mountain bike tour their grades; the grades' outcome as it came. */ +/** The ways of a route, and where asked for their grades; the grades' outcome as it came. */ async function askRouteWays( - question: Pick & { route: [number, number][] }, + { tripId, route, matching, withGrades }: Question, signal: AbortSignal, onWays: (ways: RouteWays) => void ): Promise { - const { tripId, route, matchAs, withGrades } = question; - const ways = routeWaysFrom(await valhallaTraceAttributes(route, signal, matchAs)); + const ways = routeWaysFrom(await valhallaTraceAttributes(route, signal, matching)); if (!ways) return { status: 'failed', ways: null, grades: null }; if (!withGrades) return { status: 'ready', ways, grades: null }; onWays(ways); @@ -71,13 +67,21 @@ async function askRouteWays( } /** - * BIK-01 to BIK-05, RUN-02: what the bike tour or the run being planned runs - * on, asked of the router once its route has settled, and for a mountain bike - * tour the trail grades of its ways. A new route makes the answer on its way - * stale and takes the map's highlight down; nothing of it changes the tour. + * BIK-01 to BIK-05 and MAP-03: what the tour being planned runs on, asked of + * the router once its route has settled, and where asked for the trail grades + * of its ways (a mountain bike tour's, a hike's SAC grades). A new route makes + * the answer on its way stale and takes the map's highlight down; nothing of + * it changes the tour. */ -export function useRouteWays(input: RouteWaysInput): RouteWaysState & { retry: () => void } { - const { tripId, active, route, matchAs, withGrades } = input; +export function useRouteWays(input: { + tripId: number | string; + active: boolean; + route: [number, number][] | null; + /** How the line is matched to its ways: as a bike rides it, or on foot for a hike. */ + matching: TraceMatching; + withGrades: boolean; +}): RouteWaysState & { retry: () => void } { + const { tripId, active, route, matching, withGrades } = input; const { offline } = useNetworkMode(); const [answer, setAnswer] = useState(null); const [attempt, setAttempt] = useState(0); @@ -89,10 +93,10 @@ export function useRouteWays(input: RouteWaysInput): RouteWaysState & { retry: ( if (!asking || !route) return; const controller = new AbortController(); const settle = (state: RouteWaysState) => { - if (!controller.signal.aborted) setAnswer({ ...state, route, matchAs, withGrades, attempt }); + if (!controller.signal.aborted) setAnswer({ ...state, route, matching, withGrades, attempt }); }; const timer = window.setTimeout(() => { - askRouteWays({ tripId, route, matchAs, withGrades }, controller.signal, (ways) => + askRouteWays({ tripId, route, matching, withGrades }, controller.signal, (ways) => settle({ status: 'ready', ways, grades: { status: 'loading', ...NO_GRADES } }) ) .then(settle) @@ -103,7 +107,7 @@ export function useRouteWays(input: RouteWaysInput): RouteWaysState & { retry: ( window.clearTimeout(timer); controller.abort(); }; - }, [asking, attempt, matchAs, route, tripId, withGrades]); + }, [asking, attempt, matching, route, tripId, withGrades]); // The highlight belongs to the route it was drawn on. useEffect(() => () => useTourRouteHighlightStore.getState().clear(), []); @@ -112,7 +116,7 @@ export function useRouteWays(input: RouteWaysInput): RouteWaysState & { retry: ( if (offline) return { status: 'offline', ways: null, grades: null, retry }; const current = answer?.route === route && - answer.matchAs === matchAs && + answer.matching === matching && answer.withGrades === withGrades && answer.attempt === attempt; return current ? { ...answer, retry } : { status: 'loading', ways: null, grades: null, retry }; diff --git a/client/src/components/Tours/hike/SacGradesCard.test.tsx b/client/src/components/Tours/hike/SacGradesCard.test.tsx new file mode 100644 index 000000000..403ec97b1 --- /dev/null +++ b/client/src/components/Tours/hike/SacGradesCard.test.tsx @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, within } from '../../../../tests/helpers/render'; +import { SacGradesCard } from './SacGradesCard'; +import type { SacGradeRow, SacGrades } from './useSacGrades'; + +const hook = vi.hoisted(() => ({ grades: null as unknown, input: null as unknown })); +vi.mock('./useSacGrades', () => ({ + useSacGrades: (input: unknown) => { + hook.input = input; + return hook.grades; + }, +})); + +// FE-HIKE-SACCARD-001 to FE-HIKE-SACCARD-007 + +const LINE: [number, number][] = [ + [47.0, 11.0], + [47.01, 11.0], +]; +const ROWS: SacGradeRow[] = [ + { key: 't2', lengthM: 1200, share: 0.4, tags: ['mountain_hiking'] }, + { key: 't4', lengthM: 600, share: 0.2, tags: ['alpine_hiking'] }, + { key: 'unclear', lengthM: 300, share: 0.1, tags: ['T3', 'hiking;mountain_hiking'] }, + { key: 'untagged', lengthM: 700, share: 0.2, tags: [] }, + { key: 'unmatched', lengthM: 100, share: 0.05, tags: [] }, + { key: 'unasked', lengthM: 100, share: 0.05, tags: [] }, +]; + +function grades(overrides: Partial = {}): SacGrades { + return { + available: true, + shown: true, + toggle: vi.fn(), + status: 'ready', + rows: ROWS, + knownShare: 0.6, + highest: 4, + focus: null, + focusOn: vi.fn(), + retry: vi.fn(), + ...overrides, + }; +} + +function show(state: SacGrades, routingLimit: number | null = 3) { + hook.grades = state; + return render(); +} + +beforeEach(() => localStorage.clear()); + +describe('SacGradesCard (MAP-03)', () => { + it('FE-HIKE-SACCARD-001: without a line to grade there is no card', () => { + show(grades({ available: false, shown: false, status: 'off', rows: [] })); + expect(screen.queryByTestId('sac-grades-card')).toBeNull(); + expect(hook.input).toEqual({ tripId: 4, route: LINE, current: true }); + }); + + it('FE-HIKE-SACCARD-002: switched off it only offers the switch, and the switch turns it on', () => { + const state = grades({ shown: false, status: 'off', rows: [] }); + show(state); + const toggle = screen.getByRole('button', { name: 'Show along the route' }); + expect(toggle).toHaveAttribute('aria-pressed', 'false'); + expect(screen.queryByTestId('sac-row-t2')).toBeNull(); + fireEvent.click(toggle); + expect(state.toggle).toHaveBeenCalledTimes(1); + }); + + it('FE-HIKE-SACCARD-003: lists the known steps with their share, the unknown apart, and the routing limit as a setting', () => { + show(grades()); + expect(within(screen.getByTestId('sac-row-t2')).getByText('T2')).toBeInTheDocument(); + expect(screen.getByTestId('sac-row-t2')).toHaveTextContent('Mountain hiking'); + expect(screen.getByTestId('sac-row-t2')).toHaveTextContent('40 %'); + expect(within(screen.getByTestId('sac-row-untagged')).getByText('?')).toBeInTheDocument(); + expect(screen.getByText('Unknown')).toBeInTheDocument(); + expect(screen.getByTestId('sac-known')).toHaveTextContent('60 %'); + expect(screen.getByTestId('sac-highest')).toHaveTextContent('T4'); + expect(screen.getByTestId('sac-routing-limit')).toHaveTextContent('Routing limit'); + expect(screen.getByTestId('sac-routing-limit')).toHaveTextContent('T3'); + // Only a known step above the limit says so; the unknown never does. + expect(within(screen.getByTestId('sac-row-t4')).getByText('Above the limit')).toBeInTheDocument(); + expect(within(screen.getByTestId('sac-row-t2')).queryByText('Above the limit')).toBeNull(); + expect(within(screen.getByTestId('sac-row-untagged')).queryByText('Above the limit')).toBeNull(); + }); + + it('FE-HIKE-SACCARD-004: an import, planned without a routing limit, shows none and nothing above it', () => { + show(grades({ highest: null, rows: ROWS.slice(2), knownShare: 0 }), null); + expect(screen.queryByTestId('sac-routing-limit')).toBeNull(); + expect(screen.queryByTestId('sac-highest')).toBeNull(); + expect(screen.queryByText('Above the limit')).toBeNull(); + expect(screen.getByTestId('sac-known')).toHaveTextContent('0 %'); + }); + + it('FE-HIKE-SACCARD-005: a row draws its class alone on the map, and shows that it does', () => { + const state = grades({ focus: 't4' }); + show(state); + expect(screen.getByTestId('sac-row-t4')).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByTestId('sac-row-t2')).toHaveAttribute('aria-pressed', 'false'); + fireEvent.click(screen.getByTestId('sac-row-t2')); + expect(state.focusOn).toHaveBeenCalledWith('t2'); + }); + + it('FE-HIKE-SACCARD-006: says when it is asking, offline, or failed with a way to ask again', () => { + const loading = show(grades({ status: 'loading', rows: [] })); + expect(screen.getByRole('status')).toHaveTextContent('Reading the SAC grades'); + loading.unmount(); + const offline = show(grades({ status: 'offline', rows: [] })); + expect(screen.getByText('The SAC grades need a connection.')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /try again/i })).toBeNull(); + offline.unmount(); + const state = grades({ status: 'failed', rows: [] }); + show(state); + expect(screen.getByText(/The tour is unchanged/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /try again/i })); + expect(state.retry).toHaveBeenCalledTimes(1); + }); + + it('FE-HIKE-SACCARD-007: a row names its scale and its source, an unclear one what OpenStreetMap wrote', async () => { + show(grades()); + fireEvent.mouseEnter(screen.getByTestId('sac-row-t2')); + const source = 'SAC hiking scale T2, from OpenStreetMap: sac_scale=mountain_hiking'; + expect(await screen.findByText(source)).toBeInTheDocument(); + fireEvent.mouseLeave(screen.getByTestId('sac-row-t2')); + fireEvent.mouseEnter(screen.getByTestId('sac-row-unclear')); + expect(await screen.findByText(/T3, hiking;mountain_hiking/)).toBeInTheDocument(); + fireEvent.mouseLeave(screen.getByTestId('sac-row-unclear')); + fireEvent.mouseEnter(screen.getByTestId('sac-row-untagged')); + expect(await screen.findByText(/Unknown does not mean easy/)).toBeInTheDocument(); + fireEvent.mouseLeave(screen.getByTestId('sac-row-untagged')); + fireEvent.mouseEnter(screen.getByTestId('sac-row-unmatched')); + expect(await screen.findByText(/no way lies clearly under the line/i)).toBeInTheDocument(); + // A way that could not be asked about says so in its row; there is nothing more to name. + expect(screen.getByTestId('sac-row-unasked')).toHaveTextContent('Could not be asked this time'); + }); +}); diff --git a/client/src/components/Tours/hike/SacGradesCard.tsx b/client/src/components/Tours/hike/SacGradesCard.tsx new file mode 100644 index 000000000..69c92b548 --- /dev/null +++ b/client/src/components/Tours/hike/SacGradesCard.tsx @@ -0,0 +1,218 @@ +import { Info, MountainSnow, SlidersHorizontal } from 'lucide-react'; +import { useTranslation } from '../../../i18n'; +import { useSettingsStore } from '../../../store/settingsStore'; +import { formatDistance } from '../../../utils/units'; +import { SoftPill } from '../../Planner/planParts'; +import ToggleSwitch from '../../Settings/ToggleSwitch'; +import { fs } from '../../shared/DialogShell'; +import { Tooltip } from '../../shared/Tooltip'; +import { bikePaintCss } from '../bike/bikePaint'; +import { FigureBadge } from '../paddle/FigureBadge'; +import { ReadStatusNotice } from '../ReadStatusNotice'; +import { RailCard } from '../tourParts'; +import { hikePaintCss } from './hikePaint'; +import { isSacStep, sacStepOfClass, type SacClass } from './sacGrades'; +import { useSacGrades, type SacGradeRow, type SacGrades } from './useSacGrades'; + +const percent = (share: number) => `${Math.round(share * 100)} %`; +const stepName = (step: number) => `T${step}`; + +/** The colour a class is drawn in on the map, as a CSS variable: the step's own, the grey of the unknown otherwise. */ +const paintOf = (key: SacClass) => (isSacStep(key) ? hikePaintCss(key) : bikePaintCss('unknown')); + +/** A grade as a small white chip: a dot in its map colour and the step, or a question mark for the unknown. */ +function GradeChip({ grade }: { grade: SacClass }) { + const step = sacStepOfClass(grade); + return ( + + + ); +} + +/** Where a row's grade comes from, said in its tooltip: the scale and the tag, or why it is unknown. */ +function useRowSource(row: SacGradeRow): string { + const { t } = useTranslation(); + const step = sacStepOfClass(row.key); + if (step !== null) return t('tours.hike.sac.stepTip', { step: stepName(step), tag: row.tags[0] ?? '' }); + if (row.key === 'untagged') return t('tours.hike.sac.untaggedTip'); + if (row.key === 'unclear') return t('tours.hike.sac.unclearTip', { tags: row.tags.join(', ') }); + if (row.key === 'unmatched') return t('tours.hike.sac.unmatchedTip'); + return ''; +} + +/** + * One class of the hike: its chip, its name, how far and how much of the + * route it covers. Pressed, the map draws its stretches alone; pressed again, + * every grade again. + */ +function GradeRow({ row, grades, limit }: { row: SacGradeRow; grades: SacGrades; limit: number | null }) { + const { t } = useTranslation(); + const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric'; + const source = useRowSource(row); + const step = sacStepOfClass(row.key); + const pressed = grades.focus === row.key; + return ( +
  • + + + +
  • + ); +} + +/** The classes as one bar along the route's length, in the map's colours, so the mix reads at a glance. */ +function GradeBar({ rows }: { rows: SacGradeRow[] }) { + return ( +