feat(tours): take a recorded ride from a GPX file as it was ridden

A ride recorded elsewhere comes in through the one Tours GPX import
(HOR-02): pick the file, choose Horse ride in the preview and confirm.
Once a ride is chosen, the preview says in three badges what the import
will and will not do for it: the line stays as the file recorded it, no
riding time is worked out, and nothing checks whether its ways may be
ridden. Each badge gives its reason in a tooltip, reachable by keyboard.
Nothing is stored before the confirmation.

Opened again, the ride's rail carries a Recorded ride card with the same
badges above its measures, and names heights the file did not carry
instead of showing them as zero. Its origin and file name stay in the
tour's provenance, it goes on a day like any tour, and no editable
version is planned from it on foot.

The import itself was already canonical and needed no change; end to end
tests now pin what this card promises: the stored line equals the
recorded points, the ride keeps its origin, distance and heights and has
no riding time, a file without heights keeps them unknown, a failed or
refused import leaves an existing ride and its place untouched, and the
same file twice is skipped.
This commit is contained in:
Maurice
2026-10-20 13:07:41 +02:00
parent 78366a9024
commit 6577595058
34 changed files with 745 additions and 3 deletions
@@ -11,6 +11,7 @@ import { useTranslation } from '../../i18n';
import { LABEL, Segmented } from '../shared/dialogParts';
import { DialogSection } from '../shared/DialogShell';
import type { GpxImportChoicesState } from './gpxImportChoices';
import { RecordedRideFacts } from './horse/RecordedRideFacts';
import { TourTypeSelect } from './planner/TourTypeSelect';
import { RunProfileField } from './run/RunFields';
import { SkiClassificationFields } from './ski/SkiClassificationFields';
@@ -61,7 +62,8 @@ function PaddleImportFields({ choices }: { choices: GpxImportChoicesState }) {
/**
* The preview's "import as" section: the type, and below it what that type
* names: a bike or run profile, a paddle tour's boat and water, a ski track's
* discipline and technique (SKI-02).
* discipline and technique (SKI-02), and for a ride (HOR-02) what the import
* keeps and does not work out.
*/
export function GpxImportTypeSection({ choices }: { choices: GpxImportChoicesState }) {
const { t } = useTranslation();
@@ -86,6 +88,11 @@ export function GpxImportTypeSection({ choices }: { choices: GpxImportChoicesSta
{chosen.type === 'run' && <RunProfileField value={chosen.runProfile} onChange={choices.setRunProfile} />}
{chosen.type === 'kayak' && <PaddleImportFields choices={choices} />}
{chosen.type === 'ski' && <SkiClassificationFields choice={chosen.ski} onChange={choices.setSki} />}
{chosen.type === 'horse' && (
<div data-testid="gpx-preview-horse">
<RecordedRideFacts />
</div>
)}
</div>
</DialogSection>
);
@@ -64,7 +64,9 @@ function PreviewTrackRow({ name, track }: { name: string; track: GpxPreviewFile[
* map, every track with its measures, the tracks the trip already holds named
* as skipped, and the type to import them as; a paddle import names its boat
* and water, as a planned paddle tour does, a ski import its discipline
* (SKI-02). Closing it stores nothing.
* (SKI-02); a ride (HOR-02) says before anything is stored that its line stays
* as recorded, gets no riding time and is not checked for access. Closing it
* stores nothing.
*/
export default function TourGpxPreviewDialog({ gpxImport }: { gpxImport: TourGpxImport }) {
const { preview } = gpxImport;
@@ -0,0 +1,155 @@
import type { TourListItem } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { hikePlannerDefaults } from '../../../../tests/helpers/tourPlannerStub';
import { useSettingsStore } from '../../../store/settingsStore';
import { analyzeRouteGeometry } from '../../../utils/routeGeometry';
import { TourPlannerRail } from '../planner/TourPlannerPanels';
import type { TourPlannerController } from '../planner/useTourPlanner';
import TourGpxPreviewDialog from '../TourGpxPreviewDialog';
import type { TourGpxImport } from '../useTourGpxImport';
import { RecordedRideFacts } from './RecordedRideFacts';
// FE-HORSE-IMPORT-002 to FE-HORSE-IMPORT-006 (HOR-02: a ride taken from a GPX file)
vi.mock('../../Map/MapViewAuto', () => ({ MapViewAuto: () => <div data-testid="gpx-preview-map-stub" /> }));
beforeEach(() => {
resetAllStores();
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } });
});
const ride: TourListItem = {
place_id: 42,
name: 'Ausritt an der Isar',
tour_type: 'horse',
distance: 1.1,
elevation_gain: null,
elevation_loss: null,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: 0.3,
max_hiking_difficulty: null,
planned: false,
caution: true,
origin: 'gpx',
has_waypoints: false,
};
function viewing(tour: TourListItem, line: number[][]): TourPlannerController {
return {
...hikePlannerDefaults(),
mode: { type: 'view-gpx', placeId: tour.place_id, tour },
tripId: 1,
canEdit: true,
canAssign: true,
isSaving: false,
hasUnsavedChanges: false,
readOnlyGpxTour: { tour },
readOnlyGpxAnalysis: analyzeRouteGeometry(JSON.stringify(line)),
returnToNeutral: vi.fn(),
startNewTour: vi.fn(),
newTourConfirmationOpen: false,
cancelNewTour: vi.fn(),
elevationProfileExpanded: true,
toggleElevationProfile: vi.fn(),
routeProfileFocus: null,
setRouteProfileFocus: vi.fn(),
} as unknown as TourPlannerController;
}
function previewing(): TourGpxImport {
const file = new File(['<gpx/>'], 'ride.gpx', { type: 'application/gpx+xml' });
return {
importing: false,
importFiles: vi.fn(),
pick: vi.fn(),
preview: [
{
file,
tracks: [
{
name: 'Ride',
geometry: [
[48, 11],
[48.01, 11],
],
point_count: 2,
distance_km: 1.1,
elevation_gain_m: null,
elevation_loss_m: null,
duplicate: false,
},
],
},
],
confirm: vi.fn().mockResolvedValue(undefined),
cancel: vi.fn(),
};
}
describe('a ride taken from a GPX file (HOR-02)', () => {
it('FE-HORSE-IMPORT-002: says the line stays as recorded, gets no riding time and is not checked for access', () => {
render(<RecordedRideFacts />);
const facts = screen.getByTestId('recorded-ride-facts');
expect(facts).toHaveTextContent('As recorded');
expect(facts).toHaveTextContent('No riding time');
expect(facts).toHaveTextContent('Access not checked');
// Each reason is the badge's description, reachable without a pointer.
const access = screen.getByText('Access not checked').closest('[tabindex="0"]') as HTMLElement;
expect(access).toHaveAccessibleDescription(
'The import does not check whether the ways may be ridden. The signs on site count.'
);
expect(facts).not.toHaveTextContent('No heights in the file');
});
it('FE-HORSE-IMPORT-003: names heights the file did not carry instead of showing zero', () => {
render(<RecordedRideFacts heightsMissing />);
expect(screen.getByTestId('recorded-ride-facts')).toHaveTextContent('No heights in the file');
});
it('FE-HORSE-IMPORT-004: the preview says so once a ride is chosen, and imports nothing until confirmed', () => {
const gpxImport = previewing();
render(<TourGpxPreviewDialog gpxImport={gpxImport} />);
expect(screen.queryByTestId('gpx-preview-horse')).toBeNull();
fireEvent.click(screen.getByTestId('tour-type-select'));
fireEvent.click(screen.getByTestId('tour-type-option-horse'));
expect(within(screen.getByTestId('gpx-preview-horse')).getByText('Access not checked')).toBeInTheDocument();
expect(gpxImport.confirm).not.toHaveBeenCalled();
fireEvent.click(screen.getByTestId('gpx-preview-import'));
expect(gpxImport.confirm).toHaveBeenCalledWith({ tour_type: 'horse' });
});
it('FE-HORSE-IMPORT-005: a recorded ride in view carries the card, with the heights its file lacks named', () => {
render(
<TourPlannerRail
planner={viewing(ride, [
[48, 11],
[48.01, 11],
])}
/>
);
const card = screen.getByRole('region', { name: 'Recorded ride' });
expect(card).toHaveTextContent('No riding time');
expect(card).toHaveTextContent('No heights in the file');
// Nothing of a time is shown: an import of a ride gets none worked out.
expect(screen.queryByText('Riding time')).toBeNull();
});
it('FE-HORSE-IMPORT-006: heights the file has need no word, and a recorded hike gets no ride card', () => {
const line = [
[48, 11, 500],
[48.01, 11, 540],
];
const { unmount } = render(<TourPlannerRail planner={viewing(ride, line)} />);
expect(screen.getByRole('region', { name: 'Recorded ride' })).not.toHaveTextContent('No heights in the file');
unmount();
render(<TourPlannerRail planner={viewing({ ...ride, tour_type: 'hike' }, line)} />);
expect(screen.queryByRole('region', { name: 'Recorded ride' })).toBeNull();
});
});
@@ -0,0 +1,73 @@
import { useId, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { SoftPill } from '../../Planner/planParts';
import { Tooltip } from '../../shared/Tooltip';
import { RailCard } from '../tourParts';
import { ACCESS_TONE_DOT } from './horseAccess';
import { Horseshoe } from './HorseshoeIcon';
/**
* HOR-02: what a ride taken from a GPX file is, said as badges with the why in
* their tooltips. Its line stays as the file recorded it, no riding time is
* worked out for it, and nothing checked whether its ways may be ridden: the
* import makes no ride allowed or passable. Heights the file did not carry are
* named as missing rather than shown as zero.
*/
/** One badge of the row; with a reason, it takes the focus and says the reason in a tooltip. */
function FactPill({ icon, label, info }: { icon?: ReactNode; label: string; info?: string }) {
const id = useId();
const pill = <SoftPill icon={icon}>{label}</SoftPill>;
if (!info) return pill;
return (
<Tooltip label={info}>
<span
tabIndex={0}
aria-describedby={id}
className="inline-flex rounded-full focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
{pill}
<span id={id} className="sr-only">
{info}
</span>
</span>
</Tooltip>
);
}
export function RecordedRideFacts({ heightsMissing = false }: { heightsMissing?: boolean }) {
const { t } = useTranslation();
return (
<div data-testid="recorded-ride-facts" className="flex min-w-0 flex-wrap items-center gap-1.5">
<FactPill
icon={
<Horseshoe
size={11}
strokeWidth={2.4}
aria-hidden="true"
className="flex-none text-[color:var(--horse-route)]"
/>
}
label={t('tours.horse.recorded.asRecorded')}
info={t('tours.horse.recorded.asRecordedInfo')}
/>
<FactPill label={t('tours.horse.recorded.noTime')} info={t('tours.horse.recorded.noTimeInfo')} />
<FactPill
icon={<span aria-hidden="true" className={`h-2 w-2 flex-none rounded-full ${ACCESS_TONE_DOT.unknown}`} />}
label={t('tours.horse.recorded.accessUnchecked')}
info={t('tours.horse.recorded.accessUncheckedInfo')}
/>
{heightsMissing && <FactPill label={t('tours.import.previewNoHeights')} />}
</div>
);
}
/** The ride's card in the rail of a recorded ride in view, above its measures. */
export function RecordedRideCard({ heightsMissing }: { heightsMissing: boolean }) {
const { t } = useTranslation();
return (
<RailCard icon={<Horseshoe size={14} strokeWidth={2} />} title={t('tours.horse.recorded.title')}>
<RecordedRideFacts heightsMissing={heightsMissing} />
</RailCard>
);
}
@@ -52,6 +52,7 @@ import { TourPoiSearchCard } from '../poi/TourPoiSearchCard'
import { BikeWaysCard } from '../bike/BikeWaysCard'
import { RunWaysCard } from '../run/RunWaysCard'
import { HorseRoutesCard } from '../horse/HorseRoutesCard'
import { RecordedRideCard } from '../horse/RecordedRideFacts'
import { walkTimeFields } from '../walk/walkTimeFields'
const STATUS_KEY: Partial<Record<TourPlannerStatus, string>> = {
@@ -329,6 +330,8 @@ function GpxViewRail({
{tour.tour_type === 'kayak' && (
<PaddleWindSection placeId={tour.place_id} start={trackStart(analysis?.routeCoordinates)} />
)}
{/* HOR-02: a recorded ride says what the import did not do for it. */}
{tour.tour_type === 'horse' && <RecordedRideCard heightsMissing={!!analysis && analysis.gain == null} />}
<RailCard icon={<Mountain size={14} strokeWidth={2} />} title={t('tours.planner.inspector.title')}>
{analysis ? (
<div className="flex flex-col gap-3">