From 0331d9950f6c74dcd68eb179a5ad16bfa213650d Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 20 Oct 2026 12:48:41 +0200 Subject: [PATCH] feat(tours): plan rides on horseback from signposted horse routes A tour can now be a ride (HOR-01). The planner offers "Horse ride" as a tour type with its own horseshoe, and a Horse routes card beside the waypoints looks, when asked, for the route=horse relations OpenStreetMap knows in the map view. A route opens in a dialog that shows it on a map in the sections it is made of, split at forks, gaps and crossings, so nothing is ever joined across a gap. The rider picks a section and its direction, sees what the horse=* tags along it say (open, limited, closed, or unknown where untagged) and whether it is signposted the other way, and takes it as one undoable step. A section that does not start where the ride ends is only joined by a straight manual stretch once the rider switched that on. Nothing of a ride is routed: every leg follows a section or is drawn by hand, and the waypoint list and the map say which (a section as a solid line with its route, a manual stretch dashed and marked unchecked). A point at a section stays where the route has it, a ride works out no riding time, and distance, heights, day assignment, GPX import and export, the 3D view and the reversed ride keep working with each leg's origin saved and opened again. A failed lookup leaves the ride and the routes already found as they were. The server reads the routes through the shared Overpass client (GET /api/trips/:tripId/tours/horse-routes, Tours addon, trip access), keeps an answer twelve hours per grid-aligned view, and the tour contract refuses a routed leg, a via ferrata or a riding time on a ride and a horse route section on any other type. A migration enables the horse tour type. --- client/src/api/horseRoutes.test.ts | 34 ++ client/src/api/horseRoutes.ts | 21 ++ .../components/Tours/ToursSidebar.test.tsx | 18 + .../Tours/conversion/useGpxConversion.ts | 8 +- .../src/components/Tours/gpxImportChoices.ts | 2 + .../Tours/horse/HorseLegRow.test.tsx | 103 +++++ .../components/Tours/horse/HorseLegRow.tsx | 66 ++++ .../Tours/horse/HorseRouteDialog.tsx | 129 +++++++ .../Tours/horse/HorseRouteDialogParts.tsx | 219 +++++++++++ .../components/Tours/horse/HorseRouteMap.tsx | 82 ++++ .../Tours/horse/HorseRoutesCard.test.tsx | 184 +++++++++ .../Tours/horse/HorseRoutesCard.tsx | 156 ++++++++ .../components/Tours/horse/HorseshoeIcon.ts | 19 + .../src/components/Tours/horse/horseAccess.ts | 45 +++ .../Tours/horse/horseImport.test.ts | 29 ++ .../src/components/Tours/horse/horseLegs.ts | 41 ++ .../components/Tours/horse/horseModel.test.ts | 144 +++++++ .../src/components/Tours/horse/horsePaint.ts | 16 + .../Tours/horse/horseRouting.test.ts | 85 +++++ .../components/Tours/horse/horseSection.ts | 81 ++++ .../Tours/horse/useHorseRoutes.test.ts | 129 +++++++ .../components/Tours/horse/useHorseRoutes.ts | 71 ++++ .../Tours/horse/useHorseSectionPick.ts | 58 +++ .../Tours/planner/TourEditorFields.tsx | 1 + .../Tours/planner/TourPlannerPanels.tsx | 10 +- .../Tours/planner/TourTypeSelect.tsx | 7 +- .../Tours/planner/TourWaypointList.tsx | 4 +- .../Tours/planner/WaypointLegRows.tsx | 18 +- .../Tours/planner/tourPlannerFacts.ts | 10 +- .../Tours/planner/tourPlannerWaypoints.ts | 21 +- .../Tours/planner/tourRouteActions.ts | 27 +- .../components/Tours/planner/tourRouting.ts | 27 +- .../Tours/planner/useShapingClick.ts | 9 +- .../Tours/planner/useTourDraftStorage.ts | 2 +- .../planner/useTourPlanner.horse.test.tsx | 179 +++++++++ .../Tours/planner/useTourPlanner.ts | 22 +- .../components/Tours/planner/useTourRoute.ts | 25 +- client/src/components/Tours/tourDayRowIcon.ts | 3 + client/src/components/Tours/tourFacts.ts | 9 +- client/src/components/Tours/tourMapLayers.ts | 4 + client/src/components/Tours/tourParts.tsx | 4 +- client/src/main.tsx | 1 + .../tripPlanner/TourPlannerMapStage.test.tsx | 3 + .../pages/tripPlanner/TourPlannerMapStage.tsx | 4 + client/src/repo/horseRouteRepo.test.ts | 63 ++++ client/src/repo/horseRouteRepo.ts | 18 + client/src/store/tourMapViewportStore.ts | 13 + client/src/styles/horse.css | 16 + client/tests/helpers/storeRegistry.ts | 2 + client/tests/helpers/tourPlannerStub.ts | 1 + server/src/db/json-columns.ts | 4 +- ...45600_a_tour_can_be_ridden_on_horseback.ts | 21 ++ server/src/nest/tours/horse-route-sections.ts | 151 ++++++++ .../src/nest/tours/tour-gpx-export.helpers.ts | 10 +- .../tours/tour-horse-routes.controller.ts | 50 +++ .../nest/tours/tour-horse-routes.helpers.ts | 201 ++++++++++ .../nest/tours/tour-horse-routes.service.ts | 86 +++++ server/src/nest/tours/tours.mcp.ts | 4 +- server/src/nest/tours/tours.module.ts | 7 +- server/tests/e2e/tours-horse.e2e.test.ts | 351 ++++++++++++++++++ server/tests/unit/db/legacy-baseline.test.ts | 1 + .../unit/db/tour-horse-type-migration.test.ts | 72 ++++ .../unit/db/tour-types-migration.test.ts | 3 +- .../unit/nest/horse-route-sections.test.ts | 113 ++++++ .../unit/nest/tour-gpx-export.helpers.test.ts | 8 +- .../nest/tour-horse-routes.controller.test.ts | 88 +++++ .../nest/tour-horse-routes.helpers.test.ts | 135 +++++++ .../nest/tour-horse-routes.service.test.ts | 100 +++++ shared/src/i18n/ar/tours.ts | 57 +++ shared/src/i18n/az/tours.ts | 58 +++ shared/src/i18n/br/tours.ts | 57 +++ shared/src/i18n/ca/tours.ts | 59 +++ shared/src/i18n/cs/tours.ts | 57 +++ shared/src/i18n/de/tours.ts | 57 +++ shared/src/i18n/en/tours.ts | 57 +++ shared/src/i18n/es/tours.ts | 58 +++ shared/src/i18n/et/tours.ts | 58 +++ shared/src/i18n/fr/tours.ts | 61 +++ shared/src/i18n/gr/tours.ts | 59 +++ shared/src/i18n/hu/tours.ts | 57 +++ shared/src/i18n/id/tours.ts | 59 +++ shared/src/i18n/it/tours.ts | 60 +++ shared/src/i18n/ja/tours.ts | 54 +++ shared/src/i18n/ko/tours.ts | 54 +++ shared/src/i18n/nl/tours.ts | 59 +++ shared/src/i18n/pl/tours.ts | 58 +++ shared/src/i18n/ru/tours.ts | 58 +++ shared/src/i18n/sk/tours.ts | 57 +++ shared/src/i18n/sv/tours.ts | 57 +++ shared/src/i18n/th/tours.ts | 57 +++ shared/src/i18n/tours-locales.spec.ts | 2 +- shared/src/i18n/tr/tours.ts | 58 +++ shared/src/i18n/uk/tours.ts | 57 +++ shared/src/i18n/vi/tours.ts | 57 +++ shared/src/i18n/zh-TW/tours.ts | 54 +++ shared/src/i18n/zh/tours.ts | 54 +++ shared/src/index.ts | 1 + shared/src/tours/horse-route.schema.spec.ts | 88 +++++ shared/src/tours/horse-route.schema.ts | 141 +++++++ shared/src/tours/tour-poi-search.schema.ts | 2 + shared/src/tours/tours.schema.test.ts | 61 +++ shared/src/tours/tours.schema.ts | 57 ++- wiki/MCP-Addon-Tools.md | 4 +- 103 files changed, 5470 insertions(+), 82 deletions(-) create mode 100644 client/src/api/horseRoutes.test.ts create mode 100644 client/src/api/horseRoutes.ts create mode 100644 client/src/components/Tours/horse/HorseLegRow.test.tsx create mode 100644 client/src/components/Tours/horse/HorseLegRow.tsx create mode 100644 client/src/components/Tours/horse/HorseRouteDialog.tsx create mode 100644 client/src/components/Tours/horse/HorseRouteDialogParts.tsx create mode 100644 client/src/components/Tours/horse/HorseRouteMap.tsx create mode 100644 client/src/components/Tours/horse/HorseRoutesCard.test.tsx create mode 100644 client/src/components/Tours/horse/HorseRoutesCard.tsx create mode 100644 client/src/components/Tours/horse/HorseshoeIcon.ts create mode 100644 client/src/components/Tours/horse/horseAccess.ts create mode 100644 client/src/components/Tours/horse/horseImport.test.ts create mode 100644 client/src/components/Tours/horse/horseLegs.ts create mode 100644 client/src/components/Tours/horse/horseModel.test.ts create mode 100644 client/src/components/Tours/horse/horsePaint.ts create mode 100644 client/src/components/Tours/horse/horseRouting.test.ts create mode 100644 client/src/components/Tours/horse/horseSection.ts create mode 100644 client/src/components/Tours/horse/useHorseRoutes.test.ts create mode 100644 client/src/components/Tours/horse/useHorseRoutes.ts create mode 100644 client/src/components/Tours/horse/useHorseSectionPick.ts create mode 100644 client/src/components/Tours/planner/useTourPlanner.horse.test.tsx create mode 100644 client/src/repo/horseRouteRepo.test.ts create mode 100644 client/src/repo/horseRouteRepo.ts create mode 100644 client/src/store/tourMapViewportStore.ts create mode 100644 client/src/styles/horse.css create mode 100644 server/src/db/migrations/Migration20200101045600_a_tour_can_be_ridden_on_horseback.ts create mode 100644 server/src/nest/tours/horse-route-sections.ts create mode 100644 server/src/nest/tours/tour-horse-routes.controller.ts create mode 100644 server/src/nest/tours/tour-horse-routes.helpers.ts create mode 100644 server/src/nest/tours/tour-horse-routes.service.ts create mode 100644 server/tests/e2e/tours-horse.e2e.test.ts create mode 100644 server/tests/unit/db/tour-horse-type-migration.test.ts create mode 100644 server/tests/unit/nest/horse-route-sections.test.ts create mode 100644 server/tests/unit/nest/tour-horse-routes.controller.test.ts create mode 100644 server/tests/unit/nest/tour-horse-routes.helpers.test.ts create mode 100644 server/tests/unit/nest/tour-horse-routes.service.test.ts create mode 100644 shared/src/tours/horse-route.schema.spec.ts create mode 100644 shared/src/tours/horse-route.schema.ts 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 (
  • - +