From 0169be3d79e30e7f57ffb308537b4e8fb293cfbc Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 20 Oct 2026 13:45:56 +0200 Subject: [PATCH] feat(tours): import a ski track from GPX and keep it as recorded SKI-02. Ski joins the types a GPX file can be imported as, offered in the import preview's type field only: TREK routes no ski track, so the planner never offers it. The preview asks for the discipline on purpose (alpine, snowboard, cross-country or ski touring, none chosen at first) and, for cross-country, the technique (classic, skating or unknown); Import waits until both are named, and the technique stays in sight but dimmed for the other disciplines. The track is stored exactly as recorded, with its file name as provenance, its distance and heights from the file and no time: no ski time is worked out and no hiking estimate stands in for one. Opened in the planner, a ski track gets a rail card with its discipline on a tinted tile, a cross-country technique as a badge beside it, the ski time said as not worked out, and an info dot on what TREK does not say (snow, avalanches, open runs, and that the 3D gradient colours are no difficulty or avalanche rating). Change opens a dialog to classify the track again; only the classification changes, through the offline core, with the revision it was made at. Planning over the import is not offered for ski. The existing 3D terrain view, the elevation profile and its focus work on the track as for any import, with the standard map, topo and satellite and no via ferratas. Lists, the day plan and the detail show the discipline as a frosted corner badge on the tile and as a pill with the technique inside; day assignment, copies and take-overs work as for any tour. The corner badge is now one component that the winter label shares. The server keeps tours.ski_discipline and tours.ski_technique (CHECK lists, NULL on every other type), switches the ski type on without a routing profile, refuses a ski import without its discipline, a technique outside cross-country and ski fields on any other type, and takes a planned save's classification off. PUT /api/trips/:tripId/tours/:placeId/ski classifies a ski track again (place_edit, 409 on a stale revision, 400 for a tour that is no ski track) and broadcasts tours:changed. GPX export names the discipline (alpine_skiing, snowboarding, cross_country_skiing, backcountry_skiing), and list_tours describes both fields with what a ski track does not say. Ski is labelled plainly as Ski in every language, so it no longer reads as ski touring. --- client/src/api/tourSki.ts | 12 + .../components/Tours/GpxImportTypeSection.tsx | 92 +++++ .../src/components/Tours/TourCornerMark.tsx | 36 ++ .../src/components/Tours/TourDayRowFacts.tsx | 20 +- .../src/components/Tours/TourDetailDialog.tsx | 6 +- .../components/Tours/TourGpxPreviewDialog.tsx | 101 +----- .../Tours/TourWorkspaceRail.test.tsx | 9 + .../components/Tours/TourWorkspaceRail.tsx | 5 + .../components/Tours/ToursSidebar.test.tsx | 33 ++ .../conversion/TourConversionSection.test.tsx | 19 ++ .../Tours/conversion/useGpxConversion.ts | 7 +- .../components/Tours/gpxImportChoices.test.ts | 59 ++++ .../src/components/Tours/gpxImportChoices.ts | 74 ++++ .../Tours/library/TourLibraryDialog.tsx | 4 +- .../Tours/planner/TourPlannerPanels.tsx | 11 +- .../Tours/planner/TourTypeFields.test.tsx | 2 + .../Tours/planner/TourTypeSelect.tsx | 23 +- .../ski/SkiClassificationFields.test.tsx | 54 +++ .../Tours/ski/SkiClassificationFields.tsx | 72 ++++ .../Tours/ski/SkiClassifyDialog.tsx | 66 ++++ .../Tours/ski/SkiTrackCard.test.tsx | 77 +++++ .../src/components/Tours/ski/SkiTrackCard.tsx | 65 ++++ .../components/Tours/ski/skiChoice.test.ts | 35 ++ client/src/components/Tours/ski/skiChoice.ts | 40 +++ .../components/Tours/ski/skiMarks.test.tsx | 71 ++++ client/src/components/Tours/ski/skiMarks.tsx | 82 +++++ .../Tours/ski/useSkiClassification.test.tsx | 141 ++++++++ .../Tours/ski/useSkiClassification.ts | 80 +++++ client/src/components/Tours/tourDayRowIcon.ts | 4 +- .../components/Tours/tourMapLayers.test.ts | 9 + client/src/components/Tours/tourMapLayers.ts | 3 + client/src/components/Tours/tourParts.tsx | 11 +- .../components/Tours/winter/winterMarks.tsx | 21 +- .../tripPlanner/TourPlannerMapStage.test.tsx | 21 ++ .../src/pages/tripPlanner/useTourWorkspace.ts | 11 + .../pages/tripPlanner/useTripPlannerPage.ts | 3 +- client/src/repo/tourRepo.test.ts | 44 ++- client/src/repo/tourRepo.ts | 35 +- server/src/db/entities/Tours.entity.ts | 21 ++ server/src/db/kysely/Tours.table.ts | 2 + ...0200101045520_a_tour_can_be_a_ski_track.ts | 23 ++ .../src/db/repositories/Tours.repository.ts | 41 +++ server/src/nest/tours/dto/tour-ski.dto.ts | 5 + .../src/nest/tours/tour-gpx-export.helpers.ts | 17 +- server/src/nest/tours/tour-ski.controller.ts | 34 ++ server/src/nest/tours/tour-ski.service.ts | 57 ++++ server/src/nest/tours/tours.mcp.ts | 2 +- server/src/nest/tours/tours.module.ts | 4 + server/src/nest/tours/tours.service.ts | 18 +- server/tests/e2e/tours-ski.e2e.test.ts | 322 ++++++++++++++++++ server/tests/e2e/tours.e2e.test.ts | 2 +- server/tests/unit/db/legacy-baseline.test.ts | 1 + .../db/repositories/Tours.repository.test.ts | 43 ++- server/tests/unit/db/tour-ski-columns.test.ts | 69 ++++ .../unit/nest/tour-gpx-export.helpers.test.ts | 11 +- .../unit/nest/tour-ski.controller.test.ts | 46 +++ server/tests/unit/nest/tours.mcp.test.ts | 26 ++ .../nest/tours.service.permissions.test.ts | 4 + shared/src/i18n/ar/tours.ts | 23 +- shared/src/i18n/az/tours.ts | 23 +- shared/src/i18n/br/tours.ts | 23 +- shared/src/i18n/ca/tours.ts | 23 +- shared/src/i18n/cs/tours.ts | 23 +- shared/src/i18n/de/tours.ts | 23 +- shared/src/i18n/en/tours.ts | 23 +- shared/src/i18n/es/tours.ts | 23 +- shared/src/i18n/et/tours.ts | 23 +- shared/src/i18n/fr/tours.ts | 23 +- shared/src/i18n/gr/tours.ts | 23 +- shared/src/i18n/hu/tours.ts | 23 +- shared/src/i18n/id/tours.ts | 23 +- shared/src/i18n/it/tours.ts | 23 +- shared/src/i18n/ja/tours.ts | 23 +- shared/src/i18n/ko/tours.ts | 23 +- shared/src/i18n/nl/tours.ts | 23 +- shared/src/i18n/pl/tours.ts | 23 +- shared/src/i18n/ru/tours.ts | 23 +- shared/src/i18n/sk/tours.ts | 23 +- shared/src/i18n/sv/tours.ts | 23 +- shared/src/i18n/th/tours.ts | 23 +- shared/src/i18n/tours-locales.spec.ts | 2 +- shared/src/i18n/tr/tours.ts | 23 +- shared/src/i18n/uk/tours.ts | 23 +- shared/src/i18n/vi/tours.ts | 23 +- shared/src/i18n/zh-TW/tours.ts | 22 +- shared/src/i18n/zh/tours.ts | 22 +- shared/src/index.ts | 1 + shared/src/tours/ski.schema.spec.ts | 114 +++++++ shared/src/tours/ski.schema.ts | 63 ++++ .../src/tours/tour-gpx-import.schema.test.ts | 2 +- shared/src/tours/tour-gpx-import.schema.ts | 14 +- shared/src/tours/tours.schema.ts | 13 + wiki/MCP-Addon-Tools.md | 2 +- 93 files changed, 2853 insertions(+), 183 deletions(-) create mode 100644 client/src/api/tourSki.ts create mode 100644 client/src/components/Tours/GpxImportTypeSection.tsx create mode 100644 client/src/components/Tours/TourCornerMark.tsx create mode 100644 client/src/components/Tours/gpxImportChoices.test.ts create mode 100644 client/src/components/Tours/gpxImportChoices.ts create mode 100644 client/src/components/Tours/ski/SkiClassificationFields.test.tsx create mode 100644 client/src/components/Tours/ski/SkiClassificationFields.tsx create mode 100644 client/src/components/Tours/ski/SkiClassifyDialog.tsx create mode 100644 client/src/components/Tours/ski/SkiTrackCard.test.tsx create mode 100644 client/src/components/Tours/ski/SkiTrackCard.tsx create mode 100644 client/src/components/Tours/ski/skiChoice.test.ts create mode 100644 client/src/components/Tours/ski/skiChoice.ts create mode 100644 client/src/components/Tours/ski/skiMarks.test.tsx create mode 100644 client/src/components/Tours/ski/skiMarks.tsx create mode 100644 client/src/components/Tours/ski/useSkiClassification.test.tsx create mode 100644 client/src/components/Tours/ski/useSkiClassification.ts create mode 100644 server/src/db/migrations/Migration20200101045520_a_tour_can_be_a_ski_track.ts create mode 100644 server/src/nest/tours/dto/tour-ski.dto.ts create mode 100644 server/src/nest/tours/tour-ski.controller.ts create mode 100644 server/src/nest/tours/tour-ski.service.ts create mode 100644 server/tests/e2e/tours-ski.e2e.test.ts create mode 100644 server/tests/unit/db/tour-ski-columns.test.ts create mode 100644 server/tests/unit/nest/tour-ski.controller.test.ts create mode 100644 shared/src/tours/ski.schema.spec.ts create mode 100644 shared/src/tours/ski.schema.ts diff --git a/client/src/api/tourSki.ts b/client/src/api/tourSki.ts new file mode 100644 index 000000000..c3a485d2e --- /dev/null +++ b/client/src/api/tourSki.ts @@ -0,0 +1,12 @@ +import type { TourDetailResponse, TourSkiClassificationRequest } from '@trek/shared'; +import apiClient from './client'; + +/** SKI-02: a ski track classified again, its discipline and cross-country technique. */ +export const tourSkiApi = { + classify: ( + tripId: number | string, + placeId: number | string, + body: TourSkiClassificationRequest + ): Promise => + apiClient.put(`/trips/${tripId}/tours/${placeId}/ski`, body).then((r) => r.data), +}; diff --git a/client/src/components/Tours/GpxImportTypeSection.tsx b/client/src/components/Tours/GpxImportTypeSection.tsx new file mode 100644 index 000000000..85bbb1e8e --- /dev/null +++ b/client/src/components/Tours/GpxImportTypeSection.tsx @@ -0,0 +1,92 @@ +import { + BIKE_PROFILES, + importableTourTypeSchema, + type BikeProfile, + type PaddleBoatType, + type PaddleWaterContext, +} from '@trek/shared'; +import { Info } from 'lucide-react'; +import { useId } from 'react'; +import { useTranslation } from '../../i18n'; +import { LABEL, Segmented } from '../shared/dialogParts'; +import { DialogSection } from '../shared/DialogShell'; +import type { GpxImportChoicesState } from './gpxImportChoices'; +import { TourTypeSelect } from './planner/TourTypeSelect'; +import { RunProfileField } from './run/RunFields'; +import { SkiClassificationFields } from './ski/SkiClassificationFields'; +import { TourNotice } from './tourParts'; + +const BOATS: readonly PaddleBoatType[] = ['kayak', 'canoe', 'sup', 'other']; +const WATERS: readonly PaddleWaterContext[] = ['river', 'lake']; +/** Every type a GPX file can come in as: the planner's, and ski, which is only kept (SKI-02). */ +const IMPORT_TYPES = importableTourTypeSchema.options; + +/** A paddle import's boat and water, side by side, each chosen on purpose. */ +function PaddleImportFields({ choices }: { choices: GpxImportChoicesState }) { + const { t } = useTranslation(); + const { chosen } = choices; + return ( + <> +
+
+ {t('tours.paddle.boat')} + + value={(chosen.boat ?? '') as PaddleBoatType} + onChange={choices.setBoat} + label={t('tours.paddle.boat')} + fill + options={BOATS.map((value) => ({ value, label: t(`tours.paddle.boat.${value}`) }))} + /> +
+
+ {t('tours.paddle.water')} + + value={(chosen.water ?? '') as PaddleWaterContext} + onChange={choices.setWater} + label={t('tours.paddle.water')} + fill + options={WATERS.map((value) => ({ value, label: t(`tours.paddle.water.${value}`) }))} + /> +
+
+ {(chosen.boat === null || chosen.water === null) && ( + }> + {t('tours.paddle.chooseBoatImport')} + + )} + + ); +} + +/** + * The preview's "import as" section: the type, and below it what that type + * names: a bike or run profile, a paddle tour's boat and water, a ski track's + * discipline and technique (SKI-02). + */ +export function GpxImportTypeSection({ choices }: { choices: GpxImportChoicesState }) { + const { t } = useTranslation(); + const typeLabelId = useId(); + const { chosen } = choices; + return ( + {t('tours.import.previewAs')}}> +
+ + {chosen.type === 'bike' && ( +
+ {t('tours.bike.profile')} + + value={chosen.bikeProfile} + onChange={choices.setBikeProfile} + label={t('tours.bike.profile')} + fill + options={BIKE_PROFILES.map((value) => ({ value, label: t(`tours.bike.profile.${value}`) }))} + /> +
+ )} + {chosen.type === 'run' && } + {chosen.type === 'kayak' && } + {chosen.type === 'ski' && } +
+
+ ); +} diff --git a/client/src/components/Tours/TourCornerMark.tsx b/client/src/components/Tours/TourCornerMark.tsx new file mode 100644 index 000000000..7e11b21be --- /dev/null +++ b/client/src/components/Tours/TourCornerMark.tsx @@ -0,0 +1,36 @@ +import type { LucideIcon } from 'lucide-react'; +import { Tooltip } from '../shared/Tooltip'; + +/** + * A frosted badge on the top corner of a tour's round tile, carrying one + * icon: a hike's winter label (HIK-01), a ski track's discipline (SKI-02). + * Its tooltip and accessible name say what the icon stands for. + */ +export function TourCornerMark({ + icon: Icon, + label, + size = 15, + testId, +}: { + icon: LucideIcon; + label: string; + /** The badge's diameter in pixels, about half the tile's. */ + size?: number; + testId: string; +}) { + return ( + + + + + + + ); +} diff --git a/client/src/components/Tours/TourDayRowFacts.tsx b/client/src/components/Tours/TourDayRowFacts.tsx index 9589a7dfa..4bd61421b 100644 --- a/client/src/components/Tours/TourDayRowFacts.tsx +++ b/client/src/components/Tours/TourDayRowFacts.tsx @@ -1,4 +1,4 @@ -import { plannableTourTypeSchema, type TourListItem } from '@trek/shared'; +import { importableTourTypeSchema, type TourListItem } from '@trek/shared'; import { ArrowUp, CheckCheck, Clock, Gauge, Pause, Ruler, type LucideIcon } from 'lucide-react'; import { createElement, useId, useState } from 'react'; import { useTranslation } from '../../i18n'; @@ -9,6 +9,7 @@ import { fs } from '../shared/DialogShell'; import { Tooltip } from '../shared/Tooltip'; import { paddleCheckFact } from './paddle/paddleCheckModel'; import { runPaceText } from './run/runPaceText'; +import { SkiCornerMark } from './ski/skiMarks'; import { tourDayRowIcon } from './tourDayRowIcon'; import { movingTimeLabel } from './tourFacts'; import { formatPlannedTourDuration, plannedTotalText, tourPlannedTimes } from './tourPresentation'; @@ -16,9 +17,9 @@ import { useWalkProgress } from './walk/walkProgress'; import { walkStaysText } from './walk/walkTimeFields'; import { WinterCornerMark } from './winter/winterMarks'; -/** Whether the app names this type: one it plans, not a key only the catalogue holds. */ +/** Whether the app names this type: one it plans or keeps (a ski track, SKI-02), not a key only the catalogue holds. */ function knownType(type: string): boolean { - return plannableTourTypeSchema.safeParse(type).success; + return importableTourTypeSchema.safeParse(type).success; } /** The icon the time on the move goes by: a ride, a run or a paddle by its type's own, any walk by footprints. */ @@ -95,21 +96,26 @@ function Fact({ * A tour's tile in the day plan and in its detail: the T-scale grade in a round * tile, the same tile the tour lists use, so a tour reads alike wherever it * shows. A GPX import has no grade and shows its type's icon instead. A hike's - * winter label (HIK-01) sits on the tile's corner, as in the tour lists. + * winter label (HIK-01) or a ski track's discipline (SKI-02) sits on the + * tile's corner, as in the tour lists. */ export function TourDayRowAvatar({ tour, size = 32, }: { - tour: Pick & Partial>; + tour: Pick & + Partial>; size?: number; }) { const avatar = ; - if (!tour.winter_activity) return avatar; + const ski = tour.tour_type === 'ski' && !!tour.ski_discipline; + if (!tour.winter_activity && !ski) return avatar; + const markSize = Math.round(size * 0.44); return ( {avatar} - + + ); } diff --git a/client/src/components/Tours/TourDetailDialog.tsx b/client/src/components/Tours/TourDetailDialog.tsx index dc523a4b7..a0b7292ef 100644 --- a/client/src/components/Tours/TourDetailDialog.tsx +++ b/client/src/components/Tours/TourDetailDialog.tsx @@ -52,6 +52,7 @@ import { PaddleCheckSection } from './paddle/PaddleCheckSection'; import { PaddleWindSection } from './paddle/PaddleWindCard'; import { TourProvenanceBlock } from './provenance/TourProvenanceSection'; import { RunHeadPills } from './run/RunHeadPills'; +import { SkiHeadPill } from './ski/skiMarks'; import { tourDayRowIcon } from './tourDayRowIcon'; import { tourDaysOf } from './tourDays'; import { heightsUnknown, movingTimeLabel, withoutHeights } from './tourFacts'; @@ -210,7 +211,8 @@ function tourTimeFields( } /** - * The head's facts as pills: the type, a hike's winter label or a run's profile and pace, where it came from, the time (its parts + * The head's facts as pills: the type, a hike's winter label, a run's profile and pace or a ski track's + * discipline, where it came from, the time (its parts * in the tooltip), distance, ascent, descent, and a caution. */ function TourHeadPills({ @@ -256,6 +258,8 @@ function TourHeadPills({ {tour.winter_activity && } {/* RUN-01: a run's profile and the pace its time is planned at. */} + {/* SKI-02: a ski track's discipline, a cross-country technique inside it. */} + {/* A paddle tour names its boat and its water, each a badge of its own. */} {tour.boat_type && ( diff --git a/client/src/components/Tours/TourGpxPreviewDialog.tsx b/client/src/components/Tours/TourGpxPreviewDialog.tsx index c6050df37..105e6baec 100644 --- a/client/src/components/Tours/TourGpxPreviewDialog.tsx +++ b/client/src/components/Tours/TourGpxPreviewDialog.tsx @@ -1,14 +1,5 @@ -import { - BIKE_PROFILES, - type BikeProfile, - type PaddleBoatType, - type PaddleWaterContext, - type PlannableTourType, - type RunProfile, - type TourGpxImportFields, -} from '@trek/shared'; -import { AlertTriangle, FileDown, Info } from 'lucide-react'; -import { useId, useMemo, useState } from 'react'; +import { AlertTriangle, FileDown } from 'lucide-react'; +import { useId, useMemo } from 'react'; import { OFM_POSITRON } from '../../constants/mapDefaults'; import { useTileUrl } from '../../hooks/useTileUrl'; import { useTranslation } from '../../i18n'; @@ -16,7 +7,6 @@ import { useSettingsStore } from '../../store/settingsStore'; import { formatDistance, formatElevation } from '../../utils/units'; import { MapViewAuto } from '../Map/MapViewAuto'; import { SoftPill } from '../Planner/planParts'; -import { LABEL, Segmented } from '../shared/dialogParts'; import { DialogButton, DialogFooter, @@ -28,13 +18,11 @@ import { fs, NEUTRAL_TINT, } from '../shared/DialogShell'; -import { TourTypeSelect } from './planner/TourTypeSelect'; -import { RunProfileField } from './run/RunFields'; +import { importFields, importMissing, useGpxImportChoices } from './gpxImportChoices'; +import { GpxImportTypeSection } from './GpxImportTypeSection'; import { TourNotice } from './tourParts'; import type { GpxPreviewFile, TourGpxImport } from './useTourGpxImport'; -const BOATS: readonly PaddleBoatType[] = ['kayak', 'canoe', 'sup', 'other']; -const WATERS: readonly PaddleWaterContext[] = ['river', 'lake']; const BADGE = 'inline-flex flex-none items-center rounded-full bg-surface-secondary px-2 font-geist tabular-nums leading-[20px] text-content-secondary'; @@ -71,29 +59,12 @@ function PreviewTrackRow({ name, track }: { name: string; track: GpxPreviewFile[ ); } -/** What the dialog has chosen besides the type: a paddle import's boat and water, a bike or run import's profile. */ -interface ImportChoices { - boat: PaddleBoatType | null; - water: PaddleWaterContext | null; - bikeProfile: BikeProfile; - runProfile: RunProfile; -} - -/** What the import is asked to store the tracks as: the type and the fields that type names. */ -function importFields(type: PlannableTourType, chosen: ImportChoices): TourGpxImportFields { - if (type === 'kayak' && chosen.boat && chosen.water) - return { tour_type: 'kayak', boat_type: chosen.boat, water_context: chosen.water }; - 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 }; - return { tour_type: 'hike' }; -} - /** * PAD-02: a GPX file shown before anything is stored. The whole course on a * map, every track with its measures, the tracks the trip already holds named * as skipped, and the type to import them as; a paddle import names its boat - * and water, as a planned paddle tour does. Closing it stores nothing. + * and water, as a planned paddle tour does, a ski import its discipline + * (SKI-02). Closing it stores nothing. */ export default function TourGpxPreviewDialog({ gpxImport }: { gpxImport: TourGpxImport }) { const { preview } = gpxImport; @@ -104,13 +75,8 @@ export default function TourGpxPreviewDialog({ gpxImport }: { gpxImport: TourGpx function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files: GpxPreviewFile[] }) { const { t } = useTranslation(); const labelId = useId(); - const typeLabelId = useId(); const tileUrl = useTileUrl(OFM_POSITRON); - const [type, setType] = useState('hike'); - const [boat, setBoat] = useState(null); - const [water, setWater] = useState(null); - const [bikeProfile, setBikeProfile] = useState('touring'); - const [runProfile, setRunProfile] = useState('running'); + const choices = useGpxImportChoices(); const tracks = files.flatMap((file) => file.tracks.map((track) => ({ file: file.file, track }))); const fresh = tracks.filter(({ track }) => !track.duplicate).length; @@ -122,9 +88,8 @@ function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files: [files] ); const focusPoints = useMemo(() => lines.flat(), [lines]); - const paddleIncomplete = type === 'kayak' && (boat === null || water === null); - const canImport = fresh > 0 && !paddleIncomplete && !gpxImport.importing; - const fields = importFields(type, { boat, water, bikeProfile, runProfile }); + const canImport = fresh > 0 && importMissing(choices.chosen) === null && !gpxImport.importing; + const fields = importFields(choices.chosen); const title = files.length === 1 ? files[0].file.name : t('tours.import.previewFiles', { count: files.length }); return ( @@ -196,53 +161,7 @@ function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files: )} - {t('tours.import.previewAs')}}> -
- - {type === 'bike' && ( -
- {t('tours.bike.profile')} - - value={bikeProfile} - onChange={setBikeProfile} - label={t('tours.bike.profile')} - fill - options={BIKE_PROFILES.map((value) => ({ value, label: t(`tours.bike.profile.${value}`) }))} - /> -
- )} - {type === 'run' && } - {type === 'kayak' && ( -
-
- {t('tours.paddle.boat')} - - value={(boat ?? '') as PaddleBoatType} - onChange={setBoat} - label={t('tours.paddle.boat')} - fill - options={BOATS.map((value) => ({ value, label: t(`tours.paddle.boat.${value}`) }))} - /> -
-
- {t('tours.paddle.water')} - - value={(water ?? '') as PaddleWaterContext} - onChange={setWater} - label={t('tours.paddle.water')} - fill - options={WATERS.map((value) => ({ value, label: t(`tours.paddle.water.${value}`) }))} - /> -
-
- )} - {paddleIncomplete && ( - }> - {t('tours.paddle.chooseBoatImport')} - - )} -
- + ); } diff --git a/client/src/components/Tours/TourWorkspaceRail.test.tsx b/client/src/components/Tours/TourWorkspaceRail.test.tsx index 00c9c29a3..827929ae3 100644 --- a/client/src/components/Tours/TourWorkspaceRail.test.tsx +++ b/client/src/components/Tours/TourWorkspaceRail.test.tsx @@ -5,6 +5,7 @@ import type { GpxConversion } from './conversion/useGpxConversion'; import type { PaddleChecklist } from './paddle/usePaddleChecklist'; import type { TrackPaddlePlan } from './paddle/useTrackPaddlePlan'; import type { TourPlannerController } from './planner/useTourPlanner'; +import type { SkiClassification } from './ski/useSkiClassification'; import type { RecordedTrack } from './useRecordedTrack'; import type { WandererBrowser } from './wanderer/useWandererBrowser'; @@ -63,4 +64,12 @@ describe('TourWorkspaceRail', () => { expect(seen.rail[seen.rail.length - 1]?.trackPaddlePlan).toBe(trackPaddlePlan); expect(seen.rail[seen.rail.length - 1]?.paddleChecklist).toBe(paddleChecklist); }); + + it('SKI-02: hands the classification of the ski track in view on to the editor rail', () => { + const skiClassification = { tour: null } as unknown as SkiClassification; + const parts = { ...workspace(wanderer({})), skiClassification }; + seen.rail.length = 0; + render(); + expect(seen.rail[seen.rail.length - 1]?.skiClassification).toBe(skiClassification); + }); }); diff --git a/client/src/components/Tours/TourWorkspaceRail.tsx b/client/src/components/Tours/TourWorkspaceRail.tsx index d5cc9f934..59db58ae6 100644 --- a/client/src/components/Tours/TourWorkspaceRail.tsx +++ b/client/src/components/Tours/TourWorkspaceRail.tsx @@ -3,6 +3,7 @@ import type { PaddleChecklist } from './paddle/usePaddleChecklist'; import type { TrackPaddlePlan } from './paddle/useTrackPaddlePlan'; import { TourPlannerRail } from './planner/TourPlannerPanels'; import type { TourPlannerController } from './planner/useTourPlanner'; +import type { SkiClassification } from './ski/useSkiClassification'; import type { RecordedTrack } from './useRecordedTrack'; import WandererBrowserRail from './wanderer/WandererBrowserRail'; import type { WandererBrowser } from './wanderer/useWandererBrowser'; @@ -17,6 +18,8 @@ export interface TourWorkspaceRailParts { trackPaddlePlan?: TrackPaddlePlan; /** The obstacle checklist of the paddle tour open in the planner (PAD-06). */ paddleChecklist?: PaddleChecklist; + /** The classification of the ski track in view (SKI-02). */ + skiClassification?: SkiClassification; /** The trip's tours, never offered as a stop of another tour (PR1-10). */ tourPlaceIds?: ReadonlySet; } @@ -38,6 +41,7 @@ export default function TourWorkspaceRail({ recordedTrack, trackPaddlePlan, paddleChecklist, + skiClassification, tourPlaceIds, } = workspace; return wanderer.open ? ( @@ -51,6 +55,7 @@ export default function TourWorkspaceRail({ recordedTrack={recordedTrack} trackPaddlePlan={trackPaddlePlan} paddleChecklist={paddleChecklist} + skiClassification={skiClassification} tourPlaceIds={tourPlaceIds} onOpenWanderer={wanderer.enabled ? wanderer.openBrowser : undefined} /> diff --git a/client/src/components/Tours/ToursSidebar.test.tsx b/client/src/components/Tours/ToursSidebar.test.tsx index 6d54c2a5d..cb89520dc 100644 --- a/client/src/components/Tours/ToursSidebar.test.tsx +++ b/client/src/components/Tours/ToursSidebar.test.tsx @@ -363,6 +363,39 @@ describe('ToursSidebar', () => { vi.restoreAllMocks() }) + it('SKI-02: a ski import waits for its discipline, and for a cross-country technique, then sends them along', 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([''], 'loipe.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-ski')) + const confirm = screen.getByTestId('gpx-preview-import') + expect(confirm).toBeDisabled() + expect(screen.getByText('Choose a discipline to import the track as ski.')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Cross-country' })) + expect(confirm).toBeDisabled() + expect(screen.getByText('Choose the technique, or Unknown.')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Unknown' })) + expect(confirm).toBeEnabled() + fireEvent.click(confirm) + + await waitFor(() => + expect(importCall).toHaveBeenCalledWith(1, file, { + tour_type: 'ski', + ski_discipline: 'cross_country', + ski_technique: 'unknown', + }) + ) + vi.restoreAllMocks() + }) + it('RUN-01: a run import names its profile, on purpose, and sends no pace', async () => { vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack()] }) const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValue({ tours: [tour], caution: false, skipped: 0 }) diff --git a/client/src/components/Tours/conversion/TourConversionSection.test.tsx b/client/src/components/Tours/conversion/TourConversionSection.test.tsx index 76e508485..590bf6160 100644 --- a/client/src/components/Tours/conversion/TourConversionSection.test.tsx +++ b/client/src/components/Tours/conversion/TourConversionSection.test.tsx @@ -215,6 +215,25 @@ describe('useGpxConversion', () => { ); }); + it('SKI-02: a ski track is never planned over: nothing is offered and no router is asked', () => { + const ski = { + ...imported, + tour_type: 'ski' as const, + ski_discipline: 'touring' as const, + max_hiking_difficulty: null, + }; + const controller = planner({ + mode: { type: 'view-gpx', placeId: ski.place_id, tour: ski }, + readOnlyGpxTour: { tour: ski, routeGeometry: null }, + } as Partial); + const { result } = renderHook(() => useGpxConversion(controller)); + expect(result.current.available).toBe(false); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + expect(routing.route).not.toHaveBeenCalled(); + expect(routing.run).not.toHaveBeenCalled(); + }); + it('PR1-05B: nothing is adopted before the suggestion is ready', () => { const controller = planner(); const { result } = renderHook(() => useGpxConversion(controller)); diff --git a/client/src/components/Tours/conversion/useGpxConversion.ts b/client/src/components/Tours/conversion/useGpxConversion.ts index 0bad79853..9476d2ebe 100644 --- a/client/src/components/Tours/conversion/useGpxConversion.ts +++ b/client/src/components/Tours/conversion/useGpxConversion.ts @@ -125,8 +125,11 @@ export function useGpxConversion(planner: TourPlannerController) { return useMemo( () => ({ ...current, - /** The import shown can be planned from: it has a track and the user may add tours. */ - available: tour !== null && (track?.length ?? 0) >= 2 && planner.canEdit, + /** + * 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. + */ + available: tour !== null && tour.tour_type !== 'ski' && (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.test.ts b/client/src/components/Tours/gpxImportChoices.test.ts new file mode 100644 index 000000000..cad77195c --- /dev/null +++ b/client/src/components/Tours/gpxImportChoices.test.ts @@ -0,0 +1,59 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { importFields, importMissing, useGpxImportChoices, type GpxImportChoices } from './gpxImportChoices'; + +// FE-TOUR-GPX-CHOICES-001 to FE-TOUR-GPX-CHOICES-003 + +const base: GpxImportChoices = { + type: 'hike', + boat: null, + water: null, + bikeProfile: 'touring', + runProfile: 'running', + ski: { discipline: null, technique: null }, +}; + +describe('GPX import choices', () => { + it('FE-TOUR-GPX-CHOICES-001: each type names what it carries, and a hike nothing more', () => { + expect(importFields(base)).toEqual({ tour_type: 'hike' }); + expect(importFields({ ...base, type: 'bike', bikeProfile: 'gravel' })).toEqual({ + tour_type: 'bike', + bike_profile: 'gravel', + }); + expect(importFields({ ...base, type: 'run', runProfile: 'trail' })).toEqual({ + tour_type: 'run', + run_profile: 'trail', + }); + expect(importFields({ ...base, type: 'kayak', boat: 'sup', water: 'lake' })).toEqual({ + tour_type: 'kayak', + boat_type: 'sup', + water_context: 'lake', + }); + // SKI-02: a ski track its discipline, and for cross-country its technique. + expect(importFields({ ...base, type: 'ski', ski: { discipline: 'touring', technique: null } })).toEqual({ + tour_type: 'ski', + ski_discipline: 'touring', + }); + expect( + importFields({ ...base, type: 'ski', ski: { discipline: 'cross_country', technique: 'unknown' } }) + ).toEqual({ tour_type: 'ski', ski_discipline: 'cross_country', ski_technique: 'unknown' }); + }); + + it('FE-TOUR-GPX-CHOICES-002: holds the import back while a paddle tour or a ski track lacks what it must name', () => { + expect(importMissing(base)).toBeNull(); + expect(importMissing({ ...base, type: 'kayak', boat: 'canoe' })).toBe('paddle'); + expect(importMissing({ ...base, type: 'ski' })).toBe('ski'); + const crossCountry = { discipline: 'cross_country' as const, technique: null }; + expect(importMissing({ ...base, type: 'ski', ski: crossCountry })).toBe('ski'); + expect(importMissing({ ...base, type: 'ski', ski: { discipline: 'alpine', technique: null } })).toBeNull(); + }); + + it('FE-TOUR-GPX-CHOICES-003: starts as a hike and keeps each choice on its own', () => { + const { result } = renderHook(() => useGpxImportChoices()); + expect(result.current.chosen).toEqual(base); + act(() => result.current.setType('ski')); + act(() => result.current.setSki({ discipline: 'snowboard', technique: null })); + act(() => result.current.setBoat('kayak')); + expect(result.current.chosen).toMatchObject({ type: 'ski', boat: 'kayak', ski: { discipline: 'snowboard' } }); + }); +}); diff --git a/client/src/components/Tours/gpxImportChoices.ts b/client/src/components/Tours/gpxImportChoices.ts new file mode 100644 index 000000000..ebf05c2a4 --- /dev/null +++ b/client/src/components/Tours/gpxImportChoices.ts @@ -0,0 +1,74 @@ +import type { + BikeProfile, + ImportableTourType, + PaddleBoatType, + PaddleWaterContext, + RunProfile, + TourGpxImportFields, +} from '@trek/shared'; +import { useState } from 'react'; +import { NO_SKI_CHOICE, skiChoiceMissing, skiFieldsOf, type SkiChoice } from './ski/skiChoice'; + +/** + * What the GPX preview has chosen: the type to import as, and what that type + * names besides: a paddle import its boat and water, a bike or run import its + * profile, a ski import (SKI-02) its discipline and technique. + */ +export interface GpxImportChoices { + type: ImportableTourType; + boat: PaddleBoatType | null; + water: PaddleWaterContext | null; + bikeProfile: BikeProfile; + runProfile: RunProfile; + ski: SkiChoice; +} + +const START: GpxImportChoices = { + type: 'hike', + boat: null, + water: null, + bikeProfile: 'touring', + runProfile: 'running', + ski: NO_SKI_CHOICE, +}; + +/** What the chosen type still lacks before anything can be imported; null when nothing does. */ +export function importMissing(chosen: GpxImportChoices): 'paddle' | 'ski' | null { + if (chosen.type === 'kayak' && (chosen.boat === null || chosen.water === null)) return 'paddle'; + if (chosen.type === 'ski' && skiChoiceMissing(chosen.ski)) return 'ski'; + return null; +} + +/** What the import is asked to store the tracks as: the type and the fields that type names. */ +export function importFields(chosen: GpxImportChoices): TourGpxImportFields { + const { type } = chosen; + if (type === 'kayak' && chosen.boat && chosen.water) + return { tour_type: 'kayak', boat_type: chosen.boat, water_context: chosen.water }; + 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 }; + // 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 }; + return { tour_type: 'hike' }; +} + +/** The preview's choices, each field set on its own; a fresh preview starts as a hike. */ +export function useGpxImportChoices() { + const [chosen, setChosen] = useState(START); + const set = + (key: K) => + (value: GpxImportChoices[K]) => + setChosen((current) => ({ ...current, [key]: value })); + return { + chosen, + setType: set('type'), + setBoat: set('boat'), + setWater: set('water'), + setBikeProfile: set('bikeProfile'), + setRunProfile: set('runProfile'), + setSki: set('ski'), + }; +} + +export type GpxImportChoicesState = ReturnType; diff --git a/client/src/components/Tours/library/TourLibraryDialog.tsx b/client/src/components/Tours/library/TourLibraryDialog.tsx index e67c18a24..22c5eec91 100644 --- a/client/src/components/Tours/library/TourLibraryDialog.tsx +++ b/client/src/components/Tours/library/TourLibraryDialog.tsx @@ -1,4 +1,4 @@ -import { plannableTourTypeSchema, type TourLibraryItem, type TourTypeKey } from '@trek/shared'; +import { importableTourTypeSchema, type TourLibraryItem, type TourTypeKey } from '@trek/shared'; import { AlertTriangle, ArrowUp, Clock, CopyPlus, Library, Loader2, Ruler, Search, WifiOff } from 'lucide-react'; import { useId, useMemo } from 'react'; import { OFM_POSITRON } from '../../../constants/mapDefaults'; @@ -142,7 +142,7 @@ function ListFilters({ library }: { library: TourLibrary }) { ]; const typeOptions = [ { value: ALL, label: t('tours.library.allTypes') }, - ...plannableTourTypeSchema.options.map((type) => ({ value: type, label: t(`tourTypes.${type}`) })), + ...importableTourTypeSchema.options.map((type) => ({ value: type, label: t(`tourTypes.${type}`) })), ]; return (
diff --git a/client/src/components/Tours/planner/TourPlannerPanels.tsx b/client/src/components/Tours/planner/TourPlannerPanels.tsx index 17427d2be..73c9502b5 100644 --- a/client/src/components/Tours/planner/TourPlannerPanels.tsx +++ b/client/src/components/Tours/planner/TourPlannerPanels.tsx @@ -27,6 +27,8 @@ import { PaddleWindSection } from '../paddle/PaddleWindCard' import { TrackPaddlePlanCards } from '../paddle/TrackPaddlePlanCard' import type { PaddleChecklist } from '../paddle/usePaddleChecklist' import type { TrackPaddlePlan } from '../paddle/useTrackPaddlePlan' +import { SkiTrackCard } from '../ski/SkiTrackCard' +import type { SkiClassification } from '../ski/useSkiClassification' import { TourTypeFields } from './TourTypeFields' import TourWaypointList from './TourWaypointList' import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry' @@ -273,12 +275,14 @@ function GpxViewRail({ conversion, trackPaddlePlan, paddleChecklist, + skiClassification, }: { planner: TourPlannerController canEdit: boolean conversion?: GpxConversion trackPaddlePlan?: TrackPaddlePlan paddleChecklist?: PaddleChecklist + skiClassification?: SkiClassification }) { const { t } = useTranslation() const distanceUnit = useSettingsStore((state) => state.settings.distance_unit) @@ -318,6 +322,7 @@ function GpxViewRail({
{conversion && } + {skiClassification && } {trackPaddlePlan && } {paddleChecklist && tour.tour_type === 'kayak' && } {tour.tour_type === 'kayak' && ( @@ -376,7 +381,7 @@ export function TourPlannerRail({ planner, onOpenWanderer, conversion, paddleChecklist, - recordedTrack, trackPaddlePlan, tourPlaceIds, canEdit: editPermission, canAssign: assignPermission }: { planner: TourPlannerController + recordedTrack, trackPaddlePlan, skiClassification, tourPlaceIds, canEdit: editPermission, canAssign: assignPermission }: { planner: TourPlannerController /** Planning an editable tour from the import in view. */ conversion?: GpxConversion /** The way back to the recorded track of an import planned over in place. */ @@ -385,6 +390,8 @@ export function TourPlannerRail({ planner, trackPaddlePlan?: TrackPaddlePlan /** The obstacle checklist of the paddle tour open in the rail (PAD-06). */ paddleChecklist?: PaddleChecklist + /** The classification of the ski track in view (SKI-02). */ + skiClassification?: SkiClassification /** Browse the user's wanderer trails instead; offered while nothing is being edited. */ onOpenWanderer?: () => void /** The trip's tours, never offered as a stop of another tour (PR1-10). */ @@ -413,7 +420,7 @@ export function TourPlannerRail({ planner, /> if (planner.mode.type === 'view-gpx') return + paddleChecklist={paddleChecklist} skiClassification={skiClassification} /> const isSavedEdit = planner.mode.type === 'edit-saved' diff --git a/client/src/components/Tours/planner/TourTypeFields.test.tsx b/client/src/components/Tours/planner/TourTypeFields.test.tsx index 18d0fab71..be0bb5fb3 100644 --- a/client/src/components/Tours/planner/TourTypeFields.test.tsx +++ b/client/src/components/Tours/planner/TourTypeFields.test.tsx @@ -39,6 +39,8 @@ describe('TourTypeFields (PAD-01)', () => { expect(screen.getByTestId('tour-type-option-bike')).toHaveTextContent( 'By bike, routed for touring, road, gravel or MTB' ); + // SKI-02: ski is offered by the GPX import only; the planner routes no ski track. + expect(screen.queryByTestId('tour-type-option-ski')).toBeNull(); fireEvent.click(screen.getByTestId('tour-type-option-bike')); expect(hike.setTourType).toHaveBeenCalledWith('bike'); expect(field).toHaveAttribute('aria-expanded', 'false'); diff --git a/client/src/components/Tours/planner/TourTypeSelect.tsx b/client/src/components/Tours/planner/TourTypeSelect.tsx index 31a119914..5a2adc30c 100644 --- a/client/src/components/Tours/planner/TourTypeSelect.tsx +++ b/client/src/components/Tours/planner/TourTypeSelect.tsx @@ -1,4 +1,4 @@ -import { plannableTourTypeSchema, type PlannableTourType } from '@trek/shared'; +import { plannableTourTypeSchema, type ImportableTourType, type PlannableTourType } from '@trek/shared'; import { Check, ChevronDown } from 'lucide-react'; import { createElement, useId } from 'react'; import { useTranslation } from '../../../i18n'; @@ -10,7 +10,7 @@ import { tourDayRowIcon } from '../tourDayRowIcon'; /** The types the planner offers, in the order the contract lists them. */ const PLANNER_TOUR_TYPES = plannableTourTypeSchema.options; -function TypeTile({ type, active = false }: { type: PlannableTourType; active?: boolean }) { +function TypeTile({ type, active = false }: { type: ImportableTourType; active?: boolean }) { return (