diff --git a/client/src/api/horseRoutes.test.ts b/client/src/api/horseRoutes.test.ts new file mode 100644 index 000000000..af590d029 --- /dev/null +++ b/client/src/api/horseRoutes.test.ts @@ -0,0 +1,34 @@ +// FE-API-HORSE-001 to FE-API-HORSE-002 +import { http, HttpResponse } from 'msw'; +import { describe, expect, it } from 'vitest'; + +import { server } from '../../tests/helpers/msw/server'; +import { horseRoutesApi } from './horseRoutes'; + +const reply = { routes: [], truncated: false, source: 'openstreetmap', attribution: '© OpenStreetMap contributors' }; +const BBOX = '47.10000,11.20000,47.30000,11.50000'; + +describe('horseRoutesApi.list', () => { + it('FE-API-HORSE-001: asks GET /api/trips/:tripId/tours/horse-routes with the view and returns the body', async () => { + let url = ''; + server.use( + http.get('/api/trips/7/tours/horse-routes', ({ request }) => { + url = request.url; + return HttpResponse.json(reply); + }) + ); + + await expect(horseRoutesApi.list(7, BBOX)).resolves.toEqual(reply); + expect(new URL(url).searchParams.get('bbox')).toBe(BBOX); + }); + + it('FE-API-HORSE-002: no Overpass mirror answering (502) rejects', async () => { + server.use( + http.get('/api/trips/7/tours/horse-routes', () => + HttpResponse.json({ error: 'Horse route data is unavailable right now' }, { status: 502 }) + ) + ); + + await expect(horseRoutesApi.list(7, BBOX)).rejects.toBeTruthy(); + }); +}); diff --git a/client/src/api/horseRoutes.ts b/client/src/api/horseRoutes.ts new file mode 100644 index 000000000..e3f4e5e23 --- /dev/null +++ b/client/src/api/horseRoutes.ts @@ -0,0 +1,21 @@ +import apiClient from './client'; + +/** + * How long the browser waits for GET /api/trips/:tripId/tours/horse-routes. + * The server races the Overpass mirrors under its own deadline (25 s by + * default) and answers 502 when none made it, so this sits just above it, as + * the via ferrata layer's wait does. + */ +const HORSE_ROUTES_TIMEOUT_MS = 30_000; + +/** + * HOR-01 GET /api/trips/:tripId/tours/horse-routes: the signposted horse + * routes OpenStreetMap knows inside one map view. The answer comes back + * unparsed; repo/horseRouteRepo.ts checks it against the shared contract. + */ +export const horseRoutesApi = { + list: (tripId: number | string, bbox: string, signal?: AbortSignal): Promise => + apiClient + .get(`/trips/${tripId}/tours/horse-routes`, { params: { bbox }, signal, timeout: HORSE_ROUTES_TIMEOUT_MS }) + .then((response) => response.data as unknown), +}; diff --git a/client/src/components/Tours/ToursSidebar.test.tsx b/client/src/components/Tours/ToursSidebar.test.tsx index cb89520dc..dc061ff2c 100644 --- a/client/src/components/Tours/ToursSidebar.test.tsx +++ b/client/src/components/Tours/ToursSidebar.test.tsx @@ -416,6 +416,24 @@ describe('ToursSidebar', () => { vi.restoreAllMocks() }) + it('HOR-01: a recorded ride imports as a ride, with nothing of another type', async () => { + vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack()] }) + const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValue({ tours: [tour], caution: false, skipped: 0 }) + seedStore(useTripStore, { refreshPlaces: vi.fn().mockResolvedValue(undefined) }) + render( + + ) + const file = new File([''], 'ride.gpx', { type: 'application/gpx+xml' }) + pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [file]) + await screen.findByRole('dialog') + fireEvent.click(screen.getByTestId('tour-type-select')) + fireEvent.click(screen.getByTestId('tour-type-option-horse')) + fireEvent.click(screen.getByTestId('gpx-preview-import')) + + await waitFor(() => expect(importCall).toHaveBeenCalledWith(1, file, { tour_type: 'horse' })) + vi.restoreAllMocks() + }) + it('RC-04: refuses files that are not GPX before uploading anything', async () => { const importCall = vi.spyOn(tourGpxApi, 'import') render( diff --git a/client/src/components/Tours/conversion/useGpxConversion.ts b/client/src/components/Tours/conversion/useGpxConversion.ts index 9476d2ebe..ed066fc81 100644 --- a/client/src/components/Tours/conversion/useGpxConversion.ts +++ b/client/src/components/Tours/conversion/useGpxConversion.ts @@ -128,8 +128,14 @@ export function useGpxConversion(planner: TourPlannerController) { /** * The import shown can be planned from: it has a track and the user may add tours. A ski * track (SKI-02) never: TREK routes no ski track, and a hike's or a walk's route is no stand-in. + * A ride (HOR-01) neither: no router plans one, and walking it again on foot would be no ride. */ - available: tour !== null && tour.tour_type !== 'ski' && (track?.length ?? 0) >= 2 && planner.canEdit, + available: + tour !== null && + tour.tour_type !== 'ski' && + tour.tour_type !== 'horse' && + (track?.length ?? 0) >= 2 && + planner.canEdit, difficulty, /** The import is a paddle tour: its version is drawn with straight legs, not routed. */ paddle, diff --git a/client/src/components/Tours/gpxImportChoices.ts b/client/src/components/Tours/gpxImportChoices.ts index ebf05c2a4..6ab6232b9 100644 --- a/client/src/components/Tours/gpxImportChoices.ts +++ b/client/src/components/Tours/gpxImportChoices.ts @@ -47,6 +47,8 @@ export function importFields(chosen: GpxImportChoices): TourGpxImportFields { if (type === 'bike') return { tour_type: 'bike', bike_profile: chosen.bikeProfile }; // RUN-01: a run comes in with its profile and no pace; its time stays unknown until one is planned from it. if (type === 'run') return { tour_type: 'run', run_profile: chosen.runProfile }; + // HOR-01: a recorded ride comes in as a ride, with nothing of another type. + if (type === 'horse') return { tour_type: 'horse' }; // SKI-02: a ski track comes in with its discipline and, for cross-country, its technique, and no time. const ski = type === 'ski' ? skiFieldsOf(chosen.ski) : null; if (ski) return { tour_type: 'ski', ...ski }; diff --git a/client/src/components/Tours/horse/HorseLegRow.test.tsx b/client/src/components/Tours/horse/HorseLegRow.test.tsx new file mode 100644 index 000000000..cee5e88c5 --- /dev/null +++ b/client/src/components/Tours/horse/HorseLegRow.test.tsx @@ -0,0 +1,103 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, within } from '../../../../tests/helpers/render'; +import { TourDayRowIcon } from '../TourDayRowFacts'; +import { tourDayRowIcon } from '../tourDayRowIcon'; +import { movingTimeLabel } from '../tourFacts'; +import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints'; +import { useShapingClick } from '../planner/useShapingClick'; +import type { TourPlannerController } from '../planner/useTourPlanner'; +import { WaypointLegRows } from '../planner/WaypointLegRows'; +import { HorseLegRow } from './HorseLegRow'; +import { Horseshoe } from './HorseshoeIcon'; +import type { HorseSectionLeg } from './horseSection'; + +// FE-HORSE-LEG-001 to FE-HORSE-LEG-005 + +const section: HorseSectionLeg = { + kind: 'horse_route', + ref: 'relation/42', + name: 'Reitweg Isartal', + geometry: [ + [48, 11], + [48.01, 11], + ], + signposted: 'against', + access: [ + { value: 'designated', length_m: 800 }, + { value: 'no', length_m: 312 }, + ], +}; + +function legRows(point: TourPlannerWaypoint, isHorse: boolean) { + const planner = { isHorse, toggleWaypointFreeLeg: vi.fn() } as unknown as TourPlannerController; + return ( +
    + +
+ ); +} + +describe('a ride in the waypoint list (HOR-01)', () => { + it('FE-HORSE-LEG-001: a section shows its route, its length, the other signposted way and what its tags say', () => { + render( +
    + +
+ ); + const row = screen.getByTestId('tour-planner-horse-leg'); + expect(row).toHaveTextContent('Reitweg Isartal'); + expect(row).toHaveTextContent('1.1 km'); + expect(within(row).getByTestId('tour-planner-horse-leg-against')).toHaveAccessibleName( + 'This section is signposted the other way.' + ); + expect(within(row).getByTestId('tour-planner-horse-leg-access')).toHaveAccessibleName('Closed in part'); + }); + + it('FE-HORSE-LEG-002: a section without a name or tags is said to have neither', () => { + render( +
    + +
+ ); + const row = screen.getByTestId('tour-planner-horse-leg'); + expect(row).toHaveTextContent('Unnamed horse route'); + expect(within(row).queryByTestId('tour-planner-horse-leg-against')).not.toBeInTheDocument(); + expect(within(row).getByTestId('tour-planner-horse-leg-access')).toHaveAccessibleName('Access not in the data'); + }); + + it('FE-HORSE-LEG-003: a ride names its legs drawn by hand manual and unchecked, its sections as sections', () => { + const drawn = { kind: 'free' as const, geometry: section.geometry }; + const { rerender } = render(legRows({ id: 'a', lat: 48, lng: 11, role: 'start', fixedLeg: drawn }, true)); + expect(screen.getByTestId('tour-planner-free-leg')).toHaveTextContent('Manual, unchecked'); + rerender(legRows({ id: 'a', lat: 48, lng: 11, role: 'start', fixedLeg: drawn }, false)); + expect(screen.getByTestId('tour-planner-free-leg')).not.toHaveTextContent('Manual, unchecked'); + rerender(legRows({ id: 'a', lat: 48, lng: 11, role: 'start', fixedLeg: section }, true)); + expect(screen.getByTestId('tour-planner-horse-leg')).toBeInTheDocument(); + }); + + it('FE-HORSE-LEG-004: a ride wears the horseshoe, is named a horse ride and its time riding time', () => { + expect(tourDayRowIcon('horse')).toBe(Horseshoe); + render(); + expect(screen.getByTestId('plan-tour-icon')).toHaveAttribute('aria-label', 'Horse ride'); + expect(screen.getByTestId('plan-tour-icon').querySelector('.lucide-horseshoe')).toBeTruthy(); + expect(movingTimeLabel({ tour_type: 'horse' }, 'Walking time', (key) => key)).toBe('tours.horse.time'); + }); + + it('FE-HORSE-LEG-005: a click on a ride sets no shaping point', () => { + const planner = { waypoints: [], route: null, insertShapingPoint: vi.fn(), setSelectedWaypointId: vi.fn() }; + const ride = renderHook(() => useShapingClick({ ...planner, isHorse: true }, { editable: true, picking: false })); + expect(ride.result.current).toBeUndefined(); + const hike = renderHook(() => useShapingClick(planner, { editable: true, picking: false })); + expect(hike.result.current).toBeTypeOf('function'); + }); +}); diff --git a/client/src/components/Tours/horse/HorseLegRow.tsx b/client/src/components/Tours/horse/HorseLegRow.tsx new file mode 100644 index 000000000..c96a4166a --- /dev/null +++ b/client/src/components/Tours/horse/HorseLegRow.tsx @@ -0,0 +1,66 @@ +import { ArrowLeftRight } from 'lucide-react'; +import { useTranslation } from '../../../i18n'; +import { useSettingsStore } from '../../../store/settingsStore'; +import { formatDistance } from '../../../utils/units'; +import { fs } from '../../shared/DialogShell'; +import { Tooltip } from '../../shared/Tooltip'; +import { lineLengthM } from '../planner/tourFixedLegs'; +import { ACCESS_TONE_DOT, accessText, accessToneOf } from './horseAccess'; +import { Horseshoe } from './HorseshoeIcon'; +import type { HorseSectionLeg } from './horseSection'; + +/** + * HOR-01: a section of a horse route the ride follows, as a badge on its leg + * in the brown the map draws it in: the route's name, a dot for what its + * `horse=*` tags say (hollow where they say nothing), a mark where the route + * is signposted the other way, and its length. The tooltip spells the tags out. + */ +export function HorseLegRow({ leg }: { leg: HorseSectionLeg }) { + const { t } = useTranslation(); + const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric'; + // Read as plain numbers: this workspace compiles without strict mode, where the schema's tuples are "maybe two". + const line = leg.geometry.map(([lat, lng]): [number, number] => [Number(lat), Number(lng)]); + const length = formatDistance(lineLengthM(line) / 1000, unit); + const tone = accessToneOf(leg.access) ?? 'unknown'; + const access = accessText(leg.access, unit, t) || t('tours.horse.access.value.unknown'); + const name = leg.name ?? t('tours.horse.routes.unnamed'); + return ( +
  • + + + +
  • + ); +} diff --git a/client/src/components/Tours/horse/HorseRouteDialog.tsx b/client/src/components/Tours/horse/HorseRouteDialog.tsx new file mode 100644 index 000000000..fb7966d2f --- /dev/null +++ b/client/src/components/Tours/horse/HorseRouteDialog.tsx @@ -0,0 +1,129 @@ +import type { HorseRoute } from '@trek/shared'; +import { ArrowRight, ExternalLink, Plus } from 'lucide-react'; +import { useId } from 'react'; +import { useTranslation } from '../../../i18n'; +import { useSettingsStore } from '../../../store/settingsStore'; +import { SoftPill } from '../../Planner/planParts'; +import { + DialogButton, + DialogFooter, + DialogHeader, + DialogSection, + DialogShell, + DialogTile, + FooterSpacer, + NEUTRAL_TINT, + PILL, + fs, +} from '../../shared/DialogShell'; +import { FigureBadge } from '../paddle/FigureBadge'; +import type { TourPlannerController } from '../planner/useTourPlanner'; +import { AccessLabel, AccessPills, ConnectionField, DirectionField, SectionChips } from './HorseRouteDialogParts'; +import { HorseRouteMap } from './HorseRouteMap'; +import { Horseshoe } from './HorseshoeIcon'; +import { useHorseSectionPick } from './useHorseSectionPick'; + +type DialogPlanner = Pick; + +/** A route's name as the rider reads it: its own, else its reference, else said to have none. */ +export function horseRouteTitle(route: HorseRoute, t: (key: string) => string): string { + return route.name ?? route.ref ?? t('tours.horse.routes.unnamed'); +} + +/** What the route is at a glance: its reference, its sections, whether it goes on, and a way to OpenStreetMap. */ +function RoutePills({ route }: { route: HorseRoute }) { + const { t } = useTranslation(); + return ( + <> + {route.ref && route.name && {route.ref}} + + {route.continues && ( + }> + {t('tours.horse.routes.continues')} + + )} + + + + ); +} + +/** + * HOR-01: a signposted horse route, and the one place a section of it goes + * onto the ride. The rider sees the route on a map, picks a section and the + * direction, reads what the tags along it say and how it joins the ride, and + * only then takes it: one step of the planner's history. Being on the route + * is never read as a permission to ride it. + */ +export function HorseRouteDialog({ + planner, + route, + onClose, +}: { + planner: DialogPlanner; + route: HorseRoute; + onClose: () => void; +}) { + const { t } = useTranslation(); + const labelId = useId(); + const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric'; + const pick = useHorseSectionPick(planner, route, onClose); + return ( + + + + } + tint={NEUTRAL_TINT} + labelId={labelId} + onClose={onClose} + eyebrow={t('tours.horse.dialog.eyebrow')} + title={horseRouteTitle(route, t)} + pills={} + /> + } + footer={ + + + {t('common.cancel')} + } + disabled={!pick.canAdopt} + onClick={pick.adopt} + > + {t('tours.horse.dialog.adopt')} + + + } + > + + + + + {pick.leg && ( + <> + + + + }> + + + + + + + )} + + ); +} diff --git a/client/src/components/Tours/horse/HorseRouteDialogParts.tsx b/client/src/components/Tours/horse/HorseRouteDialogParts.tsx new file mode 100644 index 000000000..cbbf6af12 --- /dev/null +++ b/client/src/components/Tours/horse/HorseRouteDialogParts.tsx @@ -0,0 +1,219 @@ +import { horseAccessTone, type HorseRoute } from '@trek/shared'; +import { AlertTriangle, ArrowLeft, ArrowLeftRight, ArrowRight, Link2, Spline } from 'lucide-react'; +import { useId } from 'react'; +import { useTranslation } from '../../../i18n'; +import type { DistanceUnit } from '../../../types'; +import { formatDistance } from '../../../utils/units'; +import { SoftPill } from '../../Planner/planParts'; +import ToggleSwitch from '../../Settings/ToggleSwitch'; +import { Segmented } from '../../shared/dialogParts'; +import { fs } from '../../shared/DialogShell'; +import { Tooltip } from '../../shared/Tooltip'; +import { InfoDot, TourNotice } from '../tourParts'; +import { ACCESS_TONE_DOT, ACCESS_TONE_PILL, accessToneOf } from './horseAccess'; +import type { HorseSectionLeg } from './horseSection'; +import type { HorseSectionPick } from './useHorseSectionPick'; + +/** + * HOR-01: the parts of the dialog that takes a section of a horse route onto + * a ride: the sections to choose from, the direction, what the `horse=*` tags + * say along the one chosen, and how it joins the ride. + */ + +/** + * The route's sections as numbered chips, in the order the route lists its + * ways: the number carries a dot in its corner for what the tags say along the + * section, hollow where they say nothing, and the chip its length. + */ +export function SectionChips({ + route, + chosen, + onPick, + unit, +}: { + route: HorseRoute; + chosen: number | undefined; + onPick: (number: number) => void; + unit: DistanceUnit; +}) { + const { t } = useTranslation(); + return ( +
      + {route.sections.map((section) => { + const on = section.number === chosen; + const tone = accessToneOf(section.access) ?? 'unknown'; + const length = formatDistance(section.length_m / 1000, unit); + const label = t('tours.horse.dialog.section', { number: section.number }); + return ( +
    • + + + +
    • + ); + })} +
    + ); +} + +/** As mapped or the other way round, with what the route's own signposting says about the choice. */ +export function DirectionField({ pick }: { pick: HorseSectionPick }) { + const { t } = useTranslation(); + const signposted = pick.leg?.signposted ?? null; + return ( +
    + + value={pick.reversed ? 'reversed' : 'mapped'} + onChange={(value) => pick.turn(value === 'reversed')} + label={t('tours.horse.dialog.direction')} + fill + options={[ + { + value: 'mapped', + label: t('tours.horse.dialog.asMapped'), + icon: , + }, + { + value: 'reversed', + label: t('tours.horse.dialog.reversed'), + icon: , + }, + ]} + /> + {signposted === 'with' && ( + + }> + {t('tours.horse.dialog.signedWith')} + + + )} + {signposted === 'against' && ( + }> + {t('tours.horse.dialog.signedAgainst')} + + )} +
    + ); +} + +/** The label of the access block, with what it can and cannot say behind its info dot. */ +export function AccessLabel() { + const { t } = useTranslation(); + return ( + + {t('tours.horse.access.title')} + + + ); +} + +/** + * What the `horse=*` tags say along the section chosen, one pill per value + * with the metres it covers, the most restrictive first: the tags as they + * are, unknown where there are none. A tag that closes part of it is said. + */ +export function AccessPills({ leg, unit }: { leg: HorseSectionLeg; unit: DistanceUnit }) { + const { t } = useTranslation(); + const shares = leg.access ?? []; + const closed = accessToneOf(shares) === 'closed'; + return ( +
    + + {shares.map((share) => { + const tone = horseAccessTone(share.value); + return ( + + ); + })} + + {closed && ( + }> + {t('tours.horse.access.closedNotice')} + + )} +
    + ); +} + +/** + * How the section joins the ride: as its first leg, from its end, or across a + * gap no horse route covers. A gap is only drawn by hand, straight and + * unchecked, once the rider switched that on; it is never closed by itself. + */ +export function ConnectionField({ pick, unit }: { pick: HorseSectionPick; unit: DistanceUnit }) { + const { t } = useTranslation(); + const hintId = useId(); + if (pick.gapM === null) + return ( + + }> + {t('tours.horse.dialog.firstSection')} + + + ); + if (!pick.gapOpen) + return ( + + }> + {t('tours.horse.dialog.joins')} + + + ); + return ( +
    + }> + {t('tours.horse.dialog.gap', { distance: formatDistance(pick.gapM / 1000, unit) })} + +
    +
    +
    + ); +} diff --git a/client/src/components/Tours/horse/HorseRouteMap.tsx b/client/src/components/Tours/horse/HorseRouteMap.tsx new file mode 100644 index 000000000..d387526f0 --- /dev/null +++ b/client/src/components/Tours/horse/HorseRouteMap.tsx @@ -0,0 +1,82 @@ +import type { HorseRoute } from '@trek/shared'; +import { useMemo } from 'react'; +import { OFM_POSITRON } from '../../../constants/mapDefaults'; +import { useTileUrl } from '../../../hooks/useTileUrl'; +import { useTranslation } from '../../../i18n'; +import { MapViewAuto } from '../../Map/MapViewAuto'; +import { useHorsePaint } from './horsePaint'; +import type { HorseSectionLeg } from './horseSection'; + +type Line = [number, number][]; + +/** + * HOR-01: the route in the dialog that takes a section of it: the ride so far + * in the planner's blue, every section of the route in its brown, and the one + * chosen over them in the accent, from A to B in the direction chosen. The map + * frames the chosen section and the end of the ride, so a gap between them + * shows before anything is taken. + */ +export function HorseRouteMap({ + route, + chosen, + ride, +}: { + route: HorseRoute; + chosen: HorseSectionLeg | null; + /** The ride as the planner draws it now, or null while it has no line. */ + ride: Line | null; +}) { + const { t } = useTranslation(); + const tileUrl = useTileUrl(OFM_POSITRON); + const paint = useHorsePaint(); + const drawn = useMemo(() => { + const sections = route.sections.map((section) => + section.geometry.map(([lat, lng]): [number, number] => [lat, lng]) + ); + const chosenLine = chosen?.geometry.map(([lat, lng]): [number, number] => [Number(lat), Number(lng)]) ?? null; + const sectionColor = { line: paint.section, casing: paint.casing }; + return { + lines: [...(ride ? [ride] : []), ...sections, ...(chosenLine ? [chosenLine] : [])], + colors: [ + ...(ride ? [undefined] : []), + ...sections.map(() => sectionColor), + ...(chosenLine ? [{ line: paint.chosen, casing: paint.casing }] : []), + ], + focus: [...(chosenLine ?? sections.flat()), ...(ride ? ride.slice(-1) : [])], + }; + }, [chosen, paint, ride, route]); + const chosenGeometry = chosen?.geometry ?? []; + const first = chosenGeometry[0]; + const last = chosenGeometry[chosenGeometry.length - 1]; + const pin = (id: string, at: readonly unknown[], label: string, detail: string) => ({ + id, + lat: Number(at[0]), + lng: Number(at[1]), + label, + detail, + }); + const ends = + first && last + ? [ + pin('horse-section-start', first, 'A', t('tours.planner.start')), + pin('horse-section-end', last, 'B', t('tours.planner.end')), + ] + : []; + return ( +
    + +
    + ); +} diff --git a/client/src/components/Tours/horse/HorseRoutesCard.test.tsx b/client/src/components/Tours/horse/HorseRoutesCard.test.tsx new file mode 100644 index 000000000..5ead03a28 --- /dev/null +++ b/client/src/components/Tours/horse/HorseRoutesCard.test.tsx @@ -0,0 +1,184 @@ +import { HORSE_ROUTES_ATTRIBUTION, type HorseRoute } from '@trek/shared'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, waitFor, within } from '../../../../tests/helpers/render'; +import { hikePlannerDefaults } from '../../../../tests/helpers/tourPlannerStub'; +import { publishTourMapViewport } from '../../../store/tourMapViewportStore'; +import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints'; +import type { TourPlannerController } from '../planner/useTourPlanner'; +import { HorseRoutesCard } from './HorseRoutesCard'; + +const repo = vi.hoisted(() => ({ list: vi.fn() })); +vi.mock('../../../repo/horseRouteRepo', () => ({ horseRouteRepo: repo })); +vi.mock('../../Map/MapViewAuto', () => ({ + MapViewAuto: ({ route, plannerWaypoints }: { route?: unknown[]; plannerWaypoints?: Array<{ label?: string }> }) => ( +
    + {plannerWaypoints?.map((pin) => pin.label).join('')} +
    + ), +})); + +// FE-HORSE-CARD-001 to FE-HORSE-CARD-007 + +const VIEW = { south: 47.1, west: 11.2, north: 47.3, east: 11.5 }; +const ROUTE: HorseRoute = { + id: 'relation/42', + name: 'Reitweg Isartal', + ref: 'R1', + osmUrl: 'https://www.openstreetmap.org/relation/42', + continues: true, + sections: [ + { + number: 1, + geometry: [ + [47.15, 11.25], + [47.16, 11.25], + ], + length_m: 1112, + access: [ + { value: 'designated', length_m: 800 }, + { value: 'private', length_m: 312 }, + ], + signposted: 'with', + }, + { + number: 2, + geometry: [ + [47.17, 11.25], + [47.18, 11.25], + ], + length_m: 1112, + access: [], + signposted: null, + }, + ], +}; +const reply = (routes: HorseRoute[]) => ({ + routes, + truncated: false, + source: 'openstreetmap' as const, + attribution: HORSE_ROUTES_ATTRIBUTION, +}); + +function planner(overrides: Partial = {}) { + return { + ...hikePlannerDefaults(), + tourType: 'horse', + isHorse: true, + mode: { type: 'new-draft' }, + waypoints: [] as TourPlannerWaypoint[], + canEdit: true, + isSaving: false, + route: null, + editWaypoints: vi.fn(), + ...overrides, + } as unknown as TourPlannerController; +} + +async function openRoute(stub: TourPlannerController) { + repo.list.mockResolvedValue(reply([ROUTE])); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Find in map view' })); + fireEvent.click(await screen.findByTestId('horse-route-relation/42')); + return screen.getByRole('dialog'); +} + +beforeEach(() => { + repo.list.mockReset(); + publishTourMapViewport(VIEW); + localStorage.clear(); +}); + +describe('HorseRoutesCard (HOR-01)', () => { + it('FE-HORSE-CARD-001: is there only while a ride is edited', () => { + const { rerender } = render(); + expect(screen.queryByTestId('horse-routes-card')).not.toBeInTheDocument(); + rerender(); + expect(screen.queryByTestId('horse-routes-card')).not.toBeInTheDocument(); + rerender(); + expect(screen.getByTestId('horse-routes-card')).toBeInTheDocument(); + expect(repo.list).not.toHaveBeenCalled(); + }); + + it('FE-HORSE-CARD-002: lists the routes of the view with their length, sections and where they go on', async () => { + repo.list.mockResolvedValue(reply([ROUTE])); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Find in map view' })); + const row = await screen.findByTestId('horse-route-relation/42'); + expect(row).toHaveTextContent('Reitweg Isartal'); + expect(row).toHaveTextContent('2.2 km'); + expect(row).toHaveTextContent('R1'); + expect(row).toHaveTextContent('Goes on beyond the view'); + expect(within(row).getByText('Sections: 2')).toBeInTheDocument(); + expect(screen.getByText(HORSE_ROUTES_ATTRIBUTION)).toBeInTheDocument(); + // The map moved: the routes shown are from another view, and can be looked for again. + act(() => publishTourMapViewport({ ...VIEW, north: 47.31 })); + expect(await screen.findByRole('button', { name: 'Search this view' })).toBeInTheDocument(); + }); + + it('FE-HORSE-CARD-003: says why it found nothing, and lets a failed search be asked again', async () => { + repo.list.mockResolvedValueOnce(reply([])).mockRejectedValueOnce(new Error('502')); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Find in map view' })); + expect(await screen.findByText('No signposted horse routes in this view.')).toBeInTheDocument(); + act(() => publishTourMapViewport({ ...VIEW, north: 47.31 })); + fireEvent.click(await screen.findByRole('button', { name: 'Search this view' })); + expect(await screen.findByText('Horse routes could not be read. Your ride is unchanged.')).toBeInTheDocument(); + act(() => publishTourMapViewport({ south: 46, west: 11, north: 47, east: 11.4 })); + fireEvent.click(screen.getByRole('button', { name: 'Search this view' })); + expect(await screen.findByText('Zoom in further to look for horse routes.')).toBeInTheDocument(); + }); + + it('FE-HORSE-CARD-004: takes a section as the first leg of an empty ride, in one step', async () => { + const stub = planner(); + const dialog = await openRoute(stub); + expect(within(dialog).getByTestId('horse-map-stub')).toHaveAttribute('data-lines', '3'); + expect(within(dialog).getByTestId('horse-map-stub')).toHaveTextContent('AB'); + expect(within(dialog).getByText('Starts your ride')).toBeInTheDocument(); + fireEvent.click(within(dialog).getByTestId('horse-section-adopt')); + expect(stub.editWaypoints).toHaveBeenCalledTimes(1); + type Change = (points: TourPlannerWaypoint[]) => TourPlannerWaypoint[]; + const change = vi.mocked(stub.editWaypoints).mock.calls[0]?.[0] as Change; + const points = change([]); + expect(points.map((point) => point.fixedLeg?.kind ?? null)).toEqual(['horse_route', null]); + expect(points[0].fixedLeg).toMatchObject({ ref: 'relation/42', name: 'Reitweg Isartal', signposted: 'with' }); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('FE-HORSE-CARD-005: shows what the tags say, and says a closed part and the other signposted way', async () => { + const dialog = await openRoute(planner()); + const access = within(dialog).getByTestId('horse-section-access'); + expect(access).toHaveTextContent('Designated for riders'); + expect(access).toHaveTextContent('800 m'); + expect(access).toHaveTextContent('Private'); + const closed = 'Part of this section is tagged as closed or private for horses.'; + expect(within(dialog).getByText(closed)).toBeInTheDocument(); + expect(within(dialog).getByText('Signposted this way')).toBeInTheDocument(); + fireEvent.click(within(dialog).getByRole('button', { name: 'Reversed' })); + expect(within(dialog).getByText('This section is signposted the other way.')).toBeInTheDocument(); + // A section the data says nothing about reads as unknown, not as open. + fireEvent.click(within(dialog).getByTestId('horse-section-2')); + expect(within(dialog).queryByText('Signposted this way')).not.toBeInTheDocument(); + expect(within(dialog).getByRole('button', { name: 'As mapped' })).toHaveAttribute('aria-pressed', 'true'); + }); + + it('FE-HORSE-CARD-006: never closes a gap by itself: the manual join has to be switched on first', async () => { + const stub = planner({ waypoints: [{ id: 'a', lat: 47.1, lng: 11.25, role: 'start' }] }); + const dialog = await openRoute(stub); + expect(within(dialog).getByTestId('horse-section-gap')).toHaveTextContent('5.6 km'); + const adopt = within(dialog).getByTestId('horse-section-adopt'); + expect(adopt).toBeDisabled(); + fireEvent.click(within(dialog).getByRole('button', { name: 'Join with a manual section' })); + expect(adopt).toBeEnabled(); + // Another direction is another gap: the switch goes off again. + fireEvent.click(within(dialog).getByRole('button', { name: 'Reversed' })); + expect(adopt).toBeDisabled(); + }); + + it('FE-HORSE-CARD-007: goes on from the end of the ride where the section starts there', async () => { + const stub = planner({ waypoints: [{ id: 'a', lat: 47.15, lng: 11.25, role: 'start' }] }); + const dialog = await openRoute(stub); + expect(within(dialog).getByText('Joins the end of your ride')).toBeInTheDocument(); + fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })); + expect(stub.editWaypoints).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/Tours/horse/HorseRoutesCard.tsx b/client/src/components/Tours/horse/HorseRoutesCard.tsx new file mode 100644 index 000000000..46ee68748 --- /dev/null +++ b/client/src/components/Tours/horse/HorseRoutesCard.tsx @@ -0,0 +1,156 @@ +import { HORSE_ROUTES_ATTRIBUTION, type HorseRoute } from '@trek/shared'; +import { AlertTriangle, ArrowRight, ChevronRight, Loader2, RefreshCw, Search, WifiOff, ZoomIn } from 'lucide-react'; +import { useState } from 'react'; +import { useTranslation } from '../../../i18n'; +import { useSettingsStore } from '../../../store/settingsStore'; +import { formatDistance } from '../../../utils/units'; +import { SoftPill, WhiteBadge } from '../../Planner/planParts'; +import { fs } from '../../shared/DialogShell'; +import type { TourPlannerController } from '../planner/useTourPlanner'; +import { CardAction, InfoDot, RailCard, TourNotice } from '../tourParts'; +import { HorseRouteDialog, horseRouteTitle } from './HorseRouteDialog'; +import { Horseshoe } from './HorseshoeIcon'; +import { useHorseRoutes, type HorseRoutes } from './useHorseRoutes'; + +/** + * One route found in the view: its horseshoe with the number of its sections + * in the corner, its name, its length in the view, and a mark where it goes + * on beyond. Pressed, it opens the route to take a section of it. + */ +function RouteRow({ route, onOpen }: { route: HorseRoute; onOpen: () => void }) { + const { t } = useTranslation(); + const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric'; + const lengthM = route.sections.reduce((sum, section) => sum + section.length_m, 0); + return ( +
  • + +
  • + ); +} + +/** What the last search came to, when that is not a list: loading, nothing found, or why it could not look. */ +function SearchStatus({ found }: { found: HorseRoutes }) { + const { t } = useTranslation(); + if (found.status === 'loading') + return ( + + + {t('tours.horse.routes.loading')} + + ); + if (found.status === 'zoom') + return }>{t('tours.horse.routes.zoomIn')}; + if (found.status === 'offline') + return }>{t('tours.horse.routes.offline')}; + if (found.status === 'failed') + return ( + }> + {t('tours.horse.routes.failed')} + + ); + if (found.status === 'ready' && found.routes.length === 0) + return {t('tours.horse.routes.none')}; + if (found.status === 'ready' && found.truncated) + return }>{t('tours.horse.routes.truncated')}; + return null; +} + +function HorseRoutesBody({ planner }: { planner: TourPlannerController }) { + const { t } = useTranslation(); + const found = useHorseRoutes(planner.tripId); + const [open, setOpen] = useState(null); + const searched = found.searched !== null; + const again = searched && (found.stale || found.status === 'failed'); + return ( + } + title={t('tours.horse.routes.title')} + foldId="horse-routes" + aside={ + <> + {found.status === 'ready' && found.routes.length > 0 && {found.routes.length}} + + + } + > +
    + {(!searched || again) && ( + + : } + onClick={() => void found.search()} + disabled={found.status === 'loading'} + > + {t(again ? 'tours.horse.routes.searchAgain' : 'tours.horse.routes.search')} + + + )} + + {found.routes.length > 0 && ( +
      + {found.routes.map((route) => ( + setOpen(route)} /> + ))} +
    + )} + {searched && ( + + {HORSE_ROUTES_ATTRIBUTION} + + )} +
    + {open && setOpen(null)} />} +
    + ); +} + +/** + * HOR-01: the signposted horse routes of the map view, beside a ride being + * planned. Looked for when the rider asks; a route opens in a dialog to take + * a section of it. Shown only while a ride is edited. + */ +export function HorseRoutesCard({ planner }: { planner: TourPlannerController }) { + const editing = planner.mode.type === 'new-draft' || planner.mode.type === 'edit-saved'; + if (!planner.isHorse || !editing) return null; + return ; +} diff --git a/client/src/components/Tours/horse/HorseshoeIcon.ts b/client/src/components/Tours/horse/HorseshoeIcon.ts new file mode 100644 index 000000000..8c3d46c24 --- /dev/null +++ b/client/src/components/Tours/horse/HorseshoeIcon.ts @@ -0,0 +1,19 @@ +import { createLucideIcon } from 'lucide-react'; + +/** + * HOR-01: a horseshoe, the ride's own symbol, drawn on lucide's grid (24, + * stroke 2, round caps) so it sits beside its icons without a seam: the shoe + * open to the ground, its two arcs joined at the heels and four nail holes. + * Lucide has no horse; built with its own factory, it is a LucideIcon like + * any other and goes wherever one does. + */ +export const Horseshoe = createLucideIcon('horseshoe', [ + ['path', { d: 'M5.5 20C3.9 18 3 15.3 3 12.5a9 9 0 0 1 18 0c0 2.8-.9 5.5-2.5 7.5', key: 'outer' }], + ['path', { d: 'M9.5 18c-.9-1.3-1.5-3.3-1.5-5.5a4 4 0 0 1 8 0c0 2.2-.6 4.2-1.5 5.5', key: 'inner' }], + ['path', { d: 'm5.5 20 4-2', key: 'heel-start' }], + ['path', { d: 'm18.5 20-4-2', key: 'heel-end' }], + ['path', { d: 'M5.5 12.5h.01', key: 'nail-1' }], + ['path', { d: 'M7.4 7.9h.01', key: 'nail-2' }], + ['path', { d: 'M16.6 7.9h.01', key: 'nail-3' }], + ['path', { d: 'M18.5 12.5h.01', key: 'nail-4' }], +]); diff --git a/client/src/components/Tours/horse/horseAccess.ts b/client/src/components/Tours/horse/horseAccess.ts new file mode 100644 index 000000000..31f3dee16 --- /dev/null +++ b/client/src/components/Tours/horse/horseAccess.ts @@ -0,0 +1,45 @@ +import { horseAccessTone, type HorseAccessShare, type HorseAccessTone } from '@trek/shared'; +import type { DistanceUnit } from '../../../types'; +import { formatDistance } from '../../../utils/units'; + +/** + * HOR-01: how the `horse=*` tags along a section read for a rider. What is + * tagged is said as it is, step by step; what is not tagged stays unknown. + * None of it is a permission: being on a signposted route is not either. + */ + +/** The step a whole line reads as: the most restrictive one tagged along it, unknown before open. */ +const TONE_ORDER: readonly HorseAccessTone[] = ['closed', 'limited', 'unknown', 'open']; + +export function accessToneOf(shares: readonly HorseAccessShare[] | undefined): HorseAccessTone | null { + if (!shares?.length) return null; + const tones = new Set(shares.map((share) => horseAccessTone(share.value))); + return TONE_ORDER.find((tone) => tones.has(tone)) ?? null; +} + +/** The dot a step is marked with: a status colour, unknown as a hollow ring. */ +export const ACCESS_TONE_DOT: Readonly> = { + open: 'bg-success', + limited: 'bg-warning', + closed: 'bg-danger', + unknown: 'border border-content-faint bg-surface-card', +}; + +/** The pill tone a step is written in. */ +export const ACCESS_TONE_PILL: Readonly> = { + open: 'success', + limited: 'warning', + closed: 'danger', + unknown: 'neutral', +}; + +/** The tags along a line as words, the longest first: "Designated for riders 900 m, Not in the data 450 m". */ +export function accessText( + shares: readonly HorseAccessShare[] | undefined, + unit: DistanceUnit, + t: (key: string) => string +): string { + return (shares ?? []) + .map((share) => `${t(`tours.horse.access.value.${share.value}`)} ${formatDistance(share.length_m / 1000, unit)}`) + .join(', '); +} diff --git a/client/src/components/Tours/horse/horseImport.test.ts b/client/src/components/Tours/horse/horseImport.test.ts new file mode 100644 index 000000000..f99ec27ca --- /dev/null +++ b/client/src/components/Tours/horse/horseImport.test.ts @@ -0,0 +1,29 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { useGpxConversion } from '../conversion/useGpxConversion'; +import type { TourPlannerController } from '../planner/useTourPlanner'; + +// FE-HORSE-IMPORT-001 + +function viewing(tourType: string) { + const tour = { place_id: 5, tour_type: tourType, name: 'Recorded', max_hiking_difficulty: null }; + return { + mode: { type: 'view-gpx', placeId: 5, tour }, + readOnlyGpxTour: { tour, routeGeometry: null }, + readOnlyGpxAnalysis: { + routeCoordinates: [ + [48, 11], + [48.01, 11], + ], + }, + canEdit: true, + startDerivedTour: vi.fn(), + } as unknown as TourPlannerController; +} + +describe('a recorded ride (HOR-01)', () => { + it('FE-HORSE-IMPORT-001: offers no editable version planned on foot, where a hike does', () => { + expect(renderHook(() => useGpxConversion(viewing('horse'))).result.current.available).toBe(false); + expect(renderHook(() => useGpxConversion(viewing('hike'))).result.current.available).toBe(true); + }); +}); diff --git a/client/src/components/Tours/horse/horseLegs.ts b/client/src/components/Tours/horse/horseLegs.ts new file mode 100644 index 000000000..b8c2bfb26 --- /dev/null +++ b/client/src/components/Tours/horse/horseLegs.ts @@ -0,0 +1,41 @@ +import { freeLegBetween, type RoutePoint } from '../planner/tourRouteActions'; + +/** + * HOR-01: the legs of a ride. Nothing of a ride is routed: a leg either + * follows a section of a signposted horse route or is drawn by hand, straight + * and unchecked. These keep a draft to that, whatever was done to it. + */ + +/** + * Every leg of a ride that follows no horse route drawn by hand: a leg the + * router would have planned, a via ferrata, a section torn off its points by a + * move. The last point has no leg. + */ +export function rideLegs(points: readonly T[]): T[] { + return points.map((point, index) => { + const next = points[index + 1]; + const kind = point.fixedLeg?.kind; + if (!next || kind === 'horse_route' || kind === 'free') return point; + return { ...point, fixedLeg: freeLegBetween(point, next) }; + }); +} + +/** Any other type takes no section of a horse route: such a leg goes back to the router. */ +export function withoutHorseRoutes(points: readonly T[]): T[] { + return points.map((point) => { + if (point.fixedLeg?.kind !== 'horse_route') return point; + const { fixedLeg: _section, ...routed } = point; + return routed as T; + }); +} + +/** + * Whether a section of a horse route starts or ends at this point. Such a + * point stays where the route has it: dragged away, the section would no + * longer reach it and would have to give way to a straight line. + */ +export function holdsHorseSection(points: readonly RoutePoint[], id: string): boolean { + const index = points.findIndex((point) => point.id === id); + if (index < 0) return false; + return points[index]?.fixedLeg?.kind === 'horse_route' || points[index - 1]?.fixedLeg?.kind === 'horse_route'; +} diff --git a/client/src/components/Tours/horse/horseModel.test.ts b/client/src/components/Tours/horse/horseModel.test.ts new file mode 100644 index 000000000..1474ef13d --- /dev/null +++ b/client/src/components/Tours/horse/horseModel.test.ts @@ -0,0 +1,144 @@ +import type { HorseRoute } from '@trek/shared'; +import { describe, expect, it } from 'vitest'; +import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints'; +import { accessText, accessToneOf } from './horseAccess'; +import { holdsHorseSection, rideLegs, withoutHorseRoutes } from './horseLegs'; +import { preferReversed, SECTION_JOINS_M, sectionGapM, sectionLeg, withSectionAppended } from './horseSection'; + +// FE-HORSE-MODEL-001 to FE-HORSE-MODEL-009 + +const route: HorseRoute = { + id: 'relation/42', + name: null, + ref: 'R1', + osmUrl: 'https://www.openstreetmap.org/relation/42', + continues: true, + sections: [ + { + number: 1, + geometry: [ + [48, 11], + [48.005, 11], + [48.01, 11], + ], + length_m: 1112, + access: [ + { value: 'designated', length_m: 800 }, + { value: 'unknown', length_m: 312 }, + ], + signposted: 'with', + }, + ], +}; +const section = route.sections[0]; +const point = (id: string, lat: number, lng: number, extra: Partial = {}) => + ({ id, lat, lng, role: 'via', ...extra }) as TourPlannerWaypoint; +const t = (key: string) => key.split('.').pop() ?? key; + +describe('horse route sections on a ride (HOR-01)', () => { + it('FE-HORSE-MODEL-001: a section as a leg keeps its line, its route and its access, and turns round whole', () => { + expect(sectionLeg(route, section, false)).toEqual({ + kind: 'horse_route', + ref: 'relation/42', + name: 'R1', + geometry: section.geometry, + signposted: 'with', + access: section.access, + }); + const turned = sectionLeg({ ...route, name: 'Reitweg', ref: null }, section, true); + expect(turned.name).toBe('Reitweg'); + expect(turned.geometry).toEqual([...section.geometry].reverse()); + expect(turned.signposted).toBe('against'); + expect(sectionLeg({ ...route, ref: null }, { ...section, signposted: null }, true)).toMatchObject({ + name: null, + signposted: null, + }); + }); + + it('FE-HORSE-MODEL-002: measures the gap from the end of the ride, and has none on an empty ride', () => { + const leg = sectionLeg(route, section, false); + expect(sectionGapM([], leg)).toBeNull(); + expect(sectionGapM([point('a', 48, 11)], leg)).toBe(0); + expect(sectionGapM([point('a', 47.99, 11)], leg)).toBeCloseTo(1112, -1); + }); + + it('FE-HORSE-MODEL-003: offers first the direction that goes on from the ride, else the signposted one', () => { + expect(preferReversed([point('a', 48.011, 11)], section)).toBe(true); + expect(preferReversed([point('a', 47.99, 11)], section)).toBe(false); + expect(preferReversed([], section)).toBe(false); + expect(preferReversed([], { ...section, signposted: 'against' })).toBe(true); + }); + + it('FE-HORSE-MODEL-004: a section starts an empty ride, goes on from its end, or starts a point of its own', () => { + const leg = sectionLeg(route, section, false); + let next = 0; + const newId = () => `n${(next += 1)}`; + expect(withSectionAppended([], leg, newId)).toEqual([ + { id: 'n2', lat: 48, lng: 11, role: 'via', fixedLeg: leg }, + { id: 'n1', lat: 48.01, lng: 11, role: 'end' }, + ]); + const near = point('a', 48 + SECTION_JOINS_M / 200_000, 11); + expect(withSectionAppended([near], leg, newId)).toEqual([ + { ...near, fixedLeg: leg }, + { id: 'n3', lat: 48.01, lng: 11, role: 'end' }, + ]); + const far = point('b', 47.99, 11); + expect(withSectionAppended([far], leg, newId).map((entry) => [entry.id, entry.fixedLeg?.kind ?? null])).toEqual([ + ['b', null], + ['n5', 'horse_route'], + ['n4', null], + ]); + }); + + it('FE-HORSE-MODEL-005: every leg of a ride is a section or drawn by hand, the last point has none', () => { + const ferrata = { + kind: 'ferrata' as const, + ref: 'way/1', + name: null, + geometry: [[48, 11] as [number, number], [48.01, 11] as [number, number]], + }; + const leg = sectionLeg(route, section, false); + const legs = rideLegs([ + point('a', 47.99, 11), + point('b', 48, 11, { fixedLeg: leg }), + point('c', 48.01, 11, { fixedLeg: ferrata }), + point('d', 48.02, 11), + ]); + expect(legs.map((entry) => entry.fixedLeg?.kind ?? null)).toEqual(['free', 'horse_route', 'free', null]); + expect(legs[0].fixedLeg?.geometry).toEqual([ + [47.99, 11], + [48, 11], + ]); + }); + + it('FE-HORSE-MODEL-006: any other type gives a section back to the router and keeps the rest', () => { + const leg = sectionLeg(route, section, false); + const drawn = { kind: 'free' as const, geometry: [[47.99, 11] as [number, number], [48, 11] as [number, number]] }; + const points = withoutHorseRoutes([ + point('a', 47.99, 11, { fixedLeg: drawn }), + point('b', 48, 11, { fixedLeg: leg }), + ]); + expect(points.map((entry) => entry.fixedLeg?.kind ?? null)).toEqual(['free', null]); + expect('fixedLeg' in points[1]).toBe(false); + }); + + it('FE-HORSE-MODEL-007: a point that starts or ends a section is held where the route has it', () => { + const leg = sectionLeg(route, section, false); + const points = [point('a', 48, 11, { fixedLeg: leg }), point('b', 48.01, 11), point('c', 48.02, 11)]; + expect(['a', 'b', 'c', 'gone'].map((id) => holdsHorseSection(points, id))).toEqual([true, true, false, false]); + }); + + it('FE-HORSE-MODEL-008: reads a line as its most restrictive tag, unknown before open, nothing as nothing', () => { + expect(accessToneOf(undefined)).toBeNull(); + expect(accessToneOf([])).toBeNull(); + expect(accessToneOf([{ value: 'yes', length_m: 10 }])).toBe('open'); + expect(accessToneOf([{ value: 'yes', length_m: 10 }, { value: 'unknown', length_m: 1 }])).toBe('unknown'); + expect(accessToneOf([{ value: 'unknown', length_m: 10 }, { value: 'destination', length_m: 1 }])).toBe('limited'); + expect(accessToneOf([{ value: 'designated', length_m: 10 }, { value: 'private', length_m: 1 }])).toBe('closed'); + }); + + it('FE-HORSE-MODEL-009: spells the tags out with the metres each covers', () => { + expect(accessText(section.access, 'metric', t)).toBe('designated 800 m, unknown 312 m'); + expect(accessText(undefined, 'metric', t)).toBe(''); + }); +}); diff --git a/client/src/components/Tours/horse/horsePaint.ts b/client/src/components/Tours/horse/horsePaint.ts new file mode 100644 index 000000000..e5555147c --- /dev/null +++ b/client/src/components/Tours/horse/horsePaint.ts @@ -0,0 +1,16 @@ +import { useMapPaint } from '../../Map/mapPaint'; + +/** + * The colours a ride's horse routes are drawn in on a map (styles/horse.css): + * the sections of a route in its brown, the one chosen in the accent, both on + * a rim. The cards take the CSS variables directly; a map needs them resolved. + */ +const HORSE_PAINT_TOKENS = { + section: '--horse-route', + chosen: '--accent', + casing: '--horse-route-casing', +} as const; + +export function useHorsePaint(): Record { + return useMapPaint(HORSE_PAINT_TOKENS); +} diff --git a/client/src/components/Tours/horse/horseRouting.test.ts b/client/src/components/Tours/horse/horseRouting.test.ts new file mode 100644 index 000000000..86568db7a --- /dev/null +++ b/client/src/components/Tours/horse/horseRouting.test.ts @@ -0,0 +1,85 @@ +import type { TourFixedLeg } from '@trek/shared'; +import { describe, expect, it } from 'vitest'; +import { tourMapLayersFor } from '../tourMapLayers'; +import { normalizePlannerWaypoints, tourWaypointKey, type TourPlannerWaypoint } from '../planner/tourPlannerWaypoints'; +import { toggleFreeLeg } from '../planner/tourRouteActions'; +import { rideTour, routeWalkingTour } from '../planner/tourRouting'; + +// FE-HORSE-ROUTE-001 to FE-HORSE-ROUTE-006 + +const section: TourFixedLeg = { + kind: 'horse_route', + ref: 'relation/42', + name: 'Reitweg', + geometry: [ + [48, 11], + [48.005, 11], + [48.01, 11], + ], + signposted: null, + access: [], +}; +const drawn: TourFixedLeg = { + kind: 'free', + geometry: [ + [48.01, 11], + [48.011, 11], + ], +}; +const at = (id: string, lat: number, lng: number, fixedLeg?: TourFixedLeg): TourPlannerWaypoint => ({ + id, + lat, + lng, + role: 'via', + ...(fixedLeg ? { fixedLeg } : {}), +}); + +describe('a ride on the planner (HOR-01)', () => { + it('FE-HORSE-ROUTE-001: puts a ride together from its legs, a section as it is, a drawn leg sampled, no time', () => { + const ride = rideTour([at('a', 48, 11, section), at('b', 48.01, 11, drawn), at('c', 48.011, 11)]); + expect(ride).not.toBeNull(); + expect(ride?.coordinates.slice(0, 3)).toEqual(section.geometry); + expect(ride?.coordinates[ride.coordinates.length - 1]).toEqual([48.011, 11]); + expect(ride?.parts.map((part) => part.free)).toEqual([false, true]); + // The drawn leg of about 111 m gets points along it, so its heights follow the ground. + expect(ride?.parts[1]?.coordinates.length).toBeGreaterThan(2); + expect(ride?.distanceMeters).toBeCloseTo(1223, -1); + expect(ride?.durationSeconds).toBeNull(); + }); + + it('FE-HORSE-ROUTE-002: has no line while a leg has none of its own, or before there is a leg', () => { + expect(rideTour([at('a', 48, 11), at('b', 48.01, 11)])).toBeNull(); + expect(rideTour([at('a', 48, 11)])).toBeNull(); + }); + + it('FE-HORSE-ROUTE-003: no router plans a section of a horse route on another type', async () => { + await expect(routeWalkingTour([at('a', 48, 11, section), at('b', 48.01, 11)])).resolves.toBeNull(); + }); + + it('FE-HORSE-ROUTE-004: keeps a section when its leg is asked to be drawn by hand', () => { + const points = [at('a', 48, 11, section), at('b', 48.01, 11)]; + expect(toggleFreeLeg(points, 'a')).toEqual(points); + }); + + it('FE-HORSE-ROUTE-005: normalises a draft for its type, a ride all fixed, another type without sections', () => { + const points = [at('a', 47.99, 11), at('b', 48, 11, section), at('c', 48.01, 11)]; + const ride = normalizePlannerWaypoints(points, 'horse'); + expect(ride.map((point) => [point.role, point.fixedLeg?.kind ?? null])).toEqual([ + ['start', 'free'], + ['via', 'horse_route'], + ['end', null], + ]); + expect(normalizePlannerWaypoints(points).map((point) => point.fixedLeg?.kind ?? null)).toEqual([null, null, null]); + // A section taken the other way round is another route for the planner. + const turned = { ...section, geometry: [...section.geometry].reverse() }; + expect(tourWaypointKey([at('x', 48, 11, section)])).not.toBe(tourWaypointKey([at('x', 48, 11, turned)])); + }); + + it('FE-HORSE-ROUTE-006: a ride offers the standard map, topo and satellite, and no via ferratas', () => { + expect(tourMapLayersFor('horse')).toEqual({ + bases: ['default', 'topo', 'satellite'], + defaultBase: 'default', + overlays: [], + }); + }); +}); diff --git a/client/src/components/Tours/horse/horseSection.ts b/client/src/components/Tours/horse/horseSection.ts new file mode 100644 index 000000000..d11cc17e5 --- /dev/null +++ b/client/src/components/Tours/horse/horseSection.ts @@ -0,0 +1,81 @@ +import { + haversineMetres, + turnedSignposted, + type HorseRoute, + type HorseRouteSection, + type TourFixedLeg, +} from '@trek/shared'; +import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints'; + +/** + * HOR-01: a section of a horse route taken onto a ride. Pure, so the dialog's + * preview and the step that changes the tour read the same answer. + */ + +export type HorseSectionLeg = Extract; + +/** + * How close a section has to start to the end of the ride to go on from that + * point itself, in metres: the gap between two ways that meet in the data, not + * a stretch of its own. Well inside the 60 metres a saved leg may lie off its + * point. + */ +export const SECTION_JOINS_M = 25; + +/** The section as a leg of the ride, in the direction chosen, with its route's name and its access. */ +export function sectionLeg(route: HorseRoute, section: HorseRouteSection, reversed: boolean): HorseSectionLeg { + const geometry = section.geometry.map(([lat, lng]): [number, number] => [lat, lng]); + return { + kind: 'horse_route', + ref: route.id, + name: (route.name ?? route.ref)?.slice(0, 255) ?? null, + geometry: reversed ? geometry.reverse() : geometry, + signposted: reversed ? turnedSignposted(section.signposted) : section.signposted, + access: section.access, + }; +} + +type Point = Pick; + +/** Metres from the ride's last point to where the leg starts; null while the ride has no point. */ +export function sectionGapM(points: readonly Point[], leg: HorseSectionLeg): number | null { + const last = points[points.length - 1]; + const start = leg.geometry[0]; + if (!last || !start) return null; + return haversineMetres(last.lat, last.lng, Number(start[0]), Number(start[1])); +} + +/** + * The direction a section is offered in first: the one that starts nearer the + * end of the ride, so it goes on from there; on an empty ride the one the + * route is signposted in. + */ +export function preferReversed(points: readonly Point[], section: HorseRouteSection): boolean { + const last = points[points.length - 1]; + const first = section.geometry[0]; + const end = section.geometry[section.geometry.length - 1]; + if (!last || !first || !end) return section.signposted === 'against'; + return haversineMetres(last.lat, last.lng, end[0], end[1]) < haversineMetres(last.lat, last.lng, first[0], first[1]); +} + +/** + * The ride with the section on its end, as one change. On an empty ride the + * section is its first leg; where it starts at the ride's end it goes on from + * that point; anywhere else it starts at a point of its own, and the gap to it + * is left to the planner, which draws it by hand once the rider has agreed. + */ +export function withSectionAppended( + points: readonly TourPlannerWaypoint[], + leg: HorseSectionLeg, + newId: () => string +): TourPlannerWaypoint[] { + const [startLat, startLng] = leg.geometry[0] as [number, number]; + const [endLat, endLng] = leg.geometry[leg.geometry.length - 1] as [number, number]; + const end: TourPlannerWaypoint = { id: newId(), lat: endLat, lng: endLng, role: 'end' }; + const gap = sectionGapM(points, leg); + if (gap !== null && gap <= SECTION_JOINS_M) { + const last = points[points.length - 1] as TourPlannerWaypoint; + return [...points.slice(0, -1), { ...last, fixedLeg: leg }, end]; + } + return [...points, { id: newId(), lat: startLat, lng: startLng, role: 'via', fixedLeg: leg }, end]; +} diff --git a/client/src/components/Tours/horse/useHorseRoutes.test.ts b/client/src/components/Tours/horse/useHorseRoutes.test.ts new file mode 100644 index 000000000..68afb90ca --- /dev/null +++ b/client/src/components/Tours/horse/useHorseRoutes.test.ts @@ -0,0 +1,129 @@ +import { act, renderHook } from '@testing-library/react'; +import { HORSE_ROUTES_ATTRIBUTION, type HorseRoute } from '@trek/shared'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { OnlineOnlyError } from '../../../repo/onlineOnly'; +import { publishTourMapViewport } from '../../../store/tourMapViewportStore'; +import { horseRouteBbox, useHorseRoutes } from './useHorseRoutes'; + +const repo = vi.hoisted(() => ({ list: vi.fn() })); +vi.mock('../../../repo/horseRouteRepo', () => ({ horseRouteRepo: repo })); + +// FE-HORSE-ROUTES-HOOK-001 to FE-HORSE-ROUTES-HOOK-006 + +const VIEW = { south: 47.1, west: 11.2, north: 47.3, east: 11.5 }; +const ROUTE: HorseRoute = { + id: 'relation/42', + name: 'Reitweg', + ref: null, + osmUrl: 'https://www.openstreetmap.org/relation/42', + continues: false, + sections: [ + { + number: 1, + geometry: [ + [47.15, 11.25], + [47.16, 11.25], + ], + length_m: 1112, + access: [], + signposted: null, + }, + ], +}; +const answer = (routes: HorseRoute[], truncated = false) => ({ + routes, + truncated, + source: 'openstreetmap' as const, + attribution: HORSE_ROUTES_ATTRIBUTION, +}); + +beforeEach(() => { + repo.list.mockReset(); + publishTourMapViewport(VIEW); +}); + +describe('useHorseRoutes (HOR-01)', () => { + it('FE-HORSE-ROUTES-HOOK-001: looks only when asked, in the view the map reported', async () => { + repo.list.mockResolvedValue(answer([ROUTE], true)); + const { result } = renderHook(() => useHorseRoutes(7)); + expect(result.current.status).toBe('idle'); + expect(repo.list).not.toHaveBeenCalled(); + await act(async () => { + await result.current.search(); + }); + expect(repo.list).toHaveBeenCalledWith(7, '47.10000,11.20000,47.30000,11.50000', expect.any(AbortSignal)); + expect(result.current).toMatchObject({ status: 'ready', routes: [ROUTE], truncated: true, stale: false }); + }); + + it('FE-HORSE-ROUTES-HOOK-002: asks to zoom in rather than send a view too wide, or none at all', async () => { + const { result } = renderHook(() => useHorseRoutes(7)); + act(() => publishTourMapViewport({ south: 46, west: 11, north: 47, east: 11.4 })); + await act(async () => { + await result.current.search(); + }); + expect(result.current.status).toBe('zoom'); + act(() => publishTourMapViewport(null)); + await act(async () => { + await result.current.search(); + }); + expect(result.current.status).toBe('zoom'); + expect(repo.list).not.toHaveBeenCalled(); + }); + + it('FE-HORSE-ROUTES-HOOK-003: a failed search keeps the routes found before', async () => { + repo.list.mockResolvedValueOnce(answer([ROUTE])).mockRejectedValueOnce(new Error('502')); + const { result } = renderHook(() => useHorseRoutes(7)); + await act(async () => { + await result.current.search(); + }); + await act(async () => { + await result.current.search(); + }); + expect(result.current).toMatchObject({ status: 'failed', routes: [ROUTE] }); + }); + + it('FE-HORSE-ROUTES-HOOK-004: offline it says so instead of failing', async () => { + repo.list.mockRejectedValue(new OnlineOnlyError()); + const { result } = renderHook(() => useHorseRoutes(7)); + await act(async () => { + await result.current.search(); + }); + expect(result.current.status).toBe('offline'); + }); + + it('FE-HORSE-ROUTES-HOOK-005: says the routes shown are from another view once the map moved', async () => { + repo.list.mockResolvedValue(answer([])); + const { result } = renderHook(() => useHorseRoutes(7)); + await act(async () => { + await result.current.search(); + }); + expect(result.current.stale).toBe(false); + act(() => publishTourMapViewport({ ...VIEW, north: 47.31 })); + expect(result.current.stale).toBe(true); + }); + + it('FE-HORSE-ROUTES-HOOK-006: an answer to a search given up on is dropped', async () => { + let first: (value: unknown) => void = () => undefined; + repo.list + .mockReturnValueOnce( + new Promise((resolve) => { + first = resolve; + }) + ) + .mockResolvedValueOnce(answer([])); + const { result } = renderHook(() => useHorseRoutes(7)); + let earlier: Promise = Promise.resolve(); + act(() => { + earlier = result.current.search(); + }); + await act(async () => { + await result.current.search(); + }); + await act(async () => { + first(answer([ROUTE])); + await earlier; + }); + expect(result.current.routes).toEqual([]); + expect(horseRouteBbox(VIEW)).toBe('47.10000,11.20000,47.30000,11.50000'); + }); +}); diff --git a/client/src/components/Tours/horse/useHorseRoutes.ts b/client/src/components/Tours/horse/useHorseRoutes.ts new file mode 100644 index 000000000..ee092171c --- /dev/null +++ b/client/src/components/Tours/horse/useHorseRoutes.ts @@ -0,0 +1,71 @@ +import { ferrataBboxSpanOk, type FerrataBbox, type HorseRoute } from '@trek/shared'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { horseRouteRepo } from '../../../repo/horseRouteRepo'; +import { OnlineOnlyError } from '../../../repo/onlineOnly'; +import { useTourMapViewportStore } from '../../../store/tourMapViewportStore'; + +type HorseRoutesStatus = 'idle' | 'loading' | 'ready' | 'failed' | 'offline' | 'zoom'; + +interface HorseRoutesState { + status: HorseRoutesStatus; + /** What the last search that answered found; kept while a later one loads or fails. */ + routes: HorseRoute[]; + truncated: boolean; + /** The view that search looked in, as it was sent. */ + searched: string | null; +} + +const IDLE: HorseRoutesState = { status: 'idle', routes: [], truncated: false, searched: null }; + +/** A view as it is sent: four edges rounded to five places, which is a metre and keeps the cache key short. */ +export function horseRouteBbox(bounds: FerrataBbox): string { + return [bounds.south, bounds.west, bounds.north, bounds.east].map((edge) => edge.toFixed(5)).join(','); +} + +/** The rounded view, or null where it spans more than one search may ask for. */ +function askableBbox(bounds: FerrataBbox | null): string | null { + if (!bounds) return null; + const bbox = horseRouteBbox(bounds); + const [south, west, north, east] = bbox.split(',').map(Number) as [number, number, number, number]; + return ferrataBboxSpanOk({ south, west, north, east }) ? bbox : null; +} + +/** + * HOR-01: the signposted horse routes in the planner map's view, looked for + * when the rider asks, never on every pan: Overpass is shared and slow. The + * last answer stays on screen while a new search loads or fails, so a failed + * search changes neither the list nor the ride. `stale` says the map has moved + * since the routes shown were found. + */ +export function useHorseRoutes(tripId: number | string) { + const [state, setState] = useState(IDLE); + const abortRef = useRef(null); + const view = useTourMapViewportStore((store) => store.bounds); + + useEffect(() => () => abortRef.current?.abort(), []); + + const search = useCallback(async () => { + const bbox = askableBbox(useTourMapViewportStore.getState().bounds); + abortRef.current?.abort(); + if (!bbox) { + setState((current) => ({ ...current, status: 'zoom' })); + return; + } + const controller = new AbortController(); + abortRef.current = controller; + setState((current) => ({ ...current, status: 'loading' })); + try { + const answer = await horseRouteRepo.list(tripId, bbox, controller.signal); + if (controller.signal.aborted) return; + setState({ status: 'ready', routes: answer.routes, truncated: answer.truncated, searched: bbox }); + } catch (failure) { + if (controller.signal.aborted) return; + setState((current) => ({ ...current, status: failure instanceof OnlineOnlyError ? 'offline' : 'failed' })); + } + }, [tripId]); + + const stale = state.searched !== null && view !== null && horseRouteBbox(view) !== state.searched; + return { ...state, stale, search }; +} + +export type HorseRoutes = ReturnType; diff --git a/client/src/components/Tours/horse/useHorseSectionPick.ts b/client/src/components/Tours/horse/useHorseSectionPick.ts new file mode 100644 index 000000000..94498f8c7 --- /dev/null +++ b/client/src/components/Tours/horse/useHorseSectionPick.ts @@ -0,0 +1,58 @@ +import type { HorseRoute } from '@trek/shared'; +import { useMemo, useState } from 'react'; +import { randomId } from '../../../utils/randomId'; +import type { TourPlannerController } from '../planner/useTourPlanner'; +import { preferReversed, SECTION_JOINS_M, sectionGapM, sectionLeg, withSectionAppended } from './horseSection'; + +type PickPlanner = Pick; + +/** + * HOR-01: one route's sections in the dialog that takes one onto the ride: + * which section, in which direction, and whether a gap before it may be drawn + * by hand. Nothing changes until `adopt`, and then in one step of the + * planner's history, so one undo takes the whole section back. A gap is never + * closed unless the rider switched that on for this section and direction. + */ +export function useHorseSectionPick(planner: PickPlanner, route: HorseRoute, onDone: () => void) { + const [chosen, setChosen] = useState(route.sections[0]?.number ?? 1); + const [direction, setDirection] = useState(null); + const [joinGap, setJoinGap] = useState(false); + const section = route.sections.find((candidate) => candidate.number === chosen) ?? route.sections[0]; + const reversed = direction ?? (section ? preferReversed(planner.waypoints, section) : false); + // One leg per choice, so the map in the dialog draws again only when the choice changes. + const leg = useMemo(() => (section ? sectionLeg(route, section, reversed) : null), [reversed, route, section]); + const gapM = leg ? sectionGapM(planner.waypoints, leg) : null; + const gapOpen = gapM !== null && gapM > SECTION_JOINS_M; + const canAdopt = leg !== null && planner.canEdit && !planner.isSaving && (!gapOpen || joinGap); + + const pick = (number: number) => { + setChosen(number); + setDirection(null); + setJoinGap(false); + }; + const turn = (next: boolean) => { + setDirection(next); + setJoinGap(false); + }; + const adopt = () => { + if (!canAdopt || !leg) return; + planner.editWaypoints((points) => withSectionAppended(points, leg, randomId)); + onDone(); + }; + + return { + section, + reversed, + leg, + gapM, + gapOpen, + joinGap, + canAdopt, + pick, + turn, + toggleJoinGap: () => setJoinGap((on) => !on), + adopt, + }; +} + +export type HorseSectionPick = ReturnType; diff --git a/client/src/components/Tours/planner/TourEditorFields.tsx b/client/src/components/Tours/planner/TourEditorFields.tsx index b7689d6d9..36d2f53fb 100644 --- a/client/src/components/Tours/planner/TourEditorFields.tsx +++ b/client/src/components/Tours/planner/TourEditorFields.tsx @@ -82,6 +82,7 @@ export function TourNameField({ planner, locked }: TourFieldProps) { kayak: t('tours.planner.namePlaceholder.kayak'), walk: t('tours.planner.namePlaceholder.walk'), run: t('tours.planner.namePlaceholder.run'), + horse: t('tours.planner.namePlaceholder.horse'), }[planner.tourType] } className={INPUT} diff --git a/client/src/components/Tours/planner/TourPlannerPanels.tsx b/client/src/components/Tours/planner/TourPlannerPanels.tsx index 73c9502b5..6fb3e498a 100644 --- a/client/src/components/Tours/planner/TourPlannerPanels.tsx +++ b/client/src/components/Tours/planner/TourPlannerPanels.tsx @@ -51,6 +51,7 @@ import { useTourListFilters } from '../useTourListFilters' import { TourPoiSearchCard } from '../poi/TourPoiSearchCard' import { BikeWaysCard } from '../bike/BikeWaysCard' import { RunWaysCard } from '../run/RunWaysCard' +import { HorseRoutesCard } from '../horse/HorseRoutesCard' import { walkTimeFields } from '../walk/walkTimeFields' const STATUS_KEY: Partial> = { @@ -464,6 +465,8 @@ export function TourPlannerRail({ planner, + + @@ -622,20 +625,23 @@ interface TourPlannerToursRailProps extends TourPermissionProps { onTakenOver?: (tour: TourListItem) => Promise } -/** The words of the time estimate: walking, riding (BIK-01), running (RUN-01) or paddling, each saying how it is reckoned. */ +/** The words of the time estimate: walking, riding (BIK-01), running (RUN-01) or paddling, each saying how it is reckoned, and a ride none. */ const ESTIMATE_KEYS = { hike: { labelKey: 'tours.planner.walkingEstimated', shortKey: 'tours.planner.walkingEstimatedShort', infoLabelKey: 'tours.planner.walkingEstimatedInfoLabel', infoKey: 'tours.planner.walkingEstimatedInfo' }, bike: { labelKey: 'tours.bike.timeEstimated', shortKey: 'tours.bike.timeEstimatedShort', infoLabelKey: 'tours.bike.timeEstimatedInfoLabel', infoKey: 'tours.bike.timeEstimatedInfo' }, kayak: { labelKey: 'tours.paddle.timeEstimated', shortKey: 'tours.paddle.timeEstimatedShort', infoLabelKey: 'tours.paddle.timeEstimatedInfoLabel', infoKey: 'tours.paddle.timeEstimatedInfo' }, walk: { labelKey: 'tours.walk.timeEstimated', shortKey: 'tours.walk.timeEstimatedShort', infoLabelKey: 'tours.walk.timeEstimatedInfoLabel', infoKey: 'tours.walk.timeEstimatedInfo' }, run: { labelKey: 'tours.run.timeEstimated', shortKey: 'tours.run.timeEstimatedShort', infoLabelKey: 'tours.run.timeEstimatedInfoLabel', infoKey: 'tours.run.timeEstimatedInfo' }, + // HOR-01: a ride has no estimate; the field says why, and the planned total is the rider's own. + horse: { labelKey: 'tours.horse.time', shortKey: 'tours.horse.time', infoLabelKey: 'tours.horse.timeInfoLabel', infoKey: 'tours.horse.timeInfo' }, } as const -/** Which ways a point is out of reach of: paths for hikes and city walks, ways for bikes, runnable ways. */ +/** Which ways a point is out of reach of: paths for hikes, city walks and rides, ways for bikes, runnable ways. */ const UNREACHABLE_WAYS: Record = { hike: 'hike', walk: 'hike', kayak: 'hike', + horse: 'hike', bike: 'bike', run: 'run', } diff --git a/client/src/components/Tours/planner/TourTypeSelect.tsx b/client/src/components/Tours/planner/TourTypeSelect.tsx index 5a2adc30c..2dd3ea65c 100644 --- a/client/src/components/Tours/planner/TourTypeSelect.tsx +++ b/client/src/components/Tours/planner/TourTypeSelect.tsx @@ -24,9 +24,10 @@ function TypeTile({ type, active = false }: { type: ImportableTourType; active?: /** * What the tour is, as one field that opens the types with what each one * plans: a hike on footpaths within its limit, a bike tour on its profile, a - * paddle tour on the water. Room for the types still to come without the row - * of buttons it was growing too long for. The planner offers the types it - * plans; the GPX import offers ski as well (SKI-02), a track it only keeps. + * paddle tour on the water, a ride along horse routes. Room for the types + * still to come without the row of buttons it was growing too long for. The + * planner offers the types it plans; the GPX import offers ski as well + * (SKI-02), a track it only keeps. */ export function TourTypeSelect({ value, diff --git a/client/src/components/Tours/planner/TourWaypointList.tsx b/client/src/components/Tours/planner/TourWaypointList.tsx index 8fbce7de3..5d8f55fb0 100644 --- a/client/src/components/Tours/planner/TourWaypointList.tsx +++ b/client/src/components/Tours/planner/TourWaypointList.tsx @@ -355,7 +355,8 @@ type WaypointPoint = TourPlannerController['waypoints'][number]; /** * The tools for the leg after a point: drawn by hand or routed (on a paddle * tour: on the water), planned again (P2A-05), and the point a stop or a - * shaping point (P2A-03; never the first or the last). + * shaping point (P2A-03; never the first or the last). A ride (HOR-01) has + * none: no router plans any of its legs, so there is nothing to switch. */ function routeMenuItems( planner: TourPlannerController, @@ -364,6 +365,7 @@ function routeMenuItems( lastIndex: number, t: Translate ): MenuItems { + if (planner.isHorse) return []; const hasLeg = index < lastIndex; const free = point.fixedLeg?.kind === 'free'; const routed = hasLeg && !point.fixedLeg; diff --git a/client/src/components/Tours/planner/WaypointLegRows.tsx b/client/src/components/Tours/planner/WaypointLegRows.tsx index 734d6f5a4..6b08a4616 100644 --- a/client/src/components/Tours/planner/WaypointLegRows.tsx +++ b/client/src/components/Tours/planner/WaypointLegRows.tsx @@ -5,6 +5,7 @@ import { useSettingsStore } from '../../../store/settingsStore'; import type { TourFerrataCrossing } from '../../../store/tourFerrataCrossingStore'; import { formatDistance } from '../../../utils/units'; import { FerrataGlyph } from '../../Ferratas/FerrataGlyph'; +import { HorseLegRow } from '../horse/HorseLegRow'; import { fs } from '../../shared/DialogShell'; import { Tooltip } from '../../shared/Tooltip'; import { PaddleLegFailureRow, PaddleLegNotes } from '../paddle/PaddleLegParts'; @@ -23,9 +24,10 @@ type WaypointPoint = TourPlannerController['waypoints'][number]; /** * A leg drawn by hand, as a badge on that leg in the dashed look the map draws * it in: no router planned it, and the badge says so. Its length is the line - * the user drew, the only measure it has. + * the user drew, the only measure it has. On a ride (HOR-01) it is the manual + * stretch between horse routes, said to be unchecked. */ -function FreeLegRow({ leg }: { leg: TourFixedLeg }) { +function FreeLegRow({ leg, ride }: { leg: TourFixedLeg; ride: boolean }) { const { t } = useTranslation(); const unit = useSettingsStore((state) => state.settings.distance_unit); // Read as plain numbers: this workspace compiles without strict mode, where the schema's tuples are "maybe two". @@ -33,11 +35,11 @@ function FreeLegRow({ leg }: { leg: TourFixedLeg }) { const distance = formatDistance(lineLengthM(line) / 1000, unit ?? 'metric'); return (
  • - +