feat(tours): plan a trail run on forest tracks and natural paths

RUN-02. Trail running joins Running as a run profile, chosen on purpose in
the planner and in the GPX import. It asks Valhalla's pedestrian costing for
tracks wherever they serve (use_tracks 1) and admits mountain paths up to T2
(max_hiking_difficulty 2), never T3 and above, with ferries only where no
way on land exists. Checked with trace_attributes against the public
instance: above Freiburg Guenterstal up to the Schauinsland the route went
from 82 % roads and 83 % paved (running) to 78 % forest tracks and 81 %
compacted, gravel and dirt; Rathen to the Bastei took 28 % of T2 path that
running leaves out; Heidelberg's old town stayed as it was. Leaning on
footways (walkway_factor) was tried and left out, it moved runs onto paved
footways.

While a run is planned, a card says what it runs on: its surfaces, the
hiking grades of its paths (sac_scale, T1 to T6, as mappers gave them) and
its kinds of way, each as shares with lengths that point at their stretches
on the map. A way without a grade or a surface is its own row, unknown and
never easy, and an info dot says the grades say nothing about whether a way
can be run. The head carries the unpaved share and the hardest graded path
as badges. The line is matched on foot for this, so steps and mountain
paths stay on it; a bike tour's is matched as before. Reading fails, goes
offline or is retried as for a bike tour, and nothing of it changes the run.

Pace and time are RUN-01's: the running time stays the distance at the
runner's pace, with climbs, descents and difficulty left out, whatever the
profile. A profile change plans the route again and keeps the pace; a
failed trail route keeps the last route and holds Save back. The card's
building blocks moved out of the bike card into RouteWaySections, so both
cards share one set of rows, bars and status lines.

The run_profile column already allowed trail (RUN-01), so no migration.
GPX export names a trail run trail_running, list_tours describes the
profile as a routing preference, never a statement that a way can be run.
This commit is contained in:
Maurice
2026-10-20 13:15:31 +02:00
parent 1d9b9bf838
commit 51bfb5abd1
58 changed files with 1161 additions and 306 deletions
@@ -551,6 +551,21 @@ describe('valhallaTraceAttributes (BIK-01)', () => {
expect(bodies[0].shape).toEqual(SHAPE_POINTS.map(([lat, lon]) => ({ lat, lon })))
})
it('VALHALLA-TRACE-005: a run is matched on foot without a hiking limit, and every line is asked its hiking grades (RUN-02)', async () => {
const bodies: Record<string, unknown>[] = []
server.use(http.post(TRACE, async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>)
return HttpResponse.json({ shape: SHAPE, edges: [] })
}))
await valhallaTraceAttributes(SHAPE_POINTS, undefined, 'pedestrian')
expect(bodies[0]).toMatchObject({
costing: 'pedestrian',
costing_options: { pedestrian: { max_hiking_difficulty: 6 } },
shape_match: 'map_snap',
})
expect((bodies[0].filters as { attributes: string[] }).attributes).toContain('edge.sac_scale')
})
it('VALHALLA-TRACE-002: a long line is thinned evenly to what one request carries, its ends kept', async () => {
let shape: unknown[] = []
server.use(http.post(TRACE, async ({ request }) => {
+28 -10
View File
@@ -368,14 +368,32 @@ const TRACE_MAX_POINTS = 4000
const TRACE_TIMEOUT_MS = 30_000
/**
* BIK-01: how the router describes the ways under a line (`trace_attributes`):
* the surface, the use and road class, the cycle lane and the OpenStreetMap way
* of every edge, with the line as matched. The edges are matched as a mountain
* bike would ride them, the costing that refuses the fewest ways, so a road
* bike's route and a gravel one are described alike. Null for every failure:
* 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).
*/
export type TraceMatch = 'bicycle' | 'pedestrian'
const TRACE_COSTING: Record<TraceMatch, Readonly<Record<string, CostingOptions>>> = {
bicycle: { bicycle: { bicycle_type: 'Mountain' } },
pedestrian: { pedestrian: { max_hiking_difficulty: 6 } },
}
/**
* BIK-01, RUN-02: 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.
*/
export async function valhallaTraceAttributes(line: readonly [number, number][], signal?: AbortSignal): Promise<unknown> {
export async function valhallaTraceAttributes(
line: readonly [number, number][],
signal?: AbortSignal,
matchAs: TraceMatch = 'bicycle',
): Promise<unknown> {
const base = valhallaBase()
if (!base || line.length < 2) return null
const step = Math.max(1, Math.ceil(line.length / TRACE_MAX_POINTS))
@@ -389,12 +407,12 @@ export async function valhallaTraceAttributes(line: readonly [number, number][],
headers: { 'Content-Type': 'application/json', 'X-Client-Id': 'trek' },
body: JSON.stringify({
shape,
costing: 'bicycle',
costing_options: { bicycle: { bicycle_type: 'Mountain' } },
costing: matchAs,
costing_options: TRACE_COSTING[matchAs],
shape_match: 'map_snap',
filters: {
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'],
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'],
action: 'include',
},
}),
@@ -15,7 +15,7 @@ vi.mock('./useRouteWays', () => ({
},
}));
// FE-TOUR-BIKECARD-001 to FE-TOUR-BIKECARD-008
// FE-TOUR-BIKECARD-001 to FE-TOUR-BIKECARD-009
const LINE: [number, number][] = [
[47.0, 11.0],
@@ -26,9 +26,9 @@ const LINE: [number, number][] = [
const WAYS: RouteWays = {
line: LINE,
edges: [
{ from: 0, to: 1, lengthM: 600, surface: 'paved', way: 'cyclePath', wayId: 1 },
{ from: 1, to: 2, lengthM: 300, surface: 'gravel', way: 'track', wayId: 2 },
{ from: 2, to: 3, lengthM: 100, surface: 'ground', way: 'path', wayId: 3 },
{ from: 0, to: 1, lengthM: 600, surface: 'paved', way: 'cyclePath', sac: 'none', wayId: 1 },
{ from: 1, to: 2, lengthM: 300, surface: 'gravel', way: 'track', sac: 'none', wayId: 2 },
{ from: 2, to: 3, lengthM: 100, surface: 'ground', way: 'path', sac: 'none', wayId: 3 },
],
};
@@ -66,16 +66,16 @@ describe('BikeWaysCard (BIK-01 to BIK-05)', () => {
it('FE-TOUR-BIKECARD-001: the surfaces and kinds of way of the route, by share, with their lengths', () => {
show({});
const card = screen.getByTestId('bike-ways-card');
expect(within(screen.getByTestId('bike-ways-row-surface-paved')).getByText('60 %')).toBeInTheDocument();
expect(screen.getByTestId('bike-ways-row-surface-gravel')).toHaveTextContent('300 m');
expect(screen.getByTestId('bike-ways-row-way-cyclePath')).toBeInTheDocument();
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-way-cyclePath')).toBeInTheDocument();
expect(screen.getByText('40 % unpaved')).toBeInTheDocument();
expect(card).not.toHaveTextContent('Trail grade');
});
it('FE-TOUR-BIKECARD-002: a row points at its stretches on the map, and lets go when pressed again', () => {
show({});
const row = screen.getByTestId('bike-ways-row-surface-gravel');
const row = screen.getByTestId('route-ways-row-surface-gravel');
fireEvent.click(row);
expect(row).toHaveAttribute('aria-pressed', 'true');
expect(useTourRouteHighlightStore.getState().highlight).toEqual({
@@ -105,15 +105,15 @@ describe('BikeWaysCard (BIK-01 to BIK-05)', () => {
unasked: new Set([3]),
},
});
expect(screen.getByTestId('bike-ways-row-trail-s0')).toBeInTheDocument();
expect(screen.getByTestId('bike-ways-row-trail-s2')).toBeInTheDocument();
expect(screen.getByTestId('bike-ways-row-trail-unasked')).toBeInTheDocument();
expect(screen.getByTestId('bike-ways-row-uphill-u3')).toBeInTheDocument();
expect(screen.getByTestId('route-ways-row-trail-s0')).toBeInTheDocument();
expect(screen.getByTestId('route-ways-row-trail-s2')).toBeInTheDocument();
expect(screen.getByTestId('route-ways-row-trail-unasked')).toBeInTheDocument();
expect(screen.getByTestId('route-ways-row-uphill-u3')).toBeInTheDocument();
});
it('FE-TOUR-BIKECARD-005: grades nobody could be asked for are one notice with a retry, not a row for the whole tour', () => {
show({ grades: { status: 'ready', byWay: new Map(), unasked: new Set([1, 2, 3]) } });
expect(screen.queryByTestId('bike-ways-row-trail-unasked')).toBeNull();
expect(screen.queryByTestId('route-ways-row-trail-unasked')).toBeNull();
fireEvent.click(screen.getAllByRole('button', { name: /try again/i })[0]);
expect(hook.retry).toHaveBeenCalled();
});
@@ -132,7 +132,7 @@ describe('BikeWaysCard (BIK-01 to BIK-05)', () => {
it('FE-TOUR-BIKECARD-007: grades on their way or offline say so in their own section', () => {
const { unmount } = show({ grades: { status: 'loading', byWay: new Map(), unasked: new Set() } });
expect(screen.queryByTestId('bike-ways-row-trail-s0')).toBeNull();
expect(screen.queryByTestId('route-ways-row-trail-s0')).toBeNull();
unmount();
show({ grades: { status: 'offline', byWay: new Map(), unasked: new Set() } });
expect(screen.queryByRole('button', { name: /try again/i })).toBeNull();
@@ -147,6 +147,11 @@ describe('BikeWaysCard (BIK-01 to BIK-05)', () => {
hike.unmount();
show({}, { routeCurrent: false, mode: { type: 'view-gpx' } } as Partial<TourPlannerController>);
expect(screen.queryByTestId('bike-ways-card')).toBeNull();
expect(hook.input).toMatchObject({ tripId: 4, active: false, route: null, profile: 'touring' });
expect(hook.input).toMatchObject({ tripId: 4, active: false, route: null, matchAs: 'bicycle', 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<TourPlannerController>);
expect(hook.input).toMatchObject({ active: true, route: LINE, matchAs: 'bicycle', withGrades: true });
});
});
+41 -193
View File
@@ -1,5 +1,5 @@
import { AlertTriangle, Info, Layers, Loader2, RefreshCw, WifiOff } from 'lucide-react';
import { useMemo, type ReactNode } from 'react';
import { AlertTriangle, Layers, Loader2, RefreshCw, WifiOff } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
@@ -8,144 +8,29 @@ import { SoftPill } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { CardAction, RailCard, TourNotice } from '../tourParts';
import { bikePaintCss, surfacePaintKey, trailPaintKey, type BikePaintKey } from './bikePaint';
import { trailPaintKey } from './bikePaint';
import {
percent,
RouteWaysProgress,
RouteWaysSource,
ShareBar,
ShareList,
SurfaceSection,
WaySection,
WaysSection,
} from './RouteWaySections';
import {
sharesBy,
stretchesOf,
trailClassOf,
unpavedShareOf,
UNPAVED,
type ClassShare,
type RouteWayEdge,
type RouteWays,
type SurfaceClass,
type TrailClass,
type WayClass,
} from './routeWays';
import { useRouteWays, type TrailGrades } from './useRouteWays';
const percent = (share: number) => `${Math.round(share * 100)} %`;
interface ShareRowProps<C extends string> {
id: string;
row: ClassShare<C>;
label: string;
paint: BikePaintKey;
/** A dot in the class's colour; a kind of way has none. */
dot: boolean;
ways: RouteWays;
inClass: (edge: RouteWayEdge) => boolean;
}
/**
* One class of the route: its share and length, and a switch that points at
* its stretches on the map. Pressed again, or another row pressed, it lets go.
*/
function ShareRow<C extends string>({ id, row, label, paint, dot, ways, inClass }: ShareRowProps<C>) {
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const shown = useTourRouteHighlightStore((state) => state.highlight?.id === id);
const toggle = () =>
useTourRouteHighlightStore.getState().show(shown ? null : { id, lines: stretchesOf(ways, inClass), paint });
return (
<li>
<button
type="button"
aria-pressed={shown}
data-testid={`bike-ways-row-${id}`}
onClick={toggle}
className={`flex w-full min-w-0 items-center gap-2 rounded-lg px-2 py-1 text-start hover:bg-surface-tertiary ${shown ? 'bg-surface-tertiary' : ''}`}
>
<span
aria-hidden="true"
className={`h-2.5 w-2.5 flex-none rounded-full ${dot ? '' : 'border border-edge'}`}
style={dot ? { background: bikePaintCss(paint) } : undefined}
/>
<span className="min-w-0 flex-1 truncate text-content" style={fs(12, 'body')}>
{label}
</span>
<span className="flex-none font-geist tabular-nums text-content-faint" style={fs(11)}>
{formatDistance(row.lengthM / 1000, unit)}
</span>
<span className="w-10 flex-none text-end font-geist tabular-nums text-content-secondary" style={fs(11)}>
{percent(row.share)}
</span>
</button>
</li>
);
}
/** The shares as one bar, in the classes' colours, so the mix reads at a glance. */
function ShareBar<C extends string>({ rows, paintOf }: { rows: ClassShare<C>[]; paintOf: (key: C) => BikePaintKey }) {
return (
<span aria-hidden="true" className="flex h-2 w-full overflow-hidden rounded-full bg-surface-tertiary">
{rows.map((row) => (
<span
key={row.key}
className="h-full"
style={{ width: `${row.share * 100}%`, background: bikePaintCss(paintOf(row.key)) }}
/>
))}
</span>
);
}
function Section({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex min-w-0 flex-col gap-1.5">
<span className="font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5, 'caption')}>
{label}
</span>
{children}
</div>
);
}
function SurfaceSection({ ways }: { ways: RouteWays }) {
const { t } = useTranslation();
const rows = useMemo(() => sharesBy<SurfaceClass>(ways, (edge) => edge.surface), [ways]);
return (
<Section label={t('tours.bike.ways.surface')}>
<ShareBar rows={rows} paintOf={surfacePaintKey} />
<ul className="m-0 flex list-none flex-col p-0">
{rows.map((row) => (
<ShareRow
key={row.key}
id={`surface-${row.key}`}
row={row}
label={t(`tours.bike.ways.surface.${row.key}`)}
paint={surfacePaintKey(row.key)}
dot
ways={ways}
inClass={(edge) => edge.surface === row.key}
/>
))}
</ul>
</Section>
);
}
function WaySection({ ways }: { ways: RouteWays }) {
const { t } = useTranslation();
const rows = useMemo(() => sharesBy<WayClass>(ways, (edge) => edge.way), [ways]);
return (
<Section label={t('tours.bike.ways.way')}>
<ul className="m-0 flex list-none flex-col p-0">
{rows.map((row) => (
<ShareRow
key={row.key}
id={`way-${row.key}`}
row={row}
label={t(`tours.bike.ways.way.${row.key}`)}
paint="way"
dot={false}
ways={ways}
inClass={(edge) => edge.way === row.key}
/>
))}
</ul>
</Section>
);
}
/** BIK-05: the trail grades along a mountain bike tour, downhill and uphill apart, the unknown said. */
function TrailSection({ ways, grades, onRetry }: { ways: RouteWays; grades: TrailGrades; onRetry: () => void }) {
const { t } = useTranslation();
@@ -173,46 +58,33 @@ function TrailSection({ ways, grades, onRetry }: { ways: RouteWays; grades: Trai
const unread = noneAnswered ? 'failed' : grades.status;
if (unread !== 'ready')
return (
<Section label={t('tours.bike.trail.title')}>
<WaysSection label={t('tours.bike.trail.title')}>
<GradesStatus status={unread} onRetry={onRetry} />
</Section>
</WaysSection>
);
return (
<>
<Section label={t('tours.bike.trail.title')}>
<WaysSection label={t('tours.bike.trail.title')}>
<ShareBar rows={rows} paintOf={trailPaintKey} />
<ul className="m-0 flex list-none flex-col p-0">
{rows.map((row) => (
<ShareRow
key={row.key}
id={`trail-${row.key}`}
row={row}
label={t(`tours.bike.trail.${row.key}`)}
paint={trailPaintKey(row.key)}
dot
ways={ways}
inClass={(edge) => classOf(edge) === row.key}
/>
))}
</ul>
</Section>
<ShareList
ways={ways}
rows={rows}
idPrefix="trail"
classOf={classOf}
labelOf={(key) => t(`tours.bike.trail.${key}`)}
paintOf={trailPaintKey}
/>
</WaysSection>
{uphill.length > 0 && (
<Section label={t('tours.bike.trail.uphillTitle')}>
<ul className="m-0 flex list-none flex-col p-0">
{uphill.map((row) => (
<ShareRow
key={row.key}
id={`uphill-${row.key}`}
row={row}
label={t('tours.bike.trail.uphillStep', { step: row.key.slice(1) })}
paint="way"
dot={false}
ways={ways}
inClass={(edge) => uphillOf(edge) === row.key}
/>
))}
</ul>
</Section>
<WaysSection label={t('tours.bike.trail.uphillTitle')}>
<ShareList
ways={ways}
rows={uphill}
idPrefix="uphill"
classOf={uphillOf}
labelOf={(key) => t('tours.bike.trail.uphillStep', { step: key.slice(1) })}
/>
</WaysSection>
)}
<span className="text-content-faint" style={fs(11, 'body')}>
{t('tours.bike.trail.note')}
@@ -283,15 +155,12 @@ export function BikeWaysCard({ planner }: { planner: TourPlannerController }) {
tripId: planner.tripId,
active: planner.isBike && editing,
route,
profile: planner.bikeProfile,
matchAs: 'bicycle',
withGrades: planner.bikeProfile === 'mtb',
});
const { ways } = state;
const unpavedShare = useMemo(
() => (ways ? sharesBy(ways, (edge) => (UNPAVED.has(edge.surface) ? 'unpaved' : 'other')) : []),
[ways]
);
const unpaved = useMemo(() => (ways ? unpavedShareOf(ways) : null), [ways]);
if (!planner.isBike || !editing || state.status === 'idle') return null;
const unpaved = unpavedShare.find((row) => row.key === 'unpaved');
return (
<RailCard
icon={<Layers size={14} strokeWidth={2} />}
@@ -299,40 +168,19 @@ export function BikeWaysCard({ planner }: { planner: TourPlannerController }) {
foldId="bike-ways"
aside={
unpaved ? (
<SoftPill tone="warning">{t('tours.bike.ways.unpavedBadge', { share: percent(unpaved.share) })}</SoftPill>
<SoftPill tone="warning">{t('tours.bike.ways.unpavedBadge', { share: percent(unpaved) })}</SoftPill>
) : undefined
}
>
<div data-testid="bike-ways-card" className="flex min-w-0 flex-col gap-3">
{state.status === 'loading' && (
<span className="inline-flex items-center gap-1.5 text-content-muted" style={fs(11.5, 'body')}>
<Loader2 size={12} strokeWidth={2} className="animate-spin" />
{t('tours.bike.ways.loading')}
</span>
)}
{state.status === 'offline' && (
<TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{t('tours.bike.ways.offline')}</TourNotice>
)}
{state.status === 'failed' && (
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span>{t('tours.bike.ways.failed')}</span>
<CardAction icon={<RefreshCw size={12} strokeWidth={2} />} onClick={state.retry}>
{t('common.errorRetry')}
</CardAction>
</span>
</TourNotice>
)}
<RouteWaysProgress state={state} />
{ways && (
<>
{planner.bikeProfile === 'road' && <UnpavedNotice ways={ways} />}
<SurfaceSection ways={ways} />
<WaySection ways={ways} />
{state.grades && <TrailSection ways={ways} grades={state.grades} onRetry={state.retry} />}
<span className="inline-flex gap-1.5 text-content-faint" style={fs(11, 'body')}>
<Info size={12} strokeWidth={2} className="mt-0.5 flex-none" />
{t('tours.bike.ways.source')}
</span>
<RouteWaysSource />
</>
)}
</div>
@@ -0,0 +1,236 @@
import { AlertTriangle, Info, Loader2, RefreshCw, WifiOff } 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 { bikePaintCss, surfacePaintKey, type BikePaintKey } from './bikePaint';
import {
sharesBy,
stretchesOf,
type ClassShare,
type RouteWayEdge,
type RouteWays,
type SurfaceClass,
type WayClass,
} from './routeWays';
import type { RouteWaysState } from './useRouteWays';
/**
* BIK-01 to BIK-05, RUN-02: the pieces a card about what a route runs on is
* built from, shared by the bike tour's card and the run's: a class's share
* as a bar and as rows that point at its stretches on the map, the surfaces
* and kinds of way, and how the reading went.
*/
export const percent = (share: number) => `${Math.round(share * 100)} %`;
interface ShareRowProps<C extends string> {
id: string;
row: ClassShare<C>;
label: string;
paint: BikePaintKey;
/** A dot in the class's colour; a kind of way has none. */
dot: boolean;
ways: RouteWays;
inClass: (edge: RouteWayEdge) => boolean;
}
/**
* One class of the route: its share and length, and a switch that points at
* its stretches on the map. Pressed again, or another row pressed, it lets go.
*/
function ShareRow<C extends string>({ id, row, label, paint, dot, ways, inClass }: ShareRowProps<C>) {
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const shown = useTourRouteHighlightStore((state) => state.highlight?.id === id);
const toggle = () =>
useTourRouteHighlightStore.getState().show(shown ? null : { id, lines: stretchesOf(ways, inClass), paint });
return (
<li>
<button
type="button"
aria-pressed={shown}
data-testid={`route-ways-row-${id}`}
onClick={toggle}
className={`flex w-full min-w-0 items-center gap-2 rounded-lg px-2 py-1 text-start hover:bg-surface-tertiary ${shown ? 'bg-surface-tertiary' : ''}`}
>
<span
aria-hidden="true"
className={`h-2.5 w-2.5 flex-none rounded-full ${dot ? '' : 'border border-edge'}`}
style={dot ? { background: bikePaintCss(paint) } : undefined}
/>
<span className="min-w-0 flex-1 truncate text-content" style={fs(12, 'body')}>
{label}
</span>
<span className="flex-none font-geist tabular-nums text-content-faint" style={fs(11)}>
{formatDistance(row.lengthM / 1000, unit)}
</span>
<span className="w-10 flex-none text-end font-geist tabular-nums text-content-secondary" style={fs(11)}>
{percent(row.share)}
</span>
</button>
</li>
);
}
/**
* The classes of the route as rows, the largest first. With `paintOf` each
* row has a dot in its class's colour and points at its stretches in it; a
* kind of way has no colour and is pointed at in the accent.
*/
export function ShareList<C extends string>({
ways,
rows,
idPrefix,
classOf,
labelOf,
paintOf,
}: {
ways: RouteWays;
rows: ClassShare<C>[];
idPrefix: string;
classOf: (edge: RouteWayEdge) => C;
labelOf: (key: C) => string;
paintOf?: (key: C) => BikePaintKey;
}) {
return (
<ul className="m-0 flex list-none flex-col p-0">
{rows.map((row) => (
<ShareRow
key={row.key}
id={`${idPrefix}-${row.key}`}
row={row}
label={labelOf(row.key)}
paint={paintOf ? paintOf(row.key) : 'way'}
dot={!!paintOf}
ways={ways}
inClass={(edge) => classOf(edge) === row.key}
/>
))}
</ul>
);
}
/** The shares as one bar, in the classes' colours, so the mix reads at a glance. */
export function ShareBar<C extends string>({
rows,
paintOf,
}: {
rows: ClassShare<C>[];
paintOf: (key: C) => BikePaintKey;
}) {
return (
<span aria-hidden="true" className="flex h-2 w-full overflow-hidden rounded-full bg-surface-tertiary">
{rows.map((row) => (
<span
key={row.key}
className="h-full"
style={{ width: `${row.share * 100}%`, background: bikePaintCss(paintOf(row.key)) }}
/>
))}
</span>
);
}
/** A labelled part of the card; `info` puts what the part means behind a dot rather than in a line of its own. */
export function WaysSection({
label,
info,
children,
}: {
label: string;
info?: { label: string; description: string };
children: ReactNode;
}) {
return (
<div className="flex min-w-0 flex-col gap-1.5">
<span className="flex min-h-5 min-w-0 items-center gap-1">
<span
className="min-w-0 truncate font-geist font-bold uppercase tracking-[.08em] text-content-faint"
style={fs(9.5, 'caption')}
>
{label}
</span>
{info && <InfoDot label={info.label} description={info.description} />}
</span>
{children}
</div>
);
}
const surfaceOf = (edge: RouteWayEdge) => edge.surface;
const wayClassOf = (edge: RouteWayEdge) => edge.way;
/** The surfaces under the route, the unknown named as such. */
export function SurfaceSection({ ways }: { ways: RouteWays }) {
const { t } = useTranslation();
const rows = useMemo(() => sharesBy<SurfaceClass>(ways, surfaceOf), [ways]);
return (
<WaysSection label={t('tours.bike.ways.surface')}>
<ShareBar rows={rows} paintOf={surfacePaintKey} />
<ShareList
ways={ways}
rows={rows}
idPrefix="surface"
classOf={surfaceOf}
labelOf={(key) => t(`tours.bike.ways.surface.${key}`)}
paintOf={surfacePaintKey}
/>
</WaysSection>
);
}
/** The kinds of way the route takes. */
export function WaySection({ ways }: { ways: RouteWays }) {
const { t } = useTranslation();
const rows = useMemo(() => sharesBy<WayClass>(ways, wayClassOf), [ways]);
return (
<WaysSection label={t('tours.bike.ways.way')}>
<ShareList
ways={ways}
rows={rows}
idPrefix="way"
classOf={wayClassOf}
labelOf={(key) => t(`tours.bike.ways.way.${key}`)}
/>
</WaysSection>
);
}
/** 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<RouteWaysState, 'status'> & { retry: () => void } }) {
const { t } = useTranslation();
if (state.status === 'loading')
return (
<span className="inline-flex items-center gap-1.5 text-content-muted" style={fs(11.5, 'body')}>
<Loader2 size={12} strokeWidth={2} className="animate-spin" />
{t('tours.bike.ways.loading')}
</span>
);
if (state.status === 'offline')
return <TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{t('tours.bike.ways.offline')}</TourNotice>;
if (state.status !== 'failed') return null;
return (
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span>{t('tours.bike.ways.failed')}</span>
<CardAction icon={<RefreshCw size={12} strokeWidth={2} />} onClick={state.retry}>
{t('common.errorRetry')}
</CardAction>
</span>
</TourNotice>
);
}
/** Where what the card shows comes from, and that a gap in it is unknown, not fine. */
export function RouteWaysSource() {
const { t } = useTranslation();
return (
<span className="inline-flex gap-1.5 text-content-faint" style={fs(11, 'body')}>
<Info size={12} strokeWidth={2} className="mt-0.5 flex-none" />
{t('tours.bike.ways.source')}
</span>
);
}
+21 -2
View File
@@ -1,8 +1,8 @@
import { useMapPaint } from '../../Map/mapPaint';
import type { SurfaceClass, TrailClass } from './routeWays';
import type { SacClass, SurfaceClass, TrailClass } from './routeWays';
/**
* The colours of a bike tour's surfaces and trail grades (styles/bike.css),
* The colours of a bike tour's or a run's surfaces and grades (styles/bike.css),
* by name. The cards take the CSS variable, which follows the theme on its
* own; the map reads them resolved (`useBikePaint`). A kind of way has no
* colour of its own: it is pointed at in the accent.
@@ -30,6 +30,25 @@ 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,10 +1,19 @@
import { describe, expect, it } from 'vitest';
import { encodePolyline } from '../../../../tests/helpers/polyline';
import { bikePaintCss, surfacePaintKey, trailPaintKey } from './bikePaint';
import { bikePaintCss, sacPaintKey, surfacePaintKey, trailPaintKey } from './bikePaint';
import { withRouteHighlight } from './routeHighlight';
import { routeWaysFrom, sharesBy, stretchesOf, trailClassOf, wayIdsOf, type RouteWays } from './routeWays';
import {
routeWaysFrom,
sacClassOf,
sharesBy,
stretchesOf,
trailClassOf,
unpavedShareOf,
wayIdsOf,
type RouteWays,
} from './routeWays';
// FE-TOUR-WAYS-001 to FE-TOUR-WAYS-010
// FE-TOUR-WAYS-001 to FE-TOUR-WAYS-013
const LINE: [number, number][] = [
[47.0, 11.0],
@@ -65,6 +74,20 @@ describe('routeWaysFrom (BIK-01 to BIK-04)', () => {
]);
});
it('FE-TOUR-WAYS-011: reads the hiking grade of each edge, and no grade, a 0 or anything else as none (RUN-02)', () => {
const ways = routeWaysFrom(
answer([
edge(0, 1, { use: 'path', sac_scale: 1 }),
edge(1, 2, { use: 'path', sac_scale: 2 }),
edge(2, 3, { use: 'road', sac_scale: 0 }),
edge(3, 4, { use: 'road' }),
])
);
expect(ways?.edges.map((e) => e.sac)).toEqual(['t1', 't2', 'none', 'none']);
expect([3, 4, 5, 6].map(sacClassOf)).toEqual(['t3', 't4', 't5', 't6']);
for (const value of [7, -1, 2.5, '2', null]) expect(sacClassOf(value)).toBe('none');
});
it('FE-TOUR-WAYS-003: drops an edge without its place on the line, and is null for no answer of that kind', () => {
const ways = routeWaysFrom(
answer([edge(0, 1), { ...edge(1, 2), end_shape_index: 9 }, { ...edge(2, 1) }, { ...edge(2, 3), length: -1 }])
@@ -80,10 +103,10 @@ describe('routeWaysFrom (BIK-01 to BIK-04)', () => {
const WAYS: RouteWays = {
line: LINE,
edges: [
{ from: 0, to: 1, lengthM: 300, surface: 'paved', way: 'road', wayId: 1 },
{ from: 1, to: 2, lengthM: 100, surface: 'gravel', way: 'track', wayId: 2 },
{ from: 2, to: 3, lengthM: 100, surface: 'gravel', way: 'track', wayId: 2 },
{ from: 3, to: 4, lengthM: 0, surface: 'ground', way: 'path', wayId: null },
{ from: 0, to: 1, lengthM: 300, surface: 'paved', way: 'road', sac: 'none', wayId: 1 },
{ from: 1, to: 2, lengthM: 100, surface: 'gravel', way: 'track', sac: 'none', wayId: 2 },
{ from: 2, to: 3, lengthM: 100, surface: 'gravel', way: 'track', sac: 'none', wayId: 2 },
{ from: 3, to: 4, lengthM: 0, surface: 'ground', way: 'path', sac: 'none', wayId: null },
],
};
@@ -96,6 +119,11 @@ describe('what the line runs on (BIK-01 to BIK-05)', () => {
expect(sharesBy({ line: LINE, edges: [] }, (e) => e.surface)).toEqual([]);
});
it('FE-TOUR-WAYS-013: the share known to be unpaved, and none where nothing is', () => {
expect(unpavedShareOf(WAYS)).toBe(0.4);
expect(unpavedShareOf({ line: LINE, edges: [WAYS.edges[0]] })).toBeNull();
});
it('FE-TOUR-WAYS-005: the stretches of a class, neighbours joined into one line each', () => {
expect(stretchesOf(WAYS, (e) => e.surface === 'gravel')).toEqual([LINE.slice(1, 4)]);
expect(stretchesOf(WAYS, (e) => e.surface !== 'gravel')).toEqual([LINE.slice(0, 2), LINE.slice(3, 5)]);
@@ -126,6 +154,12 @@ 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', () => {
+30 -4
View File
@@ -2,10 +2,11 @@ import type { TourTrailGrade } from '@trek/shared';
import { decodePolyline } from '../../Map/transitGeometry';
/**
* BIK-01 to BIK-05: what a bike tour runs on, read from the router's own
* description of the ways along its line (Valhalla's trace attributes): the
* surface, the kind of way, and the OpenStreetMap way behind each stretch.
* Pure, so the classes and their shares are tested apart from the request.
* BIK-01 to BIK-05, RUN-02: what a bike tour or a run runs on, read from the
* router's own description of the ways along its line (Valhalla's trace
* attributes): the surface, the kind of way, the hiking grade, and the
* OpenStreetMap way behind each stretch. Pure, so the classes and their
* shares are tested apart from the request.
*/
/** Surfaces from smooth to rough; `unknown` where the data names none. */
@@ -21,6 +22,13 @@ export type WayClass = 'cyclePath' | 'cycleLane' | 'quiet' | 'road' | 'track' |
*/
export type TrailClass = 's0' | 's1' | 's2' | 's3' | 's4' | 'none' | 'unasked';
/**
* RUN-02: sac_scale steps, T1 to T6, as OpenStreetMap mappers gave them to a
* way; `none` where the way has no grade (the router's 0), which is unknown,
* never easy.
*/
export type SacClass = 't1' | 't2' | 't3' | 't4' | 't5' | 't6' | 'none';
/** Surfaces a road bike's rider calls unpaved (BIK-02): what is no paving of any kind. */
export const UNPAVED: ReadonlySet<SurfaceClass> = new Set(['compacted', 'gravel', 'ground']);
@@ -31,6 +39,7 @@ export interface RouteWayEdge {
lengthM: number;
surface: SurfaceClass;
way: WayClass;
sac: SacClass;
/** The OpenStreetMap way, where the router names it. */
wayId: number | null;
}
@@ -89,6 +98,7 @@ interface TraceEdge {
use?: unknown;
road_class?: unknown;
cycle_lane?: unknown;
sac_scale?: unknown;
length?: unknown;
way_id?: unknown;
begin_shape_index?: unknown;
@@ -98,6 +108,13 @@ interface TraceEdge {
const text = (value: unknown) => (typeof value === 'string' ? value : undefined);
const count = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null);
/** The router's sac_scale, 1 to 6, as its step; 0, no value or anything else is no grade. */
export function sacClassOf(value: unknown): SacClass {
return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 6
? (`t${value}` as SacClass)
: 'none';
}
/**
* The router's answer read into stretches, or null when it is no answer of
* that kind. Edge lengths come in kilometres; an edge without its place on the
@@ -122,6 +139,7 @@ export function routeWaysFrom(answer: unknown): RouteWays | null {
lengthM: km * 1000,
surface,
way: wayOf(text(raw.use), text(raw.road_class), text(raw.cycle_lane)),
sac: sacClassOf(raw.sac_scale),
wayId: wayId && wayId > 0 ? wayId : null,
});
}
@@ -153,6 +171,14 @@ export function sharesBy<C extends string>(ways: RouteWays, classOf: (edge: Rout
.sort((a, b) => b.lengthM - a.lengthM);
}
/** The share of the line known to be unpaved, 0 to 1, or null when none of it is. */
export function unpavedShareOf(ways: RouteWays): number | null {
const row = sharesBy(ways, (edge) => (UNPAVED.has(edge.surface) ? 'unpaved' : 'other')).find(
(share) => share.key === 'unpaved'
);
return row ? row.share : null;
}
/** The stretches of the line in one class, neighbours joined, each as its own line for the map. */
export function stretchesOf(ways: RouteWays, inClass: (edge: RouteWayEdge) => boolean): [number, number][][] {
const stretches: [number, number][][] = [];
@@ -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-008
// FE-TOUR-WAYS-HOOK-001 to FE-TOUR-WAYS-HOOK-009
const ROUTE: [number, number][] = [
[47.0, 11.0],
@@ -32,7 +32,8 @@ const input = (overrides: Partial<Input> = {}): Input => ({
tripId: 3,
active: true,
route: ROUTE,
profile: 'touring',
matchAs: 'bicycle',
withGrades: false,
...overrides,
});
@@ -51,7 +52,7 @@ beforeEach(() => {
afterEach(() => vi.useRealTimers());
describe('useRouteWays (BIK-01 to BIK-05)', () => {
describe('useRouteWays (BIK-01 to BIK-05, RUN-02)', () => {
it('FE-TOUR-WAYS-HOOK-001: asks nothing while inactive or without a route of two points', () => {
for (const overrides of [{ active: false }, { route: null }, { route: ROUTE.slice(0, 1) }]) {
const { result } = renderHook(() => useRouteWays(input(overrides)));
@@ -65,7 +66,7 @@ describe('useRouteWays (BIK-01 to BIK-05)', () => {
expect(result.current.status).toBe('loading');
expect(trace.valhallaTraceAttributes).not.toHaveBeenCalled();
await settle();
expect(trace.valhallaTraceAttributes).toHaveBeenCalledWith(ROUTE, expect.any(AbortSignal));
expect(trace.valhallaTraceAttributes).toHaveBeenCalledWith(ROUTE, expect.any(AbortSignal), 'bicycle');
expect(result.current.status).toBe('ready');
expect(result.current.ways?.edges.map((edge) => edge.surface)).toEqual(['gravel', 'ground']);
expect(result.current.grades).toBeNull();
@@ -77,7 +78,7 @@ describe('useRouteWays (BIK-01 to BIK-05)', () => {
ways: [{ id: 11, mtb_scale: '2', mtb_scale_uphill: null, mtb_scale_imba: null }],
unanswered: [12],
});
const { result } = renderHook(() => useRouteWays(input({ profile: 'mtb' })));
const { result } = renderHook(() => useRouteWays(input({ withGrades: true })));
await settle();
expect(gradesRepo.grades).toHaveBeenCalledWith(3, { way_ids: [11, 12] }, expect.any(AbortSignal));
expect(result.current.grades?.status).toBe('ready');
@@ -87,14 +88,14 @@ describe('useRouteWays (BIK-01 to BIK-05)', () => {
it('FE-TOUR-WAYS-HOOK-004: grades that cannot be had say why: offline, or failed', async () => {
gradesRepo.grades.mockRejectedValueOnce(new OnlineOnlyError());
const offline = renderHook(() => useRouteWays(input({ profile: 'mtb' })));
const offline = renderHook(() => useRouteWays(input({ withGrades: true })));
await settle();
expect(offline.result.current.status).toBe('ready');
expect(offline.result.current.grades?.status).toBe('offline');
offline.unmount();
gradesRepo.grades.mockRejectedValueOnce(new Error('overpass'));
const failed = renderHook(() => useRouteWays(input({ profile: 'mtb' })));
const failed = renderHook(() => useRouteWays(input({ withGrades: true })));
await settle();
expect(failed.result.current.grades?.status).toBe('failed');
});
@@ -126,6 +127,19 @@ describe('useRouteWays (BIK-01 to BIK-05)', () => {
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 () => {
const { result, rerender } = renderHook((props: Input) => useRouteWays(props), {
initialProps: input({ matchAs: 'pedestrian' }),
});
await settle();
expect(trace.valhallaTraceAttributes).toHaveBeenLastCalledWith(ROUTE, expect.any(AbortSignal), 'pedestrian');
expect(result.current.status).toBe('ready');
rerender(input({ matchAs: 'bicycle' }));
expect(result.current.status).toBe('loading');
await settle();
expect(trace.valhallaTraceAttributes).toHaveBeenLastCalledWith(ROUTE, expect.any(AbortSignal), 'bicycle');
});
it('FE-TOUR-WAYS-HOOK-008: a new route drops the highlight and the old answer, and leaving takes the highlight down', async () => {
const { result, rerender, unmount } = renderHook((props: Input) => useRouteWays(props), { initialProps: input() });
await settle();
@@ -1,10 +1,10 @@
import { TOUR_TRAIL_GRADE_MAX_WAYS, type BikeProfile, type TourTrailGrade } from '@trek/shared';
import { TOUR_TRAIL_GRADE_MAX_WAYS, type TourTrailGrade } from '@trek/shared';
import { useCallback, useEffect, useState } from 'react';
import { useNetworkMode } from '../../../hooks/useNetworkMode';
import { OnlineOnlyError } from '../../../repo/onlineOnly';
import { tourTrailGradeRepo } from '../../../repo/tourTrailGradeRepo';
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
import { valhallaTraceAttributes } from '../../Map/valhallaRoute';
import { valhallaTraceAttributes, type TraceMatch } 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. */
@@ -30,21 +30,31 @@ interface Answer extends RouteWaysState {
attempt: number;
/** The route and the question it answers; another one makes it stale. */
route: [number, number][];
matchAs: TraceMatch;
withGrades: boolean;
}
const IDLE: RouteWaysState = { status: 'idle', ways: null, grades: null };
const NO_GRADES = { byWay: new Map<number, TourTrailGrade>(), unasked: new Set<number>() };
interface RouteWaysInput {
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. */
withGrades: boolean;
}
/** The ways of a route, and for a mountain bike tour their grades; the grades' outcome as it came. */
async function askRouteWays(
tripId: number | string,
route: [number, number][],
withGrades: boolean,
question: Pick<RouteWaysInput, 'tripId' | 'matchAs' | 'withGrades'> & { route: [number, number][] },
signal: AbortSignal,
onWays: (ways: RouteWays) => void
): Promise<RouteWaysState> {
const ways = routeWaysFrom(await valhallaTraceAttributes(route, signal));
const { tripId, route, matchAs, withGrades } = question;
const ways = routeWaysFrom(await valhallaTraceAttributes(route, signal, matchAs));
if (!ways) return { status: 'failed', ways: null, grades: null };
if (!withGrades) return { status: 'ready', ways, grades: null };
onWays(ways);
@@ -61,23 +71,17 @@ async function askRouteWays(
}
/**
* BIK-01 to BIK-05: what the bike tour 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, 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.
*/
export function useRouteWays(input: {
tripId: number | string;
active: boolean;
route: [number, number][] | null;
profile: BikeProfile;
}): RouteWaysState & { retry: () => void } {
const { tripId, active, route, profile } = input;
export function useRouteWays(input: RouteWaysInput): RouteWaysState & { retry: () => void } {
const { tripId, active, route, matchAs, withGrades } = input;
const { offline } = useNetworkMode();
const [answer, setAnswer] = useState<Answer | null>(null);
const [attempt, setAttempt] = useState(0);
const retry = useCallback(() => setAttempt((value) => value + 1), []);
const withGrades = profile === 'mtb';
const asking = active && !offline && !!route && route.length >= 2;
useEffect(() => {
@@ -85,10 +89,10 @@ export function useRouteWays(input: {
if (!asking || !route) return;
const controller = new AbortController();
const settle = (state: RouteWaysState) => {
if (!controller.signal.aborted) setAnswer({ ...state, route, withGrades, attempt });
if (!controller.signal.aborted) setAnswer({ ...state, route, matchAs, withGrades, attempt });
};
const timer = window.setTimeout(() => {
askRouteWays(tripId, route, withGrades, controller.signal, (ways) =>
askRouteWays({ tripId, route, matchAs, withGrades }, controller.signal, (ways) =>
settle({ status: 'ready', ways, grades: { status: 'loading', ...NO_GRADES } })
)
.then(settle)
@@ -99,13 +103,17 @@ export function useRouteWays(input: {
window.clearTimeout(timer);
controller.abort();
};
}, [asking, attempt, route, tripId, withGrades]);
}, [asking, attempt, matchAs, route, tripId, withGrades]);
// The highlight belongs to the route it was drawn on.
useEffect(() => () => useTourRouteHighlightStore.getState().clear(), []);
if (!active || !route || route.length < 2) return { ...IDLE, retry };
if (offline) return { status: 'offline', ways: null, grades: null, retry };
const current = answer?.route === route && answer.withGrades === withGrades && answer.attempt === attempt;
const current =
answer?.route === route &&
answer.matchAs === matchAs &&
answer.withGrades === withGrades &&
answer.attempt === attempt;
return current ? { ...answer, retry } : { status: 'loading', ways: null, grades: null, retry };
}
@@ -48,6 +48,7 @@ import { TourScopeSummary } from '../TourScopeSummary'
import { useTourListFilters } from '../useTourListFilters'
import { TourPoiSearchCard } from '../poi/TourPoiSearchCard'
import { BikeWaysCard } from '../bike/BikeWaysCard'
import { RunWaysCard } from '../run/RunWaysCard'
import { walkTimeFields } from '../walk/walkTimeFields'
const STATUS_KEY: Partial<Record<TourPlannerStatus, string>> = {
@@ -454,6 +455,8 @@ export function TourPlannerRail({ planner,
<BikeWaysCard planner={planner} />
<RunWaysCard planner={planner} />
<TourPoiSearchCard planner={planner} />
<PaddleEditorCards planner={planner} locked={locked} checklist={paddleChecklist} />
@@ -245,4 +245,14 @@ describe('routing on land, bike profiles and unreachable points (BIK-01)', () =>
valhallaRun.mockResolvedValueOnce(run([3, 900]))
expect(await routeRun(waypoints, 'running')).toEqual({ unreachable: 1, snappedMeters: 900 })
})
it('TOUR-ROUTING-035: a trail run asks for tracks and natural paths, up to mountain paths, and still no ferry (RUN-02)', async () => {
valhallaRun.mockResolvedValueOnce(run([3, 4]))
expect(await routeRun(waypoints, 'trail')).toMatchObject({ distanceMeters: 1500 })
expect(valhallaRun).toHaveBeenCalledWith(waypoints, 'walking', [], undefined, {
use_tracks: 1,
use_ferry: 0,
max_hiking_difficulty: 2,
})
})
})
@@ -120,24 +120,36 @@ export async function routeCityWalk(
const CITY_WALK_COSTING: Readonly<CostingOptions> = { use_ferry: 0 }
/**
* RUN-01: what each run profile asks Valhalla's pedestrian costing for.
* Running keeps to runnable, mostly paved ways: farm and forest tracks are
* taken only where there is no other way (`use_tracks` 0), and a run is run,
* so a ferry only where no way on land exists (`use_ferry` 0). Checked with
* trace_attributes against the public instance on 20.10.2026 (shares of the
* length): Berlin S Grunewald into the forest went from 95 % tracks with 3 %
* paved to 67 % smooth paved paths over 3.58 km instead of 3.50 km; Munich
* Großhesseloher Brücke to Pullach from 79 % to 88 % smooth asphalt and no
* track left; Marienplatz to the Englischer Garten stayed as it was. No hiking
* limit is sent, so the router keeps to the ways it takes for walking anyway
* (T1). The router's own time is a walker's and is never used: a run's time is
* its distance at the runner's pace. Nothing here says a way can be run.
* RUN-01, RUN-02: what each run profile asks Valhalla's pedestrian costing
* for. A run is run, so a ferry only where no way on land exists
* (`use_ferry` 0). Checked with trace_attributes against the public instance
* on 20.10.2026 (shares of the length):
* - running keeps to runnable, mostly paved ways: farm and forest tracks only
* where there is no other way (`use_tracks` 0), and no hiking limit, so the
* router keeps to the ways it takes for walking anyway (T1). Berlin S
* Grunewald into the forest went from 95 % tracks with 3 % paved to 67 %
* smooth paved paths over 3.58 km instead of 3.50 km; Munich Großhesseloher
* Brücke to Pullach from 79 % to 88 % smooth asphalt and no track left;
* Marienplatz to the Englischer Garten stayed as it was.
* - trail takes tracks wherever they serve (`use_tracks` 1) and admits
* mountain paths up to T2 (`max_hiking_difficulty` 2), never T3 and above.
* Above Freiburg Günterstal up to the Schauinsland it went from 82 % roads
* and 83 % paved (running) to 78 % forest tracks and 81 % compacted, gravel
* and dirt over 9.57 km instead of 10.15 km; Rathen to the Bastei took 28 % of T2
* path that running leaves out; Heidelberg's old town, all streets, stayed
* as it was. Leaning on footways (`walkway_factor`) was tried and left out:
* along the Isar it moved the run onto paved footways, from 33 % to 18 %
* unpaved.
* The router's own time is a walker's and is never used: a run's time is its
* distance at the runner's pace, whatever the profile. Nothing here says a
* way can be run.
*/
const RUN_COSTING: Record<RunProfile, Readonly<CostingOptions>> = {
running: { use_tracks: 0, use_ferry: 0 },
trail: { use_tracks: 1, use_ferry: 0, max_hiking_difficulty: 2 },
}
/** RUN-01: a run, on foot for its profile; a leg drawn by hand is kept as drawn. */
/** RUN-01, RUN-02: a run, on foot for its profile; a leg drawn by hand is kept as drawn. */
export async function routeRun(
waypoints: LandWaypoint[],
profile: RunProfile,
@@ -16,7 +16,7 @@ vi.mock('../useTourPermissions', () => ({ useTourPermissions: () => ({ canEdit:
import { useTourPlanner, type DerivedTourDraft } from './useTourPlanner';
// FE-TOUR-RUN-001 to FE-TOUR-RUN-008
// FE-TOUR-RUN-001 to FE-TOUR-RUN-010
const routed = {
coordinates: [
@@ -242,4 +242,38 @@ describe("useTourPlanner, a run at the runner's own pace (RUN-01)", () => {
expect(result.current.runPace.paceSecondsPerKm).toBeNull();
expect(result.current.runPaceMissing).toBe(true);
});
it('FE-TOUR-RUN-009: trail running plans the route again for trails, keeps the pace and its sum, and saves as trail (RUN-02)', async () => {
createTour.mockResolvedValue({ tour: { ...savedRun, run_profile: 'trail' }, waypoints: savedPoints });
const { result } = await plannedRun(79);
act(() => result.current.runPace.setPace(450));
routeRun.mockClear();
act(() => result.current.setRunProfile('trail'));
await settle();
expect(routeRun).toHaveBeenCalledWith(expect.any(Array), 'trail', expect.any(AbortSignal));
expect(result.current.runProfile).toBe('trail');
const distanceKm = result.current.routeAnalysis?.distanceKm ?? 0;
// The profile changes the ways, never the time: still the distance at the pace, nothing for the terrain.
expect(result.current.walkingDurationMinutes).toBe(Math.round(distanceKm * 7.5));
await act(async () => {
await result.current.save();
});
expect(createTour).toHaveBeenCalledWith(
79,
expect.objectContaining({ tour_type: 'run', run_profile: 'trail', run_pace_seconds_per_km: 450 })
);
});
it('FE-TOUR-RUN-010: a trail route the router cannot plan keeps the last route, and the run waits rather than saving it (RUN-02)', async () => {
const { result } = await plannedRun(80);
act(() => result.current.runPace.setPace(450));
routeRun.mockResolvedValueOnce(null);
act(() => result.current.setRunProfile('trail'));
await settle();
expect(result.current.status).toBe('routing-failed');
expect(result.current.route).toEqual(routed.coordinates);
expect(result.current.tourType).toBe('run');
expect(result.current.runPace.paceSecondsPerKm).toBe(450);
expect(result.current.canSave).toBe(false);
});
});
@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest';
import { RunFields, RunProfileField } from './RunFields';
import { runPaceInvalid } from './runPace';
// FE-TOUR-RUN-FIELDS-001 to FE-TOUR-RUN-FIELDS-006
// FE-TOUR-RUN-FIELDS-001 to FE-TOUR-RUN-FIELDS-008
type Planner = ComponentProps<typeof RunFields>['planner'];
@@ -14,14 +14,16 @@ const analysis = { distanceKm: 8 } as Planner['routeAnalysis'];
function Stateful({
initial = null,
distance = analysis,
profile = 'running',
}: {
initial?: number | null;
distance?: Planner['routeAnalysis'];
profile?: Planner['runProfile'];
}) {
const [pace, setPace] = useState<number | null>(initial);
const invalid = runPaceInvalid(pace);
const planner: Planner = {
runProfile: 'running',
runProfile: profile,
setRunProfile: vi.fn(),
runPace: { paceSecondsPerKm: pace, setPace, load: setPace },
runPaceMissing: pace === null,
@@ -127,4 +129,23 @@ describe('RunFields (RUN-01)', () => {
expect(paceField()).toHaveValue('5:00');
expect(screen.queryByTestId('tour-run-time-sum')).toBeNull();
});
it('FE-TOUR-RUN-FIELDS-007: offers trail running beside running, with the ways it asks for (RUN-02)', () => {
const onChange = vi.fn();
render(<RunProfileField value="trail" onChange={onChange} />);
expect(screen.getByRole('button', { name: 'tours.run.profile.trail' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByText('tours.run.profileHint.trail')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'tours.run.profile.running' }));
expect(onChange).toHaveBeenCalledWith('running');
});
it('FE-TOUR-RUN-FIELDS-008: a trail run is timed as any run, distance times pace, nothing added for the terrain (RUN-02)', () => {
render(<Stateful initial={450} profile="trail" />);
const sum = screen.getByTestId('tour-run-time-sum');
expect(sum).toHaveTextContent('7:30 /km');
expect(sum).toHaveTextContent('1 h');
expect(screen.getByRole('button', { name: 'tours.run.paceInfoLabel' })).toHaveAccessibleDescription(
'tours.run.paceInfo'
);
});
});
@@ -4,7 +4,7 @@ import {
RUN_PROFILES,
type RunProfile,
} from '@trek/shared';
import { Activity, Clock, Gauge, Info, Ruler, type LucideIcon } from 'lucide-react';
import { Clock, Gauge, Info, Ruler } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from '../../../i18n';
import { formatDistance } from '../../../utils/units';
@@ -15,13 +15,12 @@ import type { TourPlannerController } from '../planner/useTourPlanner';
import { InfoDot, TourNotice } from '../tourParts';
import { formatPlannedTourDuration } from '../tourPresentation';
import { formatRunPace, parseRunPace, RUN_PACE_INPUT_MAX_LENGTH, runPaceInvalid } from './runPace';
const PROFILE_ICON: Record<RunProfile, LucideIcon> = { running: Activity };
import { RUN_PROFILE_ICON } from './runProfileIcon';
/**
* RUN-01: what a run is planned as, chosen on purpose like a bike tour's
* profile, with a line on the ways it asks the router for. The planner and
* the GPX import share it.
* RUN-01, RUN-02: what a run is planned as, running or trail running, chosen
* on purpose like a bike tour's profile, with a line on the ways it asks the
* router for. The planner and the GPX import share it.
*/
export function RunProfileField({ value, onChange }: { value: RunProfile; onChange: (profile: RunProfile) => void }) {
const { t } = useTranslation();
@@ -34,7 +33,7 @@ export function RunProfileField({ value, onChange }: { value: RunProfile; onChan
label={t('tours.run.profile')}
fill
options={RUN_PROFILES.map((profile) => {
const Icon = PROFILE_ICON[profile];
const Icon = RUN_PROFILE_ICON[profile];
return {
value: profile,
label: t(`tours.run.profile.${profile}`),
@@ -1,12 +1,13 @@
import type { TourListItem } from '@trek/shared';
import { Activity, Gauge } from 'lucide-react';
import { Gauge } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { runPaceText } from './runPaceText';
import { RUN_PROFILE_ICON } from './runProfileIcon';
/**
* RUN-01: a run's profile and planned pace as pills of a tour's head, beside
* RUN-01, RUN-02: a run's profile and planned pace as pills of a tour's head, beside
* its type. The pace's tooltip says the running time is that pace over the
* distance and nothing more; a run without a pace shows no pace pill.
*/
@@ -20,11 +21,12 @@ export function RunHeadPills({
const { t } = useTranslation();
if (tour.tour_type !== 'run') return null;
const pace = runPaceText(tour, t);
const ProfileIcon = RUN_PROFILE_ICON[tour.run_profile ?? 'running'];
return (
<>
{tour.run_profile && (
<span data-testid="tour-detail-run-profile" className={className} style={fs(10.5)}>
<Activity size={11} strokeWidth={2} className="flex-none text-content-faint" aria-hidden="true" />
<ProfileIcon size={11} strokeWidth={2} className="flex-none text-content-faint" aria-hidden="true" />
{t(`tours.run.profile.${tour.run_profile}`)}
</span>
)}
@@ -0,0 +1,138 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
import type { RouteWays } from '../bike/routeWays';
import type { RouteWaysState } from '../bike/useRouteWays';
import { hardestSac, RunWaysCard } from './RunWaysCard';
const hook = vi.hoisted(() => ({ state: null as unknown, retry: vi.fn(), input: null as unknown }));
vi.mock('../bike/useRouteWays', () => ({
useRouteWays: (input: unknown) => {
hook.input = input;
return { ...(hook.state as object), retry: hook.retry };
},
}));
// FE-TOUR-RUNWAYS-001 to FE-TOUR-RUNWAYS-007
type Planner = Parameters<typeof RunWaysCard>[0]['planner'];
const LINE: [number, number][] = [
[47.0, 11.0],
[47.001, 11.0],
[47.002, 11.0],
[47.003, 11.0],
];
const WAYS: RouteWays = {
line: LINE,
edges: [
{ from: 0, to: 1, lengthM: 600, surface: 'paved', way: 'road', sac: 'none', wayId: 1 },
{ from: 1, to: 2, lengthM: 300, surface: 'gravel', way: 'track', sac: 't1', wayId: 2 },
{ from: 2, to: 3, lengthM: 100, surface: 'ground', way: 'path', sac: 't2', wayId: 3 },
],
};
function planner(overrides: Partial<Planner> = {}): Planner {
return {
tripId: 4,
mode: { type: 'new-draft' },
isRun: true,
route: LINE,
routeCurrent: true,
...overrides,
} as Planner;
}
function show(state: Partial<RouteWaysState>, overrides: Partial<Planner> = {}) {
hook.state = { status: 'ready', ways: WAYS, grades: null, ...state };
return render(<RunWaysCard planner={planner(overrides)} />);
}
beforeEach(() => {
localStorage.clear();
useTourRouteHighlightStore.getState().clear();
});
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');
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-002: the head says how much is unpaved and the hardest graded path, in words for assistive tech', () => {
show({});
expect(screen.getByText('40 % unpaved')).toBeInTheDocument();
const hardest = screen.getByTestId('run-ways-hardest');
expect(hardest).toHaveTextContent('T2');
expect(hardest).toHaveTextContent(/Hardest graded path: T2/);
});
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 });
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-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,
matchAs: 'pedestrian',
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', () => {
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();
});
});
@@ -0,0 +1,127 @@
import { Layers, 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 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 words the hiking limit uses for it, a way without one as unknown. */
function useSacLabel() {
const { t } = useTranslation();
return (key: SacClass) => (key === 'none' ? t('tours.bike.trail.none') : t(`tours.planner.difficulty.${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));
return [...SAC_STEPS].reverse().find((step) => found.has(step)) ?? 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.
*/
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) }) : '';
return (
<span className="flex flex-none items-center gap-1">
{unpaved !== null && <SoftPill>{t('tours.bike.ways.unpavedBadge', { share: percent(unpaved) })}</SoftPill>}
{hardest && (
<Tooltip label={hardestText}>
<span data-testid="run-ways-hardest" className="inline-flex">
<SoftPill icon={<Mountain size={10} strokeWidth={2.2} aria-hidden="true" />}>
<span aria-hidden="true">{hardest.toUpperCase()}</span>
<span className="sr-only">{hardestText}</span>
</SoftPill>
</span>
</Tooltip>
)}
</span>
);
}
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.
*/
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,
matchAs: 'pedestrian',
withGrades: false,
});
const { ways } = state;
if (!planner.isRun || !editing || state.status === 'idle') 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>
);
}
@@ -10,7 +10,7 @@ import { TourGradeTile } from '../tourParts';
import { RunHeadPills } from './RunHeadPills';
import { runPaceText } from './runPaceText';
// FE-TOUR-RUN-MARK-001 to FE-TOUR-RUN-MARK-006
// FE-TOUR-RUN-MARK-001 to FE-TOUR-RUN-MARK-007
const run: TourListItem = {
place_id: 7,
@@ -87,4 +87,13 @@ describe('a run wherever a tour shows (RUN-01)', () => {
rerender(<RunHeadPills tour={{ ...run, tour_type: 'hike', run_profile: null }} className="head-pill" />);
expect(screen.queryByTestId('tour-detail-run-profile')).toBeNull();
});
it('FE-TOUR-RUN-MARK-007: a trail run goes by its own profile, and its time and pace read as any run's (RUN-02)', () => {
const trail = { ...run, run_profile: 'trail' as const, run_pace_seconds_per_km: 450, duration: 60 };
render(<RunHeadPills tour={trail} className="head-pill" />);
expect(screen.getByTestId('tour-detail-run-profile')).toHaveTextContent('Trail running');
expect(screen.getByTestId('tour-detail-run-pace')).toHaveTextContent('7:30 min/km');
render(<TourDayRowFacts tour={trail} />);
expect(screen.getByTestId('plan-tour-run-pace')).toHaveAccessibleName('Planned pace: 7:30 min/km');
});
});
@@ -0,0 +1,5 @@
import type { RunProfile } from '@trek/shared';
import { Activity, Trees, type LucideIcon } from 'lucide-react';
/** RUN-01, RUN-02: the icon a run profile goes by, in its picker and on a run's head. */
export const RUN_PROFILE_ICON: Record<RunProfile, LucideIcon> = { running: Activity, trail: Trees };
+5 -4
View File
@@ -1,10 +1,11 @@
import { create } from 'zustand';
/**
* BIK-01 to BIK-05: the stretches of the planned route a card points at (a
* surface, a kind of way, a trail grade), drawn over the route on the planner
* map in the colour of what they are. `paint` names a token of the bike
* paints, resolved where the map is drawn, so the theme decides the colour.
* BIK-01 to BIK-05, RUN-02: the stretches of the planned route a card points
* at (a surface, a kind of way, a trail or hiking grade), drawn over the route
* on the planner map in the colour of what they are. `paint` names a token of
* the bike paints, resolved where the map is drawn, so the theme decides the
* colour.
*/
export interface RouteHighlight {
/** What is pointed at, so its row can show it is on. */
@@ -16,9 +16,10 @@ const BIKE_ACTIVITY: Record<BikeProfile, string> = {
mtb: 'mountain_biking',
};
/** RUN-01: a run says how it is run, in the same words. */
/** RUN-01, RUN-02: a run says how it is run, in the same words. */
const RUN_ACTIVITY: Record<RunProfile, string> = {
running: 'running',
trail: 'trail_running',
};
/**
+1 -1
View File
@@ -85,7 +85,7 @@ export class ToursMcp {
@Tool({
name: 'list_tours',
description:
'List the saved tours of a trip (hikes, city walks, runs, bike tours and paddle tours planned or imported in the Tours addon): name, type (hike, walk for a city walk, run, bike, or kayak for a paddle tour), where it came from (origin: trek, gpx or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_climbs for gentler ways, avoid_steps, avoid_ferries; weightings, never a grade of the path), the profile a bike tour is routed for (bike_profile: touring for paved cycle paths and quiet roads, road for asphalt, gravel for gravel and farm tracks, mtb for rough tracks; a routing preference, never a statement that a way is rideable or safe), the winter label of a hike (winter_activity: winter_hike or snowshoe, null for none; a label the travellers chose, while the route and its time are planned as for any hike, never a statement that a way can be walked in winter or anything about snow or avalanches), the profile and pace of a run (run_profile: running for runnable, mostly paved ways in towns and parks; run_pace_seconds_per_km, the pace the runners planned with, null while they entered none; its duration is the distance at that pace and nothing else, no climb, descent or difficulty is in it, and it is a plan, never a recorded time), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), for a city walk the number of its stations (station_count: its points without the shaping points and the way back of a loop), their stays as far as entered (station_stay_minutes) and how many have none (station_stays_unknown), whether a tour still waits for its way to be planned (route_pending: saved with its points only, so its distance and walking time stay null until someone opens it in the planner and saves it), the revision (raised by every saved change of the tour), and the days of the trip each tour is on.',
'List the saved tours of a trip (hikes, city walks, runs, bike tours and paddle tours planned or imported in the Tours addon): name, type (hike, walk for a city walk, run, bike, or kayak for a paddle tour), where it came from (origin: trek, gpx or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_climbs for gentler ways, avoid_steps, avoid_ferries; weightings, never a grade of the path), the profile a bike tour is routed for (bike_profile: touring for paved cycle paths and quiet roads, road for asphalt, gravel for gravel and farm tracks, mtb for rough tracks; a routing preference, never a statement that a way is rideable or safe), the winter label of a hike (winter_activity: winter_hike or snowshoe, null for none; a label the travellers chose, while the route and its time are planned as for any hike, never a statement that a way can be walked in winter or anything about snow or avalanches), the profile and pace of a run (run_profile: running for runnable, mostly paved ways in towns and parks, trail for forest tracks and natural paths up to mountain paths (T2); a routing preference, never a statement that a way can be run; run_pace_seconds_per_km, the pace the runners planned with, null while they entered none; its duration is the distance at that pace and nothing else, no climb, descent or difficulty is in it, and it is a plan, never a recorded time), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), for a city walk the number of its stations (station_count: its points without the shaping points and the way back of a loop), their stays as far as entered (station_stay_minutes) and how many have none (station_stays_unknown), whether a tour still waits for its way to be planned (route_pending: saved with its points only, so its distance and walking time stay null until someone opens it in the planner and saves it), the revision (raised by every saved change of the tour), and the days of the trip each tour is on.',
inputSchema: { tripId: idSchema },
annotations: TOOL_ANNOTATIONS_READONLY,
when: addonGate(ADDON_IDS.TOURS),
+55 -2
View File
@@ -1,5 +1,5 @@
/**
* Tours e2e for runs (RUN-01): a run saved with its profile and pace, listed
* Tours e2e for runs (RUN-01, RUN-02): a run saved with its profile and pace, listed
* and opened so, its time the distance at the pace, its pace changed or taken
* out without touching the route or the days, refused without a profile or
* with a pace out of bounds, copied, exported and imported, through the real
@@ -204,7 +204,7 @@ describe('Runs e2e (real guards + temp SQLite)', () => {
const refused = await Promise.all(
[
runBody({ run_profile: null }),
runBody({ run_profile: 'trail' }),
runBody({ run_profile: 'road' }),
runBody({ run_pace_seconds_per_km: 0 }),
runBody({ run_pace_seconds_per_km: -360 }),
runBody({ run_pace_seconds_per_km: 1201 }),
@@ -276,4 +276,57 @@ describe('Runs e2e (real guards + temp SQLite)', () => {
origin: 'gpx',
});
});
it('TOURS-RUN-E2E-010: a trail run keeps its profile, pace and days through a save, an opening and a new pace', async () => {
const placeId = await createViaApi(
runBody({ run_profile: 'trail', run_pace_seconds_per_km: 450, duration_seconds: 60 * 60 }),
);
await insertRows(orm, DayAssignments, [{ day: 3, place: placeId, order_index: 0 }]);
expect(await findRow(orm, Tours, { place: placeId })).toMatchObject({
run_profile: 'trail',
run_pace_seconds_per_km: 450,
duration: 60,
});
const slower = await send(
'put',
`/${placeId}`,
runBody({ run_profile: 'trail', run_pace_seconds_per_km: 480, duration_seconds: 64 * 60, expected_revision: 1 }),
);
expect(slower.status).toBe(200);
const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER));
expect(detail.body.tour).toMatchObject({
tour_type: 'run',
run_profile: 'trail',
run_pace_seconds_per_km: 480,
duration: 64,
});
expect(await countRows(orm, DayAssignments, { place: placeId })).toBe(1);
});
it('TOURS-RUN-E2E-011: a trail run exports as trail running, and a GPX file imports as one', async () => {
const placeId = await createViaApi(runBody({ run_profile: 'trail' }));
const res = await request(server)
.get(`/api/trips/5/tours/${placeId}/export.gpx`)
.set('Cookie', sessionCookie(OWNER))
.buffer(true)
.parse((response, done) => {
let text = '';
response.setEncoding('utf8');
response.on('data', (chunk: string) => (text += chunk));
response.on('end', () => done(null, text));
});
expect(/<type>([^<]+)<\/type>/.exec(String(res.body))?.[1]).toBe('trail_running');
const imported = await request(server)
.post('/api/trips/5/tours/import/gpx')
.set('Cookie', sessionCookie(OWNER))
.field('tour_type', 'run')
.field('run_profile', 'trail')
.attach('file', Buffer.from(GPX), { filename: 'ridge.gpx', contentType: 'application/gpx+xml' });
expect(imported.status).toBe(201);
expect(imported.body.tours[0]).toMatchObject({
tour_type: 'run',
run_profile: 'trail',
run_pace_seconds_per_km: null,
});
});
});
@@ -24,10 +24,8 @@ async function ormBeforeTarget(): Promise<MikroORM> {
}
describe('run columns match the contract', () => {
it('TOURRUNMIG-001: stores every profile the contract knows, and besides them only trail running (RUN-02)', () => {
const stored = Object.values(ToursRunProfile);
expect(RUN_PROFILES.every((profile) => (stored as readonly string[]).includes(profile))).toBe(true);
expect(stored.filter((profile) => !(RUN_PROFILES as readonly string[]).includes(profile))).toEqual(['trail']);
it('TOURRUNMIG-001: stores exactly the profiles the contract knows, trail running (RUN-02) included', () => {
expect([...Object.values(ToursRunProfile)].sort()).toEqual([...RUN_PROFILES].sort());
});
it('TOURRUNMIG-002: switches the run type on, leaves every tour as it was and refuses a pace out of bounds', async () => {
@@ -33,5 +33,6 @@ describe('gpxActivityOf (PR1-06A, BIK-01, HIK-01, RUN-01)', () => {
expect(gpxActivityOf({ tour_type: 'run', run_profile: 'running' })).toBe('running');
expect(gpxActivityOf({ tour_type: 'run', run_profile: null })).toBe('running');
expect(gpxActivityOf({ tour_type: 'run', run_profile: 'sprint' })).toBe('running');
expect(gpxActivityOf({ tour_type: 'run', run_profile: 'trail' })).toBe('trail_running');
});
});
+19
View File
@@ -392,4 +392,23 @@ describe('ToursMcp', () => {
expect(description).toContain('no climb, descent or difficulty is in it');
expect(description).toContain('never a recorded time');
});
it('TOURS-MCP-017: list_tours names a trail run as such, and its profile as a routing preference only (RUN-02)', async () => {
const { user, trip, ridge } = fixture();
const orm = await createTestOrm(db);
await updateRows(
orm,
Tours,
{ place: ridge.id },
{ tourTypeRef: 'run', max_hiking_difficulty: null, run_profile: 'trail', run_pace_seconds_per_km: 450 },
);
await orm.close();
const { tours } = read(await mcp.listTours({ tripId: trip.id }, ctx(user.id)));
const listed = tours.find((tour: { place_id: number }) => tour.place_id === ridge.id);
expect(listed).toMatchObject({ tour_type: 'run', run_profile: 'trail', run_pace_seconds_per_km: 450 });
const entry = getEntry(ToursMcp as unknown as ClassRef, 'listTours');
const description = entry?.kind === 'tool' ? (entry.options.description ?? '') : '';
expect(description).toContain('trail for forest tracks and natural paths up to mountain paths (T2)');
expect(description).toContain('never a statement that a way can be run');
});
});
+7
View File
@@ -435,6 +435,13 @@ const tours: TranslationStrings = {
'tours.run.timeEstimatedInfo':
'المسافة × وتيرتك ولا شيء غير ذلك: لا تُحتسب الصعودات والنزولات ولا الصعوبة. إنه تخطيط، وليس وقتًا مسجّلًا.',
'tours.run.timeSum': '{distance} بوتيرة {pace} دقيقة/كم تساوي {time}',
'tours.run.profile.trail': 'الجري على المسارات الطبيعية',
'tours.run.profileHint.trail': 'يفضّل طرق الغابات والممرات الطبيعية، حتى الممرات الجبلية (T2).',
'tours.run.ways.grade': 'صعوبة المسار (sac_scale)',
'tours.run.ways.gradeInfoLabel': 'حول صعوبة المسار',
'tours.run.ways.gradeInfo':
'درجات المشي كما حددها مساهمو OpenStreetMap. الطريق بلا درجة مجهول، وليس سهلًا. لا تقول شيئًا عن إمكانية الجري عليه أو مدى ملاءمته لك.',
'tours.run.ways.gradeTop': 'أصعب مسار مصنّف: {grade}',
'tours.bike.profile': 'الملف',
'tours.bike.profile.touring': 'تجوال',
'tours.bike.profile.road': 'طريق',
+7
View File
@@ -425,6 +425,13 @@ const tours: TranslationStrings = {
'tours.run.timeEstimatedInfo':
'Məsafə × sizin temp, başqa heç nə: yoxuşlar, enişlər və çətinlik nəzərə alınmır. Bu plandır, qeydə alınmış vaxt deyil.',
'tours.run.timeSum': '{distance}, {pace} dəq/km tempdə: {time}',
'tours.run.profile.trail': 'Trail qaçışı',
'tours.run.profileHint.trail': 'Meşə yollarına və təbii cığırlara üstünlük verir, dağ cığırlarına (T2) qədər.',
'tours.run.ways.grade': 'Yolun çətinliyi (sac_scale)',
'tours.run.ways.gradeInfoLabel': 'Yolun çətinliyi haqqında',
'tours.run.ways.gradeInfo':
'OpenStreetMap xəritəçilərinin verdiyi yürüş dərəcələri. Dərəcəsi olmayan yol asan deyil, naməlumdur. Onlar yolun qaçmağa yararlı olub-olmadığı və ya sizə uyğun olub-olmadığı barədə heç nə demir.',
'tours.run.ways.gradeTop': 'Ən çətin qiymətləndirilmiş cığır: {grade}',
'tours.bike.profile': 'Profil',
'tours.bike.profile.touring': 'Turinq',
'tours.bike.profile.road': 'Yol',
+7
View File
@@ -431,6 +431,13 @@ const tours: TranslationStrings = {
'tours.run.timeEstimatedInfo':
'Distância × seu ritmo e nada mais: subidas, descidas e dificuldade não entram. Um plano, não um tempo registrado.',
'tours.run.timeSum': '{distance} a {pace} min/km dão {time}',
'tours.run.profile.trail': 'Corrida em trilha',
'tours.run.profileHint.trail': 'Prefere estradas florestais e trilhas naturais, até trilhas de montanha (T2).',
'tours.run.ways.grade': 'Dificuldade da trilha (sac_scale)',
'tours.run.ways.gradeInfoLabel': 'Sobre a dificuldade da trilha',
'tours.run.ways.gradeInfo':
'Graus de caminhada como os mapeadores do OpenStreetMap os informaram. Um caminho sem grau é desconhecido, não fácil. Eles não dizem se dá para correr no caminho nem se ele combina com você.',
'tours.run.ways.gradeTop': 'Trilha classificada mais difícil: {grade}',
'tours.bike.profile': 'Perfil',
'tours.bike.profile.touring': 'Cicloturismo',
'tours.bike.profile.road': 'Estrada',
+7
View File
@@ -432,6 +432,13 @@ const tours: TranslationStrings = {
'tours.run.timeEstimatedInfo':
'Distància × el teu ritme i res més: el desnivell i la dificultat no hi compten. Una planificació, no un temps enregistrat.',
'tours.run.timeSum': '{distance} a {pace} min/km són {time}',
'tours.run.profile.trail': 'Curses de muntanya',
'tours.run.profileHint.trail': 'Prefereix pistes forestals i corriols naturals, fins a camins de muntanya (T2).',
'tours.run.ways.grade': 'Dificultat del camí (sac_scale)',
'tours.run.ways.gradeInfoLabel': 'Sobre la dificultat del camí',
'tours.run.ways.gradeInfo':
'Graus d’excursionisme tal com els han posat els mapejadors d’OpenStreetMap. Un camí sense grau és desconegut, no fàcil. No diuen si un camí es pot córrer ni si et convé.',
'tours.run.ways.gradeTop': 'Camí classificat més difícil: {grade}',
'tours.bike.profile': 'Perfil',
'tours.bike.profile.touring': 'Cicloturisme',
'tours.bike.profile.road': 'Carretera',
+7
View File
@@ -424,6 +424,13 @@ const tours: TranslationStrings = {
'tours.run.timeEstimatedInfo':
'Vzdálenost × vaše tempo a nic dalšího: převýšení a obtížnost se nezapočítávají. Plán, ne zaznamenaný čas.',
'tours.run.timeSum': '{distance} při {pace} min/km dává {time}',
'tours.run.profile.trail': 'Trailový běh',
'tours.run.profileHint.trail': 'Upřednostňuje lesní cesty a přírodní stezky, až po horské stezky (T2).',
'tours.run.ways.grade': 'Obtížnost cesty (sac_scale)',
'tours.run.ways.gradeInfoLabel': 'O obtížnosti cesty',
'tours.run.ways.gradeInfo':
'Turistické stupně, jak je zadali mapovači OpenStreetMap. Cesta bez stupně je neznámá, ne snadná. Neříkají nic o tom, zda se po cestě dá běžet nebo zda se pro tebe hodí.',
'tours.run.ways.gradeTop': 'Nejtěžší hodnocená cesta: {grade}',
'tours.bike.profile': 'Profil',
'tours.bike.profile.touring': 'Treking',
'tours.bike.profile.road': 'Silnice',
+7
View File
@@ -434,6 +434,13 @@ const tours: TranslationStrings = {
'tours.run.timeEstimatedInfo':
'Distanz × deine Pace und sonst nichts: Höhenmeter und Schwierigkeit gehen nicht ein. Eine Planung, keine aufgezeichnete Zeit.',
'tours.run.timeSum': '{distance} bei {pace} min/km ergibt {time}',
'tours.run.profile.trail': 'Trailrunning',
'tours.run.profileHint.trail': 'Bevorzugt Forstwege und naturbelassene Pfade, bis zu Bergwegen (T2).',
'tours.run.ways.grade': 'Wegschwierigkeit (sac_scale)',
'tours.run.ways.gradeInfoLabel': 'Zur Wegschwierigkeit',
'tours.run.ways.gradeInfo':
'Wanderskala, wie OpenStreetMap-Mapper sie vergeben haben. Ein Weg ohne Angabe ist unbekannt, nicht leicht. Sie sagt nichts darüber, ob ein Weg laufbar ist oder zu dir passt.',
'tours.run.ways.gradeTop': 'Schwierigster bewerteter Weg: {grade}',
'tours.bike.profile': 'Profil',
'tours.bike.profile.touring': 'Touring', // same-as-en
'tours.bike.profile.road': 'Rennrad',
+7
View File
@@ -423,6 +423,13 @@ const tours: TranslationStrings = {
'tours.run.timeEstimatedInfo':
'Distance × your pace and nothing else: climbs, descents and difficulty are not included. A plan, not a recorded time.',
'tours.run.timeSum': '{distance} at {pace} min/km is {time}',
'tours.run.profile.trail': 'Trail running',
'tours.run.profileHint.trail': 'Prefers forest tracks and natural paths, up to mountain paths (T2).',
'tours.run.ways.grade': 'Path difficulty (sac_scale)',
'tours.run.ways.gradeInfoLabel': 'About the path difficulty',
'tours.run.ways.gradeInfo':
'Hiking grades as OpenStreetMap mappers gave them. A way without one is unknown, not easy. They say nothing about whether a way can be run or suits you.',
'tours.run.ways.gradeTop': 'Hardest graded path: {grade}',
'tours.bike.profile': 'Profile',
'tours.bike.profile.touring': 'Touring',
'tours.bike.profile.road': 'Road',
+7
View File
@@ -431,6 +431,13 @@ const tours: TranslationStrings = {
'tours.run.timeEstimatedInfo':
'Distancia × tu ritmo y nada más: el desnivel y la dificultad no se tienen en cuenta. Una planificación, no un tiempo registrado.',
'tours.run.timeSum': '{distance} a {pace} min/km son {time}',
'tours.run.profile.trail': 'Carrera de montaña',
'tours.run.profileHint.trail': 'Prefiere pistas forestales y senderos naturales, hasta senderos de montaña (T2).',
'tours.run.ways.grade': 'Dificultad del camino (sac_scale)',
'tours.run.ways.gradeInfoLabel': 'Sobre la dificultad del camino',
'tours.run.ways.gradeInfo':
'Grados de senderismo tal como los asignaron los mapeadores de OpenStreetMap. Un camino sin grado es desconocido, no fácil. No dicen si un camino se puede correr ni si te conviene.',
'tours.run.ways.gradeTop': 'Camino graduado más difícil: {grade}',
'tours.bike.profile': 'Perfil',
'tours.bike.profile.touring': 'Cicloturismo',
'tours.bike.profile.road': 'Carretera',
+7
View File
@@ -426,6 +426,13 @@ const tours: TranslationStrings = {
'tours.run.timeEstimatedInfo':
'Vahemaa × sinu tempo ja ei midagi muud: tõusud, langused ja raskus ei lähe arvesse. Plaan, mitte salvestatud aeg.',
'tours.run.timeSum': '{distance} tempos {pace} min/km teeb {time}',
'tours.run.profile.trail': 'Rajajooks',
'tours.run.profileHint.trail': 'Eelistab metsateid ja looduslikke radu, kuni mägiradadeni (T2).',
'tours.run.ways.grade': 'Raja raskus (sac_scale)',
'tours.run.ways.gradeInfoLabel': 'Raja raskusest',
'tours.run.ways.gradeInfo':
'Matkaraskused, nagu OpenStreetMapi kaardistajad need andsid. Ilma raskuseta tee on teadmata, mitte lihtne. Need ei ütle midagi selle kohta, kas teel saab joosta või kas see sulle sobib.',
'tours.run.ways.gradeTop': 'Raskeim hinnatud rada: {grade}',
'tours.bike.profile': 'Profiil',
'tours.bike.profile.touring': 'Matk',
'tours.bike.profile.road': 'Maantee',
+8
View File
@@ -437,6 +437,14 @@ const tours: TranslationStrings = {
'tours.run.timeEstimatedInfo':
'Distance × ton allure, rien d’autre : dénivelé et difficulté ne sont pas pris en compte. Une prévision, pas un temps enregistré.',
'tours.run.timeSum': '{distance} à {pace} min/km font {time}',
'tours.run.profile.trail': 'Trail',
'tours.run.profileHint.trail':
'Privilégie les chemins forestiers et les sentiers naturels, jusqu’aux sentiers de montagne (T2).',
'tours.run.ways.grade': 'Difficulté du chemin (sac_scale)',
'tours.run.ways.gradeInfoLabel': 'À propos de la difficulté du chemin',
'tours.run.ways.gradeInfo':
'Cotations de randonnée telles que les contributeurs OpenStreetMap les ont saisies. Un chemin sans cotation est inconnu, pas facile. Elles ne disent pas si un chemin se court ni s’il vous convient.',
'tours.run.ways.gradeTop': 'Chemin coté le plus difficile : {grade}',
'tours.bike.profile': 'Profil',
'tours.bike.profile.touring': 'Voyage',
'tours.bike.profile.road': 'Route',
+7
View File
@@ -440,6 +440,13 @@ const tours: TranslationStrings = {
'tours.run.timeEstimatedInfo':
'Απόσταση × ο ρυθμός σου και τίποτα άλλο: ανηφόρες, κατηφόρες και δυσκολία δεν λαμβάνονται υπόψη. Πρόγραμμα, όχι καταγεγραμμένος χρόνος.',
'tours.run.timeSum': '{distance} με {pace} λεπ/km κάνει {time}',
'tours.run.profile.trail': 'Ορεινό τρέξιμο',
'tours.run.profileHint.trail': 'Προτιμά δασικούς δρόμους και φυσικά μονοπάτια, έως ορεινά μονοπάτια (T2).',
'tours.run.ways.grade': 'Δυσκολία μονοπατιού (sac_scale)',
'tours.run.ways.gradeInfoLabel': 'Σχετικά με τη δυσκολία μονοπατιού',
'tours.run.ways.gradeInfo':
'Βαθμοί πεζοπορίας όπως τους έδωσαν οι χαρτογράφοι του OpenStreetMap. Ένας δρόμος χωρίς βαθμό είναι άγνωστος, όχι εύκολος. Δεν λένε τίποτα για το αν ένας δρόμος τρέχεται ή αν σου ταιριάζει.',
'tours.run.ways.gradeTop': 'Δυσκολότερο βαθμολογημένο μονοπάτι: {grade}',
'tours.bike.profile': 'Προφίλ',
'tours.bike.profile.touring': 'Περιήγηση',
'tours.bike.profile.road': 'Δρόμου',
+8
View File
@@ -431,6 +431,14 @@ const tours: TranslationStrings = {
'tours.run.timeEstimatedInfo':
'Távolság × a tempód, semmi más: a szintemelkedés és a nehézség nem számít bele. Terv, nem rögzített idő.',
'tours.run.timeSum': '{distance} {pace} perc/km tempóval: {time}',
'tours.run.profile.trail': 'Terepfutás',
'tours.run.profileHint.trail':
'Előnyben részesíti az erdei utakat és a természetes ösvényeket, a hegyi ösvényekig (T2).',
'tours.run.ways.grade': 'Útnehézség (sac_scale)',
'tours.run.ways.gradeInfoLabel': 'Az útnehézségről',
'tours.run.ways.gradeInfo':
'Túranehézségi fokozatok, ahogy az OpenStreetMap térképezői megadták. A fokozat nélküli út ismeretlen, nem könnyű. Semmit sem mondanak arról, hogy egy út futható-e, vagy illik-e hozzád.',
'tours.run.ways.gradeTop': 'Legnehezebb besorolt ösvény: {grade}',
'tours.bike.profile': 'Profil',
'tours.bike.profile.touring': 'Túra',
'tours.bike.profile.road': 'Országúti',
+7
View File
@@ -426,6 +426,13 @@ const tours: TranslationStrings = {
'tours.run.timeEstimatedInfo':
'Jarak × pace-mu, tidak ada yang lain: tanjakan, turunan, dan kesulitan tidak dihitung. Sebuah rencana, bukan waktu yang direkam.',
'tours.run.timeSum': '{distance} dengan pace {pace} mnt/km menjadi {time}',
'tours.run.profile.trail': 'Lari trail',
'tours.run.profileHint.trail': 'Mengutamakan jalan hutan dan jalur alami, hingga jalur gunung (T2).',
'tours.run.ways.grade': 'Kesulitan jalur (sac_scale)',
'tours.run.ways.gradeInfoLabel': 'Tentang kesulitan jalur',
'tours.run.ways.gradeInfo':
'Tingkat pendakian seperti yang diberikan pemeta OpenStreetMap. Jalan tanpa tingkat tidak diketahui, bukan mudah. Tingkat ini tidak menyatakan apakah jalan bisa dilari atau cocok untukmu.',
'tours.run.ways.gradeTop': 'Jalur bertingkat tersulit: {grade}',
'tours.bike.profile': 'Profil',
'tours.bike.profile.touring': 'Touring', // same-as-en
'tours.bike.profile.road': 'Road', // same-as-en
+7
View File
@@ -434,6 +434,13 @@ const tours: TranslationStrings = {
'tours.run.timeEstimatedInfo':
'Distanza × il tuo passo e nient’altro: dislivello e difficoltà non sono considerati. Una pianificazione, non un tempo registrato.',
'tours.run.timeSum': '{distance} a {pace} min/km fanno {time}',
'tours.run.profile.trail': 'Corsa su sentiero',
'tours.run.profileHint.trail': 'Preferisce strade forestali e sentieri naturali, fino ai sentieri di montagna (T2).',
'tours.run.ways.grade': 'Difficoltà del sentiero (sac_scale)',
'tours.run.ways.gradeInfoLabel': 'Sulla difficoltà del sentiero',
'tours.run.ways.gradeInfo':
'Gradi escursionistici come li hanno assegnati i mappatori di OpenStreetMap. Un sentiero senza grado è sconosciuto, non facile. Non dicono se un sentiero si può correre né se fa per te.',
'tours.run.ways.gradeTop': 'Sentiero classificato più difficile: {grade}',
'tours.bike.profile': 'Profilo',
'tours.bike.profile.touring': 'Turismo',
'tours.bike.profile.road': 'Strada',
+6
View File
@@ -413,6 +413,12 @@ const tours: TranslationStrings = {
'tours.run.timeEstimatedInfoLabel': '走行時間の計画方法',
'tours.run.timeEstimatedInfo': '距離 × あなたのペースのみで、登り、下り、難しさは含みません。記録された時間ではなく計画です。',
'tours.run.timeSum': '{distance} を {pace} 分/km で {time}',
'tours.run.profile.trail': 'トレイルランニング',
'tours.run.profileHint.trail': '林道や自然の小道を優先し、山道 (T2) まで含みます。',
'tours.run.ways.grade': '道の難易度 (sac_scale)',
'tours.run.ways.gradeInfoLabel': '道の難易度について',
'tours.run.ways.gradeInfo': 'OpenStreetMap のマッパーが付けたハイキングの難易度です。難易度のない道は簡単という意味ではなく、不明です。道を走れるかどうか、あなたに合うかどうかは示しません。',
'tours.run.ways.gradeTop': '最も難しい格付けの道: {grade}',
'tours.bike.profile': 'プロファイル',
'tours.bike.profile.touring': 'ツーリング',
'tours.bike.profile.road': 'ロード',
+7
View File
@@ -411,6 +411,13 @@ const tours: TranslationStrings = {
'tours.run.timeEstimatedInfoLabel': '달리기 시간 계획 방법',
'tours.run.timeEstimatedInfo': '거리 × 나의 페이스뿐이며, 오르막, 내리막, 난이도는 포함되지 않습니다. 기록된 시간이 아니라 계획입니다.',
'tours.run.timeSum': '{distance}, {pace}분/km 페이스로 {time}',
'tours.run.profile.trail': '트레일 러닝',
'tours.run.profileHint.trail': '임도와 자연 오솔길을 우선하며, 산길(T2)까지 포함합니다.',
'tours.run.ways.grade': '길 난이도 (sac_scale)',
'tours.run.ways.gradeInfoLabel': '길 난이도 안내',
'tours.run.ways.gradeInfo':
'OpenStreetMap 매퍼가 매긴 하이킹 등급입니다. 등급이 없는 길은 쉬운 것이 아니라 알 수 없는 것입니다. 길을 달릴 수 있는지, 나에게 맞는지는 알려 주지 않습니다.',
'tours.run.ways.gradeTop': '가장 어려운 등급의 길: {grade}',
'tours.bike.profile': '프로필',
'tours.bike.profile.touring': '투어링',
'tours.bike.profile.road': '로드',
+7
View File
@@ -433,6 +433,13 @@ const tours: TranslationStrings = {
'tours.run.timeEstimatedInfo':
'Afstand × je tempo en verder niets: hoogtemeters en moeilijkheid tellen niet mee. Een planning, geen opgenomen tijd.',
'tours.run.timeSum': '{distance} bij {pace} min/km is {time}',
'tours.run.profile.trail': 'Trailrunning',
'tours.run.profileHint.trail': 'Geeft de voorkeur aan bospaden en onverharde paden, tot bergpaden (T2).',
'tours.run.ways.grade': 'Moeilijkheid van het pad (sac_scale)',
'tours.run.ways.gradeInfoLabel': 'Over de moeilijkheid van het pad',
'tours.run.ways.gradeInfo':
'Wandelgraden zoals OpenStreetMap-mappers ze hebben ingevoerd. Een pad zonder graad is onbekend, niet makkelijk. Ze zeggen niets over of een pad te lopen is of bij je past.',
'tours.run.ways.gradeTop': 'Moeilijkste gegradeerde pad: {grade}',
'tours.bike.profile': 'Profiel',
'tours.bike.profile.touring': 'Toer',
'tours.bike.profile.road': 'Racefiets',
+7
View File
@@ -440,6 +440,13 @@ const tours: TranslationStrings = {
'tours.run.timeEstimatedInfo':
'Dystans × Twoje tempo i nic więcej: przewyższenia i trudność nie są uwzględniane. Plan, a nie zarejestrowany czas.',
'tours.run.timeSum': '{distance} przy {pace} min/km to {time}',
'tours.run.profile.trail': 'Bieganie terenowe',
'tours.run.profileHint.trail': 'Preferuje drogi leśne i naturalne ścieżki, aż po ścieżki górskie (T2).',
'tours.run.ways.grade': 'Trudność ścieżki (sac_scale)',
'tours.run.ways.gradeInfoLabel': 'O trudności ścieżki',
'tours.run.ways.gradeInfo':
'Stopnie trudności szlaków nadane przez maperów OpenStreetMap. Droga bez stopnia jest nieznana, a nie łatwa. Nie mówią, czy da się nią biec ani czy jest dla ciebie.',
'tours.run.ways.gradeTop': 'Najtrudniejsza oceniona ścieżka: {grade}',
'tours.bike.profile': 'Profil',
'tours.bike.profile.touring': 'Turystyka',
'tours.bike.profile.road': 'Szosa',
+7
View File
@@ -444,6 +444,13 @@ const tours: TranslationStrings = {
'tours.run.timeEstimatedInfo':
'Расстояние × ваш темп и ничего больше: набор высоты и сложность не учитываются. Это план, а не записанное время.',
'tours.run.timeSum': '{distance} в темпе {pace} мин/км: {time}',
'tours.run.profile.trail': 'Трейлраннинг',
'tours.run.profileHint.trail': 'Предпочитает лесные дороги и природные тропы, вплоть до горных троп (T2).',
'tours.run.ways.grade': 'Сложность тропы (sac_scale)',
'tours.run.ways.gradeInfoLabel': 'О сложности тропы',
'tours.run.ways.gradeInfo':
'Категории сложности троп, как их указали участники OpenStreetMap. Путь без категории неизвестен, а не прост. Они ничего не говорят о том, можно ли по пути бежать и подходит ли он вам.',
'tours.run.ways.gradeTop': 'Самая сложная оцененная тропа: {grade}',
'tours.bike.profile': 'Профиль',
'tours.bike.profile.touring': 'Туризм',
'tours.bike.profile.road': 'Шоссе',
+7
View File
@@ -425,6 +425,13 @@ const tours: TranslationStrings = {
'tours.run.timeEstimatedInfo':
'Vzdialenosť × vaše tempo a nič iné: prevýšenie a náročnosť sa nezapočítavajú. Plán, nie zaznamenaný čas.',
'tours.run.timeSum': '{distance} pri {pace} min/km dáva {time}',
'tours.run.profile.trail': 'Trailový beh',
'tours.run.profileHint.trail': 'Uprednostňuje lesné cesty a prírodné chodníky, až po horské chodníky (T2).',
'tours.run.ways.grade': 'Náročnosť cesty (sac_scale)',
'tours.run.ways.gradeInfoLabel': 'O náročnosti cesty',
'tours.run.ways.gradeInfo':
'Turistické stupne, ako ich zadali mapovači OpenStreetMap. Cesta bez stupňa je neznáma, nie ľahká. Nehovoria nič o tom, či sa po ceste dá bežať alebo či sa pre teba hodí.',
'tours.run.ways.gradeTop': 'Najnáročnejšia hodnotená cesta: {grade}',
'tours.bike.profile': 'Profil',
'tours.bike.profile.touring': 'Trekking',
'tours.bike.profile.road': 'Cestný',
+7
View File
@@ -422,6 +422,13 @@ const tours: TranslationStrings = {
'tours.run.timeEstimatedInfo':
'Sträcka × ditt tempo och inget annat: stigningar och svårighet räknas inte in. En planering, ingen inspelad tid.',
'tours.run.timeSum': '{distance} i {pace} min/km blir {time}',
'tours.run.profile.trail': 'Traillöpning',
'tours.run.profileHint.trail': 'Föredrar skogsvägar och naturstigar, upp till fjällstigar (T2).',
'tours.run.ways.grade': 'Ledens svårighet (sac_scale)',
'tours.run.ways.gradeInfoLabel': 'Om ledens svårighet',
'tours.run.ways.gradeInfo':
'Vandringsgrader som OpenStreetMap-kartläggare har angett dem. En väg utan grad är okänd, inte lätt. De säger inget om huruvida en väg går att springa eller passar dig.',
'tours.run.ways.gradeTop': 'Svåraste graderade led: {grade}',
'tours.bike.profile': 'Profil',
'tours.bike.profile.touring': 'Tur',
'tours.bike.profile.road': 'Landsväg',
+7
View File
@@ -403,6 +403,13 @@ const tours: TranslationStrings = {
'tours.run.timeEstimatedInfo':
'ระยะทาง × เพซของคุณเท่านั้น ไม่รวมทางขึ้น ทางลง และความยาก เป็นแผน ไม่ใช่เวลาที่บันทึกไว้',
'tours.run.timeSum': '{distance} ที่เพซ {pace} นาที/km เท่ากับ {time}',
'tours.run.profile.trail': 'วิ่งเทรล',
'tours.run.profileHint.trail': 'เน้นถนนในป่าและทางเดินธรรมชาติ จนถึงทางเดินบนภูเขา (T2)',
'tours.run.ways.grade': 'ความยากของเส้นทาง (sac_scale)',
'tours.run.ways.gradeInfoLabel': 'เกี่ยวกับความยากของเส้นทาง',
'tours.run.ways.gradeInfo':
'ระดับการเดินป่าตามที่ผู้ทำแผนที่ OpenStreetMap ระบุไว้ ทางที่ไม่มีระดับคือไม่ทราบ ไม่ใช่ง่าย และไม่ได้บอกว่าทางนั้นวิ่งได้หรือเหมาะกับคุณหรือไม่',
'tours.run.ways.gradeTop': 'ทางที่มีระดับยากที่สุด: {grade}',
'tours.bike.profile': 'โปรไฟล์',
'tours.bike.profile.touring': 'ทัวริ่ง',
'tours.bike.profile.road': 'เสือหมอบ',
+1 -1
View File
@@ -20,7 +20,7 @@ const tokens = (value: string) => [...value.matchAll(/\{[^{}]+\}/g)].map((match)
// placeholder spec; here only the general keys are.
const groups = pluralGroups(Object.keys(english));
const generalKeys = (keys: string[]) => keys.filter((key) => !pluralFormOf(key, groups));
const GENERAL_KEY_COUNT = 849;
const GENERAL_KEY_COUNT = 855;
function literalKeys(source: string): string[] {
const tree = ts.createSourceFile('locale.ts', source, ts.ScriptTarget.Latest, true);
+7
View File
@@ -425,6 +425,13 @@ const tours: TranslationStrings = {
'tours.run.timeEstimatedInfo':
'Mesafe × tempon, başka hiçbir şey değil: tırmanışlar, inişler ve zorluk hesaba katılmaz. Kayıtlı bir süre değil, bir plan.',
'tours.run.timeSum': '{distance}, {pace} dk/km tempoyla {time}',
'tours.run.profile.trail': 'Patika koşusu',
'tours.run.profileHint.trail': 'Orman yollarını ve doğal patikaları tercih eder, dağ patikalarına (T2) kadar.',
'tours.run.ways.grade': 'Patika zorluğu (sac_scale)',
'tours.run.ways.gradeInfoLabel': 'Patika zorluğu hakkında',
'tours.run.ways.gradeInfo':
'OpenStreetMap haritacılarının verdiği yürüyüş dereceleri. Derecesi olmayan bir yol kolay değil, bilinmiyor demektir. Bir yolun koşulabilir olup olmadığı ya da sana uyup uymadığı hakkında bir şey söylemezler.',
'tours.run.ways.gradeTop': 'En zor derecelendirilmiş patika: {grade}',
'tours.bike.profile': 'Profil',
'tours.bike.profile.touring': 'Tur',
'tours.bike.profile.road': 'Yol',
+7
View File
@@ -439,6 +439,13 @@ const tours: TranslationStrings = {
'tours.run.timeEstimatedInfo':
'Відстань × ваш темп і нічого більше: набір висоти й складність не враховуються. Це план, а не записаний час.',
'tours.run.timeSum': '{distance} у темпі {pace} хв/км: {time}',
'tours.run.profile.trail': 'Трейлранінг',
'tours.run.profileHint.trail': 'Надає перевагу лісовим дорогам і природним стежкам, аж до гірських стежок (T2).',
'tours.run.ways.grade': 'Складність стежки (sac_scale)',
'tours.run.ways.gradeInfoLabel': 'Про складність стежки',
'tours.run.ways.gradeInfo':
'Категорії складності стежок, як їх вказали учасники OpenStreetMap. Шлях без категорії невідомий, а не легкий. Вони нічого не кажуть про те, чи можна ним бігти і чи підходить він вам.',
'tours.run.ways.gradeTop': 'Найскладніша оцінена стежка: {grade}',
'tours.bike.profile': 'Профіль',
'tours.bike.profile.touring': 'Туризм',
'tours.bike.profile.road': 'Шосе',
+7
View File
@@ -423,6 +423,13 @@ const tours: TranslationStrings = {
'tours.run.timeEstimatedInfo':
'Quãng đường × nhịp chạy của bạn, không gì khác: độ dốc lên xuống và độ khó không được tính. Đây là kế hoạch, không phải thời gian đã ghi.',
'tours.run.timeSum': '{distance} với nhịp {pace} phút/km là {time}',
'tours.run.profile.trail': 'Chạy địa hình',
'tours.run.profileHint.trail': 'Ưu tiên đường rừng và lối mòn tự nhiên, đến cả đường núi (T2).',
'tours.run.ways.grade': 'Độ khó của đường (sac_scale)',
'tours.run.ways.gradeInfoLabel': 'Về độ khó của đường',
'tours.run.ways.gradeInfo':
'Cấp độ đi bộ đường dài do người vẽ bản đồ OpenStreetMap gán. Đường không có cấp là chưa rõ, không phải dễ. Chúng không cho biết đường có chạy được hay có hợp với bạn không.',
'tours.run.ways.gradeTop': 'Đường được xếp cấp khó nhất: {grade}',
'tours.bike.profile': 'Hồ sơ',
'tours.bike.profile.touring': 'Du lịch',
'tours.bike.profile.road': 'Đường trường',
+6
View File
@@ -384,6 +384,12 @@ const tours: TranslationStrings = {
'tours.run.timeEstimatedInfoLabel': '跑步時間如何規劃',
'tours.run.timeEstimatedInfo': '僅為距離 × 你的配速:爬升、下降與難度皆不計入。這是計畫,不是記錄的時間。',
'tours.run.timeSum': '{distance},配速 {pace} 分/公里,用時 {time}',
'tours.run.profile.trail': '越野跑',
'tours.run.profileHint.trail': '優先選擇林道與自然小徑,最高到山地步道 (T2)。',
'tours.run.ways.grade': '路徑難度 (sac_scale)',
'tours.run.ways.gradeInfoLabel': '關於路徑難度',
'tours.run.ways.gradeInfo': 'OpenStreetMap 繪圖者標註的健行難度。沒有難度的路是未知,而不是容易。它們不說明一條路能否跑,也不說明是否適合你。',
'tours.run.ways.gradeTop': '最難的已評級路徑:{grade}',
'tours.bike.profile': '類型',
'tours.bike.profile.touring': '旅行',
'tours.bike.profile.road': '公路',
+6
View File
@@ -385,6 +385,12 @@ const tours: TranslationStrings = {
'tours.run.timeEstimatedInfoLabel': '跑步时间如何计划',
'tours.run.timeEstimatedInfo': '仅为距离 × 你的配速:爬升、下降和难度均不计入。这是计划,不是记录的时间。',
'tours.run.timeSum': '{distance},配速 {pace} 分/公里,用时 {time}',
'tours.run.profile.trail': '越野跑',
'tours.run.profileHint.trail': '优先选择林道和自然小径,最高到山地步道 (T2)。',
'tours.run.ways.grade': '路径难度 (sac_scale)',
'tours.run.ways.gradeInfoLabel': '关于路径难度',
'tours.run.ways.gradeInfo': 'OpenStreetMap 绘图者标注的徒步难度。没有难度的路是未知,而不是容易。它们不说明一条路能否跑,也不说明是否适合你。',
'tours.run.ways.gradeTop': '最难的已评级路径:{grade}',
'tours.bike.profile': '类型',
'tours.bike.profile.touring': '旅行',
'tours.bike.profile.road': '公路',
+11 -1
View File
@@ -605,7 +605,7 @@ describe('runs (RUN-01)', () => {
};
it('saves a run with its profile and pace, and nothing of a hike, a bike tour or a paddle tour', () => {
expect(RUN_PROFILES).toEqual(['running']);
expect(RUN_PROFILES).toEqual(['running', 'trail']);
expect(tourCreateRequestSchema.parse({ ...run, run_pace_seconds_per_km: 360 })).toMatchObject({
tour_type: 'run',
run_profile: 'running',
@@ -617,6 +617,16 @@ describe('runs (RUN-01)', () => {
expect(tourCreateRequestSchema.parse({ ...run, run_pace_seconds_per_km: null }).run_pace_seconds_per_km).toBeNull();
});
it('saves a trail run like any run, with its own profile and the same pace (RUN-02)', () => {
expect(tourCreateRequestSchema.parse({ ...run, run_profile: 'trail', run_pace_seconds_per_km: 450 })).toMatchObject({
tour_type: 'run',
run_profile: 'trail',
run_pace_seconds_per_km: 450,
});
expect(tourGpxImportFieldsSchema.parse({ tour_type: 'run', run_profile: 'trail' }).run_profile).toBe('trail');
expect(() => tourCreateRequestSchema.parse({ ...baseRequest, run_profile: 'trail' })).toThrow(/belongs to a run/);
});
it('refuses a pace that is no whole number of seconds, not positive or out of bounds', () => {
for (const pace of [0, -300, 119, 1201, 360.5, Number.NaN]) {
expect(tourCreateRequestSchema.safeParse({ ...run, run_pace_seconds_per_km: pace }).success).toBe(false);
+6 -5
View File
@@ -49,12 +49,13 @@ export const bikeProfileSchema = z.enum(BIKE_PROFILES);
export type BikeProfile = z.infer<typeof bikeProfileSchema>;
/**
* RUN-01: what a run is planned as, the one profile it is routed for. Running
* keeps to runnable, mostly paved ways in towns, parks and along rivers;
* trail running follows with RUN-02. A profile asks the router for ways, it
* says nothing about whether one can be run.
* RUN-01, RUN-02: what a run is planned as, the one profile it is routed for.
* Running keeps to runnable, mostly paved ways in towns, parks and along
* rivers; trail running to forest tracks and natural paths, up to mountain
* paths (T2). A profile asks the router for ways, it says nothing about
* whether one can be run, and it changes nothing of the running time.
*/
export const RUN_PROFILES = ['running'] as const;
export const RUN_PROFILES = ['running', 'trail'] as const;
export const runProfileSchema = z.enum(RUN_PROFILES);
export type RunProfile = z.infer<typeof runProfileSchema>;