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 (