diff --git a/client/src/components/Atlas/tourActivity/tourActivityStatsModel.test.ts b/client/src/components/Atlas/tourActivity/tourActivityStatsModel.test.ts index aed23fbc6..4cbd7a968 100644 --- a/client/src/components/Atlas/tourActivity/tourActivityStatsModel.test.ts +++ b/client/src/components/Atlas/tourActivity/tourActivityStatsModel.test.ts @@ -89,4 +89,9 @@ describe('tourActivityStatsModel', () => { ]); expect(typeOptions([entry({ tour_type: 'walk', date: TODAY })], TODAY)).toEqual(['all']); }); + + it('FE-STA2-MODEL-008: a planned ski tour is a type of its own, after the types planned before it', () => { + const entries = [entry({ tour_type: 'ski' }), entry({ tour_type: 'horse' }), entry({ tour_type: 'hike' })]; + expect(typeOptions(entries, TODAY)).toEqual(['all', 'hike', 'horse', 'ski']); + }); }); diff --git a/client/src/components/Map/MapLayerSwitcher.tsx b/client/src/components/Map/MapLayerSwitcher.tsx index 810fb62e8..4e8bac7f6 100644 --- a/client/src/components/Map/MapLayerSwitcher.tsx +++ b/client/src/components/Map/MapLayerSwitcher.tsx @@ -209,7 +209,7 @@ interface OverlayLook { const OVERLAY_LOOK: Record = { seamarks: { Icon: Anchor, labelKey: 'tours.map.seamarks', downKey: 'tours.map.seamarksDown' }, - // SKI-01: a hike's row already holds four maps, the ferratas and 3D, so the pistes go by their icon. + // SKI-01: the row already holds the maps and 3D, so the pistes go by their icon. pistes: { Icon: MountainSnow, labelKey: 'tours.map.pistes', diff --git a/client/src/components/Tours/TourCornerMark.tsx b/client/src/components/Tours/TourCornerMark.tsx index 7e11b21be..304f0b780 100644 --- a/client/src/components/Tours/TourCornerMark.tsx +++ b/client/src/components/Tours/TourCornerMark.tsx @@ -3,7 +3,7 @@ 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). + * icon: a ski tour's discipline (SKI-02). * Its tooltip and accessible name say what the icon stands for. */ export function TourCornerMark({ diff --git a/client/src/components/Tours/TourDayRowFacts.tsx b/client/src/components/Tours/TourDayRowFacts.tsx index a41a1b69e..4c6bbc1b2 100644 --- a/client/src/components/Tours/TourDayRowFacts.tsx +++ b/client/src/components/Tours/TourDayRowFacts.tsx @@ -15,16 +15,18 @@ import { movingTimeLabel } from './tourFacts'; import { formatPlannedTourDuration, plannedTotalText, tourPlannedTimes } from './tourPresentation'; import { useWalkProgress } from './walk/walkProgress'; import { walkStaysText } from './walk/walkTimeFields'; -import { WinterCornerMark } from './winter/winterMarks'; -/** Whether the app names this type: one it plans or keeps (a ski track, SKI-02), not a key only the catalogue holds. */ +/** Whether the app names this type: one it plans or keeps, not a key only the catalogue holds. */ function knownType(type: string): boolean { return importableTourTypeSchema.safeParse(type).success; } -/** The icon the time on the move goes by: a bike tour, a ride on horseback, a run or a paddle by its type's own, any walk by footprints. */ +/** + * The icon the time on the move goes by: a bike tour, a ride on horseback, a + * run, a paddle or a ski tour by its type's own, any walk by footprints. + */ function movingIconType(type: string): string { - return type === 'kayak' || type === 'bike' || type === 'run' || type === 'horse' ? type : 'walk'; + return type === 'kayak' || type === 'bike' || type === 'run' || type === 'horse' || type === 'ski' ? type : 'walk'; } export function TourDayRowIcon({ type, size = 27 }: { type: string; size?: number }) { @@ -95,26 +97,23 @@ 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) or a ski track's discipline (SKI-02) sits on the - * tile's corner, as in the tour lists. + * shows. A GPX import has no grade and shows its type's icon instead. A ski + * tour's discipline (SKI-02) sits on the tile's corner, as in the tour lists. */ export function TourDayRowAvatar({ tour, size = 32, }: { tour: Pick & - Partial>; + Partial>; size?: number; }) { const avatar = ; - const ski = tour.tour_type === 'ski' && !!tour.ski_discipline; - if (!tour.winter_activity && !ski) return avatar; + if (tour.tour_type !== 'ski' || !tour.ski_discipline) return avatar; const markSize = Math.round(size * 0.44); return ( {avatar} - ); diff --git a/client/src/components/Tours/TourDetailDialog.test.tsx b/client/src/components/Tours/TourDetailDialog.test.tsx index cc720af2d..198f4ea95 100644 --- a/client/src/components/Tours/TourDetailDialog.test.tsx +++ b/client/src/components/Tours/TourDetailDialog.test.tsx @@ -306,16 +306,16 @@ describe('TourDetailDialog', () => { expect(screen.queryByText(/Based on/)).not.toBeInTheDocument() }) - it('HIK-01: names the winter label of a hike in its head and on its tile, and nothing for a hike without one', () => { + it('SKI-02: names a snowshoe tour by its discipline in its head and on its tile, and a hike by none', () => { const { unmount } = render() - expect(screen.queryByTestId('tour-detail-winter')).not.toBeInTheDocument() - expect(screen.queryByTestId('tour-winter-mark')).not.toBeInTheDocument() + expect(screen.queryByTestId('tour-detail-ski')).not.toBeInTheDocument() + expect(screen.queryByTestId('tour-ski-mark')).not.toBeInTheDocument() unmount() - render() - expect(screen.getByTestId('tour-detail-winter')).toHaveTextContent('Snowshoe hike') - expect(screen.getByRole('img', { name: 'Snowshoe hike' })).toBeInTheDocument() - expect(screen.getAllByText('T2').length).toBeGreaterThan(0) + const snowshoe = { ...tour, tour_type: 'ski' as const, max_hiking_difficulty: null, ski_discipline: 'snowshoe' as const } + render() + expect(screen.getByTestId('tour-detail-ski')).toHaveTextContent('Snowshoeing') + expect(screen.getByRole('img', { name: 'Snowshoeing' })).toBeInTheDocument() }) it('ACT-01: a recording that is the actual track of a tour names that tour in its head', () => { diff --git a/client/src/components/Tours/TourDetailDialog.tsx b/client/src/components/Tours/TourDetailDialog.tsx index c05e83490..aa46f42f4 100644 --- a/client/src/components/Tours/TourDetailDialog.tsx +++ b/client/src/components/Tours/TourDetailDialog.tsx @@ -76,7 +76,6 @@ import { useTourPermissions, type TourPermissionProps } from './useTourPermissio import { WalkRoutePendingHint } from './walk/WalkRoutePending'; import { WalkStationsCard } from './walk/WalkStations'; import { walkTimeFields } from './walk/walkTimeFields'; -import { WinterActivityPill } from './winter/winterMarks'; interface TourDetailDialogProps extends TourPermissionProps { tour: TourListItem; @@ -220,7 +219,7 @@ function tourTimeFields( } /** - * The head's facts as pills: the type, a hike's winter label, a run's profile and pace or a ski track's + * The head's facts as pills: the type, a run's profile and pace or a ski tour's * discipline, where it came from, the time (its parts * in the tooltip), distance, ascent, descent, and a caution. */ @@ -267,11 +266,9 @@ function TourHeadPills({ })} {t(`tourTypes.${tour.tour_type}`)} - {/* HIK-01: a hike's winter label, the travellers' own, beside its type. */} - {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. */} + {/* SKI-02: a ski tour'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/activity/tourActivityModel.test.ts b/client/src/components/Tours/activity/tourActivityModel.test.ts index a5db75d1b..4b22dc17e 100644 --- a/client/src/components/Tours/activity/tourActivityModel.test.ts +++ b/client/src/components/Tours/activity/tourActivityModel.test.ts @@ -141,6 +141,15 @@ describe('the GPX preset', () => { }); expect(importFieldsFor(tour({ tour_type: 'horse' }))).toEqual({ tour_type: 'horse' }); }); + + it('FE-ACT-MODEL-010: the track of a planned snowshoe or winter hike tour comes in as ski with that discipline (SKI-02)', () => { + for (const discipline of ['snowshoe', 'winter_hike'] as const) { + expect(importFieldsFor(tour({ tour_type: 'ski', ski_discipline: discipline, ski_technique: null }))).toEqual({ + tour_type: 'ski', + ski_discipline: discipline, + }); + } + }); }); describe('measures', () => { diff --git a/client/src/components/Tours/collection/collectionTours.test.tsx b/client/src/components/Tours/collection/collectionTours.test.tsx index 164934165..f816d3869 100644 --- a/client/src/components/Tours/collection/collectionTours.test.tsx +++ b/client/src/components/Tours/collection/collectionTours.test.tsx @@ -370,10 +370,10 @@ describe('CollectionToursSection', () => { await waitFor(() => expect(screen.queryByTestId('collection-tours')).toBeNull()); }); - it('FE-COLLECTION-TOURS-015: a kept tour looks as it did in its trip list: winter mark, run pace and stages', async () => { + it('FE-COLLECTION-TOURS-015: a kept tour looks as it did in its trip list: ski mark, run pace and stages', async () => { vi.spyOn(collectionToursRepo, 'list').mockResolvedValue({ tours: [ - kept({ id: 5, winter_activity: 'snowshoe', stage_count: 3 }), + kept({ id: 5, tour_type: 'ski', max_hiking_difficulty: null, ski_discipline: 'snowshoe', stage_count: 3 }), kept({ id: 6, tour_type: 'run', @@ -384,10 +384,10 @@ describe('CollectionToursSection', () => { ], }); render(); - const hike = await screen.findByTestId('kept-tour-5'); - expect(within(hike).getByTestId('tour-winter-mark')).toBeInTheDocument(); - expect(within(hike).getByTestId('kept-tour-stages')).toHaveTextContent('3 stages'); - expect(within(hike).queryByTestId('kept-tour-pace')).toBeNull(); + const ski = await screen.findByTestId('kept-tour-5'); + expect(within(ski).getByTestId('tour-ski-mark')).toBeInTheDocument(); + expect(within(ski).getByTestId('kept-tour-stages')).toHaveTextContent('3 stages'); + expect(within(ski).queryByTestId('kept-tour-pace')).toBeNull(); const run = screen.getByTestId('kept-tour-6'); expect(within(run).getByTestId('kept-tour-pace')).toHaveTextContent('6:00'); expect(within(run).queryByTestId('kept-tour-stages')).toBeNull(); diff --git a/client/src/components/Tours/conversion/TourConversionSection.test.tsx b/client/src/components/Tours/conversion/TourConversionSection.test.tsx index 590bf6160..0fded6c53 100644 --- a/client/src/components/Tours/conversion/TourConversionSection.test.tsx +++ b/client/src/components/Tours/conversion/TourConversionSection.test.tsx @@ -215,11 +215,12 @@ describe('useGpxConversion', () => { ); }); - it('SKI-02: a ski track is never planned over: nothing is offered and no router is asked', () => { + it('SKI-02: a ski track is planned over as a ski tour of its discipline, its legs drawn straight', async () => { const ski = { ...imported, tour_type: 'ski' as const, - ski_discipline: 'touring' as const, + ski_discipline: 'cross_country' as const, + ski_technique: 'classic' as const, max_hiking_difficulty: null, }; const controller = planner({ @@ -227,6 +228,29 @@ describe('useGpxConversion', () => { readOnlyGpxTour: { tour: ski, routeGeometry: null }, } as Partial); const { result } = renderHook(() => useGpxConversion(controller)); + expect(result.current.available).toBe(true); + expect(result.current.paddle).toBe(true); + await act(async () => { + await result.current.analyze(); + }); + const legs = routing.route.mock.calls[0]?.[0] as Array<{ fixedLeg: { kind: string } | null }>; + expect(legs.slice(0, -1).every((point) => point.fixedLeg?.kind === 'free')).toBe(true); + expect(routing.run).not.toHaveBeenCalled(); + act(() => { + result.current.adopt('new'); + }); + expect(controller.startDerivedTour).toHaveBeenCalledWith( + expect.objectContaining({ tourType: 'ski', ski: { discipline: 'cross_country', technique: 'classic' } }) + ); + }); + + it('SKI-02: a ski track that names no discipline is not planned over: nothing is offered and no router is asked', () => { + const ski = { ...imported, tour_type: 'ski' as const, ski_discipline: null, 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(); diff --git a/client/src/components/Tours/conversion/TourConversionSection.tsx b/client/src/components/Tours/conversion/TourConversionSection.tsx index 54f8ca7a4..7b6e4ad31 100644 --- a/client/src/components/Tours/conversion/TourConversionSection.tsx +++ b/client/src/components/Tours/conversion/TourConversionSection.tsx @@ -77,7 +77,13 @@ export default function TourConversionSection({ conversion }: { conversion: GpxC return card(

- {t(conversion.paddle ? 'tours.conversion.introPaddle' : 'tours.conversion.intro')} + {t( + conversion.ski + ? 'tours.conversion.introSki' + : conversion.paddle + ? 'tours.conversion.introPaddle' + : 'tours.conversion.intro' + )}

void conversion.analyze()} icon={}> {t('tours.conversion.analyze')} diff --git a/client/src/components/Tours/conversion/useGpxConversion.ts b/client/src/components/Tours/conversion/useGpxConversion.ts index ed066fc81..3781dcb9d 100644 --- a/client/src/components/Tours/conversion/useGpxConversion.ts +++ b/client/src/components/Tours/conversion/useGpxConversion.ts @@ -5,6 +5,7 @@ import { isTourRoute } from '../planner/tourRouteOutcome'; import { routeBikeTour, routeRun, routeWalkingTour } from '../planner/tourRouting'; import { paddleFieldsOf } from '../planner/usePaddleFields'; import type { TourPlannerController } from '../planner/useTourPlanner'; +import { skiChoiceMissing, skiChoiceOf } from '../ski/skiChoice'; import { compareRoutes, deriveControlPoints, type RouteComparison, type TrackPoint } from './gpxConversion'; /** The grade a planned version routes with when the import carries none: the paths a T3 walk may take. */ @@ -47,6 +48,9 @@ export function useGpxConversion(planner: TourPlannerController) { // RUN-01: a run import is planned as a run for its profile, at the pace it holds; a fresh // import holds none, so its pace is the runner's to enter. const runProfile = tour?.tour_type === 'run' ? (tour.run_profile ?? 'running') : null; + // SKI-02: a ski import is planned as a ski tour of its discipline, its legs drawn as the planner draws them. + const ski = useMemo(() => (tour?.tour_type === 'ski' ? skiChoiceOf(tour) : null), [tour]); + const drawn = paddle || ski !== null; useEffect(() => () => abort.current?.abort(), []); @@ -63,7 +67,7 @@ export function useGpxConversion(planner: TourPlannerController) { return; } setState({ ...base, status: 'analyzing' }); - const legs = paddle + const legs = drawn ? points.map((point, index) => ({ ...point, fixedLeg: index < points.length - 1 ? freeLegBetween(point, points[index + 1]) : null, @@ -86,7 +90,7 @@ export function useGpxConversion(planner: TourPlannerController) { line: routed.coordinates, comparison: compareRoutes(trackPoints, suggestion), }); - }, [bikeProfile, difficulty, paddle, runProfile, tour, track]); + }, [bikeProfile, difficulty, drawn, runProfile, tour, track]); const cancel = useCallback(() => { abort.current?.abort(); @@ -115,11 +119,12 @@ export function useGpxConversion(planner: TourPlannerController) { ...(runProfile ? { tourType: 'run' as const, runProfile, runPaceSecondsPerKm: tour.run_pace_seconds_per_km ?? null } : {}), + ...(ski ? { tourType: 'ski' as const, ski } : {}), }); if (started) setState(IDLE); return started; }, - [bikeProfile, current.points, current.status, difficulty, paddle, runProfile, startDerivedTour, tour] + [bikeProfile, current.points, current.status, difficulty, paddle, runProfile, ski, startDerivedTour, tour] ); return useMemo( @@ -127,23 +132,25 @@ export function useGpxConversion(planner: TourPlannerController) { ...current, /** * 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. + * track (SKI-02) once it names its discipline, planned as a ski tour with its legs drawn. + * A ride (HOR-01) never: no router plans one, and walking it again on foot would be no ride. */ available: tour !== null && - tour.tour_type !== 'ski' && + (ski === null || skiChoiceMissing(ski) === null) && 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, + /** The import is a paddle or a ski tour: its version is drawn with straight legs, not routed. */ + paddle: drawn, + /** SKI-02: the import is a ski track, planned as a ski tour of its discipline. */ + ski: ski !== null, analyze, cancel, adopt, }), - [adopt, analyze, cancel, current, difficulty, paddle, planner.canEdit, tour, track] + [adopt, analyze, cancel, current, difficulty, drawn, planner.canEdit, ski, tour, track] ); } diff --git a/client/src/components/Tours/planner/TourEditorFields.tsx b/client/src/components/Tours/planner/TourEditorFields.tsx index 97b056978..cd4d0701d 100644 --- a/client/src/components/Tours/planner/TourEditorFields.tsx +++ b/client/src/components/Tours/planner/TourEditorFields.tsx @@ -9,7 +9,6 @@ import { FoldButton, InfoDot, TourNotice, TourSection } from '../tourParts'; import { HikingPreferencesField } from './HikingPreferencesField'; import { TOUR_DURATION_INPUT_MAX_LENGTH, formatTourDuration, parseTourDuration } from './tourDurationInput'; import type { TourPlannerController } from './useTourPlanner'; -import { WinterActivityField } from './WinterActivityField'; type Difficulty = 1 | 2 | 3 | 4 | 5 | 6; @@ -83,6 +82,7 @@ export function TourNameField({ planner, locked }: TourFieldProps) { walk: t('tours.planner.namePlaceholder.walk'), run: t('tours.planner.namePlaceholder.run'), horse: t('tours.planner.namePlaceholder.horse'), + ski: t('tours.planner.namePlaceholder.ski'), }[planner.tourType] } className={INPUT} @@ -327,8 +327,7 @@ function TourDifficultyField({ } /** - * Description, website, a hike's difficulty cap, route choice and winter label - * (HIK-01), and the time, folded away with the chevron. The section opens by itself while one of its fields holds a value + * Description, website, a hike's difficulty cap and route choice, and the time, folded away with the chevron. The section opens by itself while one of its fields holds a value * the planner refuses, so the reason Save is held back is never out of sight. */ export function TourDetailsSection({ @@ -367,7 +366,6 @@ export function TourDetailsSection({ <> - )} diff --git a/client/src/components/Tours/planner/TourPlannerPanels.tsx b/client/src/components/Tours/planner/TourPlannerPanels.tsx index e2dcc2de6..6edb13a4f 100644 --- a/client/src/components/Tours/planner/TourPlannerPanels.tsx +++ b/client/src/components/Tours/planner/TourPlannerPanels.tsx @@ -670,6 +670,8 @@ const ESTIMATE_KEYS = { 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' }, + // SKI-02: nor has a ski tour, whose legs are drawn; the field says so. + ski: { labelKey: 'tours.ski.time', shortKey: 'tours.ski.time', infoLabelKey: 'tours.ski.timeInfoLabel', infoKey: 'tours.ski.timeInfo' }, } as const /** Which ways a point is out of reach of: paths for hikes, city walks and rides, ways for bikes, runnable ways. */ @@ -678,6 +680,7 @@ const UNREACHABLE_WAYS: Record = { walk: 'hike', kayak: 'hike', horse: 'hike', + ski: 'hike', bike: 'bike', run: 'run', } @@ -730,7 +733,7 @@ function plannerTimeFields(planner: TourPlannerController, t: Translate): TourMe { ...estimate, value: planner.walkingDurationMinutes == null - ? t('tours.planner.walkingTimeUnknown') + ? t(planner.isSki ? 'tours.ski.timeUnknown' : 'tours.planner.walkingTimeUnknown') : formatPlannedTourDuration(planner.walkingDurationMinutes), }, { diff --git a/client/src/components/Tours/planner/TourTypeFields.test.tsx b/client/src/components/Tours/planner/TourTypeFields.test.tsx index be0bb5fb3..519ab1cad 100644 --- a/client/src/components/Tours/planner/TourTypeFields.test.tsx +++ b/client/src/components/Tours/planner/TourTypeFields.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen } from '../../../../tests/helpers/render'; import { hikePlannerDefaults } from '../../../../tests/helpers/tourPlannerStub'; import { TourTypeFields } from './TourTypeFields'; @@ -39,8 +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(); + // SKI-02: ski and winter tours are planned too, under the type's new name. + expect(screen.getByTestId('tour-type-option-ski')).toHaveTextContent('Ski & winter'); fireEvent.click(screen.getByTestId('tour-type-option-bike')); expect(hike.setTourType).toHaveBeenCalledWith('bike'); expect(field).toHaveAttribute('aria-expanded', 'false'); @@ -124,6 +124,52 @@ describe('TourTypeFields (PAD-01)', () => { }); }); +describe('TourTypeFields ski and winter (SKI-02)', () => { + const skiPlanner = (overrides: Record = {}) => + planner({ tourType: 'ski', isPaddle: false, isSki: true, skiMissing: 'discipline', ...overrides }); + + it('offers six disciplines with nothing chosen, says what is missing and how the legs are drawn', () => { + const ski = skiPlanner(); + render(); + const fields = screen.getByTestId('tour-planner-ski-fields'); + for (const name of ['Alpine', 'Snowboard', 'Cross-country', 'Ski touring', 'Snowshoeing', 'Winter hiking']) { + expect(screen.getByRole('button', { name })).toHaveAttribute('aria-pressed', 'false'); + } + expect(fields).toHaveTextContent('Choose a discipline to save the tour.'); + expect(fields).toHaveTextContent('Each leg is drawn straight between your points.'); + expect(screen.queryByTestId('tour-planner-paddle-fields')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Snowshoeing' })); + expect(ski.ski.change).toHaveBeenCalledWith({ discipline: 'snowshoe', technique: null }); + }); + + it('asks a cross-country tour for its technique and keeps it dimmed for any other discipline', () => { + const crossCountry = skiPlanner({ + ski: { choice: { discipline: 'cross_country', technique: null }, change: vi.fn(), load: vi.fn() }, + skiMissing: 'technique', + }); + const { unmount } = render(); + expect(screen.getByTestId('tour-ski-technique')).not.toBeDisabled(); + expect(screen.getByTestId('tour-planner-ski-fields')).toHaveTextContent('Choose the technique, or Unknown.'); + fireEvent.click(screen.getByRole('button', { name: 'Skating' })); + expect(crossCountry.ski.change).toHaveBeenCalledWith({ discipline: 'cross_country', technique: 'skating' }); + unmount(); + + const alpine = skiPlanner({ + ski: { choice: { discipline: 'alpine', technique: null }, change: vi.fn(), load: vi.fn() }, + skiMissing: null, + }); + render(); + expect(screen.getByTestId('tour-ski-technique')).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Alpine' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('shows no ski fields on any other tour', () => { + render(); + expect(screen.queryByTestId('tour-planner-ski-fields')).not.toBeInTheDocument(); + }); +}); + describe('TourTypeFields lake style (PAD-11)', () => { it('asks how to cross lakes on lake water or a route over a lake, straight over by default, and hands a change on', () => { const river = planner({}, { waterContext: 'river' }); diff --git a/client/src/components/Tours/planner/TourTypeFields.tsx b/client/src/components/Tours/planner/TourTypeFields.tsx index 3928991e5..7bf675ac0 100644 --- a/client/src/components/Tours/planner/TourTypeFields.tsx +++ b/client/src/components/Tours/planner/TourTypeFields.tsx @@ -14,6 +14,7 @@ import { fs } from '../../shared/DialogShell'; import { crossesLake } from '../paddle/lakeStyleAdvice'; import { PaddleLakeStyleField } from '../paddle/PaddleLakeStyleField'; import { RunFields } from '../run/RunFields'; +import { SkiClassificationFields } from '../ski/SkiClassificationFields'; import { TourNotice } from '../tourParts'; import { TourTypeSelect } from './TourTypeSelect'; import type { TourPlannerController } from './useTourPlanner'; @@ -105,11 +106,28 @@ function BikeProfileField({ planner }: { planner: TourPlannerController }) { } /** - * PAD-01, BIK-01, RUN-01: what the tour is. A hike keeps its fields where they - * were; a bike tour names its profile; a run its profile and the runner's pace; - * a paddle tour asks for exactly one boat and the water it is on, and offers - * the boat's planning speed beside the paddler's own. Nothing here puts the - * tour on a day. + * SKI-02: what a ski tour is, chosen on purpose and nothing preselected: the + * discipline and, for cross-country, the technique. A short line says how its + * legs are laid: straight between the points, nothing checked against pistes. + */ +function SkiFields({ planner }: { planner: TourPlannerController }) { + const { t } = useTranslation(); + return ( +
+ + + {t('tours.ski.drawnFreely')} + +
+ ); +} + +/** + * PAD-01, BIK-01, RUN-01, SKI-02: what the tour is. A hike keeps its fields + * where they were; a bike tour names its profile; a run its profile and the + * runner's pace; a ski tour its discipline; a paddle tour asks for exactly one + * boat and the water it is on, and offers the boat's planning speed beside the + * paddler's own. Nothing here puts the tour on a day. */ export function TourTypeFields({ planner, locked }: TourTypeFieldsProps) { const { t } = useTranslation(); @@ -134,6 +152,8 @@ export function TourTypeFields({ planner, locked }: TourTypeFieldsProps) { {planner.isRun && } + {planner.isSki && } + {planner.isPaddle && (
diff --git a/client/src/components/Tours/planner/TourTypeSelect.tsx b/client/src/components/Tours/planner/TourTypeSelect.tsx index 2dd3ea65c..f4add21c7 100644 --- a/client/src/components/Tours/planner/TourTypeSelect.tsx +++ b/client/src/components/Tours/planner/TourTypeSelect.tsx @@ -24,10 +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, 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. + * paddle tour on the water, a ride along horse routes, a ski and winter tour + * (SKI-02) drawn point to point. 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 the types a track can be kept as. */ export function TourTypeSelect({ value, diff --git a/client/src/components/Tours/planner/TourWaypointList.tsx b/client/src/components/Tours/planner/TourWaypointList.tsx index ba6e07900..9cab6f463 100644 --- a/client/src/components/Tours/planner/TourWaypointList.tsx +++ b/client/src/components/Tours/planner/TourWaypointList.tsx @@ -370,7 +370,8 @@ type WaypointPoint = TourPlannerController['waypoints'][number]; * tour: on the water), planned again (P2A-05), other ways for it compared on * a hike (RTE-02), and the point a stop or a 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. + * legs, so there is nothing to switch. Nor has a ski tour's leg (SKI-02), + * drawn straight until the piste router plans it; its points can still shape. */ function routeMenuItems( planner: TourPlannerController, @@ -382,9 +383,9 @@ function routeMenuItems( if (planner.isHorse) return []; const hasLeg = index < lastIndex; const free = point.fixedLeg?.kind === 'free'; - const routed = hasLeg && !point.fixedLeg; + const routed = hasLeg && !point.fixedLeg && !planner.isSki; const items: MenuItems = []; - if (free || routed) { + if ((free || routed) && !planner.isSki) { const routeAgain = planner.isPaddle ? t('tours.paddle.routeOnWater') : t('tours.planner.routeLeg'); items.push( { divider: true }, diff --git a/client/src/components/Tours/planner/WinterActivityField.test.tsx b/client/src/components/Tours/planner/WinterActivityField.test.tsx deleted file mode 100644 index 0d147af3c..000000000 --- a/client/src/components/Tours/planner/WinterActivityField.test.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react'; -import type { WinterActivity } from '@trek/shared'; -import { useState } from 'react'; -import { describe, expect, it, vi } from 'vitest'; -import { WinterActivityField } from './WinterActivityField'; - -// FE-TOUR-WINTER-FIELD-001 to FE-TOUR-WINTER-FIELD-006 - -const toggle = () => screen.getByRole('button', { name: 'tours.winter.title' }); -const option = (activity: WinterActivity) => screen.getByRole('button', { name: `tours.winter.activity.${activity}` }); - -/** - * The field over a planner that keeps what it is given and the label the - * switch brings back, as the real one does. Reset and open stand for the - * planner starting a new tour and opening a saved one. - */ -function Stateful({ initial = null }: { initial?: WinterActivity | null }) { - const [winterActivity, setActivity] = useState(initial); - const [winterActivityChoice, setChoice] = useState(initial ?? 'winter_hike'); - const setWinterActivity = (value: WinterActivity | null) => { - setActivity(value); - if (value) setChoice(value); - }; - const load = (value: WinterActivity | null) => { - setActivity(value); - setChoice(value ?? 'winter_hike'); - }; - return ( - <> - - {winterActivity ?? 'none'} - - - - ); -} - -const field = (winterActivity: WinterActivity | null, setWinterActivity = vi.fn(), choice?: WinterActivity) => ({ - winterActivity, - setWinterActivity, - winterActivityChoice: choice ?? winterActivity ?? 'winter_hike', -}); - -describe('WinterActivityField (HIK-01)', () => { - it('FE-TOUR-WINTER-FIELD-001: is off by default, its choice dimmed and out of reach until it is switched on', () => { - const setWinterActivity = vi.fn(); - render(); - expect(toggle()).toHaveAttribute('aria-pressed', 'false'); - expect(screen.getByTestId('tour-winter-kind')).toHaveClass('opacity-50'); - expect(option('winter_hike')).toBeDisabled(); - expect(option('snowshoe')).toBeDisabled(); - fireEvent.click(option('snowshoe')); - expect(setWinterActivity).not.toHaveBeenCalled(); - expect(screen.getByRole('button', { name: 'tours.winter.infoLabel' })).toHaveAccessibleDescription( - 'tours.winter.info' - ); - fireEvent.click(toggle()); - expect(setWinterActivity).toHaveBeenCalledWith('winter_hike'); - }); - - it('FE-TOUR-WINTER-FIELD-002: switched on, it names the label chosen and takes the other one', () => { - const setWinterActivity = vi.fn(); - render(); - expect(toggle()).toHaveAttribute('aria-pressed', 'true'); - expect(option('snowshoe')).toHaveAttribute('aria-pressed', 'true'); - expect(option('winter_hike')).toHaveAttribute('aria-pressed', 'false'); - expect(screen.getByRole('group', { name: 'tours.winter.kind' })).toBeInTheDocument(); - fireEvent.click(option('winter_hike')); - expect(setWinterActivity).toHaveBeenLastCalledWith('winter_hike'); - fireEvent.click(toggle()); - expect(setWinterActivity).toHaveBeenLastCalledWith(null); - }); - - it('FE-TOUR-WINTER-FIELD-003: switched off and on again, it comes back with the label it had', () => { - render(); - fireEvent.click(toggle()); - expect(screen.getByTestId('saved')).toHaveTextContent('none'); - expect(option('snowshoe')).toHaveAttribute('aria-pressed', 'true'); - fireEvent.click(toggle()); - expect(screen.getByTestId('saved')).toHaveTextContent('snowshoe'); - }); - - it('FE-TOUR-WINTER-FIELD-004: changes nothing while the tour is locked', () => { - const setWinterActivity = vi.fn(); - render(); - fireEvent.click(toggle()); - fireEvent.click(option('snowshoe')); - expect(setWinterActivity).not.toHaveBeenCalled(); - expect(option('snowshoe')).toBeDisabled(); - }); - - it('FE-TOUR-WINTER-FIELD-005: keeps no choice of its own, so the next tour switches on as winter hiking', () => { - render(); - fireEvent.click(toggle()); - fireEvent.click(screen.getByRole('button', { name: 'reset' })); - expect(option('winter_hike')).toHaveAttribute('aria-pressed', 'true'); - fireEvent.click(toggle()); - expect(screen.getByTestId('saved')).toHaveTextContent('winter_hike'); - fireEvent.click(screen.getByRole('button', { name: 'open snowshoe' })); - fireEvent.click(toggle()); - fireEvent.click(screen.getByRole('button', { name: 'reset' })); - fireEvent.click(toggle()); - expect(screen.getByTestId('saved')).toHaveTextContent('winter_hike'); - }); - - it('FE-TOUR-WINTER-FIELD-006: switched on, it brings back the label the planner kept for the tour', () => { - const setWinterActivity = vi.fn(); - render(); - expect(option('snowshoe')).toHaveAttribute('aria-pressed', 'true'); - fireEvent.click(toggle()); - expect(setWinterActivity).toHaveBeenCalledWith('snowshoe'); - }); -}); diff --git a/client/src/components/Tours/planner/WinterActivityField.tsx b/client/src/components/Tours/planner/WinterActivityField.tsx deleted file mode 100644 index 6d98dfa31..000000000 --- a/client/src/components/Tours/planner/WinterActivityField.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { WINTER_ACTIVITIES, type WinterActivity } from '@trek/shared'; -import { Snowflake } from 'lucide-react'; -import { useTranslation } from '../../../i18n'; -import { BOX } from '../../Planner/bookings/bookingParts'; -import ToggleSwitch from '../../Settings/ToggleSwitch'; -import { Segmented } from '../../shared/dialogParts'; -import { fs } from '../../shared/DialogShell'; -import { InfoDot } from '../tourParts'; -import { WINTER_ACTIVITY_ICON } from '../winter/winterActivityIcon'; -import type { TourPlannerController } from './useTourPlanner'; - -/** - * HIK-01: a hike marked as one done in winter, off by default among the - * details, so a hike asks for nothing more. Switched on, it is winter hiking - * until snowshoeing is picked; switched off, the label goes and the route and - * the days stay. The choice stays in sight but dimmed while the switch is off, - * and comes back as it was when it is switched on again. The planner keeps - * that choice with the tour, so a tour saved, closed or opened after a - * snowshoe hike starts again from winter hiking. The info dot says it is a - * label only: it plans and times nothing differently. - */ -export function WinterActivityField({ - planner, - locked, -}: { - planner: Pick; - locked: boolean; -}) { - const { t } = useTranslation(); - const current = planner.winterActivity ?? null; - const on = current !== null; - const toggle = () => { - if (locked) return; - planner.setWinterActivity(on ? null : planner.winterActivityChoice); - }; - const choose = (next: WinterActivity) => { - if (locked || !on) return; - planner.setWinterActivity(next); - }; - return ( -
-
- - - - {t('tours.winter.title')} - - - - - - -
-
- - value={current ?? planner.winterActivityChoice} - onChange={choose} - label={t('tours.winter.kind')} - fill - options={WINTER_ACTIVITIES.map((activity) => { - const Icon = WINTER_ACTIVITY_ICON[activity]; - return { - value: activity, - label: t(`tours.winter.activity.${activity}`), - icon:
-
- ); -} diff --git a/client/src/components/Tours/planner/skiRouting.test.ts b/client/src/components/Tours/planner/skiRouting.test.ts new file mode 100644 index 000000000..afcd115be --- /dev/null +++ b/client/src/components/Tours/planner/skiRouting.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { routeSkiTour, skiLegs, skiRouteKey } from './skiRouting'; +import { lineLengthM } from './tourFixedLegs'; + +// FE-TOUR-SKI-ROUTE-001 to FE-TOUR-SKI-ROUTE-004 + +const start = { lat: 47, lng: 11 }; +const col = { lat: 47.01, lng: 11 }; +const hut = { lat: 47.01, lng: 11.01 }; +const touring = { discipline: 'touring' as const, technique: null }; + +describe('skiRouting (SKI-02)', () => { + it('FE-TOUR-SKI-ROUTE-001: lays a straight free leg between every two points, and keeps a leg the traveller drew', async () => { + const drawn = { + kind: 'free' as const, + geometry: [ + [47.01, 11], + [47.02, 11.005], + [47.01, 11.01], + ] as [number, number][], + }; + const legs = await skiLegs([start, { ...col, fixedLeg: drawn }, hut], touring); + expect(legs).toEqual([ + { + kind: 'free', + geometry: [ + [47, 11], + [47.01, 11], + ], + }, + drawn, + ]); + expect(await skiLegs([start], touring)).toEqual([]); + }); + + it('FE-TOUR-SKI-ROUTE-002: the line is drawn whole, sampled along each leg for its heights, with no time worked out', async () => { + const route = await routeSkiTour([start, col, hut], touring); + expect(route).not.toBeNull(); + expect(route?.durationSeconds).toBeNull(); + expect(route?.parts).toHaveLength(1); + expect(route?.parts[0]?.free).toBe(true); + const coordinates = route?.coordinates ?? []; + expect(coordinates[0]).toEqual([47, 11]); + expect(coordinates[coordinates.length - 1]).toEqual([47.01, 11.01]); + // Every 25 m a point, so the heights follow the ground between the two ends of each leg. + expect(coordinates.length).toBeGreaterThan(40); + const straight = lineLengthM([ + [47, 11], + [47.01, 11], + [47.01, 11.01], + ]); + expect(route?.distanceMeters ?? 0).toBeCloseTo(straight, 0); + }); + + it('FE-TOUR-SKI-ROUTE-003: fewer than two points, or a request called off, draw nothing', async () => { + expect(await routeSkiTour([start], touring)).toBeNull(); + const controller = new AbortController(); + controller.abort(); + expect(await routeSkiTour([start, col], touring, controller.signal)).toBeNull(); + }); + + it('FE-TOUR-SKI-ROUTE-004: no choice of the discipline changes the legs, so none plans the tour again', () => { + const keys = [ + skiRouteKey({ discipline: null, technique: null }), + skiRouteKey({ discipline: 'alpine', technique: null }), + skiRouteKey({ discipline: 'cross_country', technique: 'skating' }), + ]; + expect(new Set(keys).size).toBe(1); + }); +}); diff --git a/client/src/components/Tours/planner/skiRouting.ts b/client/src/components/Tours/planner/skiRouting.ts new file mode 100644 index 000000000..aad7a01bc --- /dev/null +++ b/client/src/components/Tours/planner/skiRouting.ts @@ -0,0 +1,60 @@ +import type { TourFixedLeg } from '@trek/shared'; +import type { SkiChoice } from '../ski/skiChoice'; +import { freeLegBetween } from './tourRouteActions'; +import { drawnTour, type TourRouteResult } from './tourRouting'; + +/** + * SKI-02: how the legs of a planned ski tour are laid. There is no piste + * router yet, so every leg is drawn straight between its two points, as a leg + * drawn by hand is, and its heights are read off the ground like any free + * leg's. No ski time is worked out from it. + * + * This file is the one place the piste router plugs in: `skiLegs` will ask it + * for the legs between the fixed ones and keep a straight leg wherever it + * finds none, and `skiRouteKey` will name what of the discipline, technique + * and options the legs were planned for, so a change of them plans the tour + * again. Nothing else of the planner has to change for it. + */ + +interface SkiLegPoint { + lat: number; + lng: number; + fixedLeg?: TourFixedLeg | null; +} + +/** + * What of the ski choice the legs are laid for. A straight leg depends on its + * two points only, so nothing of the choice yet: a discipline changed plans + * nothing again. + */ +export function skiRouteKey(_choice: SkiChoice): string { + return ''; +} + +/** + * One leg for each point but the last: the line the point carries (a leg the + * traveller drew), else a straight line to the next point. + */ +export async function skiLegs( + points: readonly SkiLegPoint[], + _choice: SkiChoice, + _signal?: AbortSignal +): Promise { + return points.slice(0, -1).map((point, index) => point.fixedLeg ?? freeLegBetween(point, points[index + 1])); +} + +/** + * A ski tour's line from its legs, drawn and never routed: each leg is sampled + * along its length so the heights read for it follow the ground. Null for + * fewer than two points or a request called off. + */ +export async function routeSkiTour( + points: readonly SkiLegPoint[], + choice: SkiChoice, + signal?: AbortSignal +): Promise { + if (points.length < 2) return null; + const legs = await skiLegs(points, choice, signal); + if (signal?.aborted) return null; + return drawnTour(points.map((point, index) => ({ ...point, fixedLeg: legs[index] ?? null }))); +} diff --git a/client/src/components/Tours/planner/tourPlannerDraft.ts b/client/src/components/Tours/planner/tourPlannerDraft.ts index e7784c481..6da9706d0 100644 --- a/client/src/components/Tours/planner/tourPlannerDraft.ts +++ b/client/src/components/Tours/planner/tourPlannerDraft.ts @@ -5,14 +5,15 @@ import { paddleLockSchema, plannableTourTypeSchema, runProfileSchema, - winterActivitySchema, + skiDisciplineSchema, + skiTechniqueSchema, type BikeProfile, type HikingPreferences, type PlannableTourType, type RunProfile, type TourMaxHikingDifficulty, - type WinterActivity, } from '@trek/shared'; +import type { SkiChoice } from '../ski/skiChoice'; import type { TourPlannerWaypoint } from './tourPlannerWaypoints'; import type { PaddleFieldValues } from './usePaddleFields'; @@ -26,7 +27,7 @@ export type DraftSource = 'google_maps'; * half read. */ export interface DraftPayload { - version: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10; + version: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11; name: string; description?: string; website?: string; @@ -47,14 +48,14 @@ export interface DraftPayload { hikingPreferences?: HikingPreferences | null; /** BIK-01: the profile a bike tour is routed for. */ bikeProfile?: BikeProfile; - /** HIK-01: the hike's winter label; absent in a draft older than it. */ - winterActivity?: WinterActivity | null; + /** SKI-02: a ski tour's discipline and cross-country technique; absent in a draft older than it. */ + ski?: SkiChoice; /** RUN-01: the profile a run is routed for, and its pace in seconds per km; absent in a draft older than them. */ runProfile?: RunProfile; runPaceSecondsPerKm?: number | null; } -const DRAFT_VERSIONS: ReadonlyArray = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; +const DRAFT_VERSIONS: ReadonlyArray = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; function optionalString(value: unknown): boolean { return value === undefined || typeof value === 'string'; @@ -101,7 +102,17 @@ function optionalPace(value: number | null | undefined): boolean { return value == null || (Number.isInteger(value) && value > 0); } -/** The type of a draft, what its route is planned by and its winter label, each one absent in a draft older than it. */ +/** SKI-02: a ski choice as a draft keeps it, each half nothing chosen or a value the contract knows. */ +function optionalSki(value: SkiChoice | undefined): boolean { + if (value === undefined) return true; + if (!value || typeof value !== 'object') return false; + return ( + (value.discipline === null || skiDisciplineSchema.safeParse(value.discipline).success) && + (value.technique === null || skiTechniqueSchema.safeParse(value.technique).success) + ); +} + +/** The type of a draft and what its route is planned by, each one absent in a draft older than it. */ function validRouteChoices(draft: Partial): boolean { return ( (draft.tourType === undefined || plannableTourTypeSchema.safeParse(draft.tourType).success) && @@ -109,7 +120,7 @@ function validRouteChoices(draft: Partial): boolean { (draft.runProfile === undefined || runProfileSchema.safeParse(draft.runProfile).success) && optionalPace(draft.runPaceSecondsPerKm) && (draft.hikingPreferences == null || hikingPreferencesSchema.safeParse(draft.hikingPreferences).success) && - (draft.winterActivity == null || winterActivitySchema.safeParse(draft.winterActivity).success) && + optionalSki(draft.ski) && optionalDifficulty(draft.maxHikingDifficulty) ); } diff --git a/client/src/components/Tours/planner/tourPlannerFacts.ts b/client/src/components/Tours/planner/tourPlannerFacts.ts index b63364218..c9ac9a6a2 100644 --- a/client/src/components/Tours/planner/tourPlannerFacts.ts +++ b/client/src/components/Tours/planner/tourPlannerFacts.ts @@ -13,11 +13,11 @@ import { type RunProfile, type TourListItem, type TourMaxHikingDifficulty, - type WinterActivity, } from '@trek/shared'; import type { RouteGeometryAnalysis } from '../../../utils/routeGeometry'; import { hikingTimeMinutes } from '../hikingTime'; import { runPaceInvalid } from '../run/runPace'; +import { NO_SKI_CHOICE, skiChoiceMissing, skiChoiceOf, skiFieldsOf, type SkiChoice } from '../ski/skiChoice'; import { walkTimesOf, type WalkTimes } from './stationModel'; /** The editable fields of a saved tour as the planner opens them, each with its empty value. */ @@ -33,9 +33,8 @@ export function openedTourFields(tour: TourListItem) { // A type nobody can plan (an import of another kind) opens as a hike, as it always has. tourType: plannableTourTypeSchema.catch('hike').parse(tour.tour_type) as PlannableTourType, bikeProfile: (tour.bike_profile ?? 'touring') as BikeProfile, - winterActivity: tour.winter_activity ?? null, - /** HIK-01: the label the winter switch brings back, the tour's own or winter hiking. */ - winterActivityChoice: tour.winter_activity ?? ('winter_hike' satisfies WinterActivity), + /** SKI-02: a ski tour's discipline and technique as saved; nothing chosen on any other tour. */ + ski: skiChoiceOf(tour.tour_type === 'ski' ? tour : {}), runProfile: (tour.run_profile ?? 'running') as RunProfile, runPaceSecondsPerKm: tour.run_pace_seconds_per_km ?? null, }; @@ -49,8 +48,8 @@ export interface TourPlannerFactsInput { hikingPreferences: HikingPreferences | null; /** BIK-01: the profile a bike tour is routed for. */ bikeProfile: BikeProfile; - /** HIK-01: a hike's winter label, none by default; it changes nothing the planner works out. */ - winterActivity: WinterActivity | null; + /** SKI-02: a ski tour's discipline and cross-country technique, nothing chosen until the traveller picks. */ + ski?: SkiChoice; /** RUN-01: the profile a run is routed for, and the pace it is planned at (null while none is entered). */ runProfile: RunProfile; runPaceSecondsPerKm: number | null; @@ -99,10 +98,11 @@ function runMinutes({ runPaceSecondsPerKm: pace, routeAnalysis }: TourPlannerFac * weighs the climbs, a hike by the signposts' rule over distance, climb and * descent once the route has its heights, and by the router's own time before that. * A ride (HOR-01) has none: its pace is the horse's and the rider's, so nothing is made up. + * Nor has a ski tour (SKI-02): its legs are drawn, not planned along pistes and lifts. */ function movingMinutes(input: TourPlannerFactsInput, routerMinutes: number | null): number | null { const { paddleEstimate, routeAnalysis } = input; - if (input.tourType === 'horse') return null; + if (input.tourType === 'horse' || input.tourType === 'ski') return null; if (paddleEstimate) return paddleEstimate.waterMinutes === null ? null : paddleEstimate.estimateMinutes; if (input.tourType === 'run') return runMinutes(input); // A bike tour is timed by the router for its profile, a city walk by the router on foot. @@ -123,9 +123,9 @@ function walkTimesFor(input: TourPlannerFactsInput, walkingMinutes: number | nul } /** - * What the type adds to a save: a hike its routing limit and its winter label, - * a bike tour its profile, a run its profile and pace, a paddle tour its boat, - * water and speed. + * What the type adds to a save: a hike its routing limit, a bike tour its + * profile, a run its profile and pace, a ski tour its discipline (and a + * cross-country technique), a paddle tour its boat, water and speed. */ function typeFieldsOf(input: TourPlannerFactsInput) { const isBike = input.tourType === 'bike'; @@ -144,10 +144,12 @@ function typeFieldsOf(input: TourPlannerFactsInput) { portage_speed_kmh: portageSpeedKmh, paddle_lake_style: lakeStyle, hiking_preferences: null, - winter_activity: null, + ski_discipline: null, + ski_technique: null, }; } const isHike = input.tourType === 'hike'; + const ski = input.tourType === 'ski' && input.ski ? skiFieldsOf(input.ski) : null; return { tour_type: input.tourType, max_hiking_difficulty: isHike ? input.maxHikingDifficulty : null, @@ -160,7 +162,8 @@ function typeFieldsOf(input: TourPlannerFactsInput) { portage_speed_kmh: null, paddle_lake_style: null, hiking_preferences: isHike ? input.hikingPreferences : null, - winter_activity: isHike ? input.winterActivity : null, + ski_discipline: ski?.ski_discipline ?? null, + ski_technique: ski?.ski_technique ?? null, }; } @@ -185,6 +188,8 @@ export function tourPlannerFacts(input: TourPlannerFactsInput) { // A city walk sends no breaks (CW-02), so one left from another type cannot hold its save up. breakAdditionalInvalid: input.tourType !== 'walk' && invalidMinutes(breakAdditionalMinutes), paddleIncomplete: isPaddle && (boatType === null || waterContext === null), + /** SKI-02: a ski tour is saved with its discipline, a cross-country one with its technique too. */ + skiMissing: input.tourType === 'ski' ? skiChoiceMissing(input.ski ?? NO_SKI_CHOICE) : null, /** RUN-01: a run is planned with a pace; without one Save waits, and a pace that is none is marked. */ runPaceMissing: isRun && input.runPaceSecondsPerKm === null, runPaceInvalid: isRun && runPaceInvalid(input.runPaceSecondsPerKm), @@ -199,10 +204,11 @@ export function tourPlannerFacts(input: TourPlannerFactsInput) { automaticPlannedTotalMinutes > maxTourMinutesFor(input.stageCount ?? 1), // A city walk's stops are its stays: it saves no breaks, so a cafe never counts as a stay and a break at once. savedBreakAdditionalMinutes: input.tourType === 'walk' ? null : breakAdditionalMinutes, - // The router's own time stands in for a hike without heights; a paddle tour has only its speed, a run its pace, a ride none. + // The router's own time stands in for a hike without heights; a paddle tour has only its speed, a run its pace, + // a ride and a ski tour none. savedDurationSeconds: walkingDurationMinutes === null - ? isPaddle || isRun || input.tourType === 'horse' + ? isPaddle || isRun || input.tourType === 'horse' || input.tourType === 'ski' ? null : durationSeconds : walkingDurationMinutes * 60, diff --git a/client/src/components/Tours/planner/tourPlannerParts.test.ts b/client/src/components/Tours/planner/tourPlannerParts.test.ts index 348fe7a84..79b0f2788 100644 --- a/client/src/components/Tours/planner/tourPlannerParts.test.ts +++ b/client/src/components/Tours/planner/tourPlannerParts.test.ts @@ -9,7 +9,6 @@ const hike = (overrides: Partial = {}): TourPlannerFactsI maxHikingDifficulty: 3, hikingPreferences: null, bikeProfile: 'touring', - winterActivity: null, runProfile: 'running', runPaceSecondsPerKm: null, website: '', @@ -59,26 +58,46 @@ describe('tourPlannerFacts', () => { expect(facts.typeFields).toMatchObject({ tour_type: 'hike', max_hiking_difficulty: 3, boat_type: null }); }); - it('HIK-01: saves a hike with its winter label and the same times as without, and no other type with one', () => { + it('SKI-02: saves a ski tour with its discipline, untimed, and asks for a discipline and a cross-country technique', () => { const analysis = { distanceKm: 8, gain: 600, loss: 600 } as TourPlannerFactsInput['routeAnalysis']; - const summer = tourPlannerFacts(hike({ routeAnalysis: analysis, breakAdditionalMinutes: 30 })); - const winter = tourPlannerFacts( - hike({ routeAnalysis: analysis, breakAdditionalMinutes: 30, winterActivity: 'snowshoe' }) + const snowshoe = tourPlannerFacts( + hike({ + tourType: 'ski', + ski: { discipline: 'snowshoe', technique: 'classic' }, + routeAnalysis: analysis, + breakAdditionalMinutes: 30, + }) ); - expect(winter.typeFields).toMatchObject({ - tour_type: 'hike', - winter_activity: 'snowshoe', - max_hiking_difficulty: 3, + expect(snowshoe.typeFields).toMatchObject({ + tour_type: 'ski', + ski_discipline: 'snowshoe', + ski_technique: null, + max_hiking_difficulty: null, + hiking_preferences: null, }); - expect(summer.typeFields).toMatchObject({ winter_activity: null }); - // A label only: the time is the signposts' rule, as for any hike. - expect(winter.walkingDurationMinutes).toBe(summer.walkingDurationMinutes); - expect(winter.plannedTotalMinutes).toBe(summer.plannedTotalMinutes); - for (const tourType of ['bike', 'walk', 'kayak'] as const) { - expect(tourPlannerFacts(hike({ tourType, winterActivity: 'winter_hike' })).typeFields.winter_activity).toBeNull(); + expect(snowshoe.skiMissing).toBeNull(); + // No ski time is worked out: no signposts' rule, no router time, nothing saved. + expect(snowshoe.walkingDurationMinutes).toBeNull(); + expect(snowshoe.plannedTotalMinutes).toBeNull(); + expect(snowshoe.savedDurationSeconds).toBeNull(); + expect(tourPlannerFacts(hike({ tourType: 'ski', durationSeconds: 3_600 })).savedDurationSeconds).toBeNull(); + + expect(tourPlannerFacts(hike({ tourType: 'ski' })).skiMissing).toBe('discipline'); + const crossCountry = hike({ tourType: 'ski', ski: { discipline: 'cross_country', technique: null } }); + expect(tourPlannerFacts(crossCountry).skiMissing).toBe('technique'); + const skating = tourPlannerFacts({ ...crossCountry, ski: { discipline: 'cross_country', technique: 'skating' } }); + expect(skating.typeFields).toMatchObject({ ski_discipline: 'cross_country', ski_technique: 'skating' }); + for (const tourType of ['hike', 'bike', 'walk', 'kayak'] as const) { + const other = tourPlannerFacts(hike({ tourType, ski: { discipline: 'alpine', technique: null } })); + expect(other.typeFields).toMatchObject({ ski_discipline: null, ski_technique: null }); + expect(other.skiMissing).toBeNull(); } }); + it('HIK-01 withdrawn: a hike saves no winter label at all', () => { + expect(tourPlannerFacts(hike()).typeFields).not.toHaveProperty('winter_activity'); + }); + it('RUN-01: times a run as its distance at the pace, nothing else, and saves its profile and pace', () => { // 8 km with 600 m up and down at 6:00 min/km: 48 minutes, the climb left out. const analysis = { distanceKm: 8, gain: 600, loss: 600 } as TourPlannerFactsInput['routeAnalysis']; @@ -96,7 +115,7 @@ describe('tourPlannerFacts', () => { run_pace_seconds_per_km: 360, max_hiking_difficulty: null, hiking_preferences: null, - winter_activity: null, + ski_discipline: null, bike_profile: null, }); const withBreak = hike({ @@ -140,8 +159,20 @@ describe('tourPlannerFacts', () => { expect(hikeOpened).toMatchObject({ runProfile: 'running', runPaceSecondsPerKm: null, - winterActivity: null, - winterActivityChoice: 'winter_hike', + ski: { discipline: null, technique: null }, + }); + }); + + it('SKI-02: opens a saved ski tour with its discipline and technique, and any other tour with none chosen', () => { + const ski = { name: 'Loipe', tour_type: 'ski', ski_discipline: 'cross_country', ski_technique: 'classic' }; + expect(openedTourFields(ski as Parameters[0])).toMatchObject({ + tourType: 'ski', + ski: { discipline: 'cross_country', technique: 'classic' }, + }); + const stray = { name: 'Ridge', tour_type: 'hike', ski_discipline: 'alpine' }; + expect(openedTourFields(stray as Parameters[0]).ski).toEqual({ + discipline: null, + technique: null, }); }); @@ -304,15 +335,20 @@ describe('useTourDraftStorage', () => { expect(localStorage.getItem('tour-draft-1')).toBeNull(); }); - it('HIK-01: gives back a draft with its winter label, and drops one whose label it does not know', () => { + it('SKI-02: gives back a draft with its ski choice, and drops one whose discipline it does not know', () => { localStorage.clear(); - const draft = { version: 10, name: 'Snowy ridge', waypoints: [{ id: 'a', lat: 47, lng: 11, role: 'start' }] }; - localStorage.setItem('tour-draft-5', JSON.stringify({ ...draft, winterActivity: 'snowshoe' })); + const draft = { version: 11, name: 'Snowy ridge', waypoints: [{ id: 'a', lat: 47, lng: 11, role: 'start' }] }; + const ski = { ...draft, tourType: 'ski', ski: { discipline: 'winter_hike', technique: null } }; + localStorage.setItem('tour-draft-5', JSON.stringify(ski)); expect(renderHook(() => useTourDraftStorage(5)).result.current.restored).toMatchObject({ - winterActivity: 'snowshoe', + tourType: 'ski', + ski: { discipline: 'winter_hike', technique: null }, }); - localStorage.setItem('tour-draft-6', JSON.stringify({ ...draft, winterActivity: 'ski_tour' })); + localStorage.setItem('tour-draft-6', JSON.stringify({ ...ski, ski: { discipline: 'freeride', technique: null } })); expect(renderHook(() => useTourDraftStorage(6)).result.current.restored).toBeNull(); + // A draft from before the label went keeps the rest of itself; the winter label is not read. + localStorage.setItem('tour-draft-11', JSON.stringify({ ...draft, version: 10, winterActivity: 'snowshoe' })); + expect(renderHook(() => useTourDraftStorage(11)).result.current.restored).toMatchObject({ name: 'Snowy ridge' }); }); it('RUN-01: gives back a draft with its run profile and pace, and drops one it cannot read', () => { diff --git a/client/src/components/Tours/planner/tourRouting.ts b/client/src/components/Tours/planner/tourRouting.ts index fc201b059..3da0e58bd 100644 --- a/client/src/components/Tours/planner/tourRouting.ts +++ b/client/src/components/Tours/planner/tourRouting.ts @@ -143,6 +143,16 @@ export async function routeBikeTour( * of its own, which a ride never keeps. */ export function rideTour(waypoints: LandWaypoint[]): TourRouteResult | null { + return drawnTour(waypoints) +} + +/** + * A tour put together from the lines its legs carry, with no router asked and + * no time worked out: a ride (HOR-01) and a ski tour (SKI-02) are. A section of + * a horse route keeps its own line; any other leg is sampled along its length + * so its heights follow the ground. Null while a leg has no line of its own. + */ +export function drawnTour(waypoints: LandWaypoint[]): TourRouteResult | null { const lines: TourRoutePart[] = [] for (const point of waypoints.slice(0, -1)) { const leg = point.fixedLeg diff --git a/client/src/components/Tours/planner/useTourMapView.ts b/client/src/components/Tours/planner/useTourMapView.ts index e5ee4fa7a..c7cd9b115 100644 --- a/client/src/components/Tours/planner/useTourMapView.ts +++ b/client/src/components/Tours/planner/useTourMapView.ts @@ -1,21 +1,40 @@ import { useCallback, useState } from 'react'; import type { TourBaseLayer } from '../../Map/MapLayerSwitcher'; +interface OverlayChoice { + /** Switched on by hand. */ + on: ReadonlySet; + /** Switched off by hand: an overlay on by default for a type (the pistes on a ski tour, SKI-01) stays off. */ + off: ReadonlySet; +} + /** * How the planner's map looks, kept with the planner so it lasts while tours - * are opened, saved and closed: the base layer and the overlays switched on. - * Planner-local by design (P2B-07): nothing here touches the global map - * setting, and none of it changes a tour. + * are opened, saved and closed: the base layer and the overlays switched on + * or off by hand. Planner-local by design (P2B-07): nothing here touches the + * global map setting, and none of it changes a tour. */ export function useTourMapView() { const [mapBaseLayer, setMapBaseLayer] = useState('default'); - const [mapOverlays, setMapOverlays] = useState>(() => new Set()); - const toggleMapOverlay = useCallback((id: string) => { - setMapOverlays((current) => { - const next = new Set(current); - if (!next.delete(id)) next.add(id); - return next; + const [overlays, setOverlays] = useState(() => ({ on: new Set(), off: new Set() })); + /** + * An overlay switched by hand. `shown` is whether it shows now, for an + * overlay that can show without being switched on; left out, it shows + * while it is switched on. + */ + const toggleMapOverlay = useCallback((id: string, shown?: boolean) => { + setOverlays(({ on, off }) => { + const nextOn = new Set(on); + const nextOff = new Set(off); + if (shown ?? on.has(id)) { + nextOn.delete(id); + nextOff.add(id); + } else { + nextOn.add(id); + nextOff.delete(id); + } + return { on: nextOn, off: nextOff }; }); }, []); - return { mapBaseLayer, setMapBaseLayer, mapOverlays, toggleMapOverlay }; + return { mapBaseLayer, setMapBaseLayer, mapOverlays: overlays.on, mapOverlaysOff: overlays.off, toggleMapOverlay }; } diff --git a/client/src/components/Tours/planner/useTourPlanner.run.test.tsx b/client/src/components/Tours/planner/useTourPlanner.run.test.tsx index 9e2c613a5..e6c9c5d0a 100644 --- a/client/src/components/Tours/planner/useTourPlanner.run.test.tsx +++ b/client/src/components/Tours/planner/useTourPlanner.run.test.tsx @@ -145,7 +145,7 @@ describe("useTourPlanner, a run at the runner's own pace (RUN-01)", () => { run_pace_seconds_per_km: 330, max_hiking_difficulty: null, bike_profile: null, - winter_activity: null, + ski_discipline: null, duration_seconds: minutes * 60, }) ); diff --git a/client/src/components/Tours/planner/useTourPlanner.ski.test.tsx b/client/src/components/Tours/planner/useTourPlanner.ski.test.tsx new file mode 100644 index 000000000..244a11af4 --- /dev/null +++ b/client/src/components/Tours/planner/useTourPlanner.ski.test.tsx @@ -0,0 +1,187 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { routeWalkingTour, enrichTourElevations, createTour, detailTour, updateTour } = vi.hoisted(() => ({ + routeWalkingTour: vi.fn(), + enrichTourElevations: vi.fn(), + createTour: vi.fn(), + detailTour: vi.fn(), + updateTour: vi.fn(), +})); + +// The ski legs are laid by the planner's own drawing; only the routers and the heights are stood in for. +vi.mock('./tourRouting', async (importOriginal) => ({ + ...(await importOriginal()), + routeWalkingTour, + enrichTourElevations, +})); +vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } })); +vi.mock('../useTourPermissions', () => ({ useTourPermissions: () => ({ canEdit: true, canAssign: true }) })); + +import { useTourPlanner } from './useTourPlanner'; + +// FE-TOUR-SKI-PLAN-001 to FE-TOUR-SKI-PLAN-006 + +const savedSki = { + place_id: 95, + name: 'Over the col', + tour_type: 'ski' as const, + ski_discipline: 'cross_country' as const, + ski_technique: 'skating' as const, + distance: 1.9, + elevation_gain: 0, + elevation_loss: 80, + duration: null, + difficulty: null, + wanderer_ref: null, + match_confidence: 1, + max_hiking_difficulty: null, + planned: false, + caution: false, + revision: 2, +}; +const savedPoints = [ + { lat: 47, lng: 11, role: 'start' as const, sequence: 0 }, + { lat: 47.01, lng: 11.02, role: 'end' as const, sequence: 1 }, +]; + +/** The heights the elevation source answers: one for every point of the line, falling gently. */ +async function heightsAlong(line: [number, number][]): Promise<[number, number, number][]> { + return line.map(([lat, lng], index): [number, number, number] => [lat, lng, 1600 - index]); +} + +async function settle() { + await act(async () => { + await vi.advanceTimersByTimeAsync(450); + }); +} + +async function plannedSki(tripId: number) { + const hook = renderHook(() => useTourPlanner({ tripId })); + act(() => { + hook.result.current.setName('Over the col'); + hook.result.current.setTourType('ski'); + hook.result.current.addWaypoint(47, 11); + hook.result.current.addWaypoint(47.01, 11.02); + }); + await settle(); + return hook; +} + +describe('useTourPlanner, a ski and winter tour (SKI-02)', () => { + beforeEach(() => { + vi.useFakeTimers(); + localStorage.clear(); + routeWalkingTour.mockReset(); + enrichTourElevations.mockReset().mockImplementation(heightsAlong); + createTour.mockReset(); + detailTour.mockReset(); + updateTour.mockReset(); + }); + + afterEach(() => vi.useRealTimers()); + + it('FE-TOUR-SKI-PLAN-001: draws every leg straight, asks no router, reads the heights, and works out no time', async () => { + const { result } = await plannedSki(81); + expect(result.current.isSki).toBe(true); + expect(routeWalkingTour).not.toHaveBeenCalled(); + expect(enrichTourElevations).toHaveBeenCalledTimes(1); + expect(result.current.status).toBe('ready'); + expect(result.current.routeParts?.every((part) => part.free)).toBe(true); + // Sampled along its length, so the heights follow the ground between the two points. + expect(result.current.route?.length ?? 0).toBeGreaterThan(2); + expect(result.current.routeAnalysis?.distanceKm ?? 0).toBeGreaterThan(1.5); + expect(result.current.durationSeconds).toBeNull(); + expect(result.current.walkingDurationMinutes).toBeNull(); + }); + + it('FE-TOUR-SKI-PLAN-002: nothing is chosen at first, and Save waits for a discipline and a cross-country technique', async () => { + const { result } = await plannedSki(82); + expect(result.current.ski.choice).toEqual({ discipline: null, technique: null }); + expect(result.current.skiMissing).toBe('discipline'); + expect(result.current.canSave).toBe(false); + act(() => result.current.ski.change({ discipline: 'cross_country', technique: null })); + expect(result.current.skiMissing).toBe('technique'); + expect(result.current.canSave).toBe(false); + act(() => result.current.ski.change({ discipline: 'cross_country', technique: 'skating' })); + expect(result.current.skiMissing).toBeNull(); + expect(result.current.canSave).toBe(true); + }); + + it('FE-TOUR-SKI-PLAN-003: a discipline changed lays no leg again and reads no height again', async () => { + const { result } = await plannedSki(83); + act(() => result.current.ski.change({ discipline: 'alpine', technique: null })); + await settle(); + act(() => result.current.ski.change({ discipline: 'snowshoe', technique: null })); + await settle(); + expect(enrichTourElevations).toHaveBeenCalledTimes(1); + expect(result.current.status).toBe('ready'); + expect(result.current.routeCurrent).toBe(true); + }); + + it('FE-TOUR-SKI-PLAN-004: saves the type, the discipline and no time, nothing of a hike', async () => { + const saved = { ...savedSki, ski_discipline: 'winter_hike', ski_technique: null }; + createTour.mockResolvedValue({ tour: saved, waypoints: savedPoints }); + const { result } = await plannedSki(84); + act(() => result.current.ski.change({ discipline: 'winter_hike', technique: 'classic' })); + await act(async () => { + await result.current.save(); + }); + expect(createTour).toHaveBeenCalledWith( + 84, + expect.objectContaining({ + tour_type: 'ski', + ski_discipline: 'winter_hike', + ski_technique: null, + max_hiking_difficulty: null, + hiking_preferences: null, + duration_seconds: null, + }) + ); + expect(result.current.status).toBe('saved'); + }); + + it('FE-TOUR-SKI-PLAN-005: opens a saved ski tour with its discipline and technique, and a hike from it saves neither', async () => { + detailTour.mockResolvedValue({ tour: savedSki, waypoints: savedPoints }); + updateTour.mockResolvedValue({ tour: { ...savedSki, tour_type: 'hike', revision: 3 }, waypoints: savedPoints }); + const { result } = renderHook(() => useTourPlanner({ tripId: 85 })); + await act(async () => { + await result.current.openTour({ place_id: 95 }); + }); + await settle(); + expect(result.current.tourType).toBe('ski'); + expect(result.current.ski.choice).toEqual({ discipline: 'cross_country', technique: 'skating' }); + expect(routeWalkingTour).not.toHaveBeenCalled(); + + routeWalkingTour.mockResolvedValue({ + coordinates: [ + [47, 11], + [47.01, 11.02], + ], + distanceMeters: 1900, + durationSeconds: 1800, + }); + act(() => result.current.setTourType('hike')); + await settle(); + await act(async () => { + await result.current.save(); + }); + expect(updateTour).toHaveBeenCalledWith( + 85, + 95, + expect.objectContaining({ tour_type: 'hike', ski_discipline: null, ski_technique: null }) + ); + }); + + it('FE-TOUR-SKI-PLAN-006: a draft keeps its ski choice across a reload, and a new tour starts with none', async () => { + const first = await plannedSki(86); + act(() => first.result.current.ski.change({ discipline: 'touring', technique: null })); + first.unmount(); + + const again = renderHook(() => useTourPlanner({ tripId: 86 })); + expect(again.result.current.tourType).toBe('ski'); + expect(again.result.current.ski.choice).toEqual({ discipline: 'touring', technique: null }); + act(() => again.result.current.returnToNeutral()); + expect(again.result.current.ski.choice).toEqual({ discipline: null, technique: null }); + }); +}); diff --git a/client/src/components/Tours/planner/useTourPlanner.ts b/client/src/components/Tours/planner/useTourPlanner.ts index b521d9676..5c1e3dc83 100644 --- a/client/src/components/Tours/planner/useTourPlanner.ts +++ b/client/src/components/Tours/planner/useTourPlanner.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { hikingPreferencesOf, isClosedLoop, paddleSpeedKmhSchema, tourWebsiteSchema, type BikeProfile, type HikingPreferences, type PlannableTourType, type RunProfile, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourMaxHikingDifficulty, type WinterActivity } from '@trek/shared' +import { hikingPreferencesOf, isClosedLoop, paddleSpeedKmhSchema, tourWebsiteSchema, type BikeProfile, type HikingPreferences, type PlannableTourType, type RunProfile, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourMaxHikingDifficulty } from '@trek/shared' import { paddleRouteRepo } from '../../../repo/paddleRouteRepo' import { tourRepo } from '../../../repo/tourRepo' import { randomId } from '../../../utils/randomId' @@ -24,6 +24,8 @@ import { adoptChangedPoints } from './paddleRouting' import { NO_PADDLE_FIELDS, paddleFieldsOf, usePaddleFields, type PaddleFieldValues } from './usePaddleFields' import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions' import { useRunPace } from '../run/useRunPace' +import { NO_SKI_CHOICE, type SkiChoice } from '../ski/skiChoice' +import { useSkiFields } from '../ski/useSkiFields' import { lostStageEnds } from '../stages/tourStages' import { useTourStages } from '../stages/useTourStages' @@ -51,6 +53,8 @@ export interface DerivedTourDraft { runProfile?: RunProfile /** RUN-01: a run planned over keeps the pace it was planned at, so the runner need not type it again. */ runPaceSecondsPerKm?: number | null + /** SKI-02: a ski import planned over keeps its discipline and technique. */ + ski?: SkiChoice /** CW-07: where the points came from, said in the planner until the draft is saved. */ source?: DraftSource } @@ -134,9 +138,6 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe bikeProfile: restored?.bikeProfile, runProfile: restored?.runProfile }) const { ref: choicesRef, load: loadChoices } = routingChoices const { difficulty: maxHikingDifficulty, preferences: hikingPreferences, bikeProfile, runProfile } = routingChoices.choices - const [winterActivity, setWinterActivityState] = useState(restored?.winterActivity ?? null) - // HIK-01: the label the switch brings back, the tour's own while it is off, winter hiking for every other tour. - const [winterActivityChoice, setWinterActivityChoice] = useState(restored?.winterActivity ?? 'winter_hike') const [tourType, setTourTypeState] = useState(restored?.tourType ?? 'hike') const [selectedWaypointId, setSelectedWaypointId] = useState(restored?.waypoints[0]?.id ?? null) const history = usePlannerHistory() @@ -234,6 +235,9 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe const paddle = usePaddleFields(restored?.paddle ?? NO_PADDLE_FIELDS, markEdited) const runPace = useRunPace(restored?.runPaceSecondsPerKm ?? null, markEdited) const loadRunPace = runPace.load + // SKI-02: a ski tour's discipline and technique, nothing chosen until the traveller picks. + const ski = useSkiFields(restored?.ski ?? NO_SKI_CHOICE, markEdited) + const loadSki = ski.load const loadPaddle = paddle.load const paddleBoat = paddle.boatType // The router's own default where the tour names none, so a plan and its cache key agree. @@ -246,7 +250,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe }, []) const forgetFocus = useCallback(() => setRouteProfileFocus(null), []) const routing = useTourRoute({ - tripId, waypoints, tourType, maxHikingDifficulty, hikingPreferences, bikeProfile, runProfile, paddleBoat, paddleLakeStyle, retryToken, + tripId, waypoints, tourType, maxHikingDifficulty, hikingPreferences, bikeProfile, runProfile, paddleBoat, paddleLakeStyle, skiChoice: ski.choice, retryToken, error, setStatus, setError, onCleared: forgetFocus, patchWaypoints, }) const { route, routeParts, paddleRoute, enrichedGeometry, routeCurrent, distanceMeters, durationSeconds, @@ -478,19 +482,12 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe if (value !== breakAdditionalMinutes && markEdited()) setBreakAdditionalMinutesState(value) }, [breakAdditionalMinutes, markEdited]) - /** HIK-01: the hike's winter label, a field like the description: it plans nothing again and is no step of the history. */ - const setWinterActivity = useCallback((value: WinterActivity | null) => { - if (value === winterActivity || !markEdited()) return - setWinterActivityState(value) - if (value) setWinterActivityChoice(value) - }, [markEdited, winterActivity]) - /** - * PAD-01, BIK-01, RUN-01, HOR-01: the type, as one step of the history with - * the route, since a paddle tour draws every leg by hand, neither a bike tour - * nor a run takes a via ferrata and a ride takes every leg from a horse route - * or by hand. Nothing is put on a day by the choice itself. + * PAD-01, BIK-01, RUN-01, HOR-01, SKI-02: the type, as one step of the history + * with the route, since a paddle tour draws every leg by hand, neither a bike + * tour, a run nor a ski tour takes a via ferrata and a ride takes every leg + * from a horse route or by hand. Nothing is put on a day by the choice itself. */ const setTourType = useCallback((value: PlannableTourType) => { if (!editPermissionRef.current || saveInFlightRef.current || value === tourTypeRef.current) return @@ -501,7 +498,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe tourTypeRef.current = value setTourTypeState(value) setStageLoss(0) - const noFerrata = value === 'bike' || value === 'run' + const noFerrata = value === 'bike' || value === 'run' || value === 'ski' const next = normalizePlannerWaypoints(noFerrata ? ridablePoints(waypointsRef.current) : waypointsRef.current, value) waypointsRef.current = next setWaypoints(next) @@ -537,17 +534,17 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe }) const stageCount = Math.max(1, stages.stages.length) const facts = useMemo(() => tourPlannerFacts({ - tourType, maxHikingDifficulty, hikingPreferences, bikeProfile, winterActivity, runProfile, runPaceSecondsPerKm, website, + tourType, maxHikingDifficulty, hikingPreferences, bikeProfile, ski: ski.choice, runProfile, runPaceSecondsPerKm, website, plannedDurationMinutes, breakAdditionalMinutes, paddle: { boatType, waterContext, speedKmh, portageSpeedKmh, lakeStyle }, paddleSpeedInvalid, paddleEstimate, routeAnalysis, durationSeconds, stays, stageCount, }), [bikeProfile, boatType, breakAdditionalMinutes, durationSeconds, hikingPreferences, lakeStyle, maxHikingDifficulty, paddleEstimate, paddleSpeedInvalid, plannedDurationMinutes, portageSpeedKmh, routeAnalysis, runPaceSecondsPerKm, runProfile, speedKmh, stageCount, stays, tourType, waterContext, - website, winterActivity]) + website, ski.choice]) // CW-02: a city walk's times as the overview says them, from the same derivation as its planned total. const { websiteInvalid, plannedDurationInvalid, breakAdditionalInvalid, paddleIncomplete, walkingDurationMinutes, plannedTotalMinutes, plannedTotalInvalid, savedDurationSeconds, savedBreakAdditionalMinutes, typeFields, stationStays, - runPaceMissing, runPaceInvalid } = facts + runPaceMissing, runPaceInvalid, skiMissing } = facts const returnToNeutral = useCallback(() => { if (saveInFlightRef.current) return @@ -568,8 +565,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe waypointsRef.current = [] loadChoices(DEFAULT_ROUTING_CHOICES) loadRunPace(null) - setWinterActivityState(null) - setWinterActivityChoice('winter_hike') + loadSki(NO_SKI_CHOICE) tourTypeRef.current = 'hike' setSelectedWaypointId(null) clearHistory() @@ -592,7 +588,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe setElevationProfileExpanded(true) setNewDraftGeneration(generation => generation + 1) setMapFocusKey(key => key + 1) - }, [adoptRevision, clearHistory, clearRoute, invalidateDetailRequest, interruptRouting, loadChoices, loadPaddle, loadRunPace, forgetDraft]) + }, [adoptRevision, clearHistory, clearRoute, invalidateDetailRequest, interruptRouting, loadChoices, loadPaddle, loadRunPace, loadSki, forgetDraft]) const forgetDeletedTour = useCallback((placeId: number) => { deletedTourPlaceIdsRef.current.add(placeId) @@ -643,6 +639,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe loadChoices({ difficulty: draft.difficulty, ...(draft.bikeProfile ? { bikeProfile: draft.bikeProfile } : {}), ...(draft.runProfile ? { runProfile: draft.runProfile } : {}) }) loadRunPace(draft.runPaceSecondsPerKm ?? null) + if (draft.ski) loadSki(draft.ski) setNameState(draft.name) setDescriptionState(draft.description ?? '') setWebsiteState(draft.website ?? '') @@ -660,7 +657,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe setHasUnsavedChanges(true) return true }, - [adoptRevision, loadChoices, loadPaddle, loadRunPace, returnToNeutral]) + [adoptRevision, loadChoices, loadPaddle, loadRunPace, loadSki, returnToNeutral]) const cancelNewTour = useCallback(() => setNewTourConfirmationOpen(false), []) const toggleElevationProfile = useCallback(() => { @@ -735,8 +732,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe loadChoices({ difficulty: opened.difficulty, preferences: opened.preferences, bikeProfile: opened.bikeProfile, runProfile: opened.runProfile }) loadRunPace(opened.runPaceSecondsPerKm) - setWinterActivityState(opened.winterActivity) - setWinterActivityChoice(opened.winterActivityChoice) + loadSki(opened.ski) tourTypeRef.current = opened.tourType setSelectedWaypointId(nextWaypoints[0]?.id ?? null) clearHistory() @@ -766,7 +762,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe setOpeningTourId(null) } } - }, [adoptRevision, clearHistory, clearRoute, interruptRouting, loadChoices, loadPaddle, loadRunPace, forgetDraft, tripId]) + }, [adoptRevision, clearHistory, clearRoute, interruptRouting, loadChoices, loadPaddle, loadRunPace, loadSki, forgetDraft, tripId]) const retry = useCallback(() => { if (waypointsRef.current.length < 2) return @@ -804,8 +800,8 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe useEffect(() => { if (!hasUnsavedChanges) return - const draft: DraftPayload = { version: 10, name, description, website, plannedDurationMinutes, breakAdditionalMinutes, waypoints, maxHikingDifficulty, editingPlaceId, - derivedFromPlaceId, draftSource, tourType, baseRevision: revision.base, hikingPreferences, bikeProfile, winterActivity, + const draft: DraftPayload = { version: 11, name, description, website, plannedDurationMinutes, breakAdditionalMinutes, waypoints, maxHikingDifficulty, editingPlaceId, + derivedFromPlaceId, draftSource, tourType, baseRevision: revision.base, hikingPreferences, bikeProfile, ski: ski.choice, runProfile, runPaceSecondsPerKm, paddle: { boatType: paddle.boatType, waterContext: paddle.waterContext, speedKmh: paddle.speedKmh, portageSpeedKmh: paddle.portageSpeedKmh, lakeStyle: paddle.lakeStyle }, } @@ -813,7 +809,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe }, [breakAdditionalMinutes, derivedFromPlaceId, draftSource, description, editingPlaceId, hasUnsavedChanges, maxHikingDifficulty, name, plannedDurationMinutes, keepDraft, waypoints, website, tourType, paddle.boatType, paddle.waterContext, paddle.speedKmh, paddle.portageSpeedKmh, paddle.lakeStyle, revision.base, hikingPreferences, - bikeProfile, winterActivity, runProfile, runPaceSecondsPerKm]) + bikeProfile, ski.choice, runProfile, runPaceSecondsPerKm]) useEffect(() => { if (!hasUnsavedChanges) return @@ -825,7 +821,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe const canSave = canEdit && !isSaving && hasUnsavedChanges && name.trim().length > 0 && status === 'ready' && routeCurrent && hasCompleteElevation(enrichedGeometry) && description.length <= 2000 && !websiteInvalid && !plannedDurationInvalid && !breakAdditionalInvalid && !plannedTotalInvalid - && !paddleIncomplete && !(isPaddle && paddleSpeedInvalid) && !runPaceMissing && !runPaceInvalid + && !paddleIncomplete && !(isPaddle && paddleSpeedInvalid) && !runPaceMissing && !runPaceInvalid && !skiMissing /** * Saves the draft: over the tour it edits, naming the revision it was loaded @@ -914,7 +910,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe return { tripId, canEdit, canAssign, name, setName, maxHikingDifficulty, setMaxHikingDifficulty, hikingPreferences, setHikingPreferences, - bikeProfile, setBikeProfile, isBike: tourType === 'bike', isHorse: tourType === 'horse', winterActivity, setWinterActivity, winterActivityChoice, + bikeProfile, setBikeProfile, isBike: tourType === 'bike', isHorse: tourType === 'horse', isSki: tourType === 'ski', ski, skiMissing, runProfile, setRunProfile, isRun: tourType === 'run', runPace, runPaceMissing, runPaceInvalid, tourType, setTourType, isPaddle, paddle, paddleSpeedInvalid, paddleIncomplete, description, setDescription, website, setWebsite, websiteInvalid, diff --git a/client/src/components/Tours/planner/useTourPlanner.winter.test.tsx b/client/src/components/Tours/planner/useTourPlanner.winter.test.tsx deleted file mode 100644 index 22d5bb64f..000000000 --- a/client/src/components/Tours/planner/useTourPlanner.winter.test.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import { act, renderHook } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -const { routeWalkingTour, routeCityWalk, enrichTourElevations, createTour, detailTour, updateTour } = vi.hoisted( - () => ({ - routeWalkingTour: vi.fn(), - routeCityWalk: vi.fn(), - enrichTourElevations: vi.fn(), - createTour: vi.fn(), - detailTour: vi.fn(), - updateTour: vi.fn(), - }) -); - -vi.mock('./tourRouting', () => ({ routeWalkingTour, routeCityWalk, enrichTourElevations })); -vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } })); -vi.mock('../useTourPermissions', () => ({ useTourPermissions: () => ({ canEdit: true, canAssign: true }) })); - -import { useTourPlanner } from './useTourPlanner'; - -// FE-TOUR-WINTER-001 to FE-TOUR-WINTER-006 - -const routed = { - coordinates: [ - [48, 11], - [48.01, 11.02], - ] as [number, number][], - distanceMeters: 2200, - durationSeconds: 1800, -}; -const enriched = [ - [48, 11, 1200], - [48.01, 11.02, 1350], -] as [number, number, number][]; -const savedHike = { - place_id: 88, - name: 'Snowy ridge', - tour_type: 'hike' as const, - winter_activity: 'snowshoe' as const, - distance: 2.2, - elevation_gain: 150, - elevation_loss: 0, - duration: 60, - difficulty: null, - wanderer_ref: null, - match_confidence: 1, - max_hiking_difficulty: 2, - planned: true, - caution: false, - revision: 5, -}; -const savedPoints = [ - { lat: 48, lng: 11, role: 'start' as const, sequence: 0 }, - { lat: 48.01, lng: 11.02, role: 'end' as const, sequence: 1 }, -]; - -async function settle() { - await act(async () => { - await vi.advanceTimersByTimeAsync(450); - }); -} - -describe('useTourPlanner, the winter label of a hike (HIK-01)', () => { - beforeEach(() => { - vi.useFakeTimers(); - localStorage.clear(); - routeWalkingTour.mockReset().mockResolvedValue(routed); - routeCityWalk.mockReset().mockResolvedValue(routed); - enrichTourElevations.mockReset().mockResolvedValue(enriched); - createTour.mockReset(); - detailTour.mockReset(); - updateTour.mockReset(); - }); - - afterEach(() => vi.useRealTimers()); - - it('FE-TOUR-WINTER-001: a hike starts without one, and saves without one as before', async () => { - createTour.mockResolvedValue({ tour: { ...savedHike, winter_activity: null }, waypoints: [] }); - const { result } = renderHook(() => useTourPlanner({ tripId: 61 })); - expect(result.current.winterActivity).toBeNull(); - act(() => { - result.current.setName('Ridge'); - result.current.addWaypoint(48, 11); - result.current.addWaypoint(48.01, 11.02); - }); - await settle(); - await act(async () => { - await result.current.save(); - }); - expect(createTour).toHaveBeenCalledWith(61, expect.objectContaining({ tour_type: 'hike', winter_activity: null })); - }); - - it('FE-TOUR-WINTER-002: the label plans nothing again, is no step of the history and is kept in the draft', async () => { - const { result } = renderHook(() => useTourPlanner({ tripId: 62 })); - act(() => { - result.current.setName('Ridge'); - result.current.addWaypoint(48, 11); - result.current.addWaypoint(48.01, 11.02); - }); - await settle(); - const { route, walkingDurationMinutes } = result.current; - routeWalkingTour.mockClear(); - act(() => result.current.setWinterActivity('winter_hike')); - await settle(); - expect(result.current.winterActivity).toBe('winter_hike'); - expect(routeWalkingTour).not.toHaveBeenCalled(); - expect(result.current.route).toEqual(route); - expect(result.current.walkingDurationMinutes).toBe(walkingDurationMinutes); - expect(result.current.status).toBe('ready'); - expect(result.current.canSave).toBe(true); - expect(JSON.parse(localStorage.getItem('tour-draft-62') || 'null')).toMatchObject({ - winterActivity: 'winter_hike', - }); - // An undo takes back the last point, not the label. - act(() => result.current.undo()); - expect(result.current.winterActivity).toBe('winter_hike'); - }); - - it('FE-TOUR-WINTER-003: a draft comes back with its label, and a new tour starts without one', () => { - const first = renderHook(() => useTourPlanner({ tripId: 63 })); - act(() => first.result.current.setWinterActivity('snowshoe')); - first.unmount(); - const again = renderHook(() => useTourPlanner({ tripId: 63 })); - expect(again.result.current.winterActivity).toBe('snowshoe'); - act(() => again.result.current.startNewTour(true)); - expect(again.result.current.winterActivity).toBeNull(); - }); - - it('FE-TOUR-WINTER-004: opens a saved hike with its label, and taking it off saves the same route', async () => { - detailTour.mockResolvedValue({ tour: savedHike, waypoints: savedPoints }); - updateTour.mockResolvedValue({ - tour: { ...savedHike, winter_activity: null, revision: 6 }, - waypoints: savedPoints, - }); - const { result } = renderHook(() => useTourPlanner({ tripId: 64 })); - await act(async () => { - await result.current.openTour({ place_id: 88 }); - }); - expect(result.current.winterActivity).toBe('snowshoe'); - expect(result.current.hasUnsavedChanges).toBe(false); - act(() => result.current.setWinterActivity(null)); - expect(result.current.hasUnsavedChanges).toBe(true); - await settle(); - await act(async () => { - await result.current.save(); - }); - expect(updateTour).toHaveBeenCalledWith( - 64, - 88, - expect.objectContaining({ - tour_type: 'hike', - winter_activity: null, - max_hiking_difficulty: 2, - waypoints: [ - expect.objectContaining({ lat: 48, lng: 11, role: 'start' }), - expect.objectContaining({ lat: 48.01, lng: 11.02, role: 'end' }), - ], - expected_revision: 5, - }) - ); - }); - - it('FE-TOUR-WINTER-005: the same label again is no edit, and another type saves none', async () => { - createTour.mockResolvedValue({ tour: { ...savedHike, tour_type: 'walk', winter_activity: null }, waypoints: [] }); - const { result } = renderHook(() => useTourPlanner({ tripId: 65 })); - act(() => result.current.setWinterActivity(null)); - expect(result.current.hasUnsavedChanges).toBe(false); - act(() => { - result.current.setName('Old town'); - result.current.setWinterActivity('snowshoe'); - result.current.addWaypoint(48, 11); - result.current.addWaypoint(48.01, 11.02); - result.current.setTourType('walk'); - }); - await settle(); - await act(async () => { - await result.current.save(); - }); - expect(createTour).toHaveBeenCalledWith(65, expect.objectContaining({ tour_type: 'walk', winter_activity: null })); - }); - - it('FE-TOUR-WINTER-006: keeps the label the switch brings back with the tour, winter hiking for the next one', async () => { - detailTour.mockResolvedValue({ tour: savedHike, waypoints: savedPoints }); - const { result } = renderHook(() => useTourPlanner({ tripId: 66 })); - expect(result.current.winterActivityChoice).toBe('winter_hike'); - act(() => result.current.setWinterActivity('snowshoe')); - act(() => result.current.setWinterActivity(null)); - expect(result.current.winterActivityChoice).toBe('snowshoe'); - act(() => result.current.startNewTour(true)); - expect(result.current.winterActivity).toBeNull(); - expect(result.current.winterActivityChoice).toBe('winter_hike'); - await act(async () => { - await result.current.openTour({ place_id: 88 }); - }); - expect(result.current.winterActivityChoice).toBe('snowshoe'); - act(() => result.current.discard()); - expect(result.current.winterActivityChoice).toBe('winter_hike'); - }); -}); diff --git a/client/src/components/Tours/planner/useTourRoute.ts b/client/src/components/Tours/planner/useTourRoute.ts index c25aaab26..ff95dab3c 100644 --- a/client/src/components/Tours/planner/useTourRoute.ts +++ b/client/src/components/Tours/planner/useTourRoute.ts @@ -18,7 +18,9 @@ import { type SetStateAction, } from 'react'; import { randomId } from '../../../utils/randomId'; +import type { SkiChoice } from '../ski/skiChoice'; import { planPaddleStep, type PaddleTourRoute } from './paddleRouting'; +import { routeSkiTour, skiRouteKey } from './skiRouting'; import { normalizePlannerWaypoints, routeInputsKey, @@ -52,6 +54,8 @@ export interface TourRouteInputs { runProfile: RunProfile; paddleBoat: PaddleBoatType | null; paddleLakeStyle: PaddleLakeStyle; + /** SKI-02: a ski tour's discipline and technique, what its legs are laid for. */ + skiChoice: SkiChoice; /** Raised to plan the same points again. */ retryToken: number; /** The planner's error, so a point found unreachable is only named while that is the error. */ @@ -131,19 +135,21 @@ async function routePaddle({ inputs, plannedKey, runKey, signal, stale, setPlann /** * A route on land for the type of the draft: a bike tour and a run for their - * profile, a city walk, a hike within its limit, and a ride (HOR-01) put - * together from its sections, never routed. + * profile, a city walk, a hike within its limit, a ride (HOR-01) put together + * from its sections, never routed, and a ski tour (SKI-02) laid by its own + * routing, which draws every leg straight until the piste router comes. */ async function routeLand(inputs: RouteRun['inputs'], signal: AbortSignal) { const { waypoints, tourType, bikeProfile, runProfile, maxHikingDifficulty, hikingPreferences } = inputs; if (tourType === 'horse') return rideTour(waypoints); + if (tourType === 'ski') return await routeSkiTour(waypoints, inputs.skiChoice, signal); if (tourType === 'bike') return await routeBikeTour(waypoints, bikeProfile, signal); if (tourType === 'run') return await routeRun(waypoints, runProfile, signal); if (tourType === 'walk') return await routeCityWalk(waypoints, signal); return await routeWalkingTour(waypoints, signal, maxHikingDifficulty, hikingPreferences); } -// A hike, a run, a city walk, a bike tour or a ride on land, then the heights along it. +// A hike, a run, a city walk, a bike tour, a ride or a ski tour on land, then the heights along it. async function routeOnLand({ inputs, plannedKey, runKey, signal, stale, setPlanned, setUnreachable }: RouteRun) { const { setStatus, setError } = inputs; setStatus('routing'); @@ -201,7 +207,8 @@ function scheduleRoute(run: RouteRun): () => void { * or its choices change: a hike on foot within its limit, a bike tour and a * run for their profile (BIK-01, RUN-01), a paddle tour on the water by the * server's router, a ride put together from its horse route sections and - * manual legs (HOR-01). Every tour on land then gets its heights; a later + * manual legs (HOR-01), a ski tour with its legs drawn (SKI-02). Every tour on + * land then gets its heights; a later * change makes an answer still on its way stale, and a failure keeps the last * route that was planned. */ @@ -216,6 +223,7 @@ export function useTourRoute(inputs: TourRouteInputs) { runProfile, paddleBoat, paddleLakeStyle, + skiChoice, retryToken, error, setStatus, @@ -241,6 +249,8 @@ export function useTourRoute(inputs: TourRouteInputs) { const waypointKey = useMemo(() => tourWaypointKey(waypoints), [waypoints]); // What a route was planned for besides its points: a route of another type or profile is not this one's. + // A ski tour's choice counts as far as its routing lays the legs by it (nothing while they are drawn straight). + const skiKey = skiRouteKey(skiChoice); const choices = [ tourType, bikeProfile, @@ -249,6 +259,7 @@ export function useTourRoute(inputs: TourRouteInputs) { hikingPreferences, paddleBoat, paddleLakeStyle, + skiKey, ]; const plannedKey = `${waypointKey}#${JSON.stringify(choices)}`; // CW-02, CW-03: a station's own fields and the keys a save answered are no part of what a route is planned from, @@ -291,6 +302,7 @@ export function useTourRoute(inputs: TourRouteInputs) { runProfile, paddleBoat, paddleLakeStyle, + skiChoice, setStatus, setError, patchWaypoints, @@ -325,6 +337,7 @@ export function useTourRoute(inputs: TourRouteInputs) { runProfile, setError, setStatus, + skiChoice, tourType, tripId, ]); diff --git a/client/src/components/Tours/ski/SkiClassificationFields.tsx b/client/src/components/Tours/ski/SkiClassificationFields.tsx index aaae3c3ee..70724055b 100644 --- a/client/src/components/Tours/ski/SkiClassificationFields.tsx +++ b/client/src/components/Tours/ski/SkiClassificationFields.tsx @@ -7,18 +7,22 @@ import { skiChoiceMissing, type SkiChoice } from './skiChoice'; import { SKI_DISCIPLINE_ICON } from './skiDiscipline'; /** - * SKI-02: what a ski track is, chosen on purpose: the discipline, none until - * one is picked, and for cross-country the technique, Unknown among its - * answers. The technique stays in sight but dimmed while another discipline - * is chosen. The info dot says what TREK does with the track. The GPX import - * and the classification dialog share it. + * SKI-02: what a ski tour is, chosen on purpose: the discipline, none until + * one is picked, six of them in two columns, and for cross-country the + * technique, Unknown among its answers. The technique stays in sight but + * dimmed while another discipline is chosen. The info dot says what TREK does + * with the tour. The GPX import, the classification dialog and the planner + * share it; `planned` says the choice is asked for a tour to save rather than + * a track to import. */ export function SkiClassificationFields({ choice, onChange, + planned = false, }: { choice: SkiChoice; onChange: (choice: SkiChoice) => void; + planned?: boolean; }) { const { t } = useTranslation(); const crossCountry = choice.discipline === 'cross_country'; @@ -34,13 +38,14 @@ export function SkiClassificationFields({ value={(choice.discipline ?? '') as SkiDiscipline} onChange={(discipline) => onChange({ ...choice, discipline })} label={t('tours.ski.discipline')} - fill + columns={2} options={SKI_DISCIPLINES.map((discipline) => { const Icon = SKI_DISCIPLINE_ICON[discipline]; return { value: discipline, - label: t(`tours.ski.discipline.${discipline}`), - icon:
diff --git a/client/src/components/Tours/ski/skiDiscipline.ts b/client/src/components/Tours/ski/skiDiscipline.ts index 458d0bfa2..ecdeaef4b 100644 --- a/client/src/components/Tours/ski/skiDiscipline.ts +++ b/client/src/components/Tours/ski/skiDiscipline.ts @@ -1,11 +1,11 @@ import type { SkiDiscipline, SkiTechnique, TourListItem } from '@trek/shared'; -import { CableCar, createLucideIcon, Spline, TrendingUp, type LucideIcon } from 'lucide-react'; +import { CableCar, createLucideIcon, Footprints, Snowflake, Spline, TrendingUp, type LucideIcon } from 'lucide-react'; /** * SKI-02: a snowboard seen from above, laid across the square with its two - * bindings. Lucide has no board, and the snowflake is the winter hike's mark - * (HIK-01): both sit in the same corner badge, so the board needs a glyph of - * its own to be told apart there. + * bindings. Lucide has no board, and the snowflake is the winter hike's mark: + * both sit in the same corner badge, so the board needs a glyph of its own to + * be told apart there. */ const BOARD = 'rotate(-45 12 12)'; const Snowboard = createLucideIcon('snowboard', [ @@ -15,26 +15,29 @@ const Snowboard = createLucideIcon('snowboard', [ ]); /** - * SKI-02: the icon each discipline goes by: a lift for alpine skiing, a board - * for snowboarding, a winding trail for cross-country and the climb on skins - * for ski touring. None of them is the winter hike's snowflake, which takes - * the same corner of a tile. + * SKI-02: the icon each discipline goes by, each its own: a lift for alpine + * skiing, a board for snowboarding, a winding trail for cross-country, the + * climb on skins for ski touring, footprints in the snow for snowshoeing and a + * snowflake for winter hiking. They all take the same corner of a tile, so no + * two share a glyph, and none is the tile's own mountain. */ export const SKI_DISCIPLINE_ICON: Record = { alpine: CableCar, snowboard: Snowboard, cross_country: Spline, touring: TrendingUp, + snowshoe: Footprints, + winter_hike: Snowflake, }; export type SkiTour = Partial>; -/** The discipline of a ski track, or null for any other tour and a ski track listed before it had one. */ +/** The discipline of a ski tour, or null for any other tour and a ski track listed before it had one. */ export function skiDisciplineOf(tour: SkiTour): SkiDiscipline | null { return tour.tour_type === 'ski' ? (tour.ski_discipline ?? null) : null; } -/** A cross-country track's technique; null for every other discipline and type. */ +/** A cross-country tour's technique; null for every other discipline and type. */ export function skiTechniqueOf(tour: SkiTour): SkiTechnique | null { return skiDisciplineOf(tour) === 'cross_country' ? (tour.ski_technique ?? null) : null; } diff --git a/client/src/components/Tours/ski/skiMarks.test.tsx b/client/src/components/Tours/ski/skiMarks.test.tsx index 60ad71767..d33b32ea8 100644 --- a/client/src/components/Tours/ski/skiMarks.test.tsx +++ b/client/src/components/Tours/ski/skiMarks.test.tsx @@ -1,12 +1,12 @@ import { render, screen } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { TourDayRowAvatar } from '../TourDayRowFacts'; +import { tourDayRowIcon } from '../tourDayRowIcon'; import { TourGradeTile } from '../tourParts'; -import { WINTER_ACTIVITY_ICON } from '../winter/winterActivityIcon'; import { SKI_DISCIPLINE_ICON, skiClassificationText, skiDisciplineOf, skiTechniqueOf } from './skiDiscipline'; import { SkiCornerMark, SkiHeadPill } from './skiMarks'; -// FE-TOUR-SKI-MARK-001 to FE-TOUR-SKI-MARK-006 +// FE-TOUR-SKI-MARK-001 to FE-TOUR-SKI-MARK-007 const t = (key: string) => key; const skiTrack = { @@ -73,8 +73,22 @@ describe('ski marks (SKI-02)', () => { expect(screen.getByTestId('tour-ski-mark')).toHaveStyle({ width: '22px' }); }); - it('FE-TOUR-SKI-MARK-006: no discipline shares its corner glyph with another, nor with a winter label', () => { - const icons = [...Object.values(SKI_DISCIPLINE_ICON), ...Object.values(WINTER_ACTIVITY_ICON)]; + it('FE-TOUR-SKI-MARK-006: each of the six disciplines has a corner glyph of its own, none the ski tile itself', () => { + const icons = Object.values(SKI_DISCIPLINE_ICON); + expect(icons).toHaveLength(6); expect(new Set(icons).size).toBe(icons.length); + expect(icons).not.toContain(tourDayRowIcon('ski')); + }); + + it('FE-TOUR-SKI-MARK-007: snowshoeing and winter hiking are ski tours with their own marks, a hike carries none', () => { + const { rerender } = render(); + const snowshoe = screen.getByRole('img', { name: 'tours.ski.discipline.snowshoe' }); + expect(snowshoe.querySelector('svg.lucide-footprints')).not.toBeNull(); + rerender(); + const winterHike = screen.getByRole('img', { name: 'tours.ski.discipline.winter_hike' }); + expect(winterHike.querySelector('svg.lucide-snowflake')).not.toBeNull(); + const hike = { tour_type: 'hike' as const, max_hiking_difficulty: 2, wanderer_ref: null, has_waypoints: true }; + render(); + expect(screen.queryAllByTestId('tour-ski-mark')).toHaveLength(1); }); }); diff --git a/client/src/components/Tours/ski/skiMarks.tsx b/client/src/components/Tours/ski/skiMarks.tsx index 429276589..9e008079b 100644 --- a/client/src/components/Tours/ski/skiMarks.tsx +++ b/client/src/components/Tours/ski/skiMarks.tsx @@ -11,9 +11,9 @@ import { } from './skiDiscipline'; /** - * SKI-02: a ski track's discipline on the corner of its round tile, a frosted - * badge carrying the discipline's icon, the way a hike carries its winter - * label there. Its tooltip names the discipline and a cross-country technique. + * SKI-02: a ski tour's discipline on the corner of its round tile, a frosted + * badge carrying the discipline's own icon. Its tooltip names the discipline + * and a cross-country technique. */ export function SkiCornerMark({ tour, size = 15 }: { tour: SkiTour; size?: number }) { const { t } = useTranslation(); @@ -24,7 +24,7 @@ export function SkiCornerMark({ tour, size = 15 }: { tour: SkiTour; size?: numbe } /** - * SKI-02: a ski track's discipline as a pill of a tour's head, beside its + * SKI-02: a ski tour's discipline as a pill of a tour's head, beside its * type, a cross-country technique as a badge inside it. The tooltip says what * TREK does with the track and what it does not say. */ diff --git a/client/src/components/Tours/ski/useSkiFields.test.ts b/client/src/components/Tours/ski/useSkiFields.test.ts new file mode 100644 index 000000000..2ec74a261 --- /dev/null +++ b/client/src/components/Tours/ski/useSkiFields.test.ts @@ -0,0 +1,30 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { NO_SKI_CHOICE } from './skiChoice'; +import { useSkiFields } from './useSkiFields'; + +// FE-TOUR-SKI-FIELDS-001 to FE-TOUR-SKI-FIELDS-002 + +describe('useSkiFields (SKI-02)', () => { + it('FE-TOUR-SKI-FIELDS-001: a change is an edit only when it says something else and the planner allows it', () => { + const onEdit = vi.fn(() => true); + const { result } = renderHook(() => useSkiFields(NO_SKI_CHOICE, onEdit)); + act(() => result.current.change({ discipline: null, technique: null })); + expect(onEdit).not.toHaveBeenCalled(); + act(() => result.current.change({ discipline: 'snowshoe', technique: null })); + expect(onEdit).toHaveBeenCalledTimes(1); + expect(result.current.choice).toEqual({ discipline: 'snowshoe', technique: null }); + + onEdit.mockReturnValue(false); + act(() => result.current.change({ discipline: 'alpine', technique: null })); + expect(result.current.choice).toEqual({ discipline: 'snowshoe', technique: null }); + }); + + it('FE-TOUR-SKI-FIELDS-002: a choice loaded from a tour or a draft is no edit', () => { + const onEdit = vi.fn(() => true); + const { result } = renderHook(() => useSkiFields(NO_SKI_CHOICE, onEdit)); + act(() => result.current.load({ discipline: 'cross_country', technique: 'classic' })); + expect(result.current.choice).toEqual({ discipline: 'cross_country', technique: 'classic' }); + expect(onEdit).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/Tours/ski/useSkiFields.ts b/client/src/components/Tours/ski/useSkiFields.ts new file mode 100644 index 000000000..b90d8c643 --- /dev/null +++ b/client/src/components/Tours/ski/useSkiFields.ts @@ -0,0 +1,26 @@ +import { useCallback, useState } from 'react'; +import type { SkiChoice } from './skiChoice'; + +/** + * SKI-02: a planned ski tour's discipline and cross-country technique, fields + * of the draft like a paddle tour's boat: nothing chosen until the traveller + * picks, and no step of the history. A technique stays remembered while + * another discipline is tried. `onEdit` tells the planner the draft changed, + * and says whether it may. + */ +export function useSkiFields(initial: SkiChoice, onEdit: () => boolean) { + const [choice, setChoice] = useState(initial); + const change = useCallback( + (next: SkiChoice) => { + if (next.discipline === choice.discipline && next.technique === choice.technique) return; + if (onEdit()) setChoice(next); + }, + [choice, onEdit] + ); + return { + choice, + change, + /** Loaded from a tour, a draft or an import, or cleared; not an edit. */ + load: setChoice, + }; +} diff --git a/client/src/components/Tours/stages/tourStages.test.ts b/client/src/components/Tours/stages/tourStages.test.ts index 845085504..d5e6b8d01 100644 --- a/client/src/components/Tours/stages/tourStages.test.ts +++ b/client/src/components/Tours/stages/tourStages.test.ts @@ -143,6 +143,13 @@ describe('tourStages', () => { expect(ride.every((stage) => stage.distanceKm !== null && !stage.overDay)).toBe(true); }); + it('FE-TOUR-STAGE-MODEL-014: a multi-day ski tour has its stages measured but untimed, never the router time (SKI-02)', () => { + const ski = tourStagesOf(input(withEnds('b'), { tourType: 'ski' })); + expect(ski).toHaveLength(2); + expect(ski.map((stage) => stage.minutes)).toEqual([null, null]); + expect(ski.every((stage) => stage.distanceKm !== null && stage.gain !== null && !stage.overDay)).toBe(true); + }); + it('FE-TOUR-STAGE-MODEL-008: a paddle stage has no heights and is timed at the paddler speed, its lock passed once', () => { const lock = { wait_minutes: 10, transit_minutes: 5 }; const points = withEnds('c').map((p) => (p.id === 'c' ? { ...p, lock } : p)); diff --git a/client/src/components/Tours/stages/tourStages.ts b/client/src/components/Tours/stages/tourStages.ts index a251f5eec..7de88aafd 100644 --- a/client/src/components/Tours/stages/tourStages.ts +++ b/client/src/components/Tours/stages/tourStages.ts @@ -219,12 +219,12 @@ function paddleStageMinutes( * pace over its distance, a hike by the signposts' rule over its distance, * climb and descent, and otherwise the router's time for the whole tour * shared out by distance, which is how the router times a ride or a walk on - * the flat. A ride on horseback (HOR-01) has no time, as the whole ride has - * none, and a run without a pace has none either. + * the flat. A ride on horseback (HOR-01) and a ski tour (SKI-02) have no + * time, as the whole tour has none, and a run without a pace has none either. */ function stageMinutes(input: TourStageInputs, span: StageSpan, stretch: Stretch, totalKm: number): number | null { if (input.paddle) return paddleStageMinutes(input.points, span, input.paddle); - if (input.tourType === 'horse') return null; + if (input.tourType === 'horse' || input.tourType === 'ski') return null; if (input.tourType === 'run') { const pace = input.runPaceSecondsPerKm; return pace === null || runPaceInvalid(pace) ? null : runMinutesAtPace(stretch.distanceKm, pace); diff --git a/client/src/components/Tours/tourFacts.ts b/client/src/components/Tours/tourFacts.ts index 670376895..5ad46ebf6 100644 --- a/client/src/components/Tours/tourFacts.ts +++ b/client/src/components/Tours/tourFacts.ts @@ -47,9 +47,10 @@ export function withoutHeights< /** * What the time on the move is called: walking for a hike (in the wording the * place asks for) and for a city walk (CW-02), riding for a bike tour and for - * a ride on horseback (HOR-01), paddling for a paddle tour, and for a run - * (RUN-01) the running time it is planned with, said as planned wherever it - * shows, so it never reads as a time someone recorded. + * a ride on horseback (HOR-01), paddling for a paddle tour, ski time for a + * ski tour (SKI-02), and for a run (RUN-01) the running time it is planned + * with, said as planned wherever it shows, so it never reads as a time + * someone recorded. */ export function movingTimeLabel( tour: { tour_type?: string } | undefined, @@ -60,5 +61,6 @@ export function movingTimeLabel( if (tour?.tour_type === 'walk') return t('tours.walk.timeEstimatedShort'); if (tour?.tour_type === 'run') return t('tours.run.timeEstimated'); if (tour?.tour_type === 'horse') return t('tours.horse.time'); + if (tour?.tour_type === 'ski') return t('tours.ski.time'); return tour?.tour_type === 'bike' ? t('tours.bike.timeEstimatedShort') : hikeLabel; } diff --git a/client/src/components/Tours/tourMapLayers.ts b/client/src/components/Tours/tourMapLayers.ts index cf0ece8b1..01b3e37d0 100644 --- a/client/src/components/Tours/tourMapLayers.ts +++ b/client/src/components/Tours/tourMapLayers.ts @@ -13,13 +13,14 @@ import type { TourBaseLayer } from '../Map/MapLayerSwitcher'; * layer (RC-10), OpenSeaMap's seamarks (PAD-10) and OpenSnowMap's pistes (SKI-01). * * - Hike: the standard map first, as before; topo, OpenHikingMap (MAP-01, while the admin - * allows it) and satellite beside it; via ferratas, and the pistes for winter hiking (SKI-01). + * allows it) and satellite beside it; via ferratas. A hike is summer only: winter hiking and + * snowshoeing are ski tours, so the pistes are not offered here. * - Bike (BIK-01): the standard map, topo for the climbs, CyclOSM (BIK-06, while the admin * allows it) and satellite. No ferratas, a bike tour climbs none. * - Run (RUN-01): the standard map, topo for the climbs and satellite. No ferratas, a run * takes none. * - Ski (SKI-02): the standard map, topo for the slopes and satellite. No ferratas. The - * pistes as an overlay (SKI-01). + * pistes as an overlay (SKI-01), the only type that offers them, shown until switched off. * - Paddle: the standard map, whose water reads clearly, and satellite. No topo and * no ferratas, those are hiking maps. Seamarks as an overlay. * - Horse (HOR-01): the standard map, topo for the lie of the land and satellite for @@ -43,7 +44,7 @@ export interface TourMapLayerSet { const HIKE: TourMapLayerSet = { bases: ['default', 'topo', 'satellite'], defaultBase: 'default', - overlays: ['ferratas', 'pistes'], + overlays: ['ferratas'], }; const HIKE_WITH_HIKING_MAP: TourMapLayerSet = { ...HIKE, bases: ['default', 'topo', 'hiking', 'satellite'] }; const BIKE: TourMapLayerSet = { bases: ['default', 'topo', 'satellite'], defaultBase: 'default', overlays: [] }; diff --git a/client/src/components/Tours/tourParts.tsx b/client/src/components/Tours/tourParts.tsx index 2e546f0b5..e0d1910fc 100644 --- a/client/src/components/Tours/tourParts.tsx +++ b/client/src/components/Tours/tourParts.tsx @@ -16,21 +16,19 @@ import { tourMetricFacts } from './tourFacts' import type { TourDayAssignment } from './useTourDayAssignment' import { useRailFold } from './railFold' import { tourDayRowIcon } from './tourDayRowIcon' -import { WinterCornerMark } from './winter/winterMarks' import { SkiCornerMark } from './ski/skiMarks' /** * A tour's tile in a list: its T-scale grade in place of the mountain, named in * full by its tooltip (a GPX import carries no grade and keeps the mountain), * where it came from, when that was not TREK, as a small badge over its foot, - * and a hike's winter label (HIK-01) or a ski track's discipline (SKI-02) at - * its top corner. + * and a ski tour's discipline (SKI-02) at its top corner. */ -export function TourGradeTile({ tour }: { tour: Pick & Partial> }) { +export function TourGradeTile({ tour }: { tour: Pick & Partial> }) { const { t } = useTranslation() const level = tour.max_hiking_difficulty const source = tourSource(tour) - // A tour without a grade (a paddle or a bike tour, a run, a ride, a ski track, an import) names its type instead; a hike keeps the mountain. + // A tour without a grade (a paddle or a bike tour, a run, a ride, a ski tour, an import) names its type instead; a hike keeps the mountain. const type = tour.tour_type && ['kayak', 'bike', 'run', 'horse', 'ski'].includes(tour.tour_type) ? tour.tour_type : 'hike' const label = level != null ? t(`tours.planner.difficulty.t${level}`) : type !== 'hike' ? t(`tourTypes.${type}`) : null return ( @@ -40,7 +38,6 @@ export function TourGradeTile({ tour }: { tour: Pick - {source !== 'trek' && ( diff --git a/client/src/components/Tours/winter/winterActivityIcon.ts b/client/src/components/Tours/winter/winterActivityIcon.ts deleted file mode 100644 index 2b1d148b9..000000000 --- a/client/src/components/Tours/winter/winterActivityIcon.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { WinterActivity } from '@trek/shared'; -import { Footprints, Snowflake, type LucideIcon } from 'lucide-react'; - -/** HIK-01: the icon each winter label goes by, a snowflake for winter hiking and footprints in the snow for snowshoes. */ -export const WINTER_ACTIVITY_ICON: Record = { - winter_hike: Snowflake, - snowshoe: Footprints, -}; diff --git a/client/src/components/Tours/winter/winterMarks.test.tsx b/client/src/components/Tours/winter/winterMarks.test.tsx deleted file mode 100644 index 4921b29e9..000000000 --- a/client/src/components/Tours/winter/winterMarks.test.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; -import { TourDayRowAvatar } from '../TourDayRowFacts'; -import { TourGradeTile } from '../tourParts'; -import { WinterActivityPill, WinterCornerMark } from './winterMarks'; - -// FE-TOUR-WINTER-MARK-001 to FE-TOUR-WINTER-MARK-005 - -const hike = { tour_type: 'hike' as const, max_hiking_difficulty: 2, wanderer_ref: null, has_waypoints: true }; - -describe('winter marks (HIK-01)', () => { - it('FE-TOUR-WINTER-MARK-001: the corner mark names the label, and a hike without one shows none', () => { - const { container, rerender } = render(); - expect(container).toBeEmptyDOMElement(); - rerender(); - const mark = screen.getByRole('img', { name: 'tours.winter.activity.snowshoe' }); - expect(mark).toHaveAttribute('data-testid', 'tour-winter-mark'); - expect(mark).toHaveStyle({ width: '20px', height: '20px' }); - }); - - it('FE-TOUR-WINTER-MARK-002: the pill names the label in the head look it is given', () => { - render(); - const pill = screen.getByTestId('tour-detail-winter'); - expect(pill).toHaveTextContent('tours.winter.activity.winter_hike'); - expect(pill).toHaveClass('head-pill'); - }); - - it('FE-TOUR-WINTER-MARK-003: a tour list tile carries the mark beside its grade', () => { - const { rerender } = render(); - expect(screen.queryByTestId('tour-winter-mark')).toBeNull(); - rerender(); - expect(screen.getByRole('img', { name: 'tours.winter.activity.winter_hike' })).toBeInTheDocument(); - expect(screen.getByText('T2')).toBeInTheDocument(); - }); - - it('FE-TOUR-WINTER-MARK-004: the day plan tile stays as it was without a label, and carries the mark with one', () => { - const { container, rerender } = render(); - expect(container.firstElementChild).toHaveAttribute('data-testid', 'plan-tour-icon'); - rerender(); - expect(screen.getByTestId('plan-tour-icon')).toHaveTextContent('T2'); - expect(screen.getByTestId('tour-winter-mark')).toHaveStyle({ width: '22px' }); - }); - - it('FE-TOUR-WINTER-MARK-005: the corner mark goes by the same icon as the head pill of its label', () => { - for (const activity of ['winter_hike', 'snowshoe'] as const) { - const icon = activity === 'snowshoe' ? 'lucide-footprints' : 'lucide-snowflake'; - const mark = render(); - expect(mark.container.querySelector(`svg.${icon}`)).not.toBeNull(); - mark.unmount(); - const pill = render(); - expect(pill.container.querySelector(`svg.${icon}`)).not.toBeNull(); - pill.unmount(); - } - }); -}); diff --git a/client/src/components/Tours/winter/winterMarks.tsx b/client/src/components/Tours/winter/winterMarks.tsx deleted file mode 100644 index 3047791dd..000000000 --- a/client/src/components/Tours/winter/winterMarks.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import type { WinterActivity } from '@trek/shared'; -import { useTranslation } from '../../../i18n'; -import { fs } from '../../shared/DialogShell'; -import { Tooltip } from '../../shared/Tooltip'; -import { TourCornerMark } from '../TourCornerMark'; -import { WINTER_ACTIVITY_ICON } from './winterActivityIcon'; - -/** - * HIK-01: a hike's winter label on its round tile, a frosted badge at the - * tile's top corner with the label's own icon, the one its head pill shows, - * the way the tile carries where a tour came from at its foot. Its tooltip - * names the label; nothing is drawn for a hike without one. - */ -export function WinterCornerMark({ - activity, - size = 15, -}: { - activity: WinterActivity | null | undefined; - /** The badge's diameter in pixels, about half the tile's. */ - size?: number; -}) { - const { t } = useTranslation(); - if (!activity) return null; - return ( - - ); -} - -/** - * HIK-01: the winter label as a pill of a tour's head, its icon in the info - * tone. The tooltip says it is the travellers' label only, never a check of - * the way in winter. - */ -export function WinterActivityPill({ activity, className }: { activity: WinterActivity; className: string }) { - const { t } = useTranslation(); - const Icon = WINTER_ACTIVITY_ICON[activity]; - return ( - - - - - ); -} diff --git a/client/src/components/shared/dialogParts.tsx b/client/src/components/shared/dialogParts.tsx index efc668d97..7c5877b8c 100644 --- a/client/src/components/shared/dialogParts.tsx +++ b/client/src/components/shared/dialogParts.tsx @@ -57,8 +57,11 @@ export interface SegmentedOption { icon?: ReactNode } +/** The track laid out as a grid of this many columns, for more choices than one row holds. */ +const SEGMENTED_COLUMNS = { 2: 'grid w-full grid-cols-2', 3: 'grid w-full grid-cols-3' } as const + /** A row of choices on a grey track, the chosen one raised like the status filter (or filled like the view switcher). */ -export function Segmented({ value, options, onChange, label, accent = false, fill = false }: { +export function Segmented({ value, options, onChange, label, accent = false, fill = false, columns }: { value: T options: readonly SegmentedOption[] onChange: (value: T) => void @@ -68,15 +71,18 @@ export function Segmented({ value, options, onChange, label, a accent?: boolean /** Stretch across the row, each option taking an equal share. */ fill?: boolean + /** Wrap the options into a grid of this many equal columns on the same track. */ + columns?: keyof typeof SEGMENTED_COLUMNS }) { + const track = columns ? SEGMENTED_COLUMNS[columns] : fill ? 'flex w-full' : 'inline-flex' return ( -
+
{options.map(o => { const on = o.value === value const look = on ? (accent ? 'bg-accent text-accent-text' : 'bg-surface-card text-content shadow-sm') : 'text-content-muted hover:text-content' return ( diff --git a/client/src/pages/tripPlanner/TourPlannerMapStage.test.tsx b/client/src/pages/tripPlanner/TourPlannerMapStage.test.tsx index 7a98649cb..97ef4a9d1 100644 --- a/client/src/pages/tripPlanner/TourPlannerMapStage.test.tsx +++ b/client/src/pages/tripPlanner/TourPlannerMapStage.test.tsx @@ -134,6 +134,7 @@ function planner( mapBaseLayer: 'default', setMapBaseLayer: vi.fn(), mapOverlays: new Set(), + mapOverlaysOff: new Set(), toggleMapOverlay: vi.fn(), routeProfileFocus: null, setRouteProfileFocus: vi.fn(), @@ -575,6 +576,28 @@ describe('TourPlannerMapStage', () => { expect(tourPlanner.undo).not.toHaveBeenCalled(); }); + it('FE-PAGE-TOURSTAGE-033: a planned ski tour flies in 3D with its grade colours, the pistes laid over it (SKI-02)', async () => { + const samples = [0, 1, 2].map((i) => ({ + distanceMeters: i * 100, + elevationMeters: 1800 - i * 60, + lat: 47 + i / 1000, + lng: 11, + })); + const tourPlanner = planner('edit-saved', { + tourType: 'ski', + routeAnalysis: { distanceIndexedProfileSamples: samples }, + }); + renderStage(tourPlanner); + expect(stage.flatMap.props?.tourLayers?.bases).toEqual(['default', 'topo', 'satellite']); + expect(stage.flatMap.props?.tourLayers?.overlays?.map((overlay) => [overlay.id, overlay.status])).toEqual([ + ['pistes', 'on'], + ]); + expect(stage.flatMap.props?.ferrataLayer).toBeUndefined(); + await openTerrain(); + expect(screen.getByRole('button', { name: 'Grade mix' })).toBeInTheDocument(); + expect(tourPlanner.addWaypoint).not.toHaveBeenCalled(); + }); + it('FE-PAGE-TOURSTAGE-019: while a section is compared its card is up, and a click on the route sets no shaping point (RTE-02)', () => { const route = [ [46, 7], @@ -821,10 +844,10 @@ describe('TourPlannerMapStage', () => { expect(overlay()).toMatchObject({ status: 'off', active: false }); }); - it('FE-PAGE-TOURSTAGE-028: the pistes switch on over the map with no tour open and stay on when a hike opens (SKI-01)', () => { + it('FE-PAGE-TOURSTAGE-028: the pistes switch on over the map with no tour open and stay on when a ski tour opens (SKI-01)', () => { function NeutralStage({ mode }: { mode: 'neutral' | 'new-draft' }) { const view = useTourMapView(); - return ; + return ; } const { rerender } = render(); const pistes = () => flat().tourLayers?.overlays?.find((overlay) => overlay.id === 'pistes'); @@ -838,18 +861,18 @@ describe('TourPlannerMapStage', () => { }); expect(pistes()?.attribution).toContain('OpenSnowMap'); expect(pistes()?.attribution).toContain('OpenStreetMap'); - // The choice is the map view's, so it outlasts the switch from no tour to a hike. + // The choice is the map view's, so it outlasts the switch from no tour to a ski tour. rerender(); expect(pistes()?.status).toBe('on'); }); it('FE-PAGE-TOURSTAGE-029: switching the pistes changes no point and no step, and a source that stopped answering keeps the map (SKI-01)', () => { - const tourPlanner = planner('edit-saved', { mapOverlays: new Set(['pistes']) }); + const tourPlanner = planner('edit-saved', { tourType: 'ski', mapOverlays: new Set(['pistes']) }); renderStage(tourPlanner); const pistes = () => flat().tourLayers?.overlays?.find((overlay) => overlay.id === 'pistes'); expect(pistes()?.status).toBe('on'); act(() => pistes()?.onToggle()); - expect(tourPlanner.toggleMapOverlay).toHaveBeenCalledWith('pistes'); + expect(tourPlanner.toggleMapOverlay).toHaveBeenCalledWith('pistes', true); act(() => { for (let i = 0; i < 4; i += 1) pistes()?.onTileError(); }); @@ -862,13 +885,13 @@ describe('TourPlannerMapStage', () => { expect(tourPlanner.undo).not.toHaveBeenCalled(); }); - it('FE-PAGE-TOURSTAGE-030: an imported ski track offers the pistes; a bike tour, a run and a paddle tour do not (SKI-01)', () => { + it('FE-PAGE-TOURSTAGE-030: an imported ski track shows the pistes at once; a hike, a bike tour, a run and a paddle tour do not offer them (SKI-01)', () => { const viewed = (tour: Record) => planner('view-gpx', { mode: { type: 'view-gpx', placeId: 9, tour: { place_id: 9, ...tour } } }); const ski = renderStage(viewed({ tour_type: 'ski', ski_discipline: 'alpine' })); - expect(overlayStates()).toEqual([['pistes', 'off']]); + expect(overlayStates()).toEqual([['pistes', 'on']]); ski.unmount(); - for (const tourType of ['bike', 'run', 'kayak']) { + for (const tourType of ['hike', 'bike', 'run', 'kayak']) { const other = renderStage(planner('new-draft', { tourType })); expect(flat().tourLayers?.overlays?.some((overlay) => overlay.id === 'pistes')).toBe(false); other.unmount(); @@ -886,5 +909,22 @@ describe('TourPlannerMapStage', () => { act(() => flat().onViewportChange?.(area, 12)); expect(pistes()?.belowMinZoom).toBe(false); }); + + it('FE-PAGE-TOURSTAGE-032: a planned ski tour shows the pistes until switched off, and off they stay for the next one (SKI-01)', () => { + function SkiStage({ placeId }: { placeId: number }) { + const view = useTourMapView(); + const mode = { type: 'edit-saved', placeId }; + return ; + } + const { rerender } = render(); + const pistes = () => flat().tourLayers?.overlays?.find((overlay) => overlay.id === 'pistes'); + expect(pistes()).toMatchObject({ status: 'on', active: true }); + act(() => pistes()?.onToggle()); + expect(pistes()).toMatchObject({ status: 'off', active: false }); + rerender(); + expect(pistes()?.status).toBe('off'); + act(() => pistes()?.onToggle()); + expect(pistes()?.status).toBe('on'); + }); }); }); diff --git a/client/src/pages/tripPlanner/useTourStageLayers.test.ts b/client/src/pages/tripPlanner/useTourStageLayers.test.ts index 2b428eff7..eba42f9ac 100644 --- a/client/src/pages/tripPlanner/useTourStageLayers.test.ts +++ b/client/src/pages/tripPlanner/useTourStageLayers.test.ts @@ -15,7 +15,14 @@ function planner( tourType: Planner['tourType'] = 'hike', mapBaseLayer: Planner['mapBaseLayer'] = 'hiking' ): Planner { - return { mode, tourType, mapBaseLayer, mapOverlays: new Set(), toggleMapOverlay: () => {} }; + return { + mode, + tourType, + mapBaseLayer, + mapOverlays: new Set(), + mapOverlaysOff: new Set(), + toggleMapOverlay: () => {}, + }; } const basesOf = (p: Planner, zoom: number | null = null) => @@ -68,17 +75,33 @@ describe('useTourStageLayers: OpenHikingMap (MAP-01)', () => { describe('useTourStageLayers: the pistes (SKI-01)', () => { const pistesOf = (p: Planner) => basesOf(p).tourLayers.overlays.find((overlay) => overlay.id === 'pistes'); - it('offers them with no tour open, for a hike and for an imported ski track, off until switched on', () => { - const ski = { type: 'view-gpx', placeId: 9, tour: { place_id: 9, tour_type: 'ski' } as TourListItem } as const; - for (const mode of [{ type: 'neutral' }, { type: 'new-draft' }, ski] as const) { - expect(pistesOf(planner(mode, 'hike', 'default'))).toMatchObject({ status: 'off', active: false }); - } + it('offers them with no tour open, off until switched on, and shows them for a ski tour until switched off', () => { + expect(pistesOf(planner({ type: 'neutral' }, 'hike', 'default'))).toMatchObject({ status: 'off', active: false }); // With no tour open they come even while the last type chosen was a paddle tour. expect(pistesOf(planner({ type: 'neutral' }, 'kayak', 'default'))).toBeDefined(); + const imported = { type: 'view-gpx', placeId: 9, tour: { place_id: 9, tour_type: 'ski' } as TourListItem } as const; + for (const p of [planner(imported, 'hike', 'default'), planner({ type: 'new-draft' }, 'ski', 'default')]) { + expect(pistesOf(p)).toMatchObject({ status: 'on', active: true }); + expect(pistesOf({ ...p, mapOverlaysOff: new Set(['pistes']) })).toMatchObject({ status: 'off', active: false }); + } }); - it('not for a bike tour, a run or a paddle tour that is open', () => { - for (const tourType of ['bike', 'run', 'kayak'] as const) + it('switches the pistes of a ski tour off by saying they show, and on again by saying they do not', () => { + const toggles: Array<[string, boolean | undefined]> = []; + const toggleMapOverlay = (id: string, shown?: boolean) => { + toggles.push([id, shown]); + }; + const shown = { ...planner({ type: 'new-draft' }, 'ski', 'default'), toggleMapOverlay }; + pistesOf(shown)?.onToggle(); + pistesOf({ ...shown, mapOverlaysOff: new Set(['pistes']) })?.onToggle(); + expect(toggles).toEqual([ + ['pistes', true], + ['pistes', false], + ]); + }); + + it('not for a hike, a bike tour, a run or a paddle tour that is open: a hike is summer only', () => { + for (const tourType of ['hike', 'bike', 'run', 'kayak'] as const) expect(pistesOf(planner({ type: 'new-draft' }, tourType, 'default'))).toBeUndefined(); }); diff --git a/client/src/pages/tripPlanner/useTourStageLayers.ts b/client/src/pages/tripPlanner/useTourStageLayers.ts index 4c778c492..e0af2225e 100644 --- a/client/src/pages/tripPlanner/useTourStageLayers.ts +++ b/client/src/pages/tripPlanner/useTourStageLayers.ts @@ -20,12 +20,16 @@ type FerrataLayerState = ReturnType; * neither draws nor warns about ferratas, even if the layer was switched on before; a base layer it * does not offer falls back to its default without changing the choice kept, * which is also what an install whose admin withdrew OpenHikingMap sees. - * The pistes (SKI-01) are offered with no tour open too, whatever type was chosen last. + * The pistes (SKI-01) are offered for a ski tour, where they show until switched off, and with + * no tour open, whatever type was chosen last; a hike no longer has them. * `mapZoom` is the map's zoom on the raster tile scale, null until it reported one, so an * overlay switched on further out than its tiles reach can say so instead of looking broken. */ export function useTourStageLayers( - tourPlanner: Pick, + tourPlanner: Pick< + TourPlannerController, + 'mode' | 'tourType' | 'mapBaseLayer' | 'mapOverlays' | 'mapOverlaysOff' | 'toggleMapOverlay' + >, ferrata: FerrataLayerState, mapZoom: number | null = null ) { @@ -36,7 +40,8 @@ export function useTourStageLayers( const hikingMapAllowed = useAddonStore((state) => state.toursHikingMap); const cycleMapAllowed = useAddonStore((state) => state.toursCycleMap); const tourOpen = mode.type !== 'neutral'; - const layerSet = tourMapLayersFor(mode.type === 'view-gpx' ? mode.tour.tour_type : tourPlanner.tourType, { + const openType = mode.type === 'view-gpx' ? mode.tour.tour_type : tourPlanner.tourType; + const layerSet = tourMapLayersFor(openType, { hikingMap: hikingMapAllowed && tourOpen, cycleMap: cycleMapAllowed && tourOpen, }); @@ -54,14 +59,20 @@ export function useTourStageLayers( toggleSeamarks, mapZoom ); - // SKI-01: the pistes for the types they serve, and with no tour open, so a ski area can be - // looked at without any route. Switching them never touches the tour: the switch is the map view's. + // SKI-01: the pistes for a ski tour, and with no tour open, so a ski area can be looked at + // without any route. A ski tour shows them until they are switched off; switching them never + // touches the tour: the switch is the map view's. const offersPistes = !tourOpen || layerSet.overlays.includes('pistes'); - const togglePistes = useCallback(() => toggleMapOverlay(PISTE_OVERLAY.id), [toggleMapOverlay]); + const pistesByDefault = tourOpen && openType === 'ski' && !tourPlanner.mapOverlaysOff.has(PISTE_OVERLAY.id); + const pistesShown = tourPlanner.mapOverlays.has(PISTE_OVERLAY.id) || pistesByDefault; + const togglePistes = useCallback( + () => toggleMapOverlay(PISTE_OVERLAY.id, pistesShown), + [toggleMapOverlay, pistesShown] + ); const pistes = useTourRasterOverlay( PISTE_OVERLAY, offersPistes, - tourPlanner.mapOverlays.has(PISTE_OVERLAY.id), + pistesShown, togglePistes, mapZoom ); diff --git a/client/src/repo/tourRepo.test.ts b/client/src/repo/tourRepo.test.ts index 1147c576b..2b5d68645 100644 --- a/client/src/repo/tourRepo.test.ts +++ b/client/src/repo/tourRepo.test.ts @@ -197,17 +197,18 @@ describe('tourRepo.update', () => { expect(await offlineDb.places.get(40)).toMatchObject({ description: 'Keep description', website: 'https://example.org/old' }) }) - it('FE-REPO-TOUR-042: offline, a hike takes its winter label at once, keeps it when a save leaves it out, and another type drops it (HIK-01)', async () => { + it('FE-REPO-TOUR-042: offline, a planned ski tour shows its discipline at once, a technique for cross-country only, and another type drops both (SKI-02)', async () => { setOnline(false) - await offlineDb.tours.put({ ...tour({ winter_activity: 'snowshoe' }), trip_id: 3 }) + await offlineDb.tours.put({ ...tour(), trip_id: 3 }) + const ski = { ...body, tour_type: 'ski' as const, max_hiking_difficulty: null, duration_seconds: null } - expect((await tourRepo.update(3, 40, body)).tour.winter_activity).toBe('snowshoe') - expect((await tourRepo.update(3, 40, { ...body, winter_activity: 'winter_hike' })).tour.winter_activity).toBe('winter_hike') - expect((await tourRepo.update(3, 40, { ...body, winter_activity: null })).tour.winter_activity).toBeNull() - await tourRepo.update(3, 40, { ...body, winter_activity: 'snowshoe' }) - const bike = { ...body, tour_type: 'bike' as const, bike_profile: 'touring' as const, max_hiking_difficulty: null } - expect((await tourRepo.update(3, 40, bike)).tour.winter_activity).toBeNull() - expect((await offlineDb.tours.get(40))?.winter_activity).toBeNull() + expect((await tourRepo.update(3, 40, { ...ski, ski_discipline: 'snowshoe' })).tour).toMatchObject({ tour_type: 'ski', ski_discipline: 'snowshoe', ski_technique: null }) + const skating = { ...ski, ski_discipline: 'cross_country' as const, ski_technique: 'skating' as const } + expect((await tourRepo.update(3, 40, skating)).tour).toMatchObject({ ski_discipline: 'cross_country', ski_technique: 'skating' }) + expect((await tourRepo.update(3, 40, { ...skating, ski_discipline: 'touring' })).tour.ski_technique).toBeNull() + expect((await tourRepo.update(3, 40, body)).tour).toMatchObject({ tour_type: 'hike', ski_discipline: null, ski_technique: null }) + expect(await offlineDb.tours.get(40)).toMatchObject({ ski_discipline: null, ski_technique: null }) + expect(await offlineDb.tours.get(40)).not.toHaveProperty('winter_activity') }) it('FE-REPO-TOUR-043: offline, a run shows its profile, pace and planned time at once, and another type drops them (RUN-01)', async () => { diff --git a/client/src/repo/tourRepo.ts b/client/src/repo/tourRepo.ts index 090c60882..1767157ac 100644 --- a/client/src/repo/tourRepo.ts +++ b/client/src/repo/tourRepo.ts @@ -84,12 +84,13 @@ function endpointsOf(routeGeometry: unknown): TourWaypoint[] { } /** - * HIK-01: a hike's winter label as the server keeps it. Another type has none, - * and a hike's save that leaves the field out keeps the label it has. + * SKI-02: a ski tour's discipline and cross-country technique as the server + * keeps them; any other type has neither. */ -function winterField(body: TourCreateRequest): Pick { - if (body.tour_type !== 'hike') return { winter_activity: null } - return body.winter_activity === undefined ? {} : { winter_activity: body.winter_activity } +function skiFields(body: TourCreateRequest): Pick { + if (body.tour_type !== 'ski') return { ski_discipline: null, ski_technique: null } + const discipline = body.ski_discipline ?? null + return { ski_discipline: discipline, ski_technique: discipline === 'cross_country' ? (body.ski_technique ?? null) : null } } /** RUN-01: a run's profile and pace as the server keeps them; any other type has neither. */ @@ -125,7 +126,7 @@ function routeFields(body: TourCreateRequest) { match_confidence: 1, caution: false, max_hiking_difficulty: body.max_hiking_difficulty, - ...winterField(body), + ...skiFields(body), ...runFields(body), has_waypoints: true, ...walkStationFacts(body.tour_type, body.waypoints), diff --git a/client/tests/helpers/tourPlannerStub.ts b/client/tests/helpers/tourPlannerStub.ts index 7fc108183..084fb2e03 100644 --- a/client/tests/helpers/tourPlannerStub.ts +++ b/client/tests/helpers/tourPlannerStub.ts @@ -14,10 +14,10 @@ export function hikePlannerDefaults() { isHorse: false, bikeProfile: 'touring' as const, setBikeProfile: vi.fn(), - // HIK-01: a hike without a winter label, as every hike starts. - winterActivity: null, - setWinterActivity: vi.fn(), - winterActivityChoice: 'winter_hike' as const, + // SKI-02: no ski tour, so no discipline chosen nor asked for. + isSki: false, + ski: { choice: { discipline: null, technique: null }, change: vi.fn(), load: vi.fn() }, + skiMissing: null, // RUN-01: no run, so no pace asked for. isRun: false, runProfile: 'running' as const, @@ -62,6 +62,7 @@ export function hikePlannerDefaults() { routeCurrent: false, routeParts: null, mapOverlays: new Set(), + mapOverlaysOff: new Set(), toggleMapOverlay: vi.fn(), tripId: 1, editWaypoints: vi.fn(), diff --git a/server/src/db/entities/CollectionTours.entity.ts b/server/src/db/entities/CollectionTours.entity.ts index 8f9d1ff0a..4fca82ef0 100644 --- a/server/src/db/entities/CollectionTours.entity.ts +++ b/server/src/db/entities/CollectionTours.entity.ts @@ -28,7 +28,6 @@ export class CollectionTours { match_confidence?: number | null; max_hiking_difficulty?: number | null; bike_profile?: string | null; - winter_activity?: string | null; run_profile?: string | null; run_pace_seconds_per_km?: number | null; ski_discipline?: string | null; @@ -76,7 +75,6 @@ export const CollectionToursSchema = defineEntity({ match_confidence: p.double().nullable(), max_hiking_difficulty: p.integer().nullable(), bike_profile: p.text().nullable(), - winter_activity: p.text().nullable(), run_profile: p.text().nullable(), run_pace_seconds_per_km: p.integer().nullable(), ski_discipline: p.text().nullable(), diff --git a/server/src/db/entities/Tours.entity.ts b/server/src/db/entities/Tours.entity.ts index 19aec8c71..614b27c77 100644 --- a/server/src/db/entities/Tours.entity.ts +++ b/server/src/db/entities/Tours.entity.ts @@ -44,7 +44,6 @@ export class Tours { taken_over_from_trip_title?: string | null; taken_over_from_tour_name?: string | null; bike_profile?: TToursBikeProfile | null; - winter_activity?: TToursWinterActivity | null; run_profile?: TToursRunProfile | null; run_pace_seconds_per_km?: number | null; ski_discipline?: TToursSkiDiscipline | null; @@ -102,13 +101,6 @@ export const ToursBikeProfile = { export type TToursBikeProfile = (typeof ToursBikeProfile)[keyof typeof ToursBikeProfile]; -export const ToursWinterActivity = { - WINTER_HIKE: 'winter_hike', - SNOWSHOE: 'snowshoe', -} as const; - -export type TToursWinterActivity = (typeof ToursWinterActivity)[keyof typeof ToursWinterActivity]; - export const ToursRunProfile = { RUNNING: 'running', TRAIL: 'trail', @@ -121,6 +113,8 @@ export const ToursSkiDiscipline = { SNOWBOARD: 'snowboard', CROSS_COUNTRY: 'cross_country', TOURING: 'touring', + SNOWSHOE: 'snowshoe', + WINTER_HIKE: 'winter_hike', } as const; export type TToursSkiDiscipline = (typeof ToursSkiDiscipline)[keyof typeof ToursSkiDiscipline]; @@ -181,7 +175,6 @@ export const ToursSchema = defineEntity({ taken_over_from_trip_title: p.text().nullable(), taken_over_from_tour_name: p.text().nullable(), bike_profile: p.enum(() => ToursBikeProfile).nullable(), - winter_activity: p.enum(() => ToursWinterActivity).nullable(), run_profile: p.enum(() => ToursRunProfile).nullable(), run_pace_seconds_per_km: p.integer().nullable(), ski_discipline: p.enum(() => ToursSkiDiscipline).nullable(), diff --git a/server/src/db/kysely/CollectionTours.table.ts b/server/src/db/kysely/CollectionTours.table.ts index adde5055c..9c13f54e5 100644 --- a/server/src/db/kysely/CollectionTours.table.ts +++ b/server/src/db/kysely/CollectionTours.table.ts @@ -23,7 +23,6 @@ export interface CollectionToursTable { match_confidence: InsertOptional; max_hiking_difficulty: InsertOptional; bike_profile: InsertOptional; - winter_activity: InsertOptional; run_profile: InsertOptional; run_pace_seconds_per_km: InsertOptional; ski_discipline: InsertOptional; diff --git a/server/src/db/kysely/Tours.table.ts b/server/src/db/kysely/Tours.table.ts index ca58f3ac6..722c0abca 100644 --- a/server/src/db/kysely/Tours.table.ts +++ b/server/src/db/kysely/Tours.table.ts @@ -31,7 +31,6 @@ export interface ToursTable { taken_over_from_trip_title: InsertOptional; taken_over_from_tour_name: InsertOptional; bike_profile: InsertOptional; - winter_activity: InsertOptional; run_profile: InsertOptional; run_pace_seconds_per_km: InsertOptional; ski_discipline: InsertOptional; diff --git a/server/src/db/migrations/Migration20200101045500_a_hike_can_be_marked_as_a_winter_activity.ts b/server/src/db/migrations/Migration20200101045500_a_hike_can_be_marked_as_a_winter_activity.ts deleted file mode 100644 index b94cb3397..000000000 --- a/server/src/db/migrations/Migration20200101045500_a_hike_can_be_marked_as_a_winter_activity.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Migration } from '@mikro-orm/migrations'; - -/** - * HIK-01: a hike can carry a winter label, winter hiking or snowshoeing. It - * is a column of the tour, NULL on every tour saved before and on every other - * type, so no existing tour changes, and nothing routes or times by it. - */ -export class Migration20200101045500_a_hike_can_be_marked_as_a_winter_activity extends Migration { - override name = 'Migration20200101045500_a_hike_can_be_marked_as_a_winter_activity'; - - override up(): void { - this.addSql( - "ALTER TABLE tours ADD COLUMN winter_activity TEXT CHECK (winter_activity IS NULL OR winter_activity IN ('winter_hike', 'snowshoe'))", - ); - } -} diff --git a/server/src/db/migrations/Migration20200101045520_a_tour_can_be_a_ski_track.ts b/server/src/db/migrations/Migration20200101045520_a_tour_can_be_a_ski_track.ts index ef3b669a8..f0daf4dcf 100644 --- a/server/src/db/migrations/Migration20200101045520_a_tour_can_be_a_ski_track.ts +++ b/server/src/db/migrations/Migration20200101045520_a_tour_can_be_a_ski_track.ts @@ -1,19 +1,21 @@ import { Migration } from '@mikro-orm/migrations'; /** - * SKI-02: a tour can be a ski track, imported from a GPX file and kept as it - * was recorded. The `ski` key has been in `tour_types`, disabled and without - * a routing profile, since every contract type was seeded; it is switched on - * here and keeps no routing profile, since TREK routes no ski track. The - * discipline and, for cross-country, the technique are columns of the tour, - * NULL on every other type, so no existing tour changes its meaning. + * SKI-02: a tour can be a ski and winter tour, planned in the planner or + * imported from a GPX file. The `ski` key has been in `tour_types`, disabled + * and without a routing profile, since every contract type was seeded; it is + * switched on here and keeps no routing profile: the planner draws its legs + * itself. The discipline (alpine, snowboard, cross-country, ski touring, + * snowshoe or winter hike) and, for cross-country, the technique are columns + * of the tour, NULL on every other type, so no existing tour changes its + * meaning. */ export class Migration20200101045520_a_tour_can_be_a_ski_track extends Migration { override name = 'Migration20200101045520_a_tour_can_be_a_ski_track'; override up(): void { this.addSql( - "ALTER TABLE tours ADD COLUMN ski_discipline TEXT CHECK (ski_discipline IS NULL OR ski_discipline IN ('alpine', 'snowboard', 'cross_country', 'touring'))", + "ALTER TABLE tours ADD COLUMN ski_discipline TEXT CHECK (ski_discipline IS NULL OR ski_discipline IN ('alpine', 'snowboard', 'cross_country', 'touring', 'snowshoe', 'winter_hike'))", ); this.addSql( "ALTER TABLE tours ADD COLUMN ski_technique TEXT CHECK (ski_technique IS NULL OR ski_technique IN ('classic', 'skating', 'unknown'))", diff --git a/server/src/db/migrations/Migration20200101046100_a_collection_keeps_whole_tours.ts b/server/src/db/migrations/Migration20200101046100_a_collection_keeps_whole_tours.ts index 635300a1d..da388c2e9 100644 --- a/server/src/db/migrations/Migration20200101046100_a_collection_keeps_whole_tours.ts +++ b/server/src/db/migrations/Migration20200101046100_a_collection_keeps_whole_tours.ts @@ -36,7 +36,6 @@ export class Migration20200101046100_a_collection_keeps_whole_tours extends Migr match_confidence REAL, max_hiking_difficulty INTEGER, bike_profile TEXT, - winter_activity TEXT, run_profile TEXT, run_pace_seconds_per_km INTEGER, ski_discipline TEXT, diff --git a/server/src/db/repositories/CollectionTours.repository.ts b/server/src/db/repositories/CollectionTours.repository.ts index dc7bc1cdf..245112da9 100644 --- a/server/src/db/repositories/CollectionTours.repository.ts +++ b/server/src/db/repositories/CollectionTours.repository.ts @@ -32,7 +32,6 @@ export interface CollectionTourRow { match_confidence: number | null; max_hiking_difficulty: number | null; bike_profile: string | null; - winter_activity: string | null; run_profile: string | null; run_pace_seconds_per_km: number | null; ski_discipline: string | null; diff --git a/server/src/db/repositories/Tours.repository.ts b/server/src/db/repositories/Tours.repository.ts index 0e1b61e0c..442c7ac95 100644 --- a/server/src/db/repositories/Tours.repository.ts +++ b/server/src/db/repositories/Tours.repository.ts @@ -11,7 +11,6 @@ import type { RunProfile, SkiDiscipline, SkiTechnique, - WinterActivity, } from '@trek/shared'; /** @@ -40,12 +39,10 @@ export interface TourListRow { origin: string; /** BIK-01: a bike tour's profile; NULL on every other type. */ bike_profile: string | null; - /** HIK-01: a hike's winter label; NULL for none and on every other type. */ - winter_activity: string | null; /** RUN-01: a run's profile and the pace it is planned at, seconds per km; NULL on every other type. */ run_profile: string | null; run_pace_seconds_per_km: number | null; - /** SKI-02: a ski track's discipline and, for cross-country, its technique; NULL on every other type. */ + /** SKI-02: a ski tour's discipline and, for cross-country, its technique; NULL on every other type. */ ski_discipline: string | null; ski_technique: string | null; boat_type: string | null; @@ -120,7 +117,6 @@ export interface TourRow { taken_over_from_trip_title: string | null; taken_over_from_tour_name: string | null; bike_profile: string | null; - winter_activity: string | null; run_profile: string | null; run_pace_seconds_per_km: number | null; ski_discipline: string | null; @@ -144,12 +140,10 @@ export interface TourInsert { max_hiking_difficulty: number | null; /** BIK-01: a bike tour's profile; left out, NULL. */ bike_profile?: BikeProfile | null; - /** HIK-01: a hike's winter label; left out, NULL on a new tour and kept as it is by an edit. */ - winter_activity?: WinterActivity | null; /** RUN-01: a run's profile and its pace, seconds per km; left out, NULL. */ run_profile?: RunProfile | null; run_pace_seconds_per_km?: number | null; - /** SKI-02: a ski track's discipline and technique; left out, NULL. */ + /** SKI-02: a ski tour's discipline and technique; left out, NULL. */ ski_discipline?: SkiDiscipline | null; ski_technique?: SkiTechnique | null; /** A paddle tour's boat, water and chosen speed; left out, a hike's NULLs. */ @@ -307,7 +301,6 @@ export class ToursRepository extends TrekRepository { 't.derived_from_place_id as derived_from_place_id', 't.origin as origin', 't.bike_profile as bike_profile', - 't.winter_activity as winter_activity', 't.run_profile as run_profile', 't.run_pace_seconds_per_km as run_pace_seconds_per_km', 't.ski_discipline as ski_discipline', @@ -774,7 +767,6 @@ export class ToursRepository extends TrekRepository { match_confidence: input.match_confidence, max_hiking_difficulty: input.max_hiking_difficulty, bike_profile: input.bike_profile ?? null, - winter_activity: input.winter_activity ?? null, run_profile: input.run_profile ?? null, run_pace_seconds_per_km: input.run_pace_seconds_per_km ?? null, ski_discipline: input.ski_discipline ?? null, @@ -876,7 +868,6 @@ export class ToursRepository extends TrekRepository { portage_speed_kmh: input.portage_speed_kmh, paddle_lake_style: input.paddle_lake_style, ...(input.hiking_preferences !== undefined ? { hiking_preferences: input.hiking_preferences } : {}), - ...(input.winter_activity !== undefined ? { winter_activity: input.winter_activity } : {}), }) .where('place_id', '=', place_id) .where('place_id', 'in', (eb) => eb.selectFrom('places').select('id').where('trip_id', '=', trip_id)) diff --git a/server/src/nest/tours/collection-tours.helpers.ts b/server/src/nest/tours/collection-tours.helpers.ts index cead1e41b..5adedc0cc 100644 --- a/server/src/nest/tours/collection-tours.helpers.ts +++ b/server/src/nest/tours/collection-tours.helpers.ts @@ -14,7 +14,6 @@ import { type SkiTechnique, type TourOrigin, type TourTypeKey, - type WinterActivity, } from '@trek/shared'; /** MDT-01: a kept multi-day tour's stages, one more than the stops that end one, as the trip's list counts them. */ @@ -26,8 +25,7 @@ function keptStageCount(row: Pick): number | nul /** * LIB-03: a kept tour as the collection lists it, its measures as they were * saved, null staying null, with what the trip's list shows of its type (the - * winter label, the ski discipline, the run's pace, the bike profile, the - * stages), so a kept tour looks as it did in the trip it came from. + * ski discipline, the run's pace, the bike profile, the stages), so a kept tour looks as it did in the trip it came from. */ export function keptTourItem(row: CollectionTourRow): CollectionTourItem { return { @@ -45,7 +43,6 @@ export function keptTourItem(row: CollectionTourRow): CollectionTourItem { max_hiking_difficulty: row.max_hiking_difficulty, wanderer_ref: row.wanderer_ref, bike_profile: row.bike_profile as BikeProfile | null, - winter_activity: row.winter_activity as WinterActivity | null, run_profile: row.run_profile as RunProfile | null, run_pace_seconds_per_km: row.run_pace_seconds_per_km, ski_discipline: row.ski_discipline as SkiDiscipline | null, diff --git a/server/src/nest/tours/tour-copy.helpers.ts b/server/src/nest/tours/tour-copy.helpers.ts index 311012437..c966d5c6b 100644 --- a/server/src/nest/tours/tour-copy.helpers.ts +++ b/server/src/nest/tours/tour-copy.helpers.ts @@ -10,7 +10,6 @@ import type { SkiDiscipline, SkiTechnique, TourOrigin, - WinterActivity, } from '@trek/shared'; /** The facet columns a copy carries over as its source has them, measures and settings alike. */ @@ -27,7 +26,6 @@ interface TourCopiedColumns { match_confidence: number | null; max_hiking_difficulty: number | null; bike_profile: string | null; - winter_activity: string | null; run_profile: string | null; run_pace_seconds_per_km: number | null; ski_discipline: string | null; @@ -67,7 +65,6 @@ export function copiedColumns(row: TourCopiedColumns): TourCopiedColumns { match_confidence: row.match_confidence, max_hiking_difficulty: row.max_hiking_difficulty, bike_profile: row.bike_profile, - winter_activity: row.winter_activity, run_profile: row.run_profile, run_pace_seconds_per_km: row.run_pace_seconds_per_km, ski_discipline: row.ski_discipline, @@ -168,7 +165,6 @@ export function copiedFacet( ...facet, place_id: placeId, bike_profile: facet.bike_profile as BikeProfile | null, - winter_activity: facet.winter_activity as WinterActivity | null, run_profile: facet.run_profile as RunProfile | null, ski_discipline: facet.ski_discipline as SkiDiscipline | null, ski_technique: facet.ski_technique as SkiTechnique | null, diff --git a/server/src/nest/tours/tour-gpx-export.helpers.ts b/server/src/nest/tours/tour-gpx-export.helpers.ts index 7e345827b..b4deae460 100644 --- a/server/src/nest/tours/tour-gpx-export.helpers.ts +++ b/server/src/nest/tours/tour-gpx-export.helpers.ts @@ -22,12 +22,18 @@ const RUN_ACTIVITY: Record = { trail: 'trail_running', }; -/** SKI-02: a ski track says how it was skied, in the same words; without a known discipline it is skiing. */ +/** + * SKI-02: a ski tour says how it is done, in the same words: snowshoes make it + * snowshoeing, and a winter hike is walked as any hike is. Without a known + * discipline it is skiing. + */ const SKI_ACTIVITY: Record = { alpine: 'alpine_skiing', snowboard: 'snowboarding', cross_country: 'cross_country_skiing', touring: 'backcountry_skiing', + snowshoe: 'snowshoeing', + winter_hike: 'hiking', }; /** The activity a ride names in GPX (HOR-01), in the words Garmin reads. */ @@ -35,9 +41,9 @@ const HORSE_ACTIVITY = 'horseback_riding'; /** * The activity a tour's track names in GPX: a paddle tour its boat, a bike - * tour and a run their profile, a ski track its discipline (SKI-02), a ride - * horseback riding (HOR-01), a hike on snowshoes snowshoeing (HIK-01) and any - * other hike hiking; whatever is missing falls back to the plainest. + * tour and a run their profile, a ski tour its discipline (SKI-02), a ride + * horseback riding (HOR-01) and a hike hiking; whatever is missing falls back + * to the plainest. */ export function gpxActivityOf(tour: { tour_type: string; @@ -45,7 +51,6 @@ export function gpxActivityOf(tour: { bike_profile?: string | null; run_profile?: string | null; ski_discipline?: string | null; - winter_activity?: string | null; }): string { const { tour_type: type, boat_type: boat, bike_profile: bike } = tour; if (type === 'kayak') return PADDLE_ACTIVITY[(boat ?? 'other') as PaddleBoatType] ?? PADDLE_ACTIVITY.other; @@ -53,7 +58,5 @@ export function gpxActivityOf(tour: { if (type === 'run') return RUN_ACTIVITY[(tour.run_profile ?? 'running') as RunProfile] ?? RUN_ACTIVITY.running; if (type === 'ski') return SKI_ACTIVITY[tour.ski_discipline as SkiDiscipline] ?? 'skiing'; if (type === 'horse') return HORSE_ACTIVITY; - if (type !== 'hike') return type; - // A winter hike is walked as any hike is; only snowshoes make it an activity of its own. - return tour.winter_activity === 'snowshoe' ? 'snowshoeing' : 'hiking'; + return type === 'hike' ? 'hiking' : type; } diff --git a/server/src/nest/tours/tour-item.helpers.ts b/server/src/nest/tours/tour-item.helpers.ts index 7289150ad..44c77bdad 100644 --- a/server/src/nest/tours/tour-item.helpers.ts +++ b/server/src/nest/tours/tour-item.helpers.ts @@ -23,7 +23,6 @@ import { type TourListItem, type TourOrigin, type TourProvenanceSource, - type WinterActivity, } from '@trek/shared'; /** MDT-02: the stops a multi-day tour's stages run between; null where its points do not tell them. */ @@ -60,7 +59,6 @@ export function tourItemOf( derived_from_place_id: r.derived_from_place_id, origin: r.origin as TourOrigin, bike_profile: r.bike_profile as BikeProfile | null, - winter_activity: r.winter_activity as WinterActivity | null, run_profile: r.run_profile as RunProfile | null, run_pace_seconds_per_km: r.run_pace_seconds_per_km, ski_discipline: r.ski_discipline as SkiDiscipline | null, diff --git a/server/src/nest/tours/tour-plan-read.ts b/server/src/nest/tours/tour-plan-read.ts index 17480fe2f..661f5943e 100644 --- a/server/src/nest/tours/tour-plan-read.ts +++ b/server/src/nest/tours/tour-plan-read.ts @@ -70,12 +70,17 @@ interface PlanLeg { /** * routed: planned by the router; track: part of a recorded line (an import * without control points of its own), which no router planned; ferrata, free - * and portage: a fixed line taken as it stands. + * and portage: a fixed line taken as it stands. A leg of a planned ski tour + * (SKI-02) is free: the planner draws it straight between its points. */ kind: 'routed' | 'track' | TourFixedLegKind; /** The via ferrata's name where OpenStreetMap gives one. */ name: string | null; - /** Derived: the length of a fixed leg's own line; null for a routed or track leg, whose length is not stored apart. */ + /** + * Derived: the length of a fixed leg's own line, or of the straight line a + * ski tour's leg is drawn as; null for a routed or track leg, whose length is + * not stored apart. + */ length_km: number | null; } @@ -88,6 +93,8 @@ function roundKm(km: number): number { interface PlanLegSource { /** The line is a recorded track (the tour stores no control points of its own), so no router planned it. */ recorded: boolean; + /** SKI-02: the planner drew every leg straight between its points (a planned ski tour), so no router planned one. */ + drawn?: boolean; } /** A fixed leg's line as points of a line: latitude and longitude, no height. */ @@ -95,15 +102,25 @@ function legLine(geometry: TourFixedLeg['geometry']): GeometryPoint[] { return geometry.map(([lat, lng]): GeometryPoint => [lat, lng]); } +/** A leg drawn straight from one control point to the next, as the planner draws a ski tour's. */ +function straightLine(from: TourWaypoint, to: TourWaypoint): GeometryPoint[] { + return [ + [from.lat, from.lng], + [to.lat, to.lng], + ]; +} + /** * The legs between consecutive control points, each saying whether the router - * planned it, it follows a recorded track, or it is a fixed line. + * planned it, it follows a recorded track, or it is a fixed line. A planned + * ski tour's leg is a straight line between its points, as the planner drew it. */ -export function planLegs(points: readonly TourWaypoint[], { recorded }: PlanLegSource): PlanLeg[] { - const unfixed = recorded ? 'track' : 'routed'; +export function planLegs(points: readonly TourWaypoint[], { recorded, drawn = false }: PlanLegSource): PlanLeg[] { + const unfixed = recorded ? 'track' : drawn ? 'free' : 'routed'; return points.slice(1).map((to, index) => { const from = points[index] as TourWaypoint; const leg = from.fixed_leg ?? null; + const line = leg ? legLine(leg.geometry) : unfixed === 'free' ? straightLine(from, to) : null; return { from_sequence: from.sequence, to_sequence: to.sequence, @@ -111,7 +128,7 @@ export function planLegs(points: readonly TourWaypoint[], { recorded }: PlanLegS to_key: to.key ?? null, kind: leg?.kind ?? unfixed, name: leg?.kind === 'ferrata' ? leg.name : null, - length_km: leg ? roundKm(computeTourMetrics(legLine(leg.geometry)).distanceKm) : null, + length_km: line ? roundKm(computeTourMetrics(line).distanceKm) : null, }; }); } diff --git a/server/src/nest/tours/tour-write.helpers.ts b/server/src/nest/tours/tour-write.helpers.ts index 553681bd5..007aa5ab9 100644 --- a/server/src/nest/tours/tour-write.helpers.ts +++ b/server/src/nest/tours/tour-write.helpers.ts @@ -18,7 +18,6 @@ import { tourStageIssues, tourWebsiteSchema, type TourWaypoint, - type WinterActivity, } from '@trek/shared'; import { randomUUID } from 'node:crypto'; @@ -30,12 +29,16 @@ function hikingFields(input: TourCreateCommand): string | null { } /** - * HIK-01: a save's winter label as stored. Only a hike keeps one; another type - * takes it off. A hike's save that leaves the field out (a client from before - * the label) keeps the one the tour has, rather than taking it off unasked. + * SKI-02: a save's ski discipline and technique as stored. Only a ski tour + * keeps them, the technique only for cross-country; another type takes them off. */ -function winterField(input: TourCreateCommand): WinterActivity | null | undefined { - return input.tour_type === 'hike' ? input.winter_activity : null; +function skiFields(input: TourCreateCommand): Pick { + if (input.tour_type !== 'ski') return { ski_discipline: null, ski_technique: null }; + const discipline = input.ski_discipline ?? null; + return { + ski_discipline: discipline, + ski_technique: discipline === 'cross_country' ? (input.ski_technique ?? null) : null, + }; } /** @@ -87,11 +90,8 @@ export function tourRouteFields(input: TourCreateCommand): TourUpdate { match_confidence: 1, max_hiking_difficulty: input.max_hiking_difficulty, bike_profile: input.tour_type === 'bike' ? (input.bike_profile ?? null) : null, - winter_activity: winterField(input), ...runFields(input), - // SKI-02: TREK plans no ski track, so a planned save is never one and takes its classification off. - ski_discipline: null, - ski_technique: null, + ...skiFields(input), boat_type: input.boat_type ?? null, water_context: input.water_context ?? null, paddle_speed_kmh: input.paddle_speed_kmh ?? null, diff --git a/server/src/nest/tours/tours.mcp.ts b/server/src/nest/tours/tours.mcp.ts index 0fed48e6a..af7bd0fea 100644 --- a/server/src/nest/tours/tours.mcp.ts +++ b/server/src/nest/tours/tours.mcp.ts @@ -61,7 +61,8 @@ function planOf({ detail, days, line, names }: PlanRead, run: WalkProgress | nul waypoints: planPoints(detail.tour.tour_type, named, run), // ACT-01: the recorded tracks assigned to the tour, with their values and without their lines. activities: (detail.activities ?? []).map(withoutLine), - legs: planLegs(detail.waypoints, { recorded }), + // SKI-02: a planned ski tour's legs are drawn straight between its points; no router planned them. + legs: planLegs(detail.waypoints, { recorded, drawn: detail.tour.tour_type === 'ski' }), line: saved, ...(isWalk ? { run: run ? planRun(run) : null } : {}), derived: derivedFields(detail, { controlsFromTrack, line: saved, run }), @@ -101,7 +102,7 @@ export class ToursMcp { @Tool({ name: 'list_tours', description: - 'List the saved tours of a trip (hikes, city walks, runs, bike tours, paddle tours, rides on horseback and ski tracks planned or imported in the Tours addon): name, type (hike, walk for a city walk, run, bike, kayak for a paddle tour, horse for a ride, or ski for a ski track; a ride has no riding time worked out, so its duration is null), where it came from (origin: trek, gpx for an imported GPX, KML or KMZ file, or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_climbs for gentler ways, avoid_steps, avoid_ferries; weightings, never a grade of the path), the profile a bike tour is routed for (bike_profile: touring for paved cycle paths and quiet roads, road for asphalt, gravel for gravel and farm tracks, mtb for rough tracks; a routing preference, never a statement that a way is rideable or safe), the winter label of a hike (winter_activity: winter_hike or snowshoe, null for none; a label the travellers chose, while the route and its time are planned as for any hike, never a statement that a way can be walked in winter or anything about snow or avalanches), the profile and pace of a run (run_profile: running for runnable, mostly paved ways in towns and parks, trail for forest tracks and natural paths up to mountain paths (T2); a routing preference, never a statement that a way can be run; run_pace_seconds_per_km, the pace the runners planned with, null while they entered none; its duration is the distance at that pace and nothing else, no climb, descent or difficulty is in it, and it is a plan, never a recorded time), the classification of a ski track (ski_discipline: alpine, snowboard, cross_country or touring for ski touring; ski_technique for cross-country only: classic, skating or unknown when the travellers did not know; a ski track is imported from a GPX file and kept as recorded, never routed, its distance and heights come from the file, it has no ski time worked out, and nothing in it says anything about snow, avalanches or whether a run or trail is open), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), for a city walk the number of its stations (station_count: its points without the shaping points and the way back of a loop), their stays as far as entered (station_stay_minutes) and how many have none (station_stays_unknown), whether a tour still waits for its way to be planned (route_pending: saved with its points only, so its distance and walking time stay null until someone opens it in the planner and saves it), for a multi-day tour the number of stages its route is split into (stage_count: one route planned whole, each stage ending at a stop marked as its end; null for a tour of one day) and the stops they run between (stage_stops, in route order with key and coordinates: stage n runs from stop n-1 to stop n, and is known by the key of the stop it starts at), the revision (raised by every saved change of the tour), the recorded tracks assigned to a tour (activity_tracks) and, for a recorded track, the tour it documents (activity_of), when an imported track was recorded (track_started_at, track_ended_at; null when the file carried no times), and the days of the trip each tour is on (tour_stage_key names the stage a day holds by its starting stop, null where the day holds the whole tour; a stage may also be on no day yet).', + 'List the saved tours of a trip (hikes, city walks, runs, bike tours, paddle tours, rides on horseback and ski and winter tours planned or imported in the Tours addon): name, type (hike, walk for a city walk, run, bike, kayak for a paddle tour, horse for a ride, or ski for a ski and winter tour; a ride and a ski tour have no time worked out, so their duration is null), where it came from (origin: trek, gpx for an imported GPX, KML or KMZ file, or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_climbs for gentler ways, avoid_steps, avoid_ferries; weightings, never a grade of the path), the profile a bike tour is routed for (bike_profile: touring for paved cycle paths and quiet roads, road for asphalt, gravel for gravel and farm tracks, mtb for rough tracks; a routing preference, never a statement that a way is rideable or safe), the profile and pace of a run (run_profile: running for runnable, mostly paved ways in towns and parks, trail for forest tracks and natural paths up to mountain paths (T2); a routing preference, never a statement that a way can be run; run_pace_seconds_per_km, the pace the runners planned with, null while they entered none; its duration is the distance at that pace and nothing else, no climb, descent or difficulty is in it, and it is a plan, never a recorded time), the discipline of a ski tour (ski_discipline: alpine, snowboard, cross_country, touring for ski touring, snowshoe or winter_hike; snowshoeing and winter hiking are ski tours, a hike is summer only; ski_technique for cross-country only: classic, skating or unknown when the travellers did not know; a planned ski tour has every leg drawn straight between its points, never routed along pistes, lifts or trails, an imported one is kept as recorded; its distance and heights come from that line or the file, it has no ski time worked out, and nothing in it says anything about snow, avalanches or whether a run or trail is open), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), for a city walk the number of its stations (station_count: its points without the shaping points and the way back of a loop), their stays as far as entered (station_stay_minutes) and how many have none (station_stays_unknown), whether a tour still waits for its way to be planned (route_pending: saved with its points only, so its distance and walking time stay null until someone opens it in the planner and saves it), for a multi-day tour the number of stages its route is split into (stage_count: one route planned whole, each stage ending at a stop marked as its end; null for a tour of one day) and the stops they run between (stage_stops, in route order with key and coordinates: stage n runs from stop n-1 to stop n, and is known by the key of the stop it starts at), the revision (raised by every saved change of the tour), the recorded tracks assigned to a tour (activity_tracks) and, for a recorded track, the tour it documents (activity_of), when an imported track was recorded (track_started_at, track_ended_at; null when the file carried no times), and the days of the trip each tour is on (tour_stage_key names the stage a day holds by its starting stop, null where the day holds the whole tour; a stage may also be on no day yet).', inputSchema: { tripId: idSchema }, annotations: TOOL_ANNOTATIONS_READONLY, when: addonGate(ADDON_IDS.TOURS), @@ -116,7 +117,7 @@ export class ToursMcp { @Tool({ name: 'get_tour_plan', description: - 'Read one saved tour of a trip as the travellers saved it: its facts (tour: place_id, name, tour_type, distance, heights and walking time as stored, null when not known; the routing options as list_tours describes them; revision, raised by every saved change; the days it is on), its ordered control points (start, via and end, each with its sequence and its key, the same across saves; an import without its own shows the two ends of its track), where it came from (provenance) and its legs. Drafts in a browser are never visible: only saved tours exist here, and reading changes nothing, not the tour, its revision nor any visit. A control point with linked_place_id stands for that place of the trip (linked_place_name names it); it keeps its own coordinates. A control point with stage_end true ends a stage of a multi-day tour, and the next stage begins there; the stages run in the order of the route, the first from the start, the last to the end, and a tour without such a point is a tour of one day. legs lists every step from one control point to the next (from_sequence, to_sequence, from_key, to_key) with its kind: routed (planned by the router), track (part of a recorded line, an import without control points of its own, which no router planned), ferrata (follows a via ferrata, name gives it), free (drawn by hand and checked by nothing), portage (a paddle tour carried over land) or horse_route (a ride only: a section of a signposted horse route from OpenStreetMap in the direction the rider chose; the fixed_leg of its control point says signposted, with or against the direction the route is signposted in, null where it is not signposted one way, and access, the metres of each horse=* value its ways carried when it was taken, unknown where untagged; being on a horse route is no permission to ride it); length_km is the length of the line a fixed leg keeps and null for a routed or track leg, whose length is not stored apart. line gives the saved line in brief: point_count, has_heights and a preview thinned to 200 points; null while the tour waits for its way to be planned. A paddle tour also carries its obstacle checklist (obstacle_check): the obstacles of the OpenStreetMap data along its line, the decisions the travellers made about them and their own portages and locks; a planning aid, never a safety or legal clearance. A city walk (tour_type walk) has stations for control points, shaping points and the way back of a loop aside, each with every field spelled out: key (the visits of a day are kept by it), name, stay_minutes (the stay in minutes; 0 for a point only passed through, null when not known), importance (optional or must; null for a normal station), protected (true for a station any change you propose has to keep where it is: neither removed, replaced nor moved; the travellers can still edit it themselves in the app), opening_hours (as OpenStreetMap writes them; a hint to check, never a promise; null when none is known) and visit_status. Visits belong to one day of the walk: name that day with dayId (one of its days) to read the status of each station that day (planned, visited or skipped) and run (the day, its assignment and what is still open); without dayId visit_status and run are null. A station visited or skipped that day is done: do not ask about it again or move it to another day. dayId is for a city walk only. The recorded tracks assigned to the tour (activities, ACT-01) each name their execution (assignment_id and day_id, null for none), the recording (track: its tour in the trip, origin, file name, when it came in) and what the recording measures (metrics: distance_km, elevation_gain_m and elevation_loss_m, null without heights; started_at, ended_at and elapsed_minutes from the first to the last time on its points, pauses included, null when the file carried no times, never estimated); these are the values of the recording, the planned values of the tour stay apart and unchanged. derived names the fields worked out on reading rather than stored with the tour: never present them as saved facts.', + 'Read one saved tour of a trip as the travellers saved it: its facts (tour: place_id, name, tour_type, distance, heights and walking time as stored, null when not known; the routing options as list_tours describes them; revision, raised by every saved change; the days it is on), its ordered control points (start, via and end, each with its sequence and its key, the same across saves; an import without its own shows the two ends of its track), where it came from (provenance) and its legs. Drafts in a browser are never visible: only saved tours exist here, and reading changes nothing, not the tour, its revision nor any visit. A control point with linked_place_id stands for that place of the trip (linked_place_name names it); it keeps its own coordinates. A control point with stage_end true ends a stage of a multi-day tour, and the next stage begins there; the stages run in the order of the route, the first from the start, the last to the end, and a tour without such a point is a tour of one day. legs lists every step from one control point to the next (from_sequence, to_sequence, from_key, to_key) with its kind: routed (planned by the router), track (part of a recorded line, an import without control points of its own, which no router planned), ferrata (follows a via ferrata, name gives it), free (drawn by hand and checked by nothing; every leg of a planned ski tour is free, a straight line between its points), portage (a paddle tour carried over land) or horse_route (a ride only: a section of a signposted horse route from OpenStreetMap in the direction the rider chose; the fixed_leg of its control point says signposted, with or against the direction the route is signposted in, null where it is not signposted one way, and access, the metres of each horse=* value its ways carried when it was taken, unknown where untagged; being on a horse route is no permission to ride it); length_km is the length of the line a fixed leg keeps (for a ski tour's leg the straight line) and null for a routed or track leg, whose length is not stored apart. line gives the saved line in brief: point_count, has_heights and a preview thinned to 200 points; null while the tour waits for its way to be planned. A paddle tour also carries its obstacle checklist (obstacle_check): the obstacles of the OpenStreetMap data along its line, the decisions the travellers made about them and their own portages and locks; a planning aid, never a safety or legal clearance. A city walk (tour_type walk) has stations for control points, shaping points and the way back of a loop aside, each with every field spelled out: key (the visits of a day are kept by it), name, stay_minutes (the stay in minutes; 0 for a point only passed through, null when not known), importance (optional or must; null for a normal station), protected (true for a station any change you propose has to keep where it is: neither removed, replaced nor moved; the travellers can still edit it themselves in the app), opening_hours (as OpenStreetMap writes them; a hint to check, never a promise; null when none is known) and visit_status. Visits belong to one day of the walk: name that day with dayId (one of its days) to read the status of each station that day (planned, visited or skipped) and run (the day, its assignment and what is still open); without dayId visit_status and run are null. A station visited or skipped that day is done: do not ask about it again or move it to another day. dayId is for a city walk only. The recorded tracks assigned to the tour (activities, ACT-01) each name their execution (assignment_id and day_id, null for none), the recording (track: its tour in the trip, origin, file name, when it came in) and what the recording measures (metrics: distance_km, elevation_gain_m and elevation_loss_m, null without heights; started_at, ended_at and elapsed_minutes from the first to the last time on its points, pauses included, null when the file carried no times, never estimated); these are the values of the recording, the planned values of the tour stay apart and unchanged. derived names the fields worked out on reading rather than stored with the tour: never present them as saved facts.', inputSchema: { tripId: idSchema, placeId: idSchema.describe('The place id of the tour, as list_tours gives it'), diff --git a/server/src/nest/tours/tours.service.ts b/server/src/nest/tours/tours.service.ts index d5e833bbb..62d9d1c1c 100644 --- a/server/src/nest/tours/tours.service.ts +++ b/server/src/nest/tours/tours.service.ts @@ -841,7 +841,6 @@ export class ToursService { route_missing: 0, revision: 1, hiking_preferences: null, - winter_activity: null, track_started_at: recorded?.started ?? null, track_ended_at: recorded?.ended ?? null, }), diff --git a/server/tests/e2e/tours-run.e2e.test.ts b/server/tests/e2e/tours-run.e2e.test.ts index 3f665b982..e8d0cf7cf 100644 --- a/server/tests/e2e/tours-run.e2e.test.ts +++ b/server/tests/e2e/tours-run.e2e.test.ts @@ -5,7 +5,7 @@ * with a pace out of bounds, copied, exported and imported, through the real * JwtAuthGuard, AddonGuard and TripAccessGuard against a migrated and seeded * temp SQLite db. The permission check is a container spy and the broadcast - * goes to a FakeRealtimeService, as in tours-winter.e2e.test.ts. + * goes to a FakeRealtimeService, as in tours-ski.e2e.test.ts. * * Fixtures: user 1 owns trip 5, user 2 is a member of trip 5 whom the * permission spy denies 'place_edit'. diff --git a/server/tests/e2e/tours-ski.e2e.test.ts b/server/tests/e2e/tours-ski.e2e.test.ts index 3ca97768b..b66f47c23 100644 --- a/server/tests/e2e/tours-ski.e2e.test.ts +++ b/server/tests/e2e/tours-ski.e2e.test.ts @@ -1,8 +1,9 @@ /** - * Tours e2e for ski tracks (SKI-02): a GPX file imported as a ski track with + * Tours e2e for ski tours (SKI-02): a GPX file imported as a ski track with * its discipline (and a cross-country technique), kept as recorded with no * time worked out, listed, opened, exported, copied and classified again - * without touching the track, its days or its provenance, through the real + * without touching the track, its days or its provenance; a ski tour planned + * with its discipline, drawn and untimed; through the real * JwtAuthGuard, AddonGuard and TripAccessGuard against a migrated and seeded * temp SQLite db. The permission check is a container spy and the broadcast * goes to a FakeRealtimeService, as in tours-run.e2e.test.ts. @@ -75,7 +76,7 @@ const GPX = ` 1980 `; -// A planned hike, the type a ski track is not. +// A planned hike; a planned ski tour is the same body as type ski with its discipline and no time. const hikeBody = { name: 'Ridge walk', tour_type: 'hike', @@ -223,13 +224,66 @@ describe('Ski tracks e2e (real guards + temp SQLite)', () => { expect(await countRows(orm, Places)).toBe(0); }); - it('TOURS-SKI-E2E-004: a ski track is never planned: a planned save of one is refused', async () => { + it('TOURS-SKI-E2E-004: a planned ski tour saves with its discipline, drawn and untimed, and never without one', async () => { + const planned = { ...hikeBody, name: 'Snowshoes to the hut', tour_type: 'ski', max_hiking_difficulty: null }; + const refused = await oneByOne( + [ + { ...planned, duration_seconds: null }, + { ...planned, duration_seconds: null, ski_discipline: 'cross_country' }, + { ...planned, duration_seconds: null, ski_discipline: 'alpine', ski_technique: 'classic' }, + { ...planned, ski_discipline: 'snowshoe' }, + { ...planned, duration_seconds: null, ski_discipline: 'snowshoe', max_hiking_difficulty: 3 }, + ], + (body) => request(server).post('/api/trips/5/tours').set('Cookie', sessionCookie(OWNER)).send(body), + ); + expect(refused.map((res) => res.status)).toEqual([400, 400, 400, 400, 400]); + expect(await countRows(orm, Tours)).toBe(0); + const res = await request(server) .post('/api/trips/5/tours') .set('Cookie', sessionCookie(OWNER)) - .send({ ...hikeBody, tour_type: 'ski', max_hiking_difficulty: null }); - expect(res.status).toBe(400); - expect(await countRows(orm, Tours)).toBe(0); + .send({ ...planned, duration_seconds: null, ski_discipline: 'snowshoe' }); + expect(res.status).toBe(201); + expect(res.body.tour).toMatchObject({ + tour_type: 'ski', + ski_discipline: 'snowshoe', + ski_technique: null, + max_hiking_difficulty: null, + duration: null, + origin: 'trek', + }); + expect(res.body.tour.elevation_gain).toBeGreaterThan(0); + }); + + it('TOURS-SKI-E2E-010: a planned cross-country tour keeps its technique, exports as its discipline, and a hike takes both off', async () => { + const planned = { ...hikeBody, tour_type: 'ski', max_hiking_difficulty: null, duration_seconds: null }; + const created = await request(server) + .post('/api/trips/5/tours') + .set('Cookie', sessionCookie(OWNER)) + .send({ ...planned, ski_discipline: 'cross_country', ski_technique: 'skating' }); + expect(created.status).toBe(201); + const placeId = created.body.tour.place_id as number; + expect(created.body.tour).toMatchObject({ ski_discipline: 'cross_country', ski_technique: 'skating' }); + + const exported = await request(server) + .get(`/api/trips/5/tours/${placeId}/export.gpx`) + .set('Cookie', sessionCookie(OWNER)) + .buffer(true) + .parse((response, done) => { + let text = ''; + response.setEncoding('utf8'); + response.on('data', (chunk: string) => (text += chunk)); + response.on('end', () => done(null, text)); + }); + expect(/([^<]+)<\/type>/.exec(String(exported.body))?.[1]).toBe('cross_country_skiing'); + + const asHike = await request(server) + .put(`/api/trips/5/tours/${placeId}`) + .set('Cookie', sessionCookie(OWNER)) + .send({ ...hikeBody, expected_revision: created.body.tour.revision }); + expect(asHike.status).toBe(200); + expect(asHike.body.tour).toMatchObject({ tour_type: 'hike', ski_discipline: null, ski_technique: null }); + expect(await findRow(orm, Tours, { place: placeId })).toMatchObject({ ski_discipline: null, ski_technique: null }); }); it('TOURS-SKI-E2E-005: classified again, only the classification changes: track, measures, days and provenance stay', async () => { diff --git a/server/tests/e2e/tours-winter.e2e.test.ts b/server/tests/e2e/tours-winter.e2e.test.ts deleted file mode 100644 index 44f06f228..000000000 --- a/server/tests/e2e/tours-winter.e2e.test.ts +++ /dev/null @@ -1,248 +0,0 @@ -/** - * Tours e2e for the winter label of a hike (HIK-01): saved, listed, kept by an - * edit that leaves it out, taken off without touching the route or the days, - * refused on any other type, copied and exported, through the real - * JwtAuthGuard, AddonGuard and TripAccessGuard against a migrated and seeded - * temp SQLite db. The permission check is a container spy and the broadcast - * goes to a FakeRealtimeService, as in tours-bike.e2e.test.ts. - * - * Fixtures: user 1 owns trip 5, user 2 is a member of trip 5 whom the - * permission spy denies 'place_edit'. - */ -import { DayAssignments } from '../../src/db/entities/DayAssignments.entity'; -import { Days } from '../../src/db/entities/Days.entity'; -import { Places } from '../../src/db/entities/Places.entity'; -import { Tours } from '../../src/db/entities/Tours.entity'; -import { Trips } from '../../src/db/entities/Trips.entity'; -import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; -import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; -import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service'; -import { MapsService } from '../../src/nest/maps/maps.service'; -import { OverpassElementsClient } from '../../src/nest/overpass/overpass-elements.service'; -import { PermissionsService } from '../../src/nest/permissions/permissions.service'; -import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; -import { RealtimeService } from '../../src/nest/realtime/realtime.service'; -import { ToursModule } from '../../src/nest/tours/tours.module'; -import { createSnapshotTestDb } from '../helpers/db-mock'; -import { countRows, deleteRows, findRow, insertRows } from '../helpers/factories/rows'; -import { setAddonEnabled } from '../helpers/factories/settings'; -import { addTripMember } from '../helpers/factories/trips'; -import { makeUser } from '../helpers/factories/users'; -import { FakeRealtimeService } from '../helpers/fake-realtime'; -import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; -import { TestUnitOfWorkModule } from '../helpers/test-uow'; -import { sessionCookie } from './harness'; -import { Test } from '@nestjs/testing'; - -import cookieParser from 'cookie-parser'; -import type { Server } from 'http'; -import request from 'supertest'; -import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest'; - -vi.mock('../../src/nest/audit/audit-log.logger', () => ({ - LOG_LEVEL: 'error', - logInfo: vi.fn(), - logDebug: vi.fn(), - logError: vi.fn(), - logWarn: vi.fn(), -})); - -const { journeyHooks } = vi.hoisted(() => ({ - journeyHooks: { - reconcileTripSkeletons: vi.fn().mockResolvedValue(undefined), - onPlaceCreated: vi.fn().mockResolvedValue(undefined), - onPlaceUpdated: vi.fn().mockResolvedValue(undefined), - onPlaceDeleted: vi.fn().mockResolvedValue(undefined), - }, -})); - -const db = createSnapshotTestDb(); -const realtime = new FakeRealtimeService(); - -let orm: TestOrm; -let checkPermission: MockInstance; - -const OWNER = 1; -const VIEWER = 2; - -const GEOMETRY = [ - [47, 11, 1200], - [47.01, 11.01, 1350], - [47.02, 11.02, 1300], -]; -const hikeBody = (overrides: Record = {}) => ({ - name: 'Snowy ridge', - tour_type: 'hike', - max_hiking_difficulty: 2, - winter_activity: 'snowshoe', - route_geometry: GEOMETRY, - waypoints: [ - { lat: 47, lng: 11, role: 'start', sequence: 0 }, - { lat: 47.02, lng: 11.02, role: 'end', sequence: 1 }, - ], - duration_seconds: 5400, - ...overrides, -}); - -describe('Winter hikes e2e (real guards + temp SQLite)', () => { - let server: Server; - let app: Awaited>; - - async function build() { - const moduleRef = await Test.createTestingModule({ - imports: [ - await TestUnitOfWorkModule.forRoot(db), - await createTestMikroOrmModule(db), - RealtimeModule, - ToursModule, - ], - }) - .overrideProvider(RealtimeService) - .useValue(realtime) - .overrideProvider(JourneyDomainService) - .useValue(journeyHooks) - .overrideProvider(MapsService) - .useValue({ pois: vi.fn() }) - // No network in a test: the trail grades read OpenStreetMap through Overpass. - .overrideProvider(OverpassElementsClient) - .useValue({ query: vi.fn() }) - .compile(); - const nest = moduleRef.createNestApplication(); - nest.use(cookieParser()); - nest.useGlobalPipes(new ZodValidationPipe()); - nest.useGlobalFilters(new TrekExceptionFilter()); - await nest.init(); - return nest; - } - - const send = (method: 'post' | 'put', path: string, body: Record, userId = OWNER) => - request(server)[method](`/api/trips/5/tours${path}`).set('Cookie', sessionCookie(userId)).send(body); - - const createViaApi = async (body: Record = hikeBody()) => { - const res = await send('post', '', body); - expect(res.status).toBe(201); - return res.body.tour.place_id as number; - }; - - const gpxTypeOf = async (placeId: number) => { - const res = await request(server) - .get(`/api/trips/5/tours/${placeId}/export.gpx`) - .set('Cookie', sessionCookie(OWNER)) - .buffer(true) - .parse((res, done) => { - let text = ''; - res.setEncoding('utf8'); - res.on('data', (chunk: string) => (text += chunk)); - res.on('end', () => done(null, text)); - }); - return /([^<]+)<\/type>/.exec(String(res.body))?.[1] ?? ''; - }; - - beforeAll(async () => { - orm = await createTestOrm(db); - // Pinned ids: the session cookies sign for exactly these users. - await makeUser(orm, { id: 1, username: 'owner', email: 'owner@example.test', role: 'user', password_version: 0 }); - await makeUser(orm, { id: 2, username: 'viewer', email: 'viewer@example.test', role: 'user', password_version: 0 }); - await insertRows(orm, Trips, [{ id: 5, user: 1, title: 'Winter week' }]); - await addTripMember(orm, 5, 2); - await insertRows(orm, Days, [{ id: 3, trip: 5, day_number: 1, date: '2027-01-10' }]); - app = await build(); - checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission'); - server = app.getHttpServer(); - }); - - beforeEach(async () => { - checkPermission.mockImplementation( - (action: string, _role: string, _owner: number, userId: number) => - !(action === 'place_edit' && userId === VIEWER), - ); - await setAddonEnabled(orm, 'tours', true); - realtime.broadcastMock.mockClear(); - await deleteRows(orm, DayAssignments); - await deleteRows(orm, Places); - }); - - afterAll(async () => { - await app.close(); - await orm.close(); - }); - - it('TOURS-WINTER-E2E-001: saves a hike with its winter label, and lists and opens it so', async () => { - const placeId = await createViaApi(); - expect(await findRow(orm, Tours, { place: placeId })).toMatchObject({ - tour_type: 'hike', - winter_activity: 'snowshoe', - max_hiking_difficulty: 2, - }); - const list = await request(server).get('/api/trips/5/tours').set('Cookie', sessionCookie(VIEWER)); - expect(list.status).toBe(200); - expect(list.body.tours[0]).toMatchObject({ place_id: placeId, winter_activity: 'snowshoe' }); - const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER)); - expect(detail.body.tour).toMatchObject({ tour_type: 'hike', winter_activity: 'snowshoe' }); - }); - - it('TOURS-WINTER-E2E-002: a hike saved without one carries none, as every hike did before', async () => { - const placeId = await createViaApi(hikeBody({ winter_activity: undefined })); - expect((await findRow(orm, Tours, { place: placeId }))?.winter_activity).toBeNull(); - }); - - it('TOURS-WINTER-E2E-003: an edit leaving it out keeps it; taking it off keeps the route and the days', async () => { - const placeId = await createViaApi(); - await insertRows(orm, DayAssignments, [{ day: 3, place: placeId, order_index: 0 }]); - - const kept = await send('put', `/${placeId}`, hikeBody({ winter_activity: undefined, expected_revision: 1 })); - expect(kept.status).toBe(200); - expect(kept.body.tour).toMatchObject({ winter_activity: 'snowshoe', revision: 2 }); - - const off = await send('put', `/${placeId}`, hikeBody({ winter_activity: null, expected_revision: 2 })); - expect(off.status).toBe(200); - expect(off.body.tour).toMatchObject({ winter_activity: null, tour_type: 'hike', planned: true }); - expect(off.body.waypoints).toHaveLength(2); - expect(await countRows(orm, DayAssignments, { place: placeId })).toBe(1); - expect((await findRow(orm, Places, { id: placeId }))?.route_geometry).toBe(JSON.stringify(GEOMETRY)); - }); - - it('TOURS-WINTER-E2E-004: refuses a label it does not know, and one on another type', async () => { - const refused = await Promise.all( - [ - hikeBody({ winter_activity: 'ski_tour' }), - hikeBody({ tour_type: 'bike', bike_profile: 'mtb', max_hiking_difficulty: undefined }), - hikeBody({ tour_type: 'walk', max_hiking_difficulty: undefined, winter_activity: 'winter_hike' }), - ].map((body) => send('post', '', body)), - ); - expect(refused.map((res) => res.status)).toEqual([400, 400, 400]); - expect(await countRows(orm, Tours)).toBe(0); - }); - - it('TOURS-WINTER-E2E-005: a hike made into a bike tour loses its label', async () => { - const placeId = await createViaApi(); - const bike = await send( - 'put', - `/${placeId}`, - hikeBody({ - tour_type: 'bike', - bike_profile: 'touring', - max_hiking_difficulty: undefined, - winter_activity: undefined, - }), - ); - expect(bike.status).toBe(200); - expect(bike.body.tour).toMatchObject({ tour_type: 'bike', winter_activity: null }); - }); - - it('TOURS-WINTER-E2E-006: a member who may only read cannot save one', async () => { - expect((await send('post', '', hikeBody(), VIEWER)).status).toBe(403); - }); - - it('TOURS-WINTER-E2E-007: a copy keeps the label', async () => { - const placeId = await createViaApi(hikeBody({ winter_activity: 'winter_hike' })); - const copy = await send('post', `/${placeId}/copy`, { name: 'Again' }); - expect(copy.status).toBe(201); - expect(copy.body.tour).toMatchObject({ tour_type: 'hike', winter_activity: 'winter_hike' }); - }); - - it('TOURS-WINTER-E2E-008: exports a snowshoe hike as snowshoeing, a winter hike as hiking', async () => { - expect(await gpxTypeOf(await createViaApi())).toBe('snowshoeing'); - expect(await gpxTypeOf(await createViaApi(hikeBody({ winter_activity: 'winter_hike' })))).toBe('hiking'); - }); -}); diff --git a/server/tests/unit/db/legacy-baseline.test.ts b/server/tests/unit/db/legacy-baseline.test.ts index cced189b1..9af0e2c32 100644 --- a/server/tests/unit/db/legacy-baseline.test.ts +++ b/server/tests/unit/db/legacy-baseline.test.ts @@ -66,7 +66,6 @@ const UNNUMBERED = [ 'Migration20200101045200_a_tour_can_be_ridden', 'Migration20200101045300_a_tour_can_be_a_city_walk', 'Migration20200101045400_a_tour_waypoint_remembers_the_kind_of_place_it_was_found_as', - 'Migration20200101045500_a_hike_can_be_marked_as_a_winter_activity', 'Migration20200101045510_a_tour_can_be_a_run', 'Migration20200101045520_a_tour_can_be_a_ski_track', 'Migration20200101045600_a_tour_can_be_ridden_on_horseback', diff --git a/server/tests/unit/db/repositories/Tours.repository.test.ts b/server/tests/unit/db/repositories/Tours.repository.test.ts index f18f50440..780ef73ea 100644 --- a/server/tests/unit/db/repositories/Tours.repository.test.ts +++ b/server/tests/unit/db/repositories/Tours.repository.test.ts @@ -110,7 +110,6 @@ describe('ToursRepository reads', () => { revision: 1, hiking_preferences: null, bike_profile: null, - winter_activity: null, run_profile: null, run_pace_seconds_per_km: null, ski_discipline: null, @@ -150,7 +149,6 @@ describe('ToursRepository reads', () => { revision: 1, hiking_preferences: null, bike_profile: null, - winter_activity: null, run_profile: null, run_pace_seconds_per_km: null, ski_discipline: null, @@ -379,7 +377,6 @@ describe('ToursRepository writes', () => { revision: 1, hiking_preferences: null, bike_profile: null, - winter_activity: null, run_profile: null, run_pace_seconds_per_km: null, ski_discipline: null, @@ -428,20 +425,19 @@ describe('ToursRepository writes', () => { }); }); - it('TOURREPO-022: a hike keeps its winter label through an edit that leaves it out, and loses it only when told (HIK-01)', async () => { + it('TOURREPO-022: a planned ski tour keeps snowshoeing or winter hiking as its discipline, and a hike takes it off (SKI-02)', async () => { const { trip } = fixture(); const place = createPlace(testDb, trip.id, { name: 'Snowy ridge' }); - await repo.insertTour({ place_id: place.id, ...METRICS, winter_activity: 'snowshoe' }); - expect((await repo.findInTrip(trip.id, place.id))?.winter_activity).toBe('snowshoe'); + const ski = { ...METRICS, tour_type: 'ski', max_hiking_difficulty: null, duration: null }; + await repo.insertTour({ place_id: place.id, ...ski, ski_discipline: 'snowshoe' }); + expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({ tour_type: 'ski', ski_discipline: 'snowshoe' }); + + const winterHike = { ...HIKE_UPDATE, ...ski, ski_discipline: 'winter_hike' as const }; + expect(await repo.updateInTrip(trip.id, place.id, winterHike)).toBe(true); + expect((await repo.listForTrip(trip.id))[0]).toMatchObject({ tour_type: 'ski', ski_discipline: 'winter_hike' }); expect(await repo.updateInTrip(trip.id, place.id, HIKE_UPDATE)).toBe(true); - expect((await repo.findInTrip(trip.id, place.id))?.winter_activity).toBe('snowshoe'); - - expect(await repo.updateInTrip(trip.id, place.id, { ...HIKE_UPDATE, winter_activity: 'winter_hike' })).toBe(true); - expect((await repo.listForTrip(trip.id))[0]?.winter_activity).toBe('winter_hike'); - - expect(await repo.updateInTrip(trip.id, place.id, { ...HIKE_UPDATE, winter_activity: null })).toBe(true); - expect((await repo.findInTrip(trip.id, place.id))?.winter_activity).toBeNull(); + expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({ ski_discipline: null, ski_technique: null }); }); it('TOURREPO-023: a run keeps its profile and pace, an edit replaces them, and another type takes them off (RUN-01)', async () => { @@ -537,7 +533,6 @@ describe('ToursRepository writes', () => { revision: 1, hiking_preferences: null, bike_profile: null, - winter_activity: null, run_profile: null, run_pace_seconds_per_km: null, ski_discipline: null, diff --git a/server/tests/unit/db/tour-ski-columns.test.ts b/server/tests/unit/db/tour-ski-columns.test.ts index f5fec2475..a8d549307 100644 --- a/server/tests/unit/db/tour-ski-columns.test.ts +++ b/server/tests/unit/db/tour-ski-columns.test.ts @@ -1,7 +1,8 @@ /** * SKI-02: `Migration20200101045520_a_tour_can_be_a_ski_track` switches the ski - * type on, without a routing profile, and gives a tour its ski discipline and - * cross-country technique. Both columns hold a CHECK list the entity carries + * type on, without a routing profile, and gives a tour its ski discipline (six + * of them, snowshoeing and winter hiking among them) and cross-country + * technique. Both columns hold a CHECK list the entity carries * as its enum and the contract holds too: a value one side takes and the * other refuses would be a ski track the import accepts and the database * cannot store. @@ -56,6 +57,9 @@ describe('ski columns match the contract', () => { orm, "INSERT INTO tours (place_id, tour_type, ski_discipline, ski_technique) VALUES (101, 'ski', 'cross_country', 'unknown')", ); + // Snowshoeing and winter hiking are disciplines of a ski tour, never labels of a hike. + await rawExec(orm, "UPDATE tours SET ski_discipline = 'snowshoe', ski_technique = NULL WHERE place_id = 101"); + await rawExec(orm, "UPDATE tours SET ski_discipline = 'winter_hike' WHERE place_id = 101"); await expect(rawExec(orm, "UPDATE tours SET ski_discipline = 'freeride' WHERE place_id = 101")).rejects.toThrow( /CHECK/, ); diff --git a/server/tests/unit/db/tour-winter-activity-column.test.ts b/server/tests/unit/db/tour-winter-activity-column.test.ts deleted file mode 100644 index 195f5ee41..000000000 --- a/server/tests/unit/db/tour-winter-activity-column.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ToursWinterActivity } from '../../../src/db/entities/Tours.entity'; -import { WINTER_ACTIVITIES } from '@trek/shared'; - -import { describe, expect, it } from 'vitest'; - -/** - * HIK-01: the winter label column holds a CHECK list (migration 045500) that - * the entity carries as its enum, and the contract holds its own list. A label - * one side knows and the other refuses would be a hike the planner can save - * and the database cannot store. - */ -describe('winter activity column matches the contract', () => { - it('knows the same winter activities as the tours contract', () => { - expect(Object.values(ToursWinterActivity).sort()).toEqual([...WINTER_ACTIVITIES].sort()); - }); -}); diff --git a/server/tests/unit/nest/collection-tours.helpers.test.ts b/server/tests/unit/nest/collection-tours.helpers.test.ts index 4ca1b939c..91b970146 100644 --- a/server/tests/unit/nest/collection-tours.helpers.test.ts +++ b/server/tests/unit/nest/collection-tours.helpers.test.ts @@ -41,7 +41,6 @@ const row: CollectionTourRow = { match_confidence: 1, max_hiking_difficulty: null, bike_profile: 'gravel', - winter_activity: null, run_profile: null, run_pace_seconds_per_km: null, ski_discipline: null, @@ -92,8 +91,7 @@ describe('collection tour helpers (LIB-03)', () => { it('LIB3-HLP-007: a kept tour carries what the trip list shows of its type, and its stages', () => { expect(keptTourItem(row)).toMatchObject({ bike_profile: 'gravel', - winter_activity: null, - ski_discipline: null, + ski_discipline: null, run_pace_seconds_per_km: null, stage_count: null, }); @@ -107,8 +105,9 @@ describe('collection tour helpers (LIB-03)', () => { expect(keptTourItem(ski)).toMatchObject({ ski_discipline: 'cross_country', ski_technique: 'skating' }); const run = { ...row, tour_type: 'run', bike_profile: null, run_profile: 'trail', run_pace_seconds_per_km: 360 }; expect(keptTourItem(run)).toMatchObject({ run_profile: 'trail', run_pace_seconds_per_km: 360 }); - const snowshoe = { ...row, tour_type: 'hike', bike_profile: null, winter_activity: 'snowshoe' }; - expect(keptTourItem(snowshoe).winter_activity).toBe('snowshoe'); + const snowshoe = { ...row, tour_type: 'ski', bike_profile: null, ski_discipline: 'snowshoe' }; + expect(keptTourItem(snowshoe)).toMatchObject({ tour_type: 'ski', ski_discipline: 'snowshoe', ski_technique: null }); + expect(keptTourItem(snowshoe)).not.toHaveProperty('winter_activity'); const hutToHut = { ...row, waypoints: [ diff --git a/server/tests/unit/nest/tour-gpx-export.helpers.test.ts b/server/tests/unit/nest/tour-gpx-export.helpers.test.ts index 442c23683..282794b1b 100644 --- a/server/tests/unit/nest/tour-gpx-export.helpers.test.ts +++ b/server/tests/unit/nest/tour-gpx-export.helpers.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'; // TOUR-GPX-ACTIVITY-001 to TOUR-GPX-ACTIVITY-007 -describe('gpxActivityOf (PR1-06A, BIK-01, HIK-01, RUN-01, SKI-02, HOR-01)', () => { +describe('gpxActivityOf (PR1-06A, BIK-01, RUN-01, SKI-02, HOR-01)', () => { it('TOUR-GPX-ACTIVITY-001: a bike tour names its profile, and the plainest riding without a known one', () => { expect(gpxActivityOf({ tour_type: 'bike', bike_profile: 'mtb' })).toBe('mountain_biking'); expect(gpxActivityOf({ tour_type: 'bike', bike_profile: null })).toBe('cycling'); @@ -22,11 +22,11 @@ describe('gpxActivityOf (PR1-06A, BIK-01, HIK-01, RUN-01, SKI-02, HOR-01)', () = expect(gpxActivityOf({ tour_type: 'walk' })).toBe('walk'); }); - it('TOUR-GPX-ACTIVITY-004: a hike on snowshoes is snowshoeing, a winter hike stays hiking (HIK-01)', () => { - expect(gpxActivityOf({ tour_type: 'hike', winter_activity: 'snowshoe' })).toBe('snowshoeing'); - expect(gpxActivityOf({ tour_type: 'hike', winter_activity: 'winter_hike' })).toBe('hiking'); - expect(gpxActivityOf({ tour_type: 'hike', winter_activity: null })).toBe('hiking'); - expect(gpxActivityOf({ tour_type: 'walk', winter_activity: 'snowshoe' })).toBe('walk'); + it('TOUR-GPX-ACTIVITY-004: snowshoes make a ski tour snowshoeing, a winter hike stays hiking, a hike is hiking (SKI-02)', () => { + expect(gpxActivityOf({ tour_type: 'ski', ski_discipline: 'snowshoe' })).toBe('snowshoeing'); + expect(gpxActivityOf({ tour_type: 'ski', ski_discipline: 'winter_hike' })).toBe('hiking'); + expect(gpxActivityOf({ tour_type: 'hike', ski_discipline: null })).toBe('hiking'); + expect(gpxActivityOf({ tour_type: 'walk' })).toBe('walk'); }); it('TOUR-GPX-ACTIVITY-005: a run is running, and stays running without a profile this build knows (RUN-01)', () => { diff --git a/server/tests/unit/nest/tour-plan-read.test.ts b/server/tests/unit/nest/tour-plan-read.test.ts index da8e45cd8..3b488a558 100644 --- a/server/tests/unit/nest/tour-plan-read.test.ts +++ b/server/tests/unit/nest/tour-plan-read.test.ts @@ -122,4 +122,24 @@ describe('tour plan read (MCP-T01)', () => { } as unknown as TourDetailResponse; expect(derivedFields(staged, none)).toEqual(expect.arrayContaining(['tour.stage_count', 'tour.stage_stops'])); }); + + it('TOUR-PLAN-READ-008: every leg of a planned ski tour is drawn freely with its straight length, a fixed one kept (SKI-02)', () => { + const free = point(1, { + fixed_leg: { + kind: 'free', + geometry: [ + [48.01, 11], + [48.02, 11], + ], + }, + }); + expect(planLegs([point(0), free, point(2)], { recorded: false, drawn: true })).toEqual([ + expect.objectContaining({ kind: 'free', name: null, length_km: 1.11 }), + expect.objectContaining({ kind: 'free', name: null, length_km: 1.11 }), + ]); + // A ski track kept as recorded stays a track. + expect(planLegs([point(0), point(1)], { recorded: true, drawn: true })).toEqual([ + expect.objectContaining({ kind: 'track', length_km: null }), + ]); + }); }); diff --git a/server/tests/unit/nest/tours.mcp.test.ts b/server/tests/unit/nest/tours.mcp.test.ts index cede50bb3..ac4a9ed7b 100644 --- a/server/tests/unit/nest/tours.mcp.test.ts +++ b/server/tests/unit/nest/tours.mcp.test.ts @@ -561,19 +561,25 @@ describe('ToursMcp', () => { } }); - it('TOURS-MCP-020: list_tours names the winter label of a hike, and says it is only a label (HIK-01)', async () => { + it('TOURS-MCP-020: list_tours lists snowshoeing as a ski tour, and a hike with no winter label at all (SKI-02)', async () => { const { user, trip, ridge, gorge } = fixture(); const orm = await createTestOrm(db); - await updateRows(orm, Tours, { place: ridge.id }, { winter_activity: 'snowshoe' }); + await updateRows( + orm, + Tours, + { place: ridge.id }, + { tourTypeRef: 'ski', max_hiking_difficulty: null, ski_discipline: 'snowshoe' }, + ); await orm.close(); const { tours } = read(await mcp.listTours({ tripId: trip.id }, ctx(user.id))); const byId = new Map(tours.map((tour: { place_id: number }) => [tour.place_id, tour])); - expect(byId.get(ridge.id)).toMatchObject({ tour_type: 'hike', winter_activity: 'snowshoe' }); - expect(byId.get(gorge.id)).toMatchObject({ winter_activity: null }); + expect(byId.get(ridge.id)).toMatchObject({ tour_type: 'ski', ski_discipline: 'snowshoe', ski_technique: null }); + expect(byId.get(gorge.id)).toMatchObject({ tour_type: 'hike' }); + expect(byId.get(gorge.id)).not.toHaveProperty('winter_activity'); const entry = getEntry(ToursMcp as unknown as ClassRef, 'listTours'); const description = entry?.kind === 'tool' ? (entry.options.description ?? '') : ''; - expect(description).toMatch(/winter_activity: winter_hike or snowshoe/); - expect(description).toContain('never a statement that a way can be walked in winter'); + expect(description).not.toContain('winter_activity'); + expect(description).toContain('a hike is summer only'); }); it('TOURS-MCP-021: list_tours names a run with its profile and pace, and says its time is a plan at that pace only (RUN-01)', async () => { @@ -620,7 +626,7 @@ describe('ToursMcp', () => { expect(description).toContain('never a statement that a way can be run'); }); - it('TOURS-MCP-023: list_tours names a ski track with its discipline and technique, and says it is kept as recorded (SKI-02)', async () => { + it('TOURS-MCP-023: list_tours names a ski tour with its discipline and technique, and says how its line came to be (SKI-02)', async () => { const { user, trip, ridge, gorge } = fixture(); const orm = await createTestOrm(db); await updateRows( @@ -640,9 +646,32 @@ describe('ToursMcp', () => { expect(byId.get(gorge.id)).toMatchObject({ ski_discipline: null, ski_technique: null }); const entry = getEntry(ToursMcp as unknown as ClassRef, 'listTours'); const description = entry?.kind === 'tool' ? (entry.options.description ?? '') : ''; - expect(description).toMatch(/ski_discipline: alpine, snowboard, cross_country or touring/); - expect(description).toContain('never routed'); + expect(description).toContain('ski_discipline: alpine, snowboard, cross_country, touring for ski touring'); + expect(description).toContain('snowshoe or winter_hike'); + expect(description).toContain('drawn straight between its points, never routed'); + expect(description).toContain('an imported one is kept as recorded'); expect(description).toContain('it has no ski time worked out'); expect(description).toContain('nothing in it says anything about snow, avalanches'); }); + + it('TOURS-MCP-024: get_tour_plan names every leg of a planned ski tour as drawn freely, with its straight length (SKI-02)', async () => { + const { member, trip } = fixture(); + const orm = await createTestOrm(db); + const ski = await makePlace(orm, trip.id, { name: 'Over the pass' }); + await makeTour(orm, ski.id, { tourTypeRef: 'ski', max_hiking_difficulty: null, ski_discipline: 'touring' }); + await addTourWaypoints(orm, ski.id, [ + { lat: 47, lng: 11, station_key: 'sk-start' }, + { lat: 47.01, lng: 11, station_key: 'sk-col' }, + { lat: 47.01, lng: 11.01, station_key: 'sk-end' }, + ]); + await orm.close(); + const plan = read(await mcp.getTourPlan({ tripId: trip.id, placeId: ski.id }, ctx(member.id))); + expect(plan.tour).toMatchObject({ tour_type: 'ski', ski_discipline: 'touring' }); + expect(plan.legs).toEqual([ + expect.objectContaining({ from_key: 'sk-start', to_key: 'sk-col', kind: 'free', name: null }), + expect.objectContaining({ from_key: 'sk-col', to_key: 'sk-end', kind: 'free', name: null }), + ]); + expect(plan.legs[0].length_km).toBeCloseTo(1.11, 1); + expect(plan.legs[1].length_km).toBeCloseTo(0.76, 1); + }); }); diff --git a/shared/src/i18n/ar/tours.ts b/shared/src/i18n/ar/tours.ts index ba1b78378..013d73ffa 100644 --- a/shared/src/i18n/ar/tours.ts +++ b/shared/src/i18n/ar/tours.ts @@ -216,7 +216,7 @@ const tours: TranslationStrings = { 'tourTypes.hike': 'مشي طويل', 'tourTypes.bike': 'جولة بالدراجة', 'tourTypes.run': 'جري', - 'tourTypes.ski': 'تزلج', + 'tourTypes.ski': 'تزلج وشتاء', 'tourTypes.kayak': 'جولة تجديف', 'tourTypes.walk': 'مشي في المدينة / مشاهدة المعالم', 'tourTypes.food': 'جولة طعام / نبيذ', @@ -407,32 +407,32 @@ const tours: TranslationStrings = { 'tours.planner.tourTypeHint.hike': 'سيرًا على الأقدام، ضمن صعوبة تحددها', 'tours.planner.tourTypeHint.bike': 'بالدراجة، مسار مناسب للتجوال أو الطريق أو الحصى أو الجبلية', 'tours.planner.tourTypeHint.kayak': 'على الماء، بقارب الكاياك أو الكانو أو لوح التجديف', - 'tours.winter.title': 'نشاط شتوي', - 'tours.winter.infoLabel': 'ماذا تعني علامة الشتاء', - 'tours.winter.info': - 'مجرد علامة للنزهة. يبقى المسار وألوان الانحدار ووقت المشي كما في أي نزهة، ولا تقول شيئًا عن الثلج أو الانهيارات الثلجية أو ما إذا كان الطريق صالحًا للمشي في الشتاء.', - 'tours.winter.kind': 'نوع النشاط الشتوي', - 'tours.winter.activity.winter_hike': 'مشي شتوي', - 'tours.winter.activity.snowshoe': 'مشي بأحذية الثلج', - 'tours.winter.labelOnly': 'وضعها المسافرون. يُخطَّط المسار والوقت كما في أي نزهة، دون فحص لظروف الشتاء.', - 'tours.planner.tourTypeHint.ski': 'من ملف GPX كما سُجّل', + 'tours.planner.tourTypeHint.ski': 'تزلج، لوح ثلج، أحذية ثلج، مشي شتوي', 'tours.ski.title': 'مسار تزلج', 'tours.ski.discipline': 'النوع', 'tours.ski.discipline.alpine': 'تزلج ألبي', 'tours.ski.discipline.snowboard': 'تزلج على اللوح', 'tours.ski.discipline.cross_country': 'تزلج ريفي', 'tours.ski.discipline.touring': 'تزلج جبلي', + 'tours.ski.discipline.snowshoe': 'أحذية الثلج', + 'tours.ski.discipline.winter_hike': 'مشي شتوي', 'tours.ski.technique': 'الأسلوب', 'tours.ski.technique.classic': 'كلاسيكي', 'tours.ski.technique.skating': 'تزحلق', 'tours.ski.technique.unknown': 'غير معروف', 'tours.ski.chooseDiscipline': 'اختر نوعًا لاستيراد المسار كتزلج.', + 'tours.ski.chooseDisciplineToSave': 'اختر نوعًا لحفظ الجولة.', + 'tours.ski.drawnFreely': + 'تُرسم كل مرحلة بخط مستقيم بين نقاطك. لا شيء يتحقق منها مقابل المنحدرات أو المصاعد أو المسارات.', 'tours.ski.chooseTechnique': 'اختر الأسلوب، أو غير معروف.', 'tours.ski.infoLabel': 'ما يفعله TREK بمسار التزلج', 'tours.ski.info': - 'يبقى كما سُجّل ولا يُعاد حسابه أبدًا. المسافة والارتفاعات من الملف. لا يحسب TREK وقتًا للتزلج، ولا يقول المسار ولا ألوان الانحدار في العرض ثلاثي الأبعاد شيئًا عن الصعوبة أو الثلج أو الانهيارات أو ما إذا كانت المسارات مفتوحة.', + 'في الجولة المخطَّطة تُرسم كل مرحلة بخط مستقيم بين نقاطها، ويبقى المسار المستورد كما سُجّل. لا يُوجَّه أيٌّ منهما على المنحدرات أو المصاعد أو المسارات. تأتي الارتفاعات من التضاريس أو من الملف. لا يحسب TREK وقتًا للتزلج، ولا شيء هنا، ومنه ألوان الانحدار في العرض ثلاثي الأبعاد، يقول شيئًا عن الصعوبة أو الثلج أو الانهيارات أو ما إذا كانت المسارات مفتوحة.', 'tours.ski.time': 'وقت التزلج', 'tours.ski.timeUnknown': 'غير محسوب', + 'tours.ski.timeInfoLabel': 'لماذا لا يوجد وقت تزلج', + 'tours.ski.timeInfo': + 'لا يحسب TREK وقت التزلج: المراحل مرسومة بخطوط مستقيمة، لا مخططة على المنحدرات أو المصاعد أو المسارات. أدخل بنفسك إجمالي الوقت المخطط إن أردت.', 'tours.ski.classifyTitle': 'تصنيف مسار التزلج', 'tours.ski.saved': 'تم حفظ التصنيف', 'tours.ski.saveFailed': 'تعذّر حفظ التصنيف.', @@ -831,6 +831,8 @@ const tours: TranslationStrings = { 'tours.import.previewConfirm.many': 'استيراد {count} جولة', 'tours.conversion.introPaddle': 'الاستيراد مسار مسجّل. خطّط لجولة تجديف قابلة للتعديل: يأخذ TREK منه نقاط تحكم، ويصلها بخطوط مستقيمة كما تُرسم جولة التجديف، ويعرض مدى قربها منه. لا يتغير شيء حتى تختار.', + 'tours.conversion.introSki': + 'الاستيراد مسار مسجّل. خطّط لجولة تزلج قابلة للتعديل: يأخذ TREK منه نقاط تحكم، ويصلها بخطوط مستقيمة كما تُرسم جولة التزلج، ويعرض مدى قربها منه. لا يتغير شيء حتى تختار.', 'tours.conversion.closePaddle': 'يتبع الاقتراح المسار عن قرب. نقاطه موصولة بخطوط مستقيمة، لذا ليس هو الخط نفسه.', 'tours.planner.moreActions': 'إجراءات أخرى', 'tours.paddle.role.put_in': 'نقطة الإنزال', @@ -1166,6 +1168,7 @@ const tours: TranslationStrings = { 'tourTypes.horse': 'جولة على ظهر الخيل', 'tours.planner.tourTypeHint.horse': 'على ظهر الخيل، عبر مسارات فروسية معلّمة', 'tours.planner.namePlaceholder.horse': 'جولة على الخيل بلا عنوان', + 'tours.planner.namePlaceholder.ski': 'جولة تزلج بلا عنوان', 'tours.horse.time': 'وقت الركوب', 'tours.horse.timeInfoLabel': 'لماذا لا يوجد وقت ركوب', 'tours.horse.timeInfo': diff --git a/shared/src/i18n/az/tours.ts b/shared/src/i18n/az/tours.ts index 722034c49..c02cedbb9 100644 --- a/shared/src/i18n/az/tours.ts +++ b/shared/src/i18n/az/tours.ts @@ -200,7 +200,7 @@ const tours: TranslationStrings = { 'tourTypes.hike': 'Yürüş', 'tourTypes.bike': 'Velosiped turu', 'tourTypes.run': 'Qaçış', - 'tourTypes.ski': 'Xizək', + 'tourTypes.ski': 'Xizək və qış', 'tourTypes.kayak': 'Avarlı tur', 'tourTypes.walk': 'Şəhər / görməli yerlər gəzintisi', 'tourTypes.food': 'Yemək/şərab turu', @@ -396,33 +396,32 @@ const tours: TranslationStrings = { 'tours.planner.tourTypeHint.hike': 'Piyada, təyin etdiyin çətinlik daxilində', 'tours.planner.tourTypeHint.bike': 'Velosipedlə, turinq, yol, qravel və ya MTB üçün marşrut', 'tours.planner.tourTypeHint.kayak': 'Suda, kayak, kanoe və ya SUP ilə', - 'tours.winter.title': 'Qış fəaliyyəti', - 'tours.winter.infoLabel': 'Qış nişanı nə edir', - 'tours.winter.info': - 'Yalnız yürüş üçün nişan. Marşrut, meyllilik rəngləri və yeriş vaxtı istənilən yürüşdəki kimi qalır və qar, uçqun və ya cığırın qışda keçilə bilməsi barədə heç nə demir.', - 'tours.winter.kind': 'Qış fəaliyyətinin növü', - 'tours.winter.activity.winter_hike': 'Qış yürüşü', - 'tours.winter.activity.snowshoe': 'Qar ayaqqabısı ilə yürüş', - 'tours.winter.labelOnly': - 'Səyahətçilər tərəfindən qeyd edilib. Marşrut və vaxt istənilən yürüşdəki kimi planlaşdırılır, qış şəraiti üçün yoxlanılmır.', - 'tours.planner.tourTypeHint.ski': 'GPX faylından, qeydə alındığı kimi', + 'tours.planner.tourTypeHint.ski': 'Xizək, snoubord, qar ayaqqabısı, qış yürüşləri', 'tours.ski.title': 'Xizək izi', 'tours.ski.discipline': 'Növ', 'tours.ski.discipline.alpine': 'Alp xizəyi', 'tours.ski.discipline.snowboard': 'Snoubord', 'tours.ski.discipline.cross_country': 'Xizək qaçışı', 'tours.ski.discipline.touring': 'Dağ xizəyi turu', + 'tours.ski.discipline.snowshoe': 'Qar ayaqqabısı', + 'tours.ski.discipline.winter_hike': 'Qış yürüşü', 'tours.ski.technique': 'Texnika', 'tours.ski.technique.classic': 'Klassik', 'tours.ski.technique.skating': 'Konki üslubu', 'tours.ski.technique.unknown': 'Naməlum', 'tours.ski.chooseDiscipline': 'İzi xizək kimi idxal etmək üçün növ seç.', + 'tours.ski.chooseDisciplineToSave': 'Turu saxlamaq üçün növ seç.', + 'tours.ski.drawnFreely': + 'Hər hissə nöqtələrin arasında düz xətlə çəkilir. Heç nə onu trassalar, liftlər və ya cığırlarla yoxlamır.', 'tours.ski.chooseTechnique': 'Texnikanı və ya Naməlum seç.', 'tours.ski.infoLabel': 'TREK xizək izi ilə nə edir', 'tours.ski.info': - 'Qeydə alındığı kimi qalır və heç vaxt marşrutlanmır. Məsafə və hündürlüklər fayldan gəlir. TREK xizək vaxtı hesablamır, nə iz, nə də 3D-dəki meyil rəngləri çətinlik, qar, uçqun və ya trassaların açıq olub-olmaması barədə heç nə demir.', + 'Planlaşdırılmış turda hər hissə nöqtələri arasında düz xətlə çəkilir, idxal olunmuş iz isə qeydə alındığı kimi qalır. Heç biri trassalar, liftlər və ya cığırlar boyunca marşrutlanmır. Hündürlüklər relyefdən və ya fayldan gəlir. TREK xizək vaxtı hesablamır və burada heç nə, 3D-dəki meyil rəngləri də daxil olmaqla, çətinlik, qar, uçqun və ya trassaların açıq olub-olmaması barədə heç nə demir.', 'tours.ski.time': 'Xizək vaxtı', 'tours.ski.timeUnknown': 'Hesablanmayıb', + 'tours.ski.timeInfoLabel': 'Niyə xizək vaxtı yoxdur', + 'tours.ski.timeInfo': + 'TREK xizək vaxtını hesablamır: hissələr düz çəkilir, trassalar, liftlər və ya cığırlar boyunca planlaşdırılmır. İstəsən, planlaşdırılan ümumi vaxtı özün daxil et.', 'tours.ski.classifyTitle': 'Xizək izini təsnif et', 'tours.ski.saved': 'Təsnifat saxlanıldı', 'tours.ski.saveFailed': 'Təsnifatı saxlamaq alınmadı.', @@ -808,6 +807,8 @@ const tours: TranslationStrings = { 'tours.import.previewConfirm.one': '{count} tur idxal et', 'tours.conversion.introPaddle': 'İdxal qeydə alınmış bir trekdir. Redaktə edilə bilən avarlı tur planla: TREK ondan nəzarət nöqtələri götürür, avarlı tur necə çəkilirsə onları düz xətlərlə birləşdirir və nə qədər yaxın olduğunu göstərir. Sən seçənə qədər heç nə dəyişmir.', + 'tours.conversion.introSki': + 'İdxal qeydə alınmış bir trekdir. Redaktə edilə bilən xizək turu planla: TREK ondan nəzarət nöqtələri götürür, xizək turu necə çəkilirsə onları düz xətlərlə birləşdirir və nə qədər yaxın olduğunu göstərir. Sən seçənə qədər heç nə dəyişmir.', 'tours.conversion.closePaddle': 'Təklif treki yaxından izləyir. Nöqtələri düz xətlərlə birləşdirilib, ona görə eyni xətt deyil.', 'tours.planner.moreActions': 'Digər əməliyyatlar', @@ -1121,6 +1122,7 @@ const tours: TranslationStrings = { 'tourTypes.horse': 'At gəzintisi', 'tours.planner.tourTypeHint.horse': 'At belində, nişanlanmış atçılıq marşrutları ilə', 'tours.planner.namePlaceholder.horse': 'Adsız at gəzintisi', + 'tours.planner.namePlaceholder.ski': 'Adsız xizək turu', 'tours.horse.time': 'Sürmə vaxtı', 'tours.horse.timeInfoLabel': 'Niyə sürmə vaxtı yoxdur', 'tours.horse.timeInfo': diff --git a/shared/src/i18n/br/tours.ts b/shared/src/i18n/br/tours.ts index a57e33354..8a27189c8 100644 --- a/shared/src/i18n/br/tours.ts +++ b/shared/src/i18n/br/tours.ts @@ -205,7 +205,7 @@ const tours: TranslationStrings = { 'tourTypes.hike': 'Trilha', 'tourTypes.bike': 'Passeio de bicicleta', 'tourTypes.run': 'Corrida', - 'tourTypes.ski': 'Esqui', + 'tourTypes.ski': 'Esqui e inverno', 'tourTypes.kayak': 'Passeio de remo', 'tourTypes.walk': 'Caminhada urbana / turística', 'tourTypes.food': 'Passeio gastronômico / enológico', @@ -402,33 +402,32 @@ const tours: TranslationStrings = { 'tours.planner.tourTypeHint.hike': 'A pé, dentro de uma dificuldade que você define', 'tours.planner.tourTypeHint.bike': 'De bicicleta, com rota para cicloturismo, estrada, gravel ou MTB', 'tours.planner.tourTypeHint.kayak': 'Na água, de caiaque, canoa ou SUP', - 'tours.winter.title': 'Atividade de inverno', - 'tours.winter.infoLabel': 'O que a marcação de inverno faz', - 'tours.winter.info': - 'Apenas uma marcação da caminhada. A rota, as cores de inclinação e o tempo de caminhada continuam como em qualquer caminhada e não dizem nada sobre neve, avalanches ou se um caminho pode ser percorrido no inverno.', - 'tours.winter.kind': 'Tipo de atividade de inverno', - 'tours.winter.activity.winter_hike': 'Caminhada de inverno', - 'tours.winter.activity.snowshoe': 'Caminhada com raquetes de neve', - 'tours.winter.labelOnly': - 'Marcado pelos viajantes. Rota e tempo são planejados como em qualquer caminhada, sem verificação para o inverno.', - 'tours.planner.tourTypeHint.ski': 'De um arquivo GPX, como foi gravado', + 'tours.planner.tourTypeHint.ski': 'Esqui, snowboard, raquetes de neve, caminhadas de inverno', 'tours.ski.title': 'Trilha de esqui', 'tours.ski.discipline': 'Modalidade', 'tours.ski.discipline.alpine': 'Alpino', 'tours.ski.discipline.snowboard': 'Snowboard', // same-as-en 'tours.ski.discipline.cross_country': 'Esqui cross-country', 'tours.ski.discipline.touring': 'Esqui de travessia', + 'tours.ski.discipline.snowshoe': 'Raquetes de neve', + 'tours.ski.discipline.winter_hike': 'Caminhada de inverno', 'tours.ski.technique': 'Técnica', 'tours.ski.technique.classic': 'Clássica', 'tours.ski.technique.skating': 'Skating', // same-as-en 'tours.ski.technique.unknown': 'Desconhecida', 'tours.ski.chooseDiscipline': 'Escolha uma modalidade para importar a trilha como esqui.', + 'tours.ski.chooseDisciplineToSave': 'Escolha uma modalidade para salvar o passeio.', + 'tours.ski.drawnFreely': + 'Cada trecho é desenhado em linha reta entre seus pontos. Nada o confere com pistas, teleféricos ou trilhas.', 'tours.ski.chooseTechnique': 'Escolha a técnica, ou Desconhecida.', 'tours.ski.infoLabel': 'O que o TREK faz com uma trilha de esqui', 'tours.ski.info': - 'Fica como foi gravada e nunca é roteada. Distância e altitudes vêm do arquivo. O TREK não calcula tempo de esqui, e nem a trilha nem as cores de inclinação em 3D dizem algo sobre dificuldade, neve, avalanches ou se pistas e trilhas estão abertas.', + 'Um passeio planejado tem cada trecho desenhado em linha reta entre seus pontos, e uma trilha importada fica como foi gravada. Nenhum dos dois é roteado por pistas, teleféricos ou trilhas. As altitudes vêm do terreno ou do arquivo. O TREK não calcula tempo de esqui, e nada aqui, nem as cores de inclinação em 3D, diz algo sobre dificuldade, neve, avalanches ou se pistas e trilhas estão abertas.', 'tours.ski.time': 'Tempo de esqui', 'tours.ski.timeUnknown': 'Não calculado', + 'tours.ski.timeInfoLabel': 'Por que não há tempo de esqui', + 'tours.ski.timeInfo': + 'O TREK não calcula tempo de esqui: os trechos são desenhados em linha reta, não planejados por pistas, teleféricos ou trilhas. Se quiser, informe você mesmo um total planejado.', 'tours.ski.classifyTitle': 'Classificar trilha de esqui', 'tours.ski.saved': 'Classificação salva', 'tours.ski.saveFailed': 'Não foi possível salvar a classificação.', @@ -813,6 +812,8 @@ const tours: TranslationStrings = { 'tours.import.previewConfirm.one': 'Importar {count} passeio', 'tours.conversion.introPaddle': 'Uma importação é uma trilha gravada. Planeje um passeio de remo editável: o TREK tira pontos de controle dela, liga-os com linhas retas como se desenha um passeio de remo e mostra o quanto chega perto. Nada muda até você escolher.', + 'tours.conversion.introSki': + 'Uma importação é uma trilha gravada. Planeje um passeio de esqui editável: o TREK tira pontos de controle dela, liga-os com linhas retas como se desenha um passeio de esqui e mostra o quanto chega perto. Nada muda até você escolher.', 'tours.conversion.closePaddle': 'A sugestão segue a trilha de perto. Os pontos são ligados por linhas retas, então não é a mesma linha.', 'tours.planner.moreActions': 'Mais ações', @@ -1133,6 +1134,7 @@ const tours: TranslationStrings = { 'tourTypes.horse': 'Passeio a cavalo', 'tours.planner.tourTypeHint.horse': 'A cavalo, por rotas equestres sinalizadas', 'tours.planner.namePlaceholder.horse': 'Passeio a cavalo sem título', + 'tours.planner.namePlaceholder.ski': 'Passeio de esqui sem título', 'tours.horse.time': 'Tempo a cavalo', 'tours.horse.timeInfoLabel': 'Por que não há tempo a cavalo', 'tours.horse.timeInfo': diff --git a/shared/src/i18n/ca/tours.ts b/shared/src/i18n/ca/tours.ts index 00851b1db..e9a85b452 100644 --- a/shared/src/i18n/ca/tours.ts +++ b/shared/src/i18n/ca/tours.ts @@ -205,7 +205,7 @@ const tours: TranslationStrings = { 'tourTypes.hike': 'Senderisme', 'tourTypes.bike': 'Ruta en bicicleta', 'tourTypes.run': 'Córrer', - 'tourTypes.ski': 'Esquí', + 'tourTypes.ski': 'Esquí i hivern', 'tourTypes.kayak': 'Ruta en piragua', 'tourTypes.walk': 'Passeig urbà / turístic', 'tourTypes.food': 'Ruta gastronòmica / enològica', @@ -403,33 +403,32 @@ const tours: TranslationStrings = { 'tours.planner.tourTypeHint.hike': "A peu, dins d'una dificultat que tu tries", 'tours.planner.tourTypeHint.bike': 'En bicicleta, amb ruta per a cicloturisme, carretera, gravel o BTT', 'tours.planner.tourTypeHint.kayak': "A l'aigua, amb caiac, canoa o SUP", - 'tours.winter.title': 'Activitat hivernal', - 'tours.winter.infoLabel': 'Què fa la marca hivernal', - 'tours.winter.info': - 'Només una marca de l’excursió. La ruta, els colors de pendent i el temps de marxa són els de qualsevol excursió i no diuen res de la neu, les allaus ni de si un camí és transitable a l’hivern.', - 'tours.winter.kind': 'Tipus d’activitat hivernal', - 'tours.winter.activity.winter_hike': 'Excursió hivernal', - 'tours.winter.activity.snowshoe': 'Excursió amb raquetes de neu', - 'tours.winter.labelOnly': - 'Marcat pels viatgers. La ruta i el temps es planifiquen com en qualsevol excursió, sense comprovar l’hivern.', - 'tours.planner.tourTypeHint.ski': 'D’un fitxer GPX, tal com es va gravar', + 'tours.planner.tourTypeHint.ski': 'Esquí, surf de neu, raquetes, excursions hivernals', 'tours.ski.title': 'Traça d’esquí', 'tours.ski.discipline': 'Disciplina', 'tours.ski.discipline.alpine': 'Alpí', 'tours.ski.discipline.snowboard': 'Surf de neu', 'tours.ski.discipline.cross_country': 'Esquí de fons', 'tours.ski.discipline.touring': 'Esquí de muntanya', + 'tours.ski.discipline.snowshoe': 'Raquetes de neu', + 'tours.ski.discipline.winter_hike': 'Excursió hivernal', 'tours.ski.technique': 'Tècnica', 'tours.ski.technique.classic': 'Clàssica', 'tours.ski.technique.skating': 'Patinador', 'tours.ski.technique.unknown': 'Desconeguda', 'tours.ski.chooseDiscipline': 'Tria una disciplina per importar la traça com a esquí.', + 'tours.ski.chooseDisciplineToSave': 'Tria una disciplina per desar la ruta.', + 'tours.ski.drawnFreely': + 'Cada tram es dibuixa en línia recta entre els teus punts. Res no el comprova amb pistes, remuntadors o senders.', 'tours.ski.chooseTechnique': 'Tria la tècnica, o Desconeguda.', 'tours.ski.infoLabel': 'Què fa TREK amb una traça d’esquí', 'tours.ski.info': - 'Es manté tal com es va gravar i mai no s’enruta. La distància i les altituds vénen del fitxer. TREK no calcula cap temps d’esquí, i ni la traça ni els colors de pendent en 3D diuen res de dificultat, neu, allaus o si les pistes estan obertes.', + 'Una ruta planificada té cada tram dibuixat en línia recta entre els seus punts, i una traça importada es manté tal com es va gravar. Cap de les dues no s’enruta per pistes, remuntadors o senders. Les altituds vénen del terreny o del fitxer. TREK no calcula cap temps d’esquí, i res d’aquí, ni els colors de pendent en 3D, diu res de dificultat, neu, allaus o si les pistes estan obertes.', 'tours.ski.time': 'Temps d’esquí', 'tours.ski.timeUnknown': 'Sense calcular', + 'tours.ski.timeInfoLabel': 'Per què no hi ha temps d’esquí', + 'tours.ski.timeInfo': + 'TREK no calcula cap temps d’esquí: els trams es dibuixen rectes, no es planifiquen per pistes, remuntadors o senders. Si vols, introdueix tu mateix un total previst.', 'tours.ski.classifyTitle': 'Classificar la traça d’esquí', 'tours.ski.saved': 'Classificació desada', 'tours.ski.saveFailed': 'No s’ha pogut desar la classificació.', @@ -815,6 +814,8 @@ const tours: TranslationStrings = { 'tours.import.previewConfirm.one': 'Importa {count} ruta', 'tours.conversion.introPaddle': 'Una importació és un recorregut enregistrat. Planifica una ruta en piragua editable: TREK n’agafa punts de control, els uneix amb línies rectes com es dibuixa una ruta en piragua i mostra com s’hi acosta. No canvia res fins que triïs.', + 'tours.conversion.introSki': + 'Una importació és un recorregut enregistrat. Planifica una ruta d’esquí editable: TREK n’agafa punts de control, els uneix amb línies rectes com es dibuixa una ruta d’esquí i mostra com s’hi acosta. No canvia res fins que triïs.', 'tours.conversion.closePaddle': 'La proposta segueix el recorregut de prop. Els punts s’uneixen amb línies rectes, per tant no és la mateixa línia.', 'tours.planner.moreActions': 'Més accions', @@ -1136,6 +1137,7 @@ const tours: TranslationStrings = { 'tourTypes.horse': 'Ruta a cavall', 'tours.planner.tourTypeHint.horse': 'A cavall, per rutes eqüestres senyalitzades', 'tours.planner.namePlaceholder.horse': 'Ruta a cavall sense títol', + 'tours.planner.namePlaceholder.ski': 'Ruta d’esquí sense títol', 'tours.horse.time': 'Temps a cavall', 'tours.horse.timeInfoLabel': 'Per què no hi ha temps a cavall', 'tours.horse.timeInfo': diff --git a/shared/src/i18n/cs/tours.ts b/shared/src/i18n/cs/tours.ts index 8e4b1acd9..345901e83 100644 --- a/shared/src/i18n/cs/tours.ts +++ b/shared/src/i18n/cs/tours.ts @@ -203,7 +203,7 @@ const tours: TranslationStrings = { 'tourTypes.hike': 'Pěší túra', 'tourTypes.bike': 'Cyklovýlet', 'tourTypes.run': 'Běh', - 'tourTypes.ski': 'Lyže', + 'tourTypes.ski': 'Lyže a zima', 'tourTypes.kayak': 'Vodácká túra', 'tourTypes.walk': 'Procházka městem / po památkách', 'tourTypes.food': 'Gastronomický / vinařský výlet', @@ -395,33 +395,32 @@ const tours: TranslationStrings = { 'tours.planner.tourTypeHint.hike': 'Pěšky, do obtížnosti, kterou nastavíš', 'tours.planner.tourTypeHint.bike': 'Na kole, trasa pro treking, silnici, gravel nebo MTB', 'tours.planner.tourTypeHint.kayak': 'Na vodě, na kajaku, kánoi nebo SUP', - 'tours.winter.title': 'Zimní aktivita', - 'tours.winter.infoLabel': 'Co dělá zimní označení', - 'tours.winter.info': - 'Jen označení túry. Trasa, barvy sklonu a doba chůze zůstávají jako u každé túry a neříkají nic o sněhu, lavinách ani o tom, zda je cesta v zimě schůdná.', - 'tours.winter.kind': 'Druh zimní aktivity', - 'tours.winter.activity.winter_hike': 'Zimní turistika', - 'tours.winter.activity.snowshoe': 'Túra na sněžnicích', - 'tours.winter.labelOnly': - 'Označeno cestovateli. Trasa a čas se plánují jako u každé túry, bez kontroly zimních podmínek.', - 'tours.planner.tourTypeHint.ski': 'Ze souboru GPX, tak jak byla zaznamenána', + 'tours.planner.tourTypeHint.ski': 'Lyže, snowboard, sněžnice, zimní túry', 'tours.ski.title': 'Lyžařská trasa', 'tours.ski.discipline': 'Disciplína', 'tours.ski.discipline.alpine': 'Sjezd', 'tours.ski.discipline.snowboard': 'Snowboard', // same-as-en 'tours.ski.discipline.cross_country': 'Běh na lyžích', 'tours.ski.discipline.touring': 'Skialpinismus', + 'tours.ski.discipline.snowshoe': 'Sněžnice', + 'tours.ski.discipline.winter_hike': 'Zimní turistika', 'tours.ski.technique': 'Technika', 'tours.ski.technique.classic': 'Klasika', 'tours.ski.technique.skating': 'Bruslení', 'tours.ski.technique.unknown': 'Neznámá', 'tours.ski.chooseDiscipline': 'Vyberte disciplínu pro import trasy jako lyže.', + 'tours.ski.chooseDisciplineToSave': 'Vyberte disciplínu, abyste mohli túru uložit.', + 'tours.ski.drawnFreely': + 'Každý úsek se kreslí rovně mezi vašimi body. Nic ho neporovnává se sjezdovkami, vleky ani stopami.', 'tours.ski.chooseTechnique': 'Vyberte techniku, nebo Neznámá.', 'tours.ski.infoLabel': 'Co TREK dělá s lyžařskou trasou', 'tours.ski.info': - 'Zůstává tak, jak byla zaznamenána, a nikdy se nepřepočítává. Vzdálenost a výšky pocházejí ze souboru. TREK nepočítá žádný čas na lyžích a ani trasa, ani barvy sklonu ve 3D neříkají nic o obtížnosti, sněhu, lavinách ani o tom, zda jsou sjezdovky a stopy otevřené.', + 'Naplánovaná túra má každý úsek nakreslený rovně mezi svými body, importovaná trasa zůstává tak, jak byla zaznamenána. Ani jedna se nevede po sjezdovkách, vlecích ani stopách. Výšky pocházejí z terénu nebo ze souboru. TREK nepočítá žádný čas na lyžích a nic tady, ani barvy sklonu ve 3D, neříká nic o obtížnosti, sněhu, lavinách ani o tom, zda jsou sjezdovky a stopy otevřené.', 'tours.ski.time': 'Čas na lyžích', 'tours.ski.timeUnknown': 'Nepočítá se', + 'tours.ski.timeInfoLabel': 'Proč tu není čas na lyžích', + 'tours.ski.timeInfo': + 'TREK čas na lyžích nepočítá: úseky jsou nakreslené rovně, ne naplánované po sjezdovkách, vlecích nebo stopách. Pokud chcete, zadejte plánovaný celkový čas sami.', 'tours.ski.classifyTitle': 'Zařadit lyžařskou trasu', 'tours.ski.saved': 'Zařazení uloženo', 'tours.ski.saveFailed': 'Zařazení se nepodařilo uložit.', @@ -807,6 +806,8 @@ const tours: TranslationStrings = { 'tours.import.previewConfirm.few': 'Importovat {count} túry', 'tours.conversion.introPaddle': 'Import je zaznamenaná trasa. Naplánujte upravitelnou vodáckou túru: TREK z ní vezme kontrolní body, spojí je přímými čarami, jak se vodácká túra kreslí, a ukáže, jak blízko to je. Nic se nezmění, dokud se nerozhodnete.', + 'tours.conversion.introSki': + 'Import je zaznamenaná trasa. Naplánujte upravitelnou lyžařskou túru: TREK z ní vezme kontrolní body, spojí je přímými čarami, jak se lyžařská túra kreslí, a ukáže, jak blízko to je. Nic se nezmění, dokud se nerozhodnete.', 'tours.conversion.closePaddle': 'Návrh trasu těsně sleduje. Jeho body spojují přímé čáry, takže to není stejná čára.', 'tours.planner.moreActions': 'Další akce', 'tours.paddle.role.put_in': 'Nástup', @@ -1123,6 +1124,7 @@ const tours: TranslationStrings = { 'tourTypes.horse': 'Vyjížďka na koni', 'tours.planner.tourTypeHint.horse': 'Na koni, po značených jezdeckých trasách', 'tours.planner.namePlaceholder.horse': 'Vyjížďka bez názvu', + 'tours.planner.namePlaceholder.ski': 'Lyžařská túra bez názvu', 'tours.horse.time': 'Doba jízdy', 'tours.horse.timeInfoLabel': 'Proč tu není doba jízdy', 'tours.horse.timeInfo': diff --git a/shared/src/i18n/de/tours.ts b/shared/src/i18n/de/tours.ts index 4da9306bf..3e0886911 100644 --- a/shared/src/i18n/de/tours.ts +++ b/shared/src/i18n/de/tours.ts @@ -206,7 +206,7 @@ const tours: TranslationStrings = { 'tourTypes.hike': 'Wanderung', 'tourTypes.bike': 'Radtour', 'tourTypes.run': 'Lauf', - 'tourTypes.ski': 'Ski', // same-as-en + 'tourTypes.ski': 'Ski & Winter', 'tourTypes.kayak': 'Paddeltour', 'tourTypes.walk': 'Stadt- und Spaziergang', 'tourTypes.food': 'Genusstour', @@ -405,33 +405,32 @@ const tours: TranslationStrings = { 'tours.planner.tourTypeHint.hike': 'Zu Fuß, bis zu einer Schwierigkeit, die du festlegst', 'tours.planner.tourTypeHint.bike': 'Mit dem Rad, geroutet für Touring, Rennrad, Gravel oder MTB', 'tours.planner.tourTypeHint.kayak': 'Auf dem Wasser, mit Kajak, Kanadier oder SUP', - 'tours.winter.title': 'Winteraktivität', - 'tours.winter.infoLabel': 'Was die Winterkennzeichnung bewirkt', - 'tours.winter.info': - 'Nur eine Kennzeichnung der Wanderung. Route, Steigungsfarben und Gehzeit bleiben wie bei jeder Wanderung und sagen nichts über Schnee, Lawinen oder die Begehbarkeit im Winter.', - 'tours.winter.kind': 'Art der Winteraktivität', - 'tours.winter.activity.winter_hike': 'Winterwandern', - 'tours.winter.activity.snowshoe': 'Schneeschuhwandern', - 'tours.winter.labelOnly': - 'Von den Reisenden gekennzeichnet. Route und Zeit sind wie bei jeder Wanderung geplant, nicht für den Winter geprüft.', - 'tours.planner.tourTypeHint.ski': 'Aus einer GPX-Datei, wie aufgezeichnet', + 'tours.planner.tourTypeHint.ski': 'Ski, Snowboard, Schneeschuh, Winterwandern', 'tours.ski.title': 'Skistrecke', 'tours.ski.discipline': 'Disziplin', 'tours.ski.discipline.alpine': 'Alpin', 'tours.ski.discipline.snowboard': 'Snowboard', // same-as-en 'tours.ski.discipline.cross_country': 'Langlauf', 'tours.ski.discipline.touring': 'Skitour', + 'tours.ski.discipline.snowshoe': 'Schneeschuh', + 'tours.ski.discipline.winter_hike': 'Winterwandern', 'tours.ski.technique': 'Technik', 'tours.ski.technique.classic': 'Klassisch', 'tours.ski.technique.skating': 'Skating', // same-as-en 'tours.ski.technique.unknown': 'Unbekannt', 'tours.ski.chooseDiscipline': 'Wähle eine Disziplin, um die Strecke als Ski zu importieren.', + 'tours.ski.chooseDisciplineToSave': 'Wähle eine Disziplin, um die Tour zu speichern.', + 'tours.ski.drawnFreely': + 'Jeder Abschnitt wird gerade zwischen deinen Punkten gezeichnet. Nichts gleicht ihn mit Pisten, Liften oder Loipen ab.', 'tours.ski.chooseTechnique': 'Wähle die Technik oder Unbekannt.', 'tours.ski.infoLabel': 'Was TREK mit einer Skistrecke macht', 'tours.ski.info': - 'Bleibt wie aufgezeichnet und wird nie geroutet. Distanz und Höhen stammen aus der Datei. TREK berechnet keine Skizeit, und weder die Strecke noch die Steigungsfarben in 3D sagen etwas über Schwierigkeit, Schnee, Lawinen oder geöffnete Pisten und Loipen.', + 'Bei einer geplanten Tour ist jeder Abschnitt gerade zwischen ihren Punkten gezeichnet, eine importierte Strecke bleibt wie aufgezeichnet. Keine von beiden wird über Pisten, Lifte oder Loipen geroutet. Die Höhen stammen aus dem Gelände oder aus der Datei. TREK berechnet keine Skizeit, und nichts hier, auch nicht die Steigungsfarben in 3D, sagt etwas über Schwierigkeit, Schnee, Lawinen oder geöffnete Pisten und Loipen.', 'tours.ski.time': 'Skizeit', 'tours.ski.timeUnknown': 'Nicht berechnet', + 'tours.ski.timeInfoLabel': 'Warum es keine Skizeit gibt', + 'tours.ski.timeInfo': + 'TREK berechnet keine Skizeit: Die Abschnitte sind gerade gezeichnet, nicht über Pisten, Lifte oder Loipen geplant. Trag bei Bedarf selbst eine geplante Gesamtzeit ein.', 'tours.ski.classifyTitle': 'Skistrecke einordnen', 'tours.ski.saved': 'Einordnung gespeichert', 'tours.ski.saveFailed': 'Die Einordnung konnte nicht gespeichert werden.', @@ -822,6 +821,8 @@ const tours: TranslationStrings = { 'tours.import.previewConfirm.one': '{count} Tour importieren', 'tours.conversion.introPaddle': 'Ein Import ist eine aufgezeichnete Strecke. Plane eine bearbeitbare Paddeltour: TREK nimmt Kontrollpunkte daraus, verbindet sie mit geraden Linien, wie eine Paddeltour gezeichnet wird, und zeigt, wie nah das herankommt. Nichts ändert sich, bis du dich entscheidest.', + 'tours.conversion.introSki': + 'Ein Import ist eine aufgezeichnete Strecke. Plane eine bearbeitbare Skitour: TREK nimmt Kontrollpunkte daraus, verbindet sie mit geraden Linien, wie eine Skitour gezeichnet wird, und zeigt, wie nah das herankommt. Nichts ändert sich, bis du dich entscheidest.', 'tours.conversion.closePaddle': 'Der Vorschlag folgt der Strecke eng. Seine Punkte sind mit geraden Linien verbunden, daher ist es nicht dieselbe Linie.', 'tours.planner.moreActions': 'Weitere Aktionen', @@ -1145,6 +1146,7 @@ const tours: TranslationStrings = { 'tourTypes.horse': 'Ausritt', 'tours.planner.tourTypeHint.horse': 'Zu Pferd, auf ausgeschilderten Reitrouten', 'tours.planner.namePlaceholder.horse': 'Unbenannter Ausritt', + 'tours.planner.namePlaceholder.ski': 'Unbenannte Skitour', 'tours.horse.time': 'Reitzeit', 'tours.horse.timeInfoLabel': 'Warum es keine Reitzeit gibt', 'tours.horse.timeInfo': diff --git a/shared/src/i18n/en/tours.ts b/shared/src/i18n/en/tours.ts index 1ef7bd99b..cbae5f39a 100644 --- a/shared/src/i18n/en/tours.ts +++ b/shared/src/i18n/en/tours.ts @@ -199,7 +199,7 @@ const tours: TranslationStrings = { 'tourTypes.hike': 'Hike', 'tourTypes.bike': 'Bike tour', 'tourTypes.run': 'Run', - 'tourTypes.ski': 'Ski', + 'tourTypes.ski': 'Ski & winter', 'tourTypes.kayak': 'Paddle tour', 'tourTypes.walk': 'City / sightseeing walk', 'tourTypes.food': 'Food/wine tour', @@ -395,33 +395,32 @@ const tours: TranslationStrings = { 'tours.planner.tourTypeHint.hike': 'On foot, within a difficulty you set', 'tours.planner.tourTypeHint.bike': 'By bike, routed for touring, road, gravel or MTB', 'tours.planner.tourTypeHint.kayak': 'On the water, by kayak, canoe or SUP', - 'tours.winter.title': 'Winter activity', - 'tours.winter.infoLabel': 'What the winter label does', - 'tours.winter.info': - 'A label for the hike only. Route, grade colours and walking time stay as for any hike and say nothing about snow, avalanches or whether a path can be walked in winter.', - 'tours.winter.kind': 'Kind of winter activity', - 'tours.winter.activity.winter_hike': 'Winter hike', - 'tours.winter.activity.snowshoe': 'Snowshoe hike', - 'tours.winter.labelOnly': - 'Marked by the travellers. Route and time are planned as for any hike, not checked for winter.', - 'tours.planner.tourTypeHint.ski': 'From a GPX file, kept as recorded', + 'tours.planner.tourTypeHint.ski': 'Skiing, snowboarding, snowshoeing, winter hikes', 'tours.ski.title': 'Ski track', 'tours.ski.discipline': 'Discipline', 'tours.ski.discipline.alpine': 'Alpine', 'tours.ski.discipline.snowboard': 'Snowboard', 'tours.ski.discipline.cross_country': 'Cross-country', 'tours.ski.discipline.touring': 'Ski touring', + 'tours.ski.discipline.snowshoe': 'Snowshoeing', + 'tours.ski.discipline.winter_hike': 'Winter hiking', 'tours.ski.technique': 'Technique', 'tours.ski.technique.classic': 'Classic', 'tours.ski.technique.skating': 'Skating', 'tours.ski.technique.unknown': 'Unknown', 'tours.ski.chooseDiscipline': 'Choose a discipline to import the track as ski.', + 'tours.ski.chooseDisciplineToSave': 'Choose a discipline to save the tour.', + 'tours.ski.drawnFreely': + 'Each leg is drawn straight between your points. Nothing checks it against pistes, lifts or trails.', 'tours.ski.chooseTechnique': 'Choose the technique, or Unknown.', 'tours.ski.infoLabel': 'What TREK does with a ski track', 'tours.ski.info': - 'Kept as recorded and never routed. Distance and heights come from the file. TREK works out no ski time, and neither the track nor the gradient colours in 3D say anything about difficulty, snow, avalanches or whether runs and trails are open.', + 'A planned tour has each leg drawn straight between its points, an imported track is kept as recorded. Neither is routed along pistes, lifts or trails. Heights come from the terrain or the file. TREK works out no ski time, and nothing here, the gradient colours in 3D included, says anything about difficulty, snow, avalanches or whether runs and trails are open.', 'tours.ski.time': 'Ski time', 'tours.ski.timeUnknown': 'Not worked out', + 'tours.ski.timeInfoLabel': 'Why there is no ski time', + 'tours.ski.timeInfo': + 'TREK works out no ski time: the legs are drawn straight, not planned along pistes, lifts or trails. Enter a planned total yourself if you want one.', 'tours.ski.classifyTitle': 'Classify ski track', 'tours.ski.saved': 'Classification saved', 'tours.ski.saveFailed': 'The classification could not be saved.', @@ -800,6 +799,8 @@ const tours: TranslationStrings = { 'tours.import.previewConfirm.one': 'Import {count} tour', 'tours.conversion.introPaddle': 'An import is a recorded track. Plan an editable paddle tour: TREK takes control points from it, joins them with straight lines as a paddle tour is drawn, and shows how close that comes. Nothing changes until you choose.', + 'tours.conversion.introSki': + 'An import is a recorded track. Plan an editable ski tour: TREK takes control points from it, joins them with straight lines as a ski tour is drawn, and shows how close that comes. Nothing changes until you choose.', 'tours.conversion.closePaddle': 'The suggestion follows the track closely. Its points are joined by straight lines, so it is not the same line.', 'tours.planner.moreActions': 'More actions', @@ -1108,6 +1109,7 @@ const tours: TranslationStrings = { 'tourTypes.horse': 'Horse ride', 'tours.planner.tourTypeHint.horse': 'On horseback, along signposted horse routes', 'tours.planner.namePlaceholder.horse': 'Untitled ride', + 'tours.planner.namePlaceholder.ski': 'Untitled ski tour', 'tours.horse.time': 'Riding time', 'tours.horse.timeInfoLabel': 'Why there is no riding time', 'tours.horse.timeInfo': diff --git a/shared/src/i18n/es/tours.ts b/shared/src/i18n/es/tours.ts index 4e1576750..a516c39f2 100644 --- a/shared/src/i18n/es/tours.ts +++ b/shared/src/i18n/es/tours.ts @@ -205,7 +205,7 @@ const tours: TranslationStrings = { 'tourTypes.hike': 'Senderismo', 'tourTypes.bike': 'Ruta en bicicleta', 'tourTypes.run': 'Carrera a pie', - 'tourTypes.ski': 'Esquí', + 'tourTypes.ski': 'Esquí e invierno', 'tourTypes.kayak': 'Ruta en piragua', 'tourTypes.walk': 'Paseo urbano / turístico', 'tourTypes.food': 'Ruta gastronómica / enológica', @@ -402,33 +402,32 @@ const tours: TranslationStrings = { 'tours.planner.tourTypeHint.hike': 'A pie, dentro de una dificultad que tú eliges', 'tours.planner.tourTypeHint.bike': 'En bici, con ruta para cicloturismo, carretera, gravel o MTB', 'tours.planner.tourTypeHint.kayak': 'En el agua, en kayak, canoa o SUP', - 'tours.winter.title': 'Actividad invernal', - 'tours.winter.infoLabel': 'Qué hace la marca invernal', - 'tours.winter.info': - 'Solo una marca de la ruta. El trazado, los colores de pendiente y el tiempo de marcha son los de cualquier ruta y no dicen nada sobre nieve, aludes ni si un camino es transitable en invierno.', - 'tours.winter.kind': 'Tipo de actividad invernal', - 'tours.winter.activity.winter_hike': 'Senderismo invernal', - 'tours.winter.activity.snowshoe': 'Ruta con raquetas de nieve', - 'tours.winter.labelOnly': - 'Marcado por los viajeros. Ruta y tiempo se planifican como en cualquier ruta, sin comprobación invernal.', - 'tours.planner.tourTypeHint.ski': 'Desde un archivo GPX, tal como se grabó', + 'tours.planner.tourTypeHint.ski': 'Esquí, snowboard, raquetas, rutas invernales', 'tours.ski.title': 'Ruta de esquí', 'tours.ski.discipline': 'Disciplina', 'tours.ski.discipline.alpine': 'Alpino', 'tours.ski.discipline.snowboard': 'Snowboard', // same-as-en 'tours.ski.discipline.cross_country': 'Esquí de fondo', 'tours.ski.discipline.touring': 'Esquí de travesía', + 'tours.ski.discipline.snowshoe': 'Raquetas', + 'tours.ski.discipline.winter_hike': 'Ruta invernal', 'tours.ski.technique': 'Técnica', 'tours.ski.technique.classic': 'Clásica', 'tours.ski.technique.skating': 'Skating', // same-as-en 'tours.ski.technique.unknown': 'Desconocida', 'tours.ski.chooseDiscipline': 'Elige una disciplina para importar la ruta como esquí.', + 'tours.ski.chooseDisciplineToSave': 'Elige una disciplina para guardar la ruta.', + 'tours.ski.drawnFreely': + 'Cada tramo se dibuja en línea recta entre tus puntos. Nada lo comprueba con pistas, remontes ni senderos.', 'tours.ski.chooseTechnique': 'Elige la técnica, o Desconocida.', 'tours.ski.infoLabel': 'Qué hace TREK con una ruta de esquí', 'tours.ski.info': - 'Se conserva tal como se grabó y nunca se enruta. La distancia y las alturas vienen del archivo. TREK no calcula ningún tiempo de esquí, y ni la ruta ni los colores de pendiente en 3D dicen nada sobre dificultad, nieve, aludes o si las pistas están abiertas.', + 'Una ruta planificada tiene cada tramo dibujado en línea recta entre sus puntos, y un recorrido importado se conserva tal como se grabó. Ninguno se enruta por pistas, remontes ni senderos. Las alturas vienen del terreno o del archivo. TREK no calcula ningún tiempo de esquí, y nada de aquí, ni los colores de pendiente en 3D, dice nada sobre dificultad, nieve, aludes o si las pistas están abiertas.', 'tours.ski.time': 'Tiempo de esquí', 'tours.ski.timeUnknown': 'Sin calcular', + 'tours.ski.timeInfoLabel': 'Por qué no hay tiempo de esquí', + 'tours.ski.timeInfo': + 'TREK no calcula ningún tiempo de esquí: los tramos se dibujan rectos, no se planifican por pistas, remontes ni senderos. Si quieres, introduce tú mismo un total previsto.', 'tours.ski.classifyTitle': 'Clasificar ruta de esquí', 'tours.ski.saved': 'Clasificación guardada', 'tours.ski.saveFailed': 'No se pudo guardar la clasificación.', @@ -813,6 +812,8 @@ const tours: TranslationStrings = { 'tours.import.previewConfirm.one': 'Importar {count} ruta', 'tours.conversion.introPaddle': 'Una importación es un recorrido grabado. Planifica una ruta en piragua editable: TREK toma puntos de control, los une con líneas rectas como se dibuja una ruta en piragua y muestra cuánto se acerca. Nada cambia hasta que elijas.', + 'tours.conversion.introSki': + 'Una importación es un recorrido grabado. Planifica una ruta de esquí editable: TREK toma puntos de control, los une con líneas rectas como se dibuja una ruta de esquí y muestra cuánto se acerca. Nada cambia hasta que elijas.', 'tours.conversion.closePaddle': 'La propuesta sigue el recorrido de cerca. Sus puntos se unen con líneas rectas, así que no es la misma línea.', 'tours.planner.moreActions': 'Más acciones', @@ -1131,6 +1132,7 @@ const tours: TranslationStrings = { 'tourTypes.horse': 'Ruta a caballo', 'tours.planner.tourTypeHint.horse': 'A caballo, por rutas ecuestres señalizadas', 'tours.planner.namePlaceholder.horse': 'Ruta a caballo sin título', + 'tours.planner.namePlaceholder.ski': 'Ruta de esquí sin título', 'tours.horse.time': 'Tiempo a caballo', 'tours.horse.timeInfoLabel': 'Por qué no hay tiempo a caballo', 'tours.horse.timeInfo': diff --git a/shared/src/i18n/et/tours.ts b/shared/src/i18n/et/tours.ts index 8d1331846..1c3229782 100644 --- a/shared/src/i18n/et/tours.ts +++ b/shared/src/i18n/et/tours.ts @@ -202,7 +202,7 @@ const tours: TranslationStrings = { 'tourTypes.hike': 'Matk', 'tourTypes.bike': 'Rattaretk', 'tourTypes.run': 'Jooks', - 'tourTypes.ski': 'Suusad', + 'tourTypes.ski': 'Suusad ja talv', 'tourTypes.kayak': 'Aerutamismatk', 'tourTypes.walk': 'Linna- / vaatamisväärsuste jalutuskäik', 'tourTypes.food': 'Toidu- / veiniretk', @@ -397,33 +397,32 @@ const tours: TranslationStrings = { 'tours.planner.tourTypeHint.hike': 'Jalgsi, sinu määratud raskuse piires', 'tours.planner.tourTypeHint.bike': 'Rattaga, marsruut matka-, maantee-, gravel- või maastikurattale', 'tours.planner.tourTypeHint.kayak': 'Vee peal, süsta, kanuu või SUP-iga', - 'tours.winter.title': 'Talvine tegevus', - 'tours.winter.infoLabel': 'Mida talvemärge teeb', - 'tours.winter.info': - 'Ainult matka märge. Marsruut, tõusuvärvid ja kõnniaeg jäävad samaks nagu igal matkal ega ütle midagi lume, laviinide ega selle kohta, kas rada on talvel läbitav.', - 'tours.winter.kind': 'Talvise tegevuse liik', - 'tours.winter.activity.winter_hike': 'Talvematk', - 'tours.winter.activity.snowshoe': 'Räätsamatk', - 'tours.winter.labelOnly': - 'Reisijate märgitud. Marsruut ja aeg on kavandatud nagu igal matkal, talve jaoks kontrollimata.', - 'tours.planner.tourTypeHint.ski': 'GPX-failist, nagu salvestati', + 'tours.planner.tourTypeHint.ski': 'Suusatamine, lumelaud, räätsad, talvematkad', 'tours.ski.title': 'Suusarada', 'tours.ski.discipline': 'Ala', 'tours.ski.discipline.alpine': 'Mäesuusatamine', 'tours.ski.discipline.snowboard': 'Lumelaud', 'tours.ski.discipline.cross_country': 'Murdmaasuusatamine', 'tours.ski.discipline.touring': 'Matkasuusatamine', + 'tours.ski.discipline.snowshoe': 'Räätsamatk', + 'tours.ski.discipline.winter_hike': 'Talvematk', 'tours.ski.technique': 'Tehnika', 'tours.ski.technique.classic': 'Klassika', 'tours.ski.technique.skating': 'Uisutehnika', 'tours.ski.technique.unknown': 'Teadmata', 'tours.ski.chooseDiscipline': 'Vali ala, et importida rada suusatamisena.', + 'tours.ski.chooseDisciplineToSave': 'Vali ala, et matk salvestada.', + 'tours.ski.drawnFreely': + 'Iga lõik joonistatakse sinu punktide vahele sirgelt. Miski ei võrdle seda nõlvade, tõstukite ega radadega.', 'tours.ski.chooseTechnique': 'Vali tehnika või Teadmata.', 'tours.ski.infoLabel': 'Mida TREK suusarajaga teeb', 'tours.ski.info': - 'Jääb nii, nagu salvestati, ja seda ei marsruudita kunagi. Pikkus ja kõrgused tulevad failist. TREK ei arvuta suusaaega ning ei rada ega 3D kallakuvärvid ütle midagi raskuse, lume, laviinide ega nõlvade ja radade avatuse kohta.', + 'Planeeritud matkal joonistatakse iga lõik punktide vahele sirgelt, imporditud rada jääb nii, nagu salvestati. Kumbagi ei marsruudita mööda nõlvu, tõstukeid ega radu. Kõrgused tulevad maastikust või failist. TREK ei arvuta suusaaega ning miski siin, ka 3D kallakuvärvid, ei ütle midagi raskuse, lume, laviinide ega nõlvade ja radade avatuse kohta.', 'tours.ski.time': 'Suusaaeg', 'tours.ski.timeUnknown': 'Arvutamata', + 'tours.ski.timeInfoLabel': 'Miks suusaaega pole', + 'tours.ski.timeInfo': + 'TREK ei arvuta suusaaega: lõigud on joonistatud sirgelt, mitte planeeritud mööda nõlvu, tõstukeid ega radu. Soovi korral sisesta kavandatud koguaeg ise.', 'tours.ski.classifyTitle': 'Liigita suusarada', 'tours.ski.saved': 'Liigitus salvestatud', 'tours.ski.saveFailed': 'Liigitust ei õnnestunud salvestada.', @@ -804,6 +803,8 @@ const tours: TranslationStrings = { 'tours.import.previewConfirm.one': 'Impordi {count} matk', 'tours.conversion.introPaddle': 'Import on salvestatud rada. Planeeri muudetav aerutamismatk: TREK võtab sellest kontrollpunktid, ühendab need sirgjoontega, nagu aerutamismatk joonistatakse, ja näitab, kui lähedale see jõuab. Midagi ei muutu enne, kui sa valid.', + 'tours.conversion.introSki': + 'Import on salvestatud rada. Planeeri muudetav suusamatk: TREK võtab sellest kontrollpunktid, ühendab need sirgjoontega, nagu suusamatk joonistatakse, ja näitab, kui lähedale see jõuab. Midagi ei muutu enne, kui sa valid.', 'tours.conversion.closePaddle': 'Ettepanek järgib rada täpselt. Selle punktid on ühendatud sirgjoontega, seega pole see sama joon.', 'tours.planner.moreActions': 'Rohkem toiminguid', @@ -1117,6 +1118,7 @@ const tours: TranslationStrings = { 'tourTypes.horse': 'Ratsamatk', 'tours.planner.tourTypeHint.horse': 'Hobusel, märgistatud ratsaradadel', 'tours.planner.namePlaceholder.horse': 'Nimetu ratsamatk', + 'tours.planner.namePlaceholder.ski': 'Nimetu suusamatk', 'tours.horse.time': 'Ratsutamisaeg', 'tours.horse.timeInfoLabel': 'Miks ratsutamisaega pole', 'tours.horse.timeInfo': diff --git a/shared/src/i18n/fr/tours.ts b/shared/src/i18n/fr/tours.ts index ac67f7f7d..cab016dbc 100644 --- a/shared/src/i18n/fr/tours.ts +++ b/shared/src/i18n/fr/tours.ts @@ -208,7 +208,7 @@ const tours: TranslationStrings = { 'tourTypes.hike': 'Randonnée', 'tourTypes.bike': 'Sortie à vélo', 'tourTypes.run': 'Course à pied', - 'tourTypes.ski': 'Ski', // same-as-en + 'tourTypes.ski': 'Ski et hiver', 'tourTypes.kayak': 'Sortie en pagaie', 'tourTypes.walk': 'Balade urbaine / touristique', 'tourTypes.food': 'Circuit gastronomique / œnologique', @@ -408,33 +408,32 @@ const tours: TranslationStrings = { 'tours.planner.tourTypeHint.hike': 'À pied, dans la difficulté que tu choisis', 'tours.planner.tourTypeHint.bike': 'À vélo, itinéraire pour le voyage, la route, le gravel ou le VTT', 'tours.planner.tourTypeHint.kayak': "Sur l'eau, en kayak, canoë ou SUP", - 'tours.winter.title': 'Activité hivernale', - 'tours.winter.infoLabel': 'À quoi sert le marquage hivernal', - 'tours.winter.info': - 'Simple marquage de la randonnée. L’itinéraire, les couleurs de pente et le temps de marche restent ceux de toute randonnée et ne disent rien de la neige, des avalanches ni de la praticabilité d’un chemin en hiver.', - 'tours.winter.kind': 'Type d’activité hivernale', - 'tours.winter.activity.winter_hike': 'Randonnée hivernale', - 'tours.winter.activity.snowshoe': 'Randonnée en raquettes', - 'tours.winter.labelOnly': - 'Marqué par les voyageurs. Itinéraire et durée sont planifiés comme pour toute randonnée, sans vérification hivernale.', - 'tours.planner.tourTypeHint.ski': 'Depuis un fichier GPX, tel qu’enregistré', + 'tours.planner.tourTypeHint.ski': 'Ski, snowboard, raquettes, randonnées hivernales', 'tours.ski.title': 'Trace de ski', 'tours.ski.discipline': 'Discipline', // same-as-en 'tours.ski.discipline.alpine': 'Alpin', 'tours.ski.discipline.snowboard': 'Snowboard', // same-as-en 'tours.ski.discipline.cross_country': 'Ski de fond', 'tours.ski.discipline.touring': 'Ski de randonnée', + 'tours.ski.discipline.snowshoe': 'Raquettes', + 'tours.ski.discipline.winter_hike': 'Rando hivernale', 'tours.ski.technique': 'Technique', // same-as-en 'tours.ski.technique.classic': 'Classique', 'tours.ski.technique.skating': 'Skating', // same-as-en 'tours.ski.technique.unknown': 'Inconnue', 'tours.ski.chooseDiscipline': 'Choisissez une discipline pour importer la trace en ski.', + 'tours.ski.chooseDisciplineToSave': 'Choisissez une discipline pour enregistrer la sortie.', + 'tours.ski.drawnFreely': + 'Chaque tronçon est tracé en ligne droite entre vos points. Rien ne le vérifie par rapport aux pistes, remontées ou sentiers.', 'tours.ski.chooseTechnique': 'Choisissez la technique, ou Inconnue.', 'tours.ski.infoLabel': 'Ce que TREK fait d’une trace de ski', 'tours.ski.info': - 'Conservée telle qu’enregistrée et jamais calculée par un routeur. Distance et altitudes viennent du fichier. TREK ne calcule aucun temps de ski, et ni la trace ni les couleurs de pente en 3D ne disent quoi que ce soit sur la difficulté, la neige, les avalanches ou l’ouverture des pistes.', + 'Une sortie planifiée a chaque tronçon tracé en ligne droite entre ses points, une trace importée est conservée telle qu’enregistrée. Aucune n’est calculée le long des pistes, remontées ou sentiers. Les altitudes viennent du terrain ou du fichier. TREK ne calcule aucun temps de ski, et rien ici, pas même les couleurs de pente en 3D, ne dit quoi que ce soit sur la difficulté, la neige, les avalanches ou l’ouverture des pistes.', 'tours.ski.time': 'Temps de ski', 'tours.ski.timeUnknown': 'Non calculé', + 'tours.ski.timeInfoLabel': 'Pourquoi il n’y a pas de temps de ski', + 'tours.ski.timeInfo': + 'TREK ne calcule aucun temps de ski : les tronçons sont tracés en ligne droite, pas planifiés le long des pistes, remontées ou sentiers. Saisissez vous-même une durée totale prévue si besoin.', 'tours.ski.classifyTitle': 'Classer la trace de ski', 'tours.ski.saved': 'Classement enregistré', 'tours.ski.saveFailed': 'Le classement n’a pas pu être enregistré.', @@ -826,6 +825,8 @@ const tours: TranslationStrings = { 'tours.import.previewConfirm.one': 'Importer {count} sortie', 'tours.conversion.introPaddle': 'Un import est une trace enregistrée. Planifiez une sortie en pagaie modifiable : TREK en tire des points de contrôle, les relie en lignes droites comme se trace une sortie en pagaie et montre à quel point c’est proche. Rien ne change tant que vous ne choisissez pas.', + 'tours.conversion.introSki': + 'Un import est une trace enregistrée. Planifiez une sortie à ski modifiable : TREK en tire des points de contrôle, les relie en lignes droites comme se trace une sortie à ski et montre à quel point c’est proche. Rien ne change tant que vous ne choisissez pas.', 'tours.conversion.closePaddle': 'La proposition suit la trace de près. Ses points sont reliés par des lignes droites, ce n’est donc pas la même ligne.', 'tours.planner.moreActions': 'Plus d’actions', @@ -1153,6 +1154,7 @@ const tours: TranslationStrings = { 'tourTypes.horse': 'Randonnée équestre', 'tours.planner.tourTypeHint.horse': 'À cheval, sur des itinéraires équestres balisés', 'tours.planner.namePlaceholder.horse': 'Randonnée équestre sans titre', + 'tours.planner.namePlaceholder.ski': 'Sortie à ski sans titre', 'tours.horse.time': 'Temps à cheval', 'tours.horse.timeInfoLabel': "Pourquoi il n'y a pas de temps à cheval", 'tours.horse.timeInfo': diff --git a/shared/src/i18n/gr/tours.ts b/shared/src/i18n/gr/tours.ts index 8ed3f5bcd..fef4ab66c 100644 --- a/shared/src/i18n/gr/tours.ts +++ b/shared/src/i18n/gr/tours.ts @@ -209,7 +209,7 @@ const tours: TranslationStrings = { 'tourTypes.hike': 'Πεζοπορία', 'tourTypes.bike': 'Ποδηλατική εκδρομή', 'tourTypes.run': 'Τρέξιμο', - 'tourTypes.ski': 'Σκι', + 'tourTypes.ski': 'Σκι και χειμώνας', 'tourTypes.kayak': 'Διαδρομή με κουπί', 'tourTypes.walk': 'Περίπατος στην πόλη / στα αξιοθέατα', 'tourTypes.food': 'Γαστρονομική / οινική εκδρομή', @@ -410,33 +410,32 @@ const tours: TranslationStrings = { 'tours.planner.tourTypeHint.hike': 'Με τα πόδια, μέσα στη δυσκολία που ορίζεις', 'tours.planner.tourTypeHint.bike': 'Με ποδήλατο, διαδρομή για περιήγηση, δρόμο, gravel ή MTB', 'tours.planner.tourTypeHint.kayak': 'Στο νερό, με καγιάκ, κανό ή SUP', - 'tours.winter.title': 'Χειμερινή δραστηριότητα', - 'tours.winter.infoLabel': 'Τι κάνει η χειμερινή σήμανση', - 'tours.winter.info': - 'Μόνο μια σήμανση της πεζοπορίας. Η διαδρομή, τα χρώματα κλίσης και ο χρόνος πορείας μένουν όπως σε κάθε πεζοπορία και δεν λένε τίποτα για χιόνι, χιονοστιβάδες ή για το αν ένα μονοπάτι είναι βατό τον χειμώνα.', - 'tours.winter.kind': 'Είδος χειμερινής δραστηριότητας', - 'tours.winter.activity.winter_hike': 'Χειμερινή πεζοπορία', - 'tours.winter.activity.snowshoe': 'Πεζοπορία με χιονοπέδιλα', - 'tours.winter.labelOnly': - 'Σημειώθηκε από τους ταξιδιώτες. Η διαδρομή και ο χρόνος σχεδιάζονται όπως σε κάθε πεζοπορία, χωρίς έλεγχο για τον χειμώνα.', - 'tours.planner.tourTypeHint.ski': 'Από αρχείο GPX, όπως καταγράφηκε', + 'tours.planner.tourTypeHint.ski': 'Σκι, σανίδα χιονιού, χιονοπέδιλα, χειμερινές πεζοπορίες', 'tours.ski.title': 'Διαδρομή σκι', 'tours.ski.discipline': 'Αγώνισμα', 'tours.ski.discipline.alpine': 'Αλπικό', 'tours.ski.discipline.snowboard': 'Σνόουμπορντ', 'tours.ski.discipline.cross_country': 'Σκι αντοχής', 'tours.ski.discipline.touring': 'Ορειβατικό σκι', + 'tours.ski.discipline.snowshoe': 'Χιονοπέδιλα', + 'tours.ski.discipline.winter_hike': 'Χειμερινή πεζοπορία', 'tours.ski.technique': 'Τεχνική', 'tours.ski.technique.classic': 'Κλασική', 'tours.ski.technique.skating': 'Ελεύθερη', 'tours.ski.technique.unknown': 'Άγνωστη', 'tours.ski.chooseDiscipline': 'Επιλέξτε αγώνισμα για εισαγωγή της διαδρομής ως σκι.', + 'tours.ski.chooseDisciplineToSave': 'Επιλέξτε αγώνισμα για να αποθηκεύσετε τη διαδρομή.', + 'tours.ski.drawnFreely': + 'Κάθε σκέλος σχεδιάζεται ευθεία ανάμεσα στα σημεία σας. Τίποτα δεν το ελέγχει με πίστες, αναβατήρες ή μονοπάτια.', 'tours.ski.chooseTechnique': 'Επιλέξτε τεχνική ή Άγνωστη.', 'tours.ski.infoLabel': 'Τι κάνει το TREK με μια διαδρομή σκι', 'tours.ski.info': - 'Μένει όπως καταγράφηκε και δεν δρομολογείται ποτέ. Απόσταση και υψόμετρα προέρχονται από το αρχείο. Το TREK δεν υπολογίζει χρόνο σκι, και ούτε η διαδρομή ούτε τα χρώματα κλίσης στο 3D λένε κάτι για δυσκολία, χιόνι, χιονοστιβάδες ή αν οι πίστες είναι ανοιχτές.', + 'Σε μια σχεδιασμένη διαδρομή κάθε σκέλος σχεδιάζεται ευθεία ανάμεσα στα σημεία της, ενώ ένα εισαγόμενο ίχνος μένει όπως καταγράφηκε. Κανένα δεν δρομολογείται σε πίστες, αναβατήρες ή μονοπάτια. Τα υψόμετρα προέρχονται από το έδαφος ή το αρχείο. Το TREK δεν υπολογίζει χρόνο σκι, και τίποτα εδώ, ούτε τα χρώματα κλίσης στο 3D, δεν λέει κάτι για δυσκολία, χιόνι, χιονοστιβάδες ή αν οι πίστες είναι ανοιχτές.', 'tours.ski.time': 'Χρόνος σκι', 'tours.ski.timeUnknown': 'Δεν υπολογίστηκε', + 'tours.ski.timeInfoLabel': 'Γιατί δεν υπάρχει χρόνος σκι', + 'tours.ski.timeInfo': + 'Το TREK δεν υπολογίζει χρόνο σκι: τα σκέλη σχεδιάζονται ευθεία, όχι κατά μήκος πιστών, αναβατήρων ή μονοπατιών. Αν θέλετε, δώστε μόνοι σας έναν προγραμματισμένο συνολικό χρόνο.', 'tours.ski.classifyTitle': 'Κατάταξη διαδρομής σκι', 'tours.ski.saved': 'Η κατάταξη αποθηκεύτηκε', 'tours.ski.saveFailed': 'Η κατάταξη δεν αποθηκεύτηκε.', @@ -831,6 +830,8 @@ const tours: TranslationStrings = { 'tours.import.previewConfirm.one': 'Εισαγωγή {count} διαδρομής', 'tours.conversion.introPaddle': 'Μια εισαγωγή είναι ένα καταγεγραμμένο ίχνος. Σχεδιάστε μια επεξεργάσιμη διαδρομή με κουπί: το TREK παίρνει σημεία ελέγχου, τα ενώνει με ευθείες, όπως σχεδιάζεται μια διαδρομή με κουπί, και δείχνει πόσο κοντά φτάνει. Τίποτα δεν αλλάζει μέχρι να επιλέξετε.', + 'tours.conversion.introSki': + 'Μια εισαγωγή είναι ένα καταγεγραμμένο ίχνος. Σχεδιάστε μια επεξεργάσιμη διαδρομή σκι: το TREK παίρνει σημεία ελέγχου, τα ενώνει με ευθείες, όπως σχεδιάζεται μια διαδρομή σκι, και δείχνει πόσο κοντά φτάνει. Τίποτα δεν αλλάζει μέχρι να επιλέξετε.', 'tours.conversion.closePaddle': 'Η πρόταση ακολουθεί στενά το ίχνος. Τα σημεία της ενώνονται με ευθείες, άρα δεν είναι η ίδια γραμμή.', 'tours.planner.moreActions': 'Περισσότερες ενέργειες', @@ -1157,6 +1158,7 @@ const tours: TranslationStrings = { 'tourTypes.horse': 'Ιππική βόλτα', 'tours.planner.tourTypeHint.horse': 'Με άλογο, σε σηματοδοτημένες ιππικές διαδρομές', 'tours.planner.namePlaceholder.horse': 'Ιππική βόλτα χωρίς τίτλο', + 'tours.planner.namePlaceholder.ski': 'Διαδρομή σκι χωρίς τίτλο', 'tours.horse.time': 'Χρόνος ιππασίας', 'tours.horse.timeInfoLabel': 'Γιατί δεν υπάρχει χρόνος ιππασίας', 'tours.horse.timeInfo': diff --git a/shared/src/i18n/hu/tours.ts b/shared/src/i18n/hu/tours.ts index b3315e2f1..8c02db029 100644 --- a/shared/src/i18n/hu/tours.ts +++ b/shared/src/i18n/hu/tours.ts @@ -204,7 +204,7 @@ const tours: TranslationStrings = { 'tourTypes.hike': 'Gyalogtúra', 'tourTypes.bike': 'Kerékpártúra', 'tourTypes.run': 'Futás', - 'tourTypes.ski': 'Sí', + 'tourTypes.ski': 'Sí és tél', 'tourTypes.kayak': 'Evezős túra', 'tourTypes.walk': 'Városi / városnéző séta', 'tourTypes.food': 'Gasztro- / bortúra', @@ -402,33 +402,32 @@ const tours: TranslationStrings = { 'tours.planner.tourTypeHint.hike': 'Gyalog, az általad megadott nehézségig', 'tours.planner.tourTypeHint.bike': 'Kerékpárral, túra, országúti, gravel vagy MTB útvonallal', 'tours.planner.tourTypeHint.kayak': 'Vízen, kajakkal, kenuval vagy SUP-pal', - 'tours.winter.title': 'Téli tevékenység', - 'tours.winter.infoLabel': 'Mit jelent a téli jelölés', - 'tours.winter.info': - 'Csak a túra jelölése. Az útvonal, a meredekségi színek és a menetidő ugyanazok, mint bármely túránál, és semmit nem mondanak a hóról, a lavinákról vagy arról, hogy az út télen járható-e.', - 'tours.winter.kind': 'A téli tevékenység fajtája', - 'tours.winter.activity.winter_hike': 'Téli túra', - 'tours.winter.activity.snowshoe': 'Hótalpas túra', - 'tours.winter.labelOnly': - 'Az utazók jelölték meg. Az útvonal és az idő úgy van tervezve, mint bármely túránál, téli szempontból nincs ellenőrizve.', - 'tours.planner.tourTypeHint.ski': 'GPX-fájlból, ahogy rögzítették', + 'tours.planner.tourTypeHint.ski': 'Sí, snowboard, hótalp, téli túrák', 'tours.ski.title': 'Sínyomvonal', 'tours.ski.discipline': 'Szakág', 'tours.ski.discipline.alpine': 'Alpesi', 'tours.ski.discipline.snowboard': 'Snowboard', // same-as-en 'tours.ski.discipline.cross_country': 'Sífutás', 'tours.ski.discipline.touring': 'Sítúra', + 'tours.ski.discipline.snowshoe': 'Hótalpas túra', + 'tours.ski.discipline.winter_hike': 'Téli túra', 'tours.ski.technique': 'Technika', 'tours.ski.technique.classic': 'Klasszikus', 'tours.ski.technique.skating': 'Korcsolyázó', 'tours.ski.technique.unknown': 'Ismeretlen', 'tours.ski.chooseDiscipline': 'Válassz szakágat, hogy sínyomvonalként importáld.', + 'tours.ski.chooseDisciplineToSave': 'Válassz szakágat a túra mentéséhez.', + 'tours.ski.drawnFreely': + 'Minden szakaszt egyenesen rajzolunk a pontjaid közé. Semmi sem veti össze pályákkal, felvonókkal vagy nyomvonalakkal.', 'tours.ski.chooseTechnique': 'Válaszd ki a technikát, vagy az Ismeretlent.', 'tours.ski.infoLabel': 'Mit kezd a TREK egy sínyomvonallal', 'tours.ski.info': - 'Úgy marad, ahogy rögzítették, útvonaltervező soha nem számolja újra. A táv és a magasságok a fájlból származnak. A TREK nem számol síidőt, és sem a nyomvonal, sem a 3D lejtőszínei nem mondanak semmit a nehézségről, a hóról, a lavinákról vagy arról, hogy nyitva vannak-e a pályák.', + 'Egy tervezett túra minden szakasza egyenesen fut a pontjai között, egy importált nyomvonal pedig úgy marad, ahogy rögzítették. Egyiket sem vezetjük pályákon, felvonókon vagy nyomvonalakon. A magasságok a terepből vagy a fájlból származnak. A TREK nem számol síidőt, és semmi itt, a 3D lejtőszínei sem, nem mond semmit a nehézségről, a hóról, a lavinákról vagy arról, hogy nyitva vannak-e a pályák.', 'tours.ski.time': 'Síidő', 'tours.ski.timeUnknown': 'Nincs kiszámolva', + 'tours.ski.timeInfoLabel': 'Miért nincs síidő', + 'tours.ski.timeInfo': + 'A TREK nem számol síidőt: a szakaszok egyenesek, nem pályák, felvonók vagy nyomvonalak mentén tervezettek. Ha szeretnéd, add meg magad a tervezett teljes időt.', 'tours.ski.classifyTitle': 'Sínyomvonal besorolása', 'tours.ski.saved': 'Besorolás mentve', 'tours.ski.saveFailed': 'A besorolást nem sikerült menteni.', @@ -817,6 +816,8 @@ const tours: TranslationStrings = { 'tours.import.previewConfirm.one': '{count} túra importálása', 'tours.conversion.introPaddle': 'Az import egy rögzített nyomvonal. Tervezz belőle szerkeszthető evezős túrát: a TREK kontrollpontokat vesz ki belőle, egyenes vonalakkal köti össze őket, ahogy az evezős túrát rajzolod, és megmutatja, mennyire közelíti meg. Semmi sem változik, amíg nem döntesz.', + 'tours.conversion.introSki': + 'Az import egy rögzített nyomvonal. Tervezz belőle szerkeszthető sítúrát: a TREK kontrollpontokat vesz ki belőle, egyenes vonalakkal köti össze őket, ahogy a sítúrát rajzolod, és megmutatja, mennyire közelíti meg. Semmi sem változik, amíg nem döntesz.', 'tours.conversion.closePaddle': 'A javaslat szorosan követi a nyomvonalat. Pontjait egyenes vonalak kötik össze, így nem ugyanaz a vonal.', 'tours.planner.moreActions': 'További műveletek', @@ -1136,6 +1137,7 @@ const tours: TranslationStrings = { 'tourTypes.horse': 'Lovastúra', 'tours.planner.tourTypeHint.horse': 'Lóháton, jelzett lovas útvonalakon', 'tours.planner.namePlaceholder.horse': 'Névtelen lovastúra', + 'tours.planner.namePlaceholder.ski': 'Névtelen sítúra', 'tours.horse.time': 'Lovaglási idő', 'tours.horse.timeInfoLabel': 'Miért nincs lovaglási idő', 'tours.horse.timeInfo': diff --git a/shared/src/i18n/id/tours.ts b/shared/src/i18n/id/tours.ts index 5bbbe7007..b94c66e46 100644 --- a/shared/src/i18n/id/tours.ts +++ b/shared/src/i18n/id/tours.ts @@ -198,7 +198,7 @@ const tours: TranslationStrings = { 'tourTypes.hike': 'Pendakian', 'tourTypes.bike': 'Tur sepeda', 'tourTypes.run': 'Lari', - 'tourTypes.ski': 'Ski', // same-as-en + 'tourTypes.ski': 'Ski & musim dingin', 'tourTypes.kayak': 'Tur dayung', 'tourTypes.walk': 'Jalan-jalan kota / wisata', 'tourTypes.food': 'Tur kuliner / anggur', @@ -397,33 +397,32 @@ const tours: TranslationStrings = { 'tours.planner.tourTypeHint.hike': 'Jalan kaki, dalam tingkat kesulitan yang kamu tentukan', 'tours.planner.tourTypeHint.bike': 'Dengan sepeda, rute untuk touring, road, gravel, atau MTB', 'tours.planner.tourTypeHint.kayak': 'Di air, dengan kayak, kano, atau SUP', - 'tours.winter.title': 'Aktivitas musim dingin', - 'tours.winter.infoLabel': 'Apa fungsi label musim dingin', - 'tours.winter.info': - 'Hanya label untuk pendakian. Rute, warna kemiringan, dan waktu jalan tetap seperti pendakian lain dan tidak mengatakan apa pun tentang salju, longsoran salju, atau apakah jalur bisa dilalui di musim dingin.', - 'tours.winter.kind': 'Jenis aktivitas musim dingin', - 'tours.winter.activity.winter_hike': 'Pendakian musim dingin', - 'tours.winter.activity.snowshoe': 'Pendakian dengan sepatu salju', - 'tours.winter.labelOnly': - 'Ditandai oleh para pelancong. Rute dan waktu direncanakan seperti pendakian lain, tidak diperiksa untuk musim dingin.', - 'tours.planner.tourTypeHint.ski': 'Dari berkas GPX, seperti yang direkam', + 'tours.planner.tourTypeHint.ski': 'Ski, snowboard, sepatu salju, pendakian musim dingin', 'tours.ski.title': 'Jalur ski', 'tours.ski.discipline': 'Disiplin', 'tours.ski.discipline.alpine': 'Alpen', 'tours.ski.discipline.snowboard': 'Papan seluncur salju', 'tours.ski.discipline.cross_country': 'Ski lintas alam', 'tours.ski.discipline.touring': 'Tur ski', + 'tours.ski.discipline.snowshoe': 'Sepatu salju', + 'tours.ski.discipline.winter_hike': 'Pendakian salju', 'tours.ski.technique': 'Teknik', 'tours.ski.technique.classic': 'Klasik', 'tours.ski.technique.skating': 'Gaya bebas', 'tours.ski.technique.unknown': 'Tidak diketahui', 'tours.ski.chooseDiscipline': 'Pilih disiplin untuk mengimpor jalur sebagai ski.', + 'tours.ski.chooseDisciplineToSave': 'Pilih disiplin untuk menyimpan tur.', + 'tours.ski.drawnFreely': + 'Setiap ruas digambar lurus di antara titik-titikmu. Tidak ada yang memeriksanya terhadap lintasan, lift, atau jalur.', 'tours.ski.chooseTechnique': 'Pilih teknik, atau Tidak diketahui.', 'tours.ski.infoLabel': 'Apa yang TREK lakukan dengan jalur ski', 'tours.ski.info': - 'Disimpan seperti yang direkam dan tidak pernah dirutekan. Jarak dan ketinggian berasal dari berkas. TREK tidak menghitung waktu ski, dan baik jalur maupun warna kemiringan dalam 3D tidak mengatakan apa pun tentang kesulitan, salju, longsoran, atau apakah lintasan sedang dibuka.', + 'Tur yang direncanakan menggambar setiap ruas lurus di antara titik-titiknya, jalur yang diimpor tetap seperti yang direkam. Keduanya tidak dirutekan lewat lintasan, lift, atau jalur. Ketinggian berasal dari medan atau berkas. TREK tidak menghitung waktu ski, dan tidak ada apa pun di sini, termasuk warna kemiringan dalam 3D, yang mengatakan sesuatu tentang kesulitan, salju, longsoran, atau apakah lintasan sedang dibuka.', 'tours.ski.time': 'Waktu ski', 'tours.ski.timeUnknown': 'Tidak dihitung', + 'tours.ski.timeInfoLabel': 'Mengapa tidak ada waktu ski', + 'tours.ski.timeInfo': + 'TREK tidak menghitung waktu ski: ruas digambar lurus, tidak direncanakan lewat lintasan, lift, atau jalur. Jika mau, isi sendiri total waktu yang direncanakan.', 'tours.ski.classifyTitle': 'Klasifikasikan jalur ski', 'tours.ski.saved': 'Klasifikasi disimpan', 'tours.ski.saveFailed': 'Klasifikasi tidak dapat disimpan.', @@ -801,6 +800,8 @@ const tours: TranslationStrings = { 'tours.import.previewConfirm': 'Impor {count} tur', 'tours.conversion.introPaddle': 'Impor adalah jejak yang direkam. Rencanakan tur dayung yang bisa diedit: TREK mengambil titik kontrol darinya, menghubungkannya dengan garis lurus seperti tur dayung digambar, dan menunjukkan seberapa dekat hasilnya. Tidak ada yang berubah sampai kamu memilih.', + 'tours.conversion.introSki': + 'Impor adalah jejak yang direkam. Rencanakan tur ski yang bisa diedit: TREK mengambil titik kontrol darinya, menghubungkannya dengan garis lurus seperti tur ski digambar, dan menunjukkan seberapa dekat hasilnya. Tidak ada yang berubah sampai kamu memilih.', 'tours.conversion.closePaddle': 'Usulan mengikuti jejak dengan dekat. Titik-titiknya dihubungkan garis lurus, jadi bukan garis yang sama.', 'tours.planner.moreActions': 'Tindakan lainnya', @@ -1113,6 +1114,7 @@ const tours: TranslationStrings = { 'tourTypes.horse': 'Tur berkuda', 'tours.planner.tourTypeHint.horse': 'Menunggang kuda, menyusuri rute berkuda bertanda', 'tours.planner.namePlaceholder.horse': 'Tur berkuda tanpa judul', + 'tours.planner.namePlaceholder.ski': 'Tur ski tanpa judul', 'tours.horse.time': 'Waktu berkuda', 'tours.horse.timeInfoLabel': 'Mengapa tidak ada waktu berkuda', 'tours.horse.timeInfo': diff --git a/shared/src/i18n/it/tours.ts b/shared/src/i18n/it/tours.ts index 29854ef1d..2388fe272 100644 --- a/shared/src/i18n/it/tours.ts +++ b/shared/src/i18n/it/tours.ts @@ -204,7 +204,7 @@ const tours: TranslationStrings = { 'tourTypes.hike': 'Escursione a piedi', 'tourTypes.bike': 'Escursione in bici', 'tourTypes.run': 'Corsa', - 'tourTypes.ski': 'Sci', + 'tourTypes.ski': 'Sci e inverno', 'tourTypes.kayak': 'Uscita in pagaia', 'tourTypes.walk': 'Passeggiata urbana / turistica', 'tourTypes.food': 'Tour enogastronomico', @@ -405,33 +405,32 @@ const tours: TranslationStrings = { 'tours.planner.tourTypeHint.hike': 'A piedi, entro una difficoltà che scegli tu', 'tours.planner.tourTypeHint.bike': 'In bici, percorso per cicloturismo, strada, gravel o MTB', 'tours.planner.tourTypeHint.kayak': 'In acqua, in kayak, canoa o SUP', - 'tours.winter.title': 'Attività invernale', - 'tours.winter.infoLabel': 'Cosa fa il contrassegno invernale', - 'tours.winter.info': - 'Solo un contrassegno dell’escursione. Percorso, colori di pendenza e tempo di cammino restano quelli di ogni escursione e non dicono nulla su neve, valanghe o percorribilità di un sentiero in inverno.', - 'tours.winter.kind': 'Tipo di attività invernale', - 'tours.winter.activity.winter_hike': 'Escursione invernale', - 'tours.winter.activity.snowshoe': 'Ciaspolata', - 'tours.winter.labelOnly': - 'Contrassegnato dai viaggiatori. Percorso e tempi sono pianificati come per ogni escursione, non verificati per l’inverno.', - 'tours.planner.tourTypeHint.ski': 'Da un file GPX, così come registrato', + 'tours.planner.tourTypeHint.ski': 'Sci, snowboard, ciaspole, escursioni invernali', 'tours.ski.title': 'Traccia di sci', 'tours.ski.discipline': 'Disciplina', 'tours.ski.discipline.alpine': 'Alpino', 'tours.ski.discipline.snowboard': 'Snowboard', // same-as-en 'tours.ski.discipline.cross_country': 'Sci di fondo', 'tours.ski.discipline.touring': 'Scialpinismo', + 'tours.ski.discipline.snowshoe': 'Ciaspole', + 'tours.ski.discipline.winter_hike': 'Escursione invernale', 'tours.ski.technique': 'Tecnica', 'tours.ski.technique.classic': 'Classica', 'tours.ski.technique.skating': 'Skating', // same-as-en 'tours.ski.technique.unknown': 'Sconosciuta', 'tours.ski.chooseDiscipline': 'Scegli una disciplina per importare la traccia come sci.', + 'tours.ski.chooseDisciplineToSave': 'Scegli una disciplina per salvare il tour.', + 'tours.ski.drawnFreely': + 'Ogni tratto è disegnato in linea retta tra i tuoi punti. Nulla lo confronta con piste, impianti o sentieri.', 'tours.ski.chooseTechnique': 'Scegli la tecnica, oppure Sconosciuta.', 'tours.ski.infoLabel': 'Cosa fa TREK con una traccia di sci', 'tours.ski.info': - 'Resta come registrata e non viene mai ricalcolata. Distanza e quote vengono dal file. TREK non calcola alcun tempo di sci, e né la traccia né i colori della pendenza in 3D dicono nulla su difficoltà, neve, valanghe o apertura di piste e anelli.', + 'Un tour pianificato ha ogni tratto disegnato in linea retta tra i suoi punti, una traccia importata resta come registrata. Nessuno dei due viene instradato lungo piste, impianti o sentieri. Le quote vengono dal terreno o dal file. TREK non calcola alcun tempo di sci, e nulla qui, nemmeno i colori della pendenza in 3D, dice qualcosa su difficoltà, neve, valanghe o apertura di piste e anelli.', 'tours.ski.time': 'Tempo di sci', 'tours.ski.timeUnknown': 'Non calcolato', + 'tours.ski.timeInfoLabel': 'Perché non c’è un tempo di sci', + 'tours.ski.timeInfo': + 'TREK non calcola alcun tempo di sci: i tratti sono disegnati in linea retta, non pianificati lungo piste, impianti o sentieri. Se vuoi, inserisci tu un totale previsto.', 'tours.ski.classifyTitle': 'Classifica traccia di sci', 'tours.ski.saved': 'Classificazione salvata', 'tours.ski.saveFailed': 'Impossibile salvare la classificazione.', @@ -818,6 +817,8 @@ const tours: TranslationStrings = { 'tours.import.previewConfirm.one': 'Importa {count} tour', 'tours.conversion.introPaddle': 'Un import è una traccia registrata. Pianifica un’uscita in pagaia modificabile: TREK ne ricava punti di controllo, li collega con linee rette come si disegna un’uscita in pagaia e mostra quanto si avvicina. Nulla cambia finché non scegli.', + 'tours.conversion.introSki': + 'Un import è una traccia registrata. Pianifica un tour sciistico modificabile: TREK ne ricava punti di controllo, li collega con linee rette come si disegna un tour sciistico e mostra quanto si avvicina. Nulla cambia finché non scegli.', 'tours.conversion.closePaddle': 'La proposta segue da vicino la traccia. I suoi punti sono collegati da linee rette, quindi non è la stessa linea.', 'tours.planner.moreActions': 'Altre azioni', @@ -1144,6 +1145,7 @@ const tours: TranslationStrings = { 'tourTypes.horse': 'Escursione a cavallo', 'tours.planner.tourTypeHint.horse': 'A cavallo, lungo itinerari equestri segnalati', 'tours.planner.namePlaceholder.horse': 'Escursione a cavallo senza titolo', + 'tours.planner.namePlaceholder.ski': 'Tour sciistico senza titolo', 'tours.horse.time': 'Tempo a cavallo', 'tours.horse.timeInfoLabel': 'Perché non c’è un tempo a cavallo', 'tours.horse.timeInfo': diff --git a/shared/src/i18n/ja/tours.ts b/shared/src/i18n/ja/tours.ts index dd1ad9cee..c2fe6cc47 100644 --- a/shared/src/i18n/ja/tours.ts +++ b/shared/src/i18n/ja/tours.ts @@ -195,7 +195,7 @@ const tours: TranslationStrings = { 'tourTypes.hike': 'ハイキング', 'tourTypes.bike': 'サイクリング', 'tourTypes.run': 'ランニング', - 'tourTypes.ski': 'スキー', + 'tourTypes.ski': 'スキーと冬', 'tourTypes.kayak': 'パドルツアー', 'tourTypes.walk': '街歩き・観光', 'tourTypes.food': 'グルメ・ワインツアー', @@ -387,33 +387,30 @@ const tours: TranslationStrings = { 'tours.planner.tourTypeHint.hike': '徒歩、設定した難易度まで', 'tours.planner.tourTypeHint.bike': '自転車で、ツーリング、ロード、グラベル、MTB向けのルート', 'tours.planner.tourTypeHint.kayak': '水上で、カヤック、カヌー、SUPで', - 'tours.winter.title': '冬のアクティビティ', - 'tours.winter.infoLabel': '冬のラベルについて', - 'tours.winter.info': - 'ハイキングのラベルにすぎません。ルート、勾配の色、歩行時間は通常のハイキングと同じで、雪や雪崩、冬に道を歩けるかどうかについては何も示しません。', - 'tours.winter.kind': '冬のアクティビティの種類', - 'tours.winter.activity.winter_hike': '冬のハイキング', - 'tours.winter.activity.snowshoe': 'スノーシューハイキング', - 'tours.winter.labelOnly': - '旅行者が付けたラベルです。ルートと時間は通常のハイキングと同じく計画され、冬向けには確認されていません。', - 'tours.planner.tourTypeHint.ski': 'GPX ファイルから、記録されたまま', + 'tours.planner.tourTypeHint.ski': 'スキー、スノーボード、スノーシュー、冬のハイキング', 'tours.ski.title': 'スキーのトラック', 'tours.ski.discipline': '種目', 'tours.ski.discipline.alpine': 'アルペン', 'tours.ski.discipline.snowboard': 'スノーボード', 'tours.ski.discipline.cross_country': 'クロスカントリー', 'tours.ski.discipline.touring': '山スキー', + 'tours.ski.discipline.snowshoe': 'スノーシュー', + 'tours.ski.discipline.winter_hike': '冬のハイキング', 'tours.ski.technique': '走法', 'tours.ski.technique.classic': 'クラシカル', 'tours.ski.technique.skating': 'スケーティング', 'tours.ski.technique.unknown': '不明', 'tours.ski.chooseDiscipline': 'スキーとして取り込むには種目を選んでください。', + 'tours.ski.chooseDisciplineToSave': '保存するには種目を選んでください。', + 'tours.ski.drawnFreely': '各区間はポイントの間を直線で描きます。コース、リフト、トレイルとの照合は行いません。', 'tours.ski.chooseTechnique': '走法を選ぶか、不明を選んでください。', 'tours.ski.infoLabel': 'TREK がスキーのトラックで行うこと', 'tours.ski.info': - '記録されたまま保持され、ルート検索されることはありません。距離と標高はファイルの値です。TREK はスキーの所要時間を計算せず、トラックも 3D の勾配の色も、難易度、積雪、雪崩、コースの営業状況について何も示しません。', + '計画したツアーは各区間をポイントの間の直線で描き、取り込んだトラックは記録されたまま保持します。どちらもコース、リフト、トレイルに沿ったルート検索は行いません。標高は地形またはファイルの値です。TREK はスキーの所要時間を計算せず、3D の勾配の色を含めここにあるものは、難易度、積雪、雪崩、コースの営業状況について何も示しません。', 'tours.ski.time': 'スキー時間', 'tours.ski.timeUnknown': '計算なし', + 'tours.ski.timeInfoLabel': 'スキー時間がない理由', + 'tours.ski.timeInfo': 'TREK はスキー時間を計算しません。区間は直線で描かれ、コース、リフト、トレイルに沿って計画されていません。必要なら予定の合計時間をご自身で入力してください。', 'tours.ski.classifyTitle': 'スキーのトラックを分類', 'tours.ski.saved': '分類を保存しました', 'tours.ski.saveFailed': '分類を保存できませんでした。', @@ -785,6 +782,8 @@ const tours: TranslationStrings = { 'tours.import.previewConfirm': '{count} 件のツアーを取り込む', 'tours.conversion.introPaddle': 'インポートは記録されたトラックです。編集できるパドルツアーを計画すると、TREK はそこから制御点を取り出し、パドルツアーの描き方どおり直線で結び、どれだけ近いかを示します。選ぶまで何も変わりません。', + 'tours.conversion.introSki': + 'インポートは記録されたトラックです。編集できるスキーツアーを計画すると、TREK はそこから制御点を取り出し、スキーツアーの描き方どおり直線で結び、どれだけ近いかを示します。選ぶまで何も変わりません。', 'tours.conversion.closePaddle': '提案はトラックによく沿っています。ポイントは直線で結ばれているため、同じ線ではありません。', 'tours.planner.moreActions': 'その他の操作', @@ -1088,6 +1087,7 @@ const tours: TranslationStrings = { 'tourTypes.horse': '乗馬ツアー', 'tours.planner.tourTypeHint.horse': '馬に乗って、標識のある乗馬ルートを進む', 'tours.planner.namePlaceholder.horse': '名前のない乗馬ツアー', + 'tours.planner.namePlaceholder.ski': '名前のないスキーツアー', 'tours.horse.time': '乗馬時間', 'tours.horse.timeInfoLabel': '乗馬時間がない理由', 'tours.horse.timeInfo': diff --git a/shared/src/i18n/ko/tours.ts b/shared/src/i18n/ko/tours.ts index 9c2175eb6..70e2db051 100644 --- a/shared/src/i18n/ko/tours.ts +++ b/shared/src/i18n/ko/tours.ts @@ -193,7 +193,7 @@ const tours: TranslationStrings = { 'tourTypes.hike': '하이킹', 'tourTypes.bike': '자전거 투어', 'tourTypes.run': '달리기', - 'tourTypes.ski': '스키', + 'tourTypes.ski': '스키와 겨울', 'tourTypes.kayak': '패들 투어', 'tourTypes.walk': '도심 산책 / 관광', 'tourTypes.food': '미식 / 와인 투어', @@ -385,33 +385,30 @@ const tours: TranslationStrings = { 'tours.planner.tourTypeHint.hike': '도보로, 설정한 난이도 안에서', 'tours.planner.tourTypeHint.bike': '자전거로, 투어링, 로드, 그래블, MTB에 맞춘 경로', 'tours.planner.tourTypeHint.kayak': '물 위에서, 카약, 카누 또는 SUP로', - 'tours.winter.title': '겨울 활동', - 'tours.winter.infoLabel': '겨울 표시의 의미', - 'tours.winter.info': - '하이킹을 위한 표시일 뿐입니다. 경로, 경사 색상, 걷는 시간은 다른 하이킹과 같으며 눈, 눈사태 또는 겨울에 길을 걸을 수 있는지에 대해서는 아무것도 알려 주지 않습니다.', - 'tours.winter.kind': '겨울 활동 종류', - 'tours.winter.activity.winter_hike': '겨울 하이킹', - 'tours.winter.activity.snowshoe': '스노슈 하이킹', - 'tours.winter.labelOnly': - '여행자가 표시했습니다. 경로와 시간은 다른 하이킹과 같이 계획되며 겨울 조건은 확인하지 않았습니다.', - 'tours.planner.tourTypeHint.ski': 'GPX 파일에서, 기록된 그대로', + 'tours.planner.tourTypeHint.ski': '스키, 스노보드, 스노슈, 겨울 하이킹', 'tours.ski.title': '스키 트랙', 'tours.ski.discipline': '종목', 'tours.ski.discipline.alpine': '알파인', 'tours.ski.discipline.snowboard': '스노보드', 'tours.ski.discipline.cross_country': '크로스컨트리', 'tours.ski.discipline.touring': '산악 스키', + 'tours.ski.discipline.snowshoe': '스노슈', + 'tours.ski.discipline.winter_hike': '겨울 하이킹', 'tours.ski.technique': '주법', 'tours.ski.technique.classic': '클래식', 'tours.ski.technique.skating': '스케이팅', 'tours.ski.technique.unknown': '알 수 없음', 'tours.ski.chooseDiscipline': '트랙을 스키로 가져오려면 종목을 선택하세요.', + 'tours.ski.chooseDisciplineToSave': '투어를 저장하려면 종목을 선택하세요.', + 'tours.ski.drawnFreely': '각 구간은 지점 사이를 직선으로 그립니다. 슬로프, 리프트, 트레일과 대조하지 않습니다.', 'tours.ski.chooseTechnique': '주법을 선택하거나 알 수 없음을 선택하세요.', 'tours.ski.infoLabel': 'TREK이 스키 트랙으로 하는 일', 'tours.ski.info': - '기록된 그대로 유지되며 경로를 다시 계산하지 않습니다. 거리와 고도는 파일에서 가져옵니다. TREK은 스키 시간을 계산하지 않으며, 트랙도 3D 경사 색상도 난이도, 눈, 눈사태, 슬로프 운영 여부에 대해 아무것도 알려 주지 않습니다.', + '계획한 투어는 각 구간을 지점 사이의 직선으로 그리고, 가져온 트랙은 기록된 그대로 유지합니다. 어느 쪽도 슬로프, 리프트, 트레일을 따라 경로를 계산하지 않습니다. 고도는 지형 또는 파일에서 가져옵니다. TREK은 스키 시간을 계산하지 않으며, 3D 경사 색상을 포함해 여기의 어떤 것도 난이도, 눈, 눈사태, 슬로프 운영 여부에 대해 알려 주지 않습니다.', 'tours.ski.time': '스키 시간', 'tours.ski.timeUnknown': '계산 안 함', + 'tours.ski.timeInfoLabel': '스키 시간이 없는 이유', + 'tours.ski.timeInfo': 'TREK은 스키 시간을 계산하지 않습니다. 구간은 직선으로 그려지며 슬로프, 리프트, 트레일을 따라 계획되지 않습니다. 원하면 예정 총 시간을 직접 입력하세요.', 'tours.ski.classifyTitle': '스키 트랙 분류', 'tours.ski.saved': '분류를 저장했습니다', 'tours.ski.saveFailed': '분류를 저장하지 못했습니다.', @@ -781,6 +778,8 @@ const tours: TranslationStrings = { 'tours.import.previewConfirm': '투어 {count}개 가져오기', 'tours.conversion.introPaddle': '가져온 항목은 기록된 트랙입니다. 편집 가능한 패들 투어를 계획하면 TREK이 트랙에서 제어 지점을 뽑아 패들 투어를 그리듯 직선으로 잇고, 얼마나 가까운지 보여 줍니다. 선택하기 전까지는 아무것도 바뀌지 않습니다.', + 'tours.conversion.introSki': + '가져온 항목은 기록된 트랙입니다. 편집 가능한 스키 투어를 계획하면 TREK이 트랙에서 제어 지점을 뽑아 스키 투어를 그리듯 직선으로 잇고, 얼마나 가까운지 보여 줍니다. 선택하기 전까지는 아무것도 바뀌지 않습니다.', 'tours.conversion.closePaddle': '제안이 트랙을 가깝게 따릅니다. 지점들이 직선으로 이어져 있어 같은 선은 아닙니다.', 'tours.planner.moreActions': '작업 더 보기', 'tours.paddle.role.put_in': '입수 지점', @@ -1083,6 +1082,7 @@ const tours: TranslationStrings = { 'tourTypes.horse': '승마 투어', 'tours.planner.tourTypeHint.horse': '말을 타고, 표지가 있는 승마 루트를 따라', 'tours.planner.namePlaceholder.horse': '이름 없는 승마 투어', + 'tours.planner.namePlaceholder.ski': '이름 없는 스키 투어', 'tours.horse.time': '승마 시간', 'tours.horse.timeInfoLabel': '승마 시간이 없는 이유', 'tours.horse.timeInfo': diff --git a/shared/src/i18n/nl/tours.ts b/shared/src/i18n/nl/tours.ts index 234399d7e..389cd8a91 100644 --- a/shared/src/i18n/nl/tours.ts +++ b/shared/src/i18n/nl/tours.ts @@ -204,7 +204,7 @@ const tours: TranslationStrings = { 'tourTypes.hike': 'Wandeling', 'tourTypes.bike': 'Fietstocht', 'tourTypes.run': 'Hardlooptocht', - 'tourTypes.ski': 'Ski', // same-as-en + 'tourTypes.ski': 'Ski en winter', 'tourTypes.kayak': 'Peddeltocht', 'tourTypes.walk': 'Stadswandeling / bezienswaardigheden', 'tourTypes.food': 'Culinaire tocht / wijntocht', @@ -404,33 +404,32 @@ const tours: TranslationStrings = { 'tours.planner.tourTypeHint.hike': 'Te voet, binnen een moeilijkheid die je kiest', 'tours.planner.tourTypeHint.bike': 'Met de fiets, gerouteerd voor toer, race, gravel of MTB', 'tours.planner.tourTypeHint.kayak': 'Op het water, met kajak, kano of SUP', - 'tours.winter.title': 'Winteractiviteit', - 'tours.winter.infoLabel': 'Wat het winterlabel doet', - 'tours.winter.info': - 'Alleen een label voor de wandeling. Route, hellingskleuren en looptijd blijven zoals bij elke wandeling en zeggen niets over sneeuw, lawines of de begaanbaarheid van een pad in de winter.', - 'tours.winter.kind': 'Soort winteractiviteit', - 'tours.winter.activity.winter_hike': 'Winterwandeling', - 'tours.winter.activity.snowshoe': 'Sneeuwschoenwandeling', - 'tours.winter.labelOnly': - 'Gemarkeerd door de reizigers. Route en tijd worden gepland als bij elke wandeling, niet gecontroleerd op de winter.', - 'tours.planner.tourTypeHint.ski': 'Uit een GPX-bestand, zoals opgenomen', + 'tours.planner.tourTypeHint.ski': 'Skiën, snowboarden, sneeuwschoenen, winterwandelingen', 'tours.ski.title': 'Skispoor', 'tours.ski.discipline': 'Discipline', // same-as-en 'tours.ski.discipline.alpine': 'Alpine', // same-as-en 'tours.ski.discipline.snowboard': 'Snowboard', // same-as-en 'tours.ski.discipline.cross_country': 'Langlaufen', 'tours.ski.discipline.touring': 'Toerskiën', + 'tours.ski.discipline.snowshoe': 'Sneeuwschoenen', + 'tours.ski.discipline.winter_hike': 'Winterwandelen', 'tours.ski.technique': 'Techniek', 'tours.ski.technique.classic': 'Klassiek', 'tours.ski.technique.skating': 'Skating', // same-as-en 'tours.ski.technique.unknown': 'Onbekend', 'tours.ski.chooseDiscipline': 'Kies een discipline om het spoor als ski te importeren.', + 'tours.ski.chooseDisciplineToSave': 'Kies een discipline om de tocht op te slaan.', + 'tours.ski.drawnFreely': + 'Elk deel wordt recht tussen je punten getekend. Niets toetst het aan pistes, liften of paden.', 'tours.ski.chooseTechnique': 'Kies de techniek, of Onbekend.', 'tours.ski.infoLabel': 'Wat TREK met een skispoor doet', 'tours.ski.info': - 'Blijft zoals opgenomen en wordt nooit gerouteerd. Afstand en hoogtes komen uit het bestand. TREK berekent geen skitijd, en noch het spoor noch de hellingkleuren in 3D zeggen iets over moeilijkheid, sneeuw, lawines of of pistes en loipes open zijn.', + 'Bij een geplande tocht is elk deel recht tussen de punten getekend, een geïmporteerd spoor blijft zoals opgenomen. Geen van beide wordt over pistes, liften of paden gerouteerd. Hoogtes komen uit het terrein of het bestand. TREK berekent geen skitijd, en niets hier, ook de hellingkleuren in 3D niet, zegt iets over moeilijkheid, sneeuw, lawines of of pistes en loipes open zijn.', 'tours.ski.time': 'Skitijd', 'tours.ski.timeUnknown': 'Niet berekend', + 'tours.ski.timeInfoLabel': 'Waarom er geen skitijd is', + 'tours.ski.timeInfo': + 'TREK berekent geen skitijd: de delen zijn recht getekend, niet over pistes, liften of paden gepland. Vul zelf een geplande totale tijd in als je die wilt.', 'tours.ski.classifyTitle': 'Skispoor indelen', 'tours.ski.saved': 'Indeling opgeslagen', 'tours.ski.saveFailed': 'De indeling kon niet worden opgeslagen.', @@ -817,6 +816,8 @@ const tours: TranslationStrings = { 'tours.import.previewConfirm.one': '{count} tocht importeren', 'tours.conversion.introPaddle': 'Een import is een opgenomen track. Plan een bewerkbare peddeltocht: TREK haalt er controlepunten uit, verbindt ze met rechte lijnen zoals een peddeltocht wordt getekend en laat zien hoe dicht dat in de buurt komt. Er verandert niets tot je kiest.', + 'tours.conversion.introSki': + 'Een import is een opgenomen track. Plan een bewerkbare skitocht: TREK haalt er controlepunten uit, verbindt ze met rechte lijnen zoals een skitocht wordt getekend en laat zien hoe dicht dat in de buurt komt. Er verandert niets tot je kiest.', 'tours.conversion.closePaddle': 'Het voorstel volgt de track nauw. De punten zijn met rechte lijnen verbonden, dus het is niet dezelfde lijn.', 'tours.planner.moreActions': 'Meer acties', @@ -1137,6 +1138,7 @@ const tours: TranslationStrings = { 'tourTypes.horse': 'Rit te paard', 'tours.planner.tourTypeHint.horse': 'Te paard, langs bewegwijzerde ruiterroutes', 'tours.planner.namePlaceholder.horse': 'Naamloze rit', + 'tours.planner.namePlaceholder.ski': 'Naamloze skitocht', 'tours.horse.time': 'Rijtijd', 'tours.horse.timeInfoLabel': 'Waarom er geen rijtijd is', 'tours.horse.timeInfo': diff --git a/shared/src/i18n/pl/tours.ts b/shared/src/i18n/pl/tours.ts index 6c3f8cf32..47a9fbe07 100644 --- a/shared/src/i18n/pl/tours.ts +++ b/shared/src/i18n/pl/tours.ts @@ -212,7 +212,7 @@ const tours: TranslationStrings = { 'tourTypes.hike': 'Wędrówka piesza', 'tourTypes.bike': 'Wycieczka rowerowa', 'tourTypes.run': 'Bieg', - 'tourTypes.ski': 'Narty', + 'tourTypes.ski': 'Narty i zima', 'tourTypes.kayak': 'Spływ', 'tourTypes.walk': 'Spacer po mieście / zwiedzanie', 'tourTypes.food': 'Wycieczka kulinarna / winiarska', @@ -411,33 +411,32 @@ const tours: TranslationStrings = { 'tours.planner.tourTypeHint.hike': 'Pieszo, w granicach wybranej trudności', 'tours.planner.tourTypeHint.bike': 'Na rowerze, trasa dla turystyki, szosy, gravela lub MTB', 'tours.planner.tourTypeHint.kayak': 'Na wodzie, kajakiem, kanadyjką lub SUP-em', - 'tours.winter.title': 'Aktywność zimowa', - 'tours.winter.infoLabel': 'Co daje oznaczenie zimowe', - 'tours.winter.info': - 'Tylko oznaczenie wędrówki. Trasa, kolory nachylenia i czas marszu pozostają jak przy każdej wędrówce i nic nie mówią o śniegu, lawinach ani o tym, czy ścieżka jest zimą do przejścia.', - 'tours.winter.kind': 'Rodzaj aktywności zimowej', - 'tours.winter.activity.winter_hike': 'Wędrówka zimowa', - 'tours.winter.activity.snowshoe': 'Wędrówka na rakietach śnieżnych', - 'tours.winter.labelOnly': - 'Oznaczone przez podróżnych. Trasa i czas są planowane jak przy każdej wędrówce, bez sprawdzania pod kątem zimy.', - 'tours.planner.tourTypeHint.ski': 'Z pliku GPX, tak jak został nagrany', + 'tours.planner.tourTypeHint.ski': 'Narty, snowboard, rakiety śnieżne, zimowe wędrówki', 'tours.ski.title': 'Ślad narciarski', 'tours.ski.discipline': 'Dyscyplina', 'tours.ski.discipline.alpine': 'Zjazdowe', 'tours.ski.discipline.snowboard': 'Snowboard', // same-as-en 'tours.ski.discipline.cross_country': 'Biegowe', 'tours.ski.discipline.touring': 'Skitury', + 'tours.ski.discipline.snowshoe': 'Rakiety śnieżne', + 'tours.ski.discipline.winter_hike': 'Wędrówka zimowa', 'tours.ski.technique': 'Technika', 'tours.ski.technique.classic': 'Klasyczna', 'tours.ski.technique.skating': 'Łyżwowa', 'tours.ski.technique.unknown': 'Nieznana', 'tours.ski.chooseDiscipline': 'Wybierz dyscyplinę, aby zaimportować ślad jako narty.', + 'tours.ski.chooseDisciplineToSave': 'Wybierz dyscyplinę, aby zapisać trasę.', + 'tours.ski.drawnFreely': + 'Każdy odcinek jest rysowany prosto między twoimi punktami. Nic nie sprawdza go względem tras, wyciągów ani szlaków.', 'tours.ski.chooseTechnique': 'Wybierz technikę lub Nieznana.', 'tours.ski.infoLabel': 'Co TREK robi ze śladem narciarskim', 'tours.ski.info': - 'Pozostaje taki, jak został nagrany, i nigdy nie jest wyznaczany na nowo. Dystans i wysokości pochodzą z pliku. TREK nie wylicza czasu na nartach, a ani ślad, ani kolory nachylenia w 3D nie mówią nic o trudności, śniegu, lawinach ani o tym, czy trasy są otwarte.', + 'Zaplanowana trasa ma każdy odcinek narysowany prosto między swoimi punktami, a zaimportowany ślad pozostaje taki, jak został nagrany. Żaden nie jest wyznaczany po trasach, wyciągach ani szlakach. Wysokości pochodzą z terenu lub z pliku. TREK nie wylicza czasu na nartach i nic tutaj, także kolory nachylenia w 3D, nie mówi nic o trudności, śniegu, lawinach ani o tym, czy trasy są otwarte.', 'tours.ski.time': 'Czas na nartach', 'tours.ski.timeUnknown': 'Nie wyliczono', + 'tours.ski.timeInfoLabel': 'Dlaczego nie ma czasu na nartach', + 'tours.ski.timeInfo': + 'TREK nie wylicza czasu na nartach: odcinki są narysowane prosto, a nie zaplanowane po trasach, wyciągach czy szlakach. Jeśli chcesz, wpisz sam planowany czas łączny.', 'tours.ski.classifyTitle': 'Sklasyfikuj ślad narciarski', 'tours.ski.saved': 'Klasyfikację zapisano', 'tours.ski.saveFailed': 'Nie udało się zapisać klasyfikacji.', @@ -838,6 +837,8 @@ const tours: TranslationStrings = { 'tours.import.previewConfirm.many': 'Importuj {count} wycieczek', 'tours.conversion.introPaddle': 'Import to nagrany ślad. Zaplanuj edytowalny spływ: TREK pobiera z niego punkty kontrolne, łączy je prostymi liniami, tak jak rysuje się spływ, i pokazuje, jak blisko to wypada. Nic się nie zmienia, dopóki nie wybierzesz.', + 'tours.conversion.introSki': + 'Import to nagrany ślad. Zaplanuj edytowalną wycieczkę narciarską: TREK pobiera z niego punkty kontrolne, łączy je prostymi liniami, tak jak rysuje się wycieczkę narciarską, i pokazuje, jak blisko to wypada. Nic się nie zmienia, dopóki nie wybierzesz.', 'tours.conversion.closePaddle': 'Propozycja ściśle trzyma się śladu. Jej punkty łączą proste linie, więc to nie jest ta sama linia.', 'tours.planner.moreActions': 'Więcej działań', @@ -1168,6 +1169,7 @@ const tours: TranslationStrings = { 'tourTypes.horse': 'Przejażdżka konna', 'tours.planner.tourTypeHint.horse': 'Konno, oznakowanymi szlakami konnymi', 'tours.planner.namePlaceholder.horse': 'Przejażdżka bez nazwy', + 'tours.planner.namePlaceholder.ski': 'Wycieczka narciarska bez nazwy', 'tours.horse.time': 'Czas jazdy', 'tours.horse.timeInfoLabel': 'Dlaczego nie ma czasu jazdy', 'tours.horse.timeInfo': diff --git a/shared/src/i18n/ru/tours.ts b/shared/src/i18n/ru/tours.ts index 0146c5c5e..02dcb5694 100644 --- a/shared/src/i18n/ru/tours.ts +++ b/shared/src/i18n/ru/tours.ts @@ -214,7 +214,7 @@ const tours: TranslationStrings = { 'tourTypes.hike': 'Пеший поход', 'tourTypes.bike': 'Велотур', 'tourTypes.run': 'Пробежка', - 'tourTypes.ski': 'Лыжи', + 'tourTypes.ski': 'Лыжи и зима', 'tourTypes.kayak': 'Сплав', 'tourTypes.walk': 'Прогулка по городу / достопримечательностям', 'tourTypes.food': 'Гастрономический / винный тур', @@ -415,33 +415,32 @@ const tours: TranslationStrings = { 'tours.planner.tourTypeHint.hike': 'Пешком, в пределах выбранной сложности', 'tours.planner.tourTypeHint.bike': 'На велосипеде, маршрут для туризма, шоссе, гравела или МТБ', 'tours.planner.tourTypeHint.kayak': 'По воде, на каяке, каноэ или SUP', - 'tours.winter.title': 'Зимняя активность', - 'tours.winter.infoLabel': 'Что означает зимняя отметка', - 'tours.winter.info': - 'Только отметка похода. Маршрут, цвета крутизны и время в пути остаются как у любого похода и ничего не говорят о снеге, лавинах или проходимости тропы зимой.', - 'tours.winter.kind': 'Вид зимней активности', - 'tours.winter.activity.winter_hike': 'Зимний поход', - 'tours.winter.activity.snowshoe': 'Поход на снегоступах', - 'tours.winter.labelOnly': - 'Отмечено путешественниками. Маршрут и время рассчитаны как для любого похода, без проверки на зимние условия.', - 'tours.planner.tourTypeHint.ski': 'Из файла GPX, как записано', + 'tours.planner.tourTypeHint.ski': 'Лыжи, сноуборд, снегоступы, зимние походы', 'tours.ski.title': 'Лыжный трек', 'tours.ski.discipline': 'Дисциплина', 'tours.ski.discipline.alpine': 'Горные лыжи', 'tours.ski.discipline.snowboard': 'Сноуборд', 'tours.ski.discipline.cross_country': 'Беговые лыжи', 'tours.ski.discipline.touring': 'Ски-тур', + 'tours.ski.discipline.snowshoe': 'Снегоступы', + 'tours.ski.discipline.winter_hike': 'Зимний поход', 'tours.ski.technique': 'Техника', 'tours.ski.technique.classic': 'Классика', 'tours.ski.technique.skating': 'Конёк', 'tours.ski.technique.unknown': 'Неизвестно', 'tours.ski.chooseDiscipline': 'Выберите дисциплину, чтобы импортировать трек как лыжи.', + 'tours.ski.chooseDisciplineToSave': 'Выберите дисциплину, чтобы сохранить маршрут.', + 'tours.ski.drawnFreely': + 'Каждый участок рисуется прямой между вашими точками. Ничто не сверяет его с трассами, подъёмниками или тропами.', 'tours.ski.chooseTechnique': 'Выберите технику или «Неизвестно».', 'tours.ski.infoLabel': 'Что TREK делает с лыжным треком', 'tours.ski.info': - 'Остаётся таким, как записан, и никогда не прокладывается заново. Расстояние и высоты взяты из файла. TREK не рассчитывает время на лыжах, а ни трек, ни цвета уклона в 3D ничего не говорят о сложности, снеге, лавинах или о том, открыты ли трассы и лыжни.', + 'В запланированном маршруте каждый участок нарисован прямой между его точками, импортированный трек остаётся таким, как записан. Ни один из них не прокладывается по трассам, подъёмникам или тропам. Высоты берутся из рельефа или из файла. TREK не рассчитывает время на лыжах, и ничто здесь, включая цвета уклона в 3D, не говорит о сложности, снеге, лавинах или о том, открыты ли трассы и лыжни.', 'tours.ski.time': 'Время на лыжах', 'tours.ski.timeUnknown': 'Не рассчитано', + 'tours.ski.timeInfoLabel': 'Почему нет времени на лыжах', + 'tours.ski.timeInfo': + 'TREK не рассчитывает время на лыжах: участки нарисованы прямыми, а не проложены по трассам, подъёмникам или тропам. При желании укажите общее время сами.', 'tours.ski.classifyTitle': 'Классифицировать лыжный трек', 'tours.ski.saved': 'Классификация сохранена', 'tours.ski.saveFailed': 'Не удалось сохранить классификацию.', @@ -841,6 +840,8 @@ const tours: TranslationStrings = { 'tours.import.previewConfirm.many': 'Импортировать {count} туров', 'tours.conversion.introPaddle': 'Импорт — это записанный трек. Спланируйте редактируемый сплав: TREK берёт из него контрольные точки, соединяет их прямыми линиями, как рисуется сплав, и показывает, насколько это близко. Ничего не меняется, пока вы не решите.', + 'tours.conversion.introSki': + 'Импорт представляет собой записанный трек. Спланируйте редактируемый лыжный маршрут: TREK берёт из него контрольные точки, соединяет их прямыми линиями, как рисуется лыжный маршрут, и показывает, насколько это близко. Ничего не меняется, пока вы не решите.', 'tours.conversion.closePaddle': 'Предложение близко следует треку. Его точки соединены прямыми линиями, поэтому это не та же линия.', 'tours.planner.moreActions': 'Другие действия', @@ -1171,6 +1172,7 @@ const tours: TranslationStrings = { 'tourTypes.horse': 'Конная прогулка', 'tours.planner.tourTypeHint.horse': 'Верхом, по маркированным конным маршрутам', 'tours.planner.namePlaceholder.horse': 'Конная прогулка без названия', + 'tours.planner.namePlaceholder.ski': 'Лыжный маршрут без названия', 'tours.horse.time': 'Время в седле', 'tours.horse.timeInfoLabel': 'Почему нет времени в седле', 'tours.horse.timeInfo': diff --git a/shared/src/i18n/sk/tours.ts b/shared/src/i18n/sk/tours.ts index f9bc787a2..b2992e7f0 100644 --- a/shared/src/i18n/sk/tours.ts +++ b/shared/src/i18n/sk/tours.ts @@ -204,7 +204,7 @@ const tours: TranslationStrings = { 'tourTypes.hike': 'Pešia túra', 'tourTypes.bike': 'Cyklotúra', 'tourTypes.run': 'Beh', - 'tourTypes.ski': 'Lyže', + 'tourTypes.ski': 'Lyže a zima', 'tourTypes.kayak': 'Vodácka túra', 'tourTypes.walk': 'Prechádzka mestom / po pamiatkach', 'tourTypes.food': 'Gastronomická / vínna túra', @@ -396,33 +396,32 @@ const tours: TranslationStrings = { 'tours.planner.tourTypeHint.hike': 'Pešo, do obtiažnosti, ktorú nastavíš', 'tours.planner.tourTypeHint.bike': 'Na bicykli, trasa pre trekking, cestu, gravel alebo MTB', 'tours.planner.tourTypeHint.kayak': 'Na vode, na kajaku, kanoe alebo SUP', - 'tours.winter.title': 'Zimná aktivita', - 'tours.winter.infoLabel': 'Čo robí zimné označenie', - 'tours.winter.info': - 'Len označenie túry. Trasa, farby sklonu a čas chôdze zostávajú ako pri každej túre a nehovoria nič o snehu, lavínach ani o tom, či je cesta v zime schodná.', - 'tours.winter.kind': 'Druh zimnej aktivity', - 'tours.winter.activity.winter_hike': 'Zimná turistika', - 'tours.winter.activity.snowshoe': 'Túra na snežniciach', - 'tours.winter.labelOnly': - 'Označené cestovateľmi. Trasa a čas sa plánujú ako pri každej túre, bez kontroly zimných podmienok.', - 'tours.planner.tourTypeHint.ski': 'Zo súboru GPX, tak ako bola zaznamenaná', + 'tours.planner.tourTypeHint.ski': 'Lyže, snowboard, snežnice, zimné túry', 'tours.ski.title': 'Lyžiarska trasa', 'tours.ski.discipline': 'Disciplína', 'tours.ski.discipline.alpine': 'Zjazd', 'tours.ski.discipline.snowboard': 'Snowboard', // same-as-en 'tours.ski.discipline.cross_country': 'Beh na lyžiach', 'tours.ski.discipline.touring': 'Skialpinizmus', + 'tours.ski.discipline.snowshoe': 'Snežnice', + 'tours.ski.discipline.winter_hike': 'Zimná turistika', 'tours.ski.technique': 'Technika', 'tours.ski.technique.classic': 'Klasika', 'tours.ski.technique.skating': 'Korčuľovanie', 'tours.ski.technique.unknown': 'Neznáma', 'tours.ski.chooseDiscipline': 'Vyberte disciplínu na import trasy ako lyže.', + 'tours.ski.chooseDisciplineToSave': 'Vyberte disciplínu, aby ste mohli túru uložiť.', + 'tours.ski.drawnFreely': + 'Každý úsek sa kreslí rovno medzi vašimi bodmi. Nič ho neporovnáva so zjazdovkami, vlekmi ani stopami.', 'tours.ski.chooseTechnique': 'Vyberte techniku, alebo Neznáma.', 'tours.ski.infoLabel': 'Čo TREK robí s lyžiarskou trasou', 'tours.ski.info': - 'Zostáva tak, ako bola zaznamenaná, a nikdy sa neprepočítava. Vzdialenosť a výšky pochádzajú zo súboru. TREK nepočíta žiadny čas na lyžiach a ani trasa, ani farby sklonu v 3D nehovoria nič o náročnosti, snehu, lavínach ani o tom, či sú zjazdovky a stopy otvorené.', + 'Naplánovaná túra má každý úsek nakreslený rovno medzi svojimi bodmi, importovaná trasa zostáva tak, ako bola zaznamenaná. Ani jedna nevedie po zjazdovkách, vlekoch ani stopách. Výšky pochádzajú z terénu alebo zo súboru. TREK nepočíta žiadny čas na lyžiach a nič tu, ani farby sklonu v 3D, nehovorí nič o náročnosti, snehu, lavínach ani o tom, či sú zjazdovky a stopy otvorené.', 'tours.ski.time': 'Čas na lyžiach', 'tours.ski.timeUnknown': 'Nepočíta sa', + 'tours.ski.timeInfoLabel': 'Prečo tu nie je čas na lyžiach', + 'tours.ski.timeInfo': + 'TREK čas na lyžiach nepočíta: úseky sú nakreslené rovno, nie naplánované po zjazdovkách, vlekoch alebo stopách. Ak chcete, zadajte plánovaný celkový čas sami.', 'tours.ski.classifyTitle': 'Zaradiť lyžiarsku trasu', 'tours.ski.saved': 'Zaradenie uložené', 'tours.ski.saveFailed': 'Zaradenie sa nepodarilo uložiť.', @@ -809,6 +808,8 @@ const tours: TranslationStrings = { 'tours.import.previewConfirm.few': 'Importovať {count} túry', 'tours.conversion.introPaddle': 'Import je zaznamenaná trasa. Naplánujte upraviteľnú vodácku túru: TREK z nej vezme kontrolné body, spojí ich priamymi čiarami, ako sa vodácka túra kreslí, a ukáže, ako blízko to je. Nič sa nezmení, kým sa nerozhodnete.', + 'tours.conversion.introSki': + 'Import je zaznamenaná trasa. Naplánujte upraviteľnú lyžiarsku túru: TREK z nej vezme kontrolné body, spojí ich priamymi čiarami, ako sa lyžiarska túra kreslí, a ukáže, ako blízko to je. Nič sa nezmení, kým sa nerozhodnete.', 'tours.conversion.closePaddle': 'Návrh trasu tesne sleduje. Jeho body spájajú priame čiary, takže to nie je tá istá čiara.', 'tours.planner.moreActions': 'Ďalšie akcie', @@ -1127,6 +1128,7 @@ const tours: TranslationStrings = { 'tourTypes.horse': 'Vychádzka na koni', 'tours.planner.tourTypeHint.horse': 'Na koni, po značených jazdeckých trasách', 'tours.planner.namePlaceholder.horse': 'Vychádzka bez názvu', + 'tours.planner.namePlaceholder.ski': 'Lyžiarska túra bez názvu', 'tours.horse.time': 'Čas jazdy', 'tours.horse.timeInfoLabel': 'Prečo tu nie je čas jazdy', 'tours.horse.timeInfo': diff --git a/shared/src/i18n/sv/tours.ts b/shared/src/i18n/sv/tours.ts index 9899178d0..e4ee0b473 100644 --- a/shared/src/i18n/sv/tours.ts +++ b/shared/src/i18n/sv/tours.ts @@ -199,7 +199,7 @@ const tours: TranslationStrings = { 'tourTypes.hike': 'Vandring', 'tourTypes.bike': 'Cykeltur', 'tourTypes.run': 'Löptur', - 'tourTypes.ski': 'Skidor', + 'tourTypes.ski': 'Skidor och vinter', 'tourTypes.kayak': 'Paddeltur', 'tourTypes.walk': 'Stadspromenad / sightseeing', 'tourTypes.food': 'Mat- och vintur', @@ -394,33 +394,32 @@ const tours: TranslationStrings = { 'tours.planner.tourTypeHint.hike': 'Till fots, inom en svårighet du väljer', 'tours.planner.tourTypeHint.bike': 'Med cykel, rutt för tur, landsväg, grus eller MTB', 'tours.planner.tourTypeHint.kayak': 'På vattnet, med kajak, kanot eller SUP', - 'tours.winter.title': 'Vinteraktivitet', - 'tours.winter.infoLabel': 'Vad vintermärkningen gör', - 'tours.winter.info': - 'Bara en märkning av vandringen. Rutt, lutningsfärger och gångtid är desamma som för alla vandringar och säger inget om snö, laviner eller om en stig går att gå på vintern.', - 'tours.winter.kind': 'Typ av vinteraktivitet', - 'tours.winter.activity.winter_hike': 'Vintervandring', - 'tours.winter.activity.snowshoe': 'Snöskovandring', - 'tours.winter.labelOnly': - 'Märkt av resenärerna. Rutt och tid planeras som för alla vandringar, inte kontrollerade för vinter.', - 'tours.planner.tourTypeHint.ski': 'Från en GPX-fil, som den spelades in', + 'tours.planner.tourTypeHint.ski': 'Skidor, snowboard, snöskor, vintervandringar', 'tours.ski.title': 'Skidspår', 'tours.ski.discipline': 'Gren', 'tours.ski.discipline.alpine': 'Utförsåkning', 'tours.ski.discipline.snowboard': 'Snowboard', // same-as-en 'tours.ski.discipline.cross_country': 'Längdskidor', 'tours.ski.discipline.touring': 'Topptur', + 'tours.ski.discipline.snowshoe': 'Snöskor', + 'tours.ski.discipline.winter_hike': 'Vintervandring', 'tours.ski.technique': 'Teknik', 'tours.ski.technique.classic': 'Klassisk', 'tours.ski.technique.skating': 'Skate', 'tours.ski.technique.unknown': 'Okänd', 'tours.ski.chooseDiscipline': 'Välj en gren för att importera spåret som skidor.', + 'tours.ski.chooseDisciplineToSave': 'Välj en gren för att spara turen.', + 'tours.ski.drawnFreely': + 'Varje etapp ritas rakt mellan dina punkter. Inget stämmer av den mot backar, liftar eller leder.', 'tours.ski.chooseTechnique': 'Välj teknik, eller Okänd.', 'tours.ski.infoLabel': 'Vad TREK gör med ett skidspår', 'tours.ski.info': - 'Behålls som det spelades in och ruttplaneras aldrig. Sträcka och höjder kommer från filen. TREK räknar inte ut någon skidtid, och varken spåret eller lutningsfärgerna i 3D säger något om svårighet, snö, laviner eller om backar och spår är öppna.', + 'En planerad tur har varje etapp ritad rakt mellan sina punkter, ett importerat spår behålls som det spelades in. Inget av dem ruttplaneras längs backar, liftar eller leder. Höjderna kommer från terrängen eller filen. TREK räknar inte ut någon skidtid, och inget här, inte heller lutningsfärgerna i 3D, säger något om svårighet, snö, laviner eller om backar och spår är öppna.', 'tours.ski.time': 'Skidtid', 'tours.ski.timeUnknown': 'Inte uträknad', + 'tours.ski.timeInfoLabel': 'Varför det inte finns någon skidtid', + 'tours.ski.timeInfo': + 'TREK räknar inte ut någon skidtid: etapperna ritas raka, inte planerade längs backar, liftar eller leder. Ange själv en planerad total tid om du vill.', 'tours.ski.classifyTitle': 'Klassa skidspår', 'tours.ski.saved': 'Klassning sparad', 'tours.ski.saveFailed': 'Klassningen kunde inte sparas.', @@ -800,6 +799,8 @@ const tours: TranslationStrings = { 'tours.import.previewConfirm.one': 'Importera {count} tur', 'tours.conversion.introPaddle': 'En import är ett inspelat spår. Planera en redigerbar paddeltur: TREK tar kontrollpunkter ur det, förbinder dem med raka linjer som en paddeltur ritas och visar hur nära det kommer. Inget ändras förrän du väljer.', + 'tours.conversion.introSki': + 'En import är ett inspelat spår. Planera en redigerbar skidtur: TREK tar kontrollpunkter ur det, förbinder dem med raka linjer som en skidtur ritas och visar hur nära det kommer. Inget ändras förrän du väljer.', 'tours.conversion.closePaddle': 'Förslaget följer spåret noga. Punkterna är förbundna med raka linjer, så det är inte samma linje.', 'tours.planner.moreActions': 'Fler åtgärder', @@ -1112,6 +1113,7 @@ const tours: TranslationStrings = { 'tourTypes.horse': 'Ridtur', 'tours.planner.tourTypeHint.horse': 'Till häst, längs skyltade ridleder', 'tours.planner.namePlaceholder.horse': 'Namnlös ridtur', + 'tours.planner.namePlaceholder.ski': 'Namnlös skidtur', 'tours.horse.time': 'Ridtid', 'tours.horse.timeInfoLabel': 'Varför det inte finns någon ridtid', 'tours.horse.timeInfo': diff --git a/shared/src/i18n/th/tours.ts b/shared/src/i18n/th/tours.ts index 2fa659464..fc761ef1f 100644 --- a/shared/src/i18n/th/tours.ts +++ b/shared/src/i18n/th/tours.ts @@ -191,7 +191,7 @@ const tours: TranslationStrings = { 'tourTypes.hike': 'เดินป่า', 'tourTypes.bike': 'ทัวร์จักรยาน', 'tourTypes.run': 'วิ่ง', - 'tourTypes.ski': 'สกี', + 'tourTypes.ski': 'สกีและฤดูหนาว', 'tourTypes.kayak': 'ทริปพายเรือ', 'tourTypes.walk': 'เดินชมเมือง / สถานที่ท่องเที่ยว', 'tourTypes.food': 'ทัวร์อาหาร/ไวน์', @@ -376,33 +376,31 @@ const tours: TranslationStrings = { 'tours.planner.tourTypeHint.hike': 'เดินเท้า ภายในระดับความยากที่คุณกำหนด', 'tours.planner.tourTypeHint.bike': 'ด้วยจักรยาน เส้นทางสำหรับทัวริ่ง เสือหมอบ กราเวล หรือเสือภูเขา', 'tours.planner.tourTypeHint.kayak': 'บนน้ำ ด้วยคายัค แคนู หรือ SUP', - 'tours.winter.title': 'กิจกรรมฤดูหนาว', - 'tours.winter.infoLabel': 'ป้ายฤดูหนาวมีไว้ทำอะไร', - 'tours.winter.info': - 'เป็นเพียงป้ายกำกับของการเดินป่า เส้นทาง สีความชัน และเวลาเดินยังคงเหมือนการเดินป่าทั่วไป และไม่ได้บอกอะไรเกี่ยวกับหิมะ หิมะถล่ม หรือว่าเส้นทางเดินได้ในฤดูหนาวหรือไม่', - 'tours.winter.kind': 'ประเภทกิจกรรมฤดูหนาว', - 'tours.winter.activity.winter_hike': 'เดินป่าฤดูหนาว', - 'tours.winter.activity.snowshoe': 'เดินป่าด้วยรองเท้าลุยหิมะ', - 'tours.winter.labelOnly': - 'ผู้เดินทางเป็นผู้ทำเครื่องหมาย เส้นทางและเวลาวางแผนเหมือนการเดินป่าทั่วไป ไม่ได้ตรวจสอบสำหรับฤดูหนาว', - 'tours.planner.tourTypeHint.ski': 'จากไฟล์ GPX ตามที่บันทึกไว้', + 'tours.planner.tourTypeHint.ski': 'สกี สโนว์บอร์ด รองเท้าลุยหิมะ เดินป่าฤดูหนาว', 'tours.ski.title': 'เส้นทางสกี', 'tours.ski.discipline': 'ประเภท', 'tours.ski.discipline.alpine': 'อัลไพน์', 'tours.ski.discipline.snowboard': 'สโนว์บอร์ด', 'tours.ski.discipline.cross_country': 'สกีทางเรียบ', 'tours.ski.discipline.touring': 'สกีปีนเขา', + 'tours.ski.discipline.snowshoe': 'รองเท้าลุยหิมะ', + 'tours.ski.discipline.winter_hike': 'เดินป่าฤดูหนาว', 'tours.ski.technique': 'เทคนิค', 'tours.ski.technique.classic': 'คลาสสิก', 'tours.ski.technique.skating': 'สเก็ตติ้ง', 'tours.ski.technique.unknown': 'ไม่ทราบ', 'tours.ski.chooseDiscipline': 'เลือกประเภทเพื่อนำเข้าเส้นทางเป็นสกี', + 'tours.ski.chooseDisciplineToSave': 'เลือกประเภทเพื่อบันทึกทริป', + 'tours.ski.drawnFreely': 'แต่ละช่วงจะวาดเป็นเส้นตรงระหว่างจุดของคุณ ไม่มีการตรวจสอบกับลานสกี ลิฟต์ หรือเส้นทาง', 'tours.ski.chooseTechnique': 'เลือกเทคนิค หรือไม่ทราบ', 'tours.ski.infoLabel': 'TREK ทำอะไรกับเส้นทางสกี', 'tours.ski.info': - 'เก็บไว้ตามที่บันทึกและไม่คำนวณเส้นทางใหม่ ระยะทางและความสูงมาจากไฟล์ TREK ไม่คำนวณเวลาเล่นสกี และทั้งเส้นทางและสีความชันในมุมมอง 3D ไม่ได้บอกอะไรเกี่ยวกับความยาก หิมะ หิมะถล่ม หรือว่าลานสกีเปิดอยู่หรือไม่', + 'ทริปที่วางแผนจะวาดแต่ละช่วงเป็นเส้นตรงระหว่างจุด ส่วนแทร็กที่นำเข้าจะเก็บไว้ตามที่บันทึก ทั้งสองแบบไม่ได้คำนวณเส้นทางตามลานสกี ลิฟต์ หรือเส้นทางเดิน ความสูงมาจากภูมิประเทศหรือไฟล์ TREK ไม่คำนวณเวลาเล่นสกี และไม่มีสิ่งใดในที่นี้ รวมถึงสีความชันในมุมมอง 3D ที่บอกอะไรเกี่ยวกับความยาก หิมะ หิมะถล่ม หรือว่าลานสกีเปิดอยู่หรือไม่', 'tours.ski.time': 'เวลาเล่นสกี', 'tours.ski.timeUnknown': 'ไม่ได้คำนวณ', + 'tours.ski.timeInfoLabel': 'ทำไมจึงไม่มีเวลาเล่นสกี', + 'tours.ski.timeInfo': + 'TREK ไม่คำนวณเวลาเล่นสกี เพราะแต่ละช่วงวาดเป็นเส้นตรง ไม่ได้วางแผนตามลานสกี ลิฟต์ หรือเส้นทาง หากต้องการ ให้กรอกเวลารวมที่วางแผนไว้เอง', 'tours.ski.classifyTitle': 'จัดประเภทเส้นทางสกี', 'tours.ski.saved': 'บันทึกการจัดประเภทแล้ว', 'tours.ski.saveFailed': 'บันทึกการจัดประเภทไม่สำเร็จ', @@ -765,6 +763,8 @@ const tours: TranslationStrings = { 'tours.import.previewConfirm': 'นำเข้า {count} ทริป', 'tours.conversion.introPaddle': 'การนำเข้าคือแทร็กที่บันทึกไว้ วางแผนทริปพายเรือที่แก้ไขได้: TREK จะดึงจุดควบคุมออกมา เชื่อมด้วยเส้นตรงตามวิธีวาดทริปพายเรือ และแสดงว่าใกล้เคียงแค่ไหน ไม่มีอะไรเปลี่ยนจนกว่าคุณจะเลือก', + 'tours.conversion.introSki': + 'การนำเข้าคือแทร็กที่บันทึกไว้ วางแผนทริปสกีที่แก้ไขได้: TREK จะดึงจุดควบคุมออกมา เชื่อมด้วยเส้นตรงตามวิธีวาดทริปสกี และแสดงว่าใกล้เคียงแค่ไหน ไม่มีอะไรเปลี่ยนจนกว่าคุณจะเลือก', 'tours.conversion.closePaddle': 'ข้อเสนอตามแทร็กอย่างใกล้ชิด จุดต่างๆ เชื่อมด้วยเส้นตรง จึงไม่ใช่เส้นเดียวกัน', 'tours.planner.moreActions': 'การดำเนินการเพิ่มเติม', 'tours.paddle.role.put_in': 'จุดลงเรือ', @@ -1064,6 +1064,7 @@ const tours: TranslationStrings = { 'tourTypes.horse': 'ทริปขี่ม้า', 'tours.planner.tourTypeHint.horse': 'บนหลังม้า ตามเส้นทางขี่ม้าที่มีป้ายบอก', 'tours.planner.namePlaceholder.horse': 'ทริปขี่ม้าที่ไม่มีชื่อ', + 'tours.planner.namePlaceholder.ski': 'ทริปสกีที่ไม่มีชื่อ', 'tours.horse.time': 'เวลาขี่ม้า', 'tours.horse.timeInfoLabel': 'ทำไมจึงไม่มีเวลาขี่ม้า', 'tours.horse.timeInfo': diff --git a/shared/src/i18n/tours-locales.spec.ts b/shared/src/i18n/tours-locales.spec.ts index 5d68df2a0..aca76aeb6 100644 --- a/shared/src/i18n/tours-locales.spec.ts +++ b/shared/src/i18n/tours-locales.spec.ts @@ -20,7 +20,7 @@ const toursModules = import.meta.glob<{ default: TranslationStrings }>('./*/tour const TOUR_DOMAINS: { file: string; generalKeyCount: number; modules: DomainModules }[] = [ { file: 'tours', - generalKeyCount: 1176, + generalKeyCount: 1177, modules: toursModules, }, { diff --git a/shared/src/i18n/tr/tours.ts b/shared/src/i18n/tr/tours.ts index 44c294600..4e5390a1f 100644 --- a/shared/src/i18n/tr/tours.ts +++ b/shared/src/i18n/tr/tours.ts @@ -200,7 +200,7 @@ const tours: TranslationStrings = { 'tourTypes.hike': 'Doğa yürüyüşü', 'tourTypes.bike': 'Bisiklet turu', 'tourTypes.run': 'Koşu', - 'tourTypes.ski': 'Kayak', + 'tourTypes.ski': 'Kayak ve kış', 'tourTypes.kayak': 'Kürek turu', 'tourTypes.walk': 'Şehir / gezi yürüyüşü', 'tourTypes.food': 'Yemek / şarap turu', @@ -396,33 +396,32 @@ const tours: TranslationStrings = { 'tours.planner.tourTypeHint.hike': 'Yürüyerek, belirlediğin zorluk içinde', 'tours.planner.tourTypeHint.bike': 'Bisikletle, tur, yol, gravel veya MTB için rota', 'tours.planner.tourTypeHint.kayak': 'Suda, kano, kanoe veya SUP ile', - 'tours.winter.title': 'Kış etkinliği', - 'tours.winter.infoLabel': 'Kış etiketi ne işe yarar', - 'tours.winter.info': - 'Yalnızca yürüyüş için bir etiket. Rota, eğim renkleri ve yürüme süresi her yürüyüşteki gibi kalır; kar, çığ ya da bir yolun kışın yürünebilir olup olmadığı hakkında hiçbir şey söylemez.', - 'tours.winter.kind': 'Kış etkinliği türü', - 'tours.winter.activity.winter_hike': 'Kış yürüyüşü', - 'tours.winter.activity.snowshoe': 'Kar ayakkabısıyla yürüyüş', - 'tours.winter.labelOnly': - 'Gezginler tarafından işaretlendi. Rota ve süre her yürüyüşteki gibi planlanır, kış koşullarına göre denetlenmez.', - 'tours.planner.tourTypeHint.ski': 'Bir GPX dosyasından, kaydedildiği gibi', + 'tours.planner.tourTypeHint.ski': 'Kayak, snowboard, kar ayakkabısı, kış yürüyüşleri', 'tours.ski.title': 'Kayak izi', 'tours.ski.discipline': 'Branş', 'tours.ski.discipline.alpine': 'Alp disiplini', 'tours.ski.discipline.snowboard': 'Snowboard', // same-as-en 'tours.ski.discipline.cross_country': 'Kayaklı koşu', 'tours.ski.discipline.touring': 'Dağ kayağı', + 'tours.ski.discipline.snowshoe': 'Kar ayakkabısı', + 'tours.ski.discipline.winter_hike': 'Kış yürüyüşü', 'tours.ski.technique': 'Teknik', 'tours.ski.technique.classic': 'Klasik', 'tours.ski.technique.skating': 'Serbest stil', 'tours.ski.technique.unknown': 'Bilinmiyor', 'tours.ski.chooseDiscipline': 'İzi kayak olarak içe aktarmak için bir branş seç.', + 'tours.ski.chooseDisciplineToSave': 'Turu kaydetmek için bir branş seç.', + 'tours.ski.drawnFreely': + 'Her bölüm noktaların arasında düz çizilir. Hiçbir şey onu pistler, teleferikler veya parkurlarla karşılaştırmaz.', 'tours.ski.chooseTechnique': 'Tekniği ya da Bilinmiyor seçeneğini seç.', 'tours.ski.infoLabel': 'TREK bir kayak iziyle ne yapar', 'tours.ski.info': - 'Kaydedildiği gibi kalır ve hiçbir zaman yeniden rotalanmaz. Mesafe ve yükseklikler dosyadan gelir. TREK kayak süresi hesaplamaz; ne iz ne de 3D eğim renkleri zorluk, kar, çığ ya da pist ve parkurların açık olup olmadığı hakkında bir şey söyler.', + 'Planlanan bir turda her bölüm noktaları arasında düz çizilir, içe aktarılan bir iz ise kaydedildiği gibi kalır. Hiçbiri pistler, teleferikler veya parkurlar boyunca rotalanmaz. Yükseklikler araziden ya da dosyadan gelir. TREK kayak süresi hesaplamaz; buradaki hiçbir şey, 3D eğim renkleri de dahil, zorluk, kar, çığ ya da pist ve parkurların açık olup olmadığı hakkında bir şey söylemez.', 'tours.ski.time': 'Kayak süresi', 'tours.ski.timeUnknown': 'Hesaplanmadı', + 'tours.ski.timeInfoLabel': 'Neden kayak süresi yok', + 'tours.ski.timeInfo': + 'TREK kayak süresi hesaplamaz: bölümler düz çizilir, pistler, teleferikler veya parkurlar boyunca planlanmaz. İstersen planlanan toplam süreyi kendin gir.', 'tours.ski.classifyTitle': 'Kayak izini sınıflandır', 'tours.ski.saved': 'Sınıflandırma kaydedildi', 'tours.ski.saveFailed': 'Sınıflandırma kaydedilemedi.', @@ -807,6 +806,8 @@ const tours: TranslationStrings = { 'tours.import.previewConfirm.one': '{count} turu içe aktar', 'tours.conversion.introPaddle': 'İçe aktarma kaydedilmiş bir izdir. Düzenlenebilir bir kürek turu planla: TREK ondan kontrol noktaları alır, kürek turu nasıl çiziliyorsa onları düz çizgilerle birleştirir ve ne kadar yaklaştığını gösterir. Sen seçene kadar hiçbir şey değişmez.', + 'tours.conversion.introSki': + 'İçe aktarma kaydedilmiş bir izdir. Düzenlenebilir bir kayak turu planla: TREK ondan kontrol noktaları alır, kayak turu nasıl çiziliyorsa onları düz çizgilerle birleştirir ve ne kadar yaklaştığını gösterir. Sen seçene kadar hiçbir şey değişmez.', 'tours.conversion.closePaddle': 'Öneri izi yakından takip ediyor. Noktaları düz çizgilerle birleştirildiği için aynı çizgi değil.', 'tours.planner.moreActions': 'Diğer işlemler', @@ -1118,6 +1119,7 @@ const tours: TranslationStrings = { 'tourTypes.horse': 'At gezisi', 'tours.planner.tourTypeHint.horse': 'At sırtında, işaretli binicilik rotalarında', 'tours.planner.namePlaceholder.horse': 'Adsız at gezisi', + 'tours.planner.namePlaceholder.ski': 'Adsız kayak turu', 'tours.horse.time': 'Sürüş süresi', 'tours.horse.timeInfoLabel': 'Neden sürüş süresi yok', 'tours.horse.timeInfo': diff --git a/shared/src/i18n/uk/tours.ts b/shared/src/i18n/uk/tours.ts index 21ede0b0d..10c72b2ec 100644 --- a/shared/src/i18n/uk/tours.ts +++ b/shared/src/i18n/uk/tours.ts @@ -211,7 +211,7 @@ const tours: TranslationStrings = { 'tourTypes.hike': 'Піший похід', 'tourTypes.bike': 'Велотур', 'tourTypes.run': 'Пробіжка', - 'tourTypes.ski': 'Лижі', + 'tourTypes.ski': 'Лижі та зима', 'tourTypes.kayak': 'Сплав', 'tourTypes.walk': 'Прогулянка містом / визначними місцями', 'tourTypes.food': 'Гастрономічний / винний тур', @@ -410,33 +410,32 @@ const tours: TranslationStrings = { 'tours.planner.tourTypeHint.hike': 'Пішки, у межах обраної складності', 'tours.planner.tourTypeHint.bike': 'На велосипеді, маршрут для туризму, шосе, гревелу або МТБ', 'tours.planner.tourTypeHint.kayak': 'По воді, на каяку, каное або SUP', - 'tours.winter.title': 'Зимова активність', - 'tours.winter.infoLabel': 'Що означає зимова позначка', - 'tours.winter.info': - 'Лише позначка походу. Маршрут, кольори крутизни й час у дорозі залишаються як для будь-якого походу й нічого не кажуть про сніг, лавини чи прохідність стежки взимку.', - 'tours.winter.kind': 'Вид зимової активності', - 'tours.winter.activity.winter_hike': 'Зимовий похід', - 'tours.winter.activity.snowshoe': 'Похід на снігоступах', - 'tours.winter.labelOnly': - 'Позначено мандрівниками. Маршрут і час сплановано як для будь-якого походу, без перевірки на зимові умови.', - 'tours.planner.tourTypeHint.ski': 'З файлу GPX, як записано', + 'tours.planner.tourTypeHint.ski': 'Лижі, сноуборд, снігоступи, зимові походи', 'tours.ski.title': 'Лижний трек', 'tours.ski.discipline': 'Дисципліна', 'tours.ski.discipline.alpine': 'Гірські лижі', 'tours.ski.discipline.snowboard': 'Сноуборд', 'tours.ski.discipline.cross_country': 'Бігові лижі', 'tours.ski.discipline.touring': 'Скітур', + 'tours.ski.discipline.snowshoe': 'Снігоступи', + 'tours.ski.discipline.winter_hike': 'Зимовий похід', 'tours.ski.technique': 'Техніка', 'tours.ski.technique.classic': 'Класика', 'tours.ski.technique.skating': 'Ковзанярський хід', 'tours.ski.technique.unknown': 'Невідомо', 'tours.ski.chooseDiscipline': 'Виберіть дисципліну, щоб імпортувати трек як лижі.', + 'tours.ski.chooseDisciplineToSave': 'Виберіть дисципліну, щоб зберегти маршрут.', + 'tours.ski.drawnFreely': + 'Кожна ділянка малюється прямою між вашими точками. Ніщо не звіряє її з трасами, підйомниками чи стежками.', 'tours.ski.chooseTechnique': 'Виберіть техніку або «Невідомо».', 'tours.ski.infoLabel': 'Що TREK робить з лижним треком', 'tours.ski.info': - 'Залишається таким, як записано, і ніколи не прокладається заново. Відстань і висоти взято з файлу. TREK не розраховує час на лижах, а ні трек, ні кольори нахилу в 3D нічого не кажуть про складність, сніг, лавини чи про те, чи відкриті траси й лижні.', + 'У запланованому маршруті кожна ділянка намальована прямою між його точками, імпортований трек лишається таким, як записано. Жоден не прокладається трасами, підйомниками чи стежками. Висоти взято з рельєфу або з файлу. TREK не розраховує час на лижах, і ніщо тут, зокрема кольори нахилу в 3D, не каже про складність, сніг, лавини чи про те, чи відкриті траси й лижні.', 'tours.ski.time': 'Час на лижах', 'tours.ski.timeUnknown': 'Не розраховано', + 'tours.ski.timeInfoLabel': 'Чому немає часу на лижах', + 'tours.ski.timeInfo': + 'TREK не розраховує час на лижах: ділянки намальовано прямими, а не прокладено трасами, підйомниками чи стежками. За бажання вкажіть загальний час самі.', 'tours.ski.classifyTitle': 'Класифікувати лижний трек', 'tours.ski.saved': 'Класифікацію збережено', 'tours.ski.saveFailed': 'Не вдалося зберегти класифікацію.', @@ -833,6 +832,8 @@ const tours: TranslationStrings = { 'tours.import.previewConfirm.many': 'Імпортувати {count} турів', 'tours.conversion.introPaddle': 'Імпорт — це записаний трек. Сплануйте редагований сплав: TREK бере з нього контрольні точки, з’єднує їх прямими лініями, як малюється сплав, і показує, наскільки це близько. Нічого не змінюється, доки ви не вирішите.', + 'tours.conversion.introSki': + 'Імпорт є записаним треком. Сплануйте редагований лижний маршрут: TREK бере з нього контрольні точки, з’єднує їх прямими лініями, як малюється лижний маршрут, і показує, наскільки це близько. Нічого не змінюється, доки ви не вирішите.', 'tours.conversion.closePaddle': 'Пропозиція близько йде за треком. Її точки з’єднані прямими лініями, тож це не та сама лінія.', 'tours.planner.moreActions': 'Інші дії', @@ -1160,6 +1161,7 @@ const tours: TranslationStrings = { 'tourTypes.horse': 'Кінна прогулянка', 'tours.planner.tourTypeHint.horse': 'Верхи, маркованими кінними маршрутами', 'tours.planner.namePlaceholder.horse': 'Кінна прогулянка без назви', + 'tours.planner.namePlaceholder.ski': 'Лижний маршрут без назви', 'tours.horse.time': 'Час у сідлі', 'tours.horse.timeInfoLabel': 'Чому немає часу в сідлі', 'tours.horse.timeInfo': diff --git a/shared/src/i18n/vi/tours.ts b/shared/src/i18n/vi/tours.ts index c14d32416..bf272fbde 100644 --- a/shared/src/i18n/vi/tours.ts +++ b/shared/src/i18n/vi/tours.ts @@ -200,7 +200,7 @@ const tours: TranslationStrings = { 'tourTypes.hike': 'Đi bộ đường dài', 'tourTypes.bike': 'Chuyến đạp xe', 'tourTypes.run': 'Chạy bộ', - 'tourTypes.ski': 'Trượt tuyết', + 'tourTypes.ski': 'Trượt tuyết & mùa đông', 'tourTypes.kayak': 'Chuyến chèo thuyền', 'tourTypes.walk': 'Đi bộ trong thành phố / ngắm cảnh', 'tourTypes.food': 'Chuyến khám phá ẩm thực / rượu vang', @@ -394,33 +394,32 @@ const tours: TranslationStrings = { 'tours.planner.tourTypeHint.hike': 'Đi bộ, trong mức độ khó bạn chọn', 'tours.planner.tourTypeHint.bike': 'Bằng xe đạp, lộ trình cho touring, đường trường, gravel hoặc MTB', 'tours.planner.tourTypeHint.kayak': 'Trên nước, bằng kayak, xuồng canoe hoặc SUP', - 'tours.winter.title': 'Hoạt động mùa đông', - 'tours.winter.infoLabel': 'Nhãn mùa đông có tác dụng gì', - 'tours.winter.info': - 'Chỉ là nhãn cho chuyến đi bộ. Tuyến đường, màu độ dốc và thời gian đi bộ vẫn như mọi chuyến đi bộ khác và không cho biết gì về tuyết, tuyết lở hay việc lối đi có đi được vào mùa đông không.', - 'tours.winter.kind': 'Loại hoạt động mùa đông', - 'tours.winter.activity.winter_hike': 'Đi bộ mùa đông', - 'tours.winter.activity.snowshoe': 'Đi bộ bằng giày tuyết', - 'tours.winter.labelOnly': - 'Do du khách đánh dấu. Tuyến đường và thời gian được lập như mọi chuyến đi bộ khác, không kiểm tra cho mùa đông.', - 'tours.planner.tourTypeHint.ski': 'Từ tệp GPX, giữ nguyên như đã ghi', + 'tours.planner.tourTypeHint.ski': 'Trượt tuyết, ván trượt, giày tuyết, đi bộ mùa đông', 'tours.ski.title': 'Đường trượt tuyết', 'tours.ski.discipline': 'Bộ môn', 'tours.ski.discipline.alpine': 'Trượt đổ dốc', 'tours.ski.discipline.snowboard': 'Trượt ván tuyết', 'tours.ski.discipline.cross_country': 'Trượt tuyết băng đồng', 'tours.ski.discipline.touring': 'Trượt tuyết leo núi', + 'tours.ski.discipline.snowshoe': 'Giày tuyết', + 'tours.ski.discipline.winter_hike': 'Đi bộ mùa đông', 'tours.ski.technique': 'Kỹ thuật', 'tours.ski.technique.classic': 'Cổ điển', 'tours.ski.technique.skating': 'Trượt kiểu lướt', 'tours.ski.technique.unknown': 'Không rõ', 'tours.ski.chooseDiscipline': 'Chọn bộ môn để nhập đường đi thành trượt tuyết.', + 'tours.ski.chooseDisciplineToSave': 'Chọn bộ môn để lưu chuyến đi.', + 'tours.ski.drawnFreely': + 'Mỗi chặng được vẽ thẳng giữa các điểm của bạn. Không có gì đối chiếu nó với đường trượt, cáp treo hay lối mòn.', 'tours.ski.chooseTechnique': 'Chọn kỹ thuật, hoặc Không rõ.', 'tours.ski.infoLabel': 'TREK làm gì với một đường trượt tuyết', 'tours.ski.info': - 'Được giữ nguyên như đã ghi và không bao giờ được định tuyến. Quãng đường và độ cao lấy từ tệp. TREK không tính thời gian trượt, và cả đường đi lẫn màu độ dốc trong 3D đều không nói gì về độ khó, tuyết, tuyết lở hay việc đường trượt có mở hay không.', + 'Chuyến đi đã lập có mỗi chặng được vẽ thẳng giữa các điểm của nó, còn vết được nhập giữ nguyên như đã ghi. Không cái nào được định tuyến theo đường trượt, cáp treo hay lối mòn. Độ cao lấy từ địa hình hoặc từ tệp. TREK không tính thời gian trượt, và không gì ở đây, kể cả màu độ dốc trong 3D, nói gì về độ khó, tuyết, tuyết lở hay việc đường trượt có mở hay không.', 'tours.ski.time': 'Thời gian trượt', 'tours.ski.timeUnknown': 'Không tính', + 'tours.ski.timeInfoLabel': 'Vì sao không có thời gian trượt', + 'tours.ski.timeInfo': + 'TREK không tính thời gian trượt: các chặng được vẽ thẳng, không lập theo đường trượt, cáp treo hay lối mòn. Nếu muốn, hãy tự nhập tổng thời gian dự kiến.', 'tours.ski.classifyTitle': 'Phân loại đường trượt tuyết', 'tours.ski.saved': 'Đã lưu phân loại', 'tours.ski.saveFailed': 'Không thể lưu phân loại.', @@ -794,6 +793,8 @@ const tours: TranslationStrings = { 'tours.import.previewConfirm': 'Nhập {count} chuyến', 'tours.conversion.introPaddle': 'Bản nhập là một vết đã ghi. Hãy lập một chuyến chèo thuyền có thể chỉnh sửa: TREK lấy các điểm kiểm soát từ đó, nối chúng bằng đường thẳng như cách vẽ chuyến chèo thuyền và cho thấy nó sát đến đâu. Không gì thay đổi cho đến khi bạn chọn.', + 'tours.conversion.introSki': + 'Bản nhập là một vết đã ghi. Hãy lập một chuyến trượt tuyết có thể chỉnh sửa: TREK lấy các điểm kiểm soát từ đó, nối chúng bằng đường thẳng như cách vẽ chuyến trượt tuyết và cho thấy nó sát đến đâu. Không gì thay đổi cho đến khi bạn chọn.', 'tours.conversion.closePaddle': 'Đề xuất bám sát vết. Các điểm được nối bằng đường thẳng nên đây không phải cùng một đường.', 'tours.planner.moreActions': 'Thêm thao tác', @@ -1104,6 +1105,7 @@ const tours: TranslationStrings = { 'tourTypes.horse': 'Chuyến cưỡi ngựa', 'tours.planner.tourTypeHint.horse': 'Trên lưng ngựa, theo các tuyến cưỡi ngựa có biển chỉ dẫn', 'tours.planner.namePlaceholder.horse': 'Chuyến cưỡi ngựa chưa đặt tên', + 'tours.planner.namePlaceholder.ski': 'Chuyến trượt tuyết chưa đặt tên', 'tours.horse.time': 'Thời gian cưỡi ngựa', 'tours.horse.timeInfoLabel': 'Vì sao không có thời gian cưỡi ngựa', 'tours.horse.timeInfo': diff --git a/shared/src/i18n/zh-TW/tours.ts b/shared/src/i18n/zh-TW/tours.ts index 3f890902a..1bf659919 100644 --- a/shared/src/i18n/zh-TW/tours.ts +++ b/shared/src/i18n/zh-TW/tours.ts @@ -184,7 +184,7 @@ const tours: TranslationStrings = { 'tourTypes.hike': '健行', 'tourTypes.bike': '單車遊程', 'tourTypes.run': '跑步', - 'tourTypes.ski': '滑雪', + 'tourTypes.ski': '滑雪與冬季', 'tourTypes.kayak': '划槳行程', 'tourTypes.walk': '城市散步 / 觀光', 'tourTypes.food': '美食 / 葡萄酒之旅', @@ -361,31 +361,30 @@ const tours: TranslationStrings = { 'tours.planner.tourTypeHint.hike': '步行,在你設定的難度內', 'tours.planner.tourTypeHint.bike': '騎乘,為旅行車、公路車、礫石車或登山車規劃路線', 'tours.planner.tourTypeHint.kayak': '水上,獨木舟、加拿大艇或立槳', - 'tours.winter.title': '冬季活動', - 'tours.winter.infoLabel': '冬季標記的作用', - 'tours.winter.info': '只是健行的標記。路線、坡度顏色與步行時間與任何健行相同,不說明積雪、雪崩或道路冬季是否可通行。', - 'tours.winter.kind': '冬季活動類型', - 'tours.winter.activity.winter_hike': '冬季健行', - 'tours.winter.activity.snowshoe': '雪鞋健行', - 'tours.winter.labelOnly': '由旅行者標記。路線與時間按一般健行規劃,未針對冬季查核。', - 'tours.planner.tourTypeHint.ski': '來自 GPX 檔案,保持記錄原樣', + 'tours.planner.tourTypeHint.ski': '滑雪、單板、雪鞋、冬季健行', 'tours.ski.title': '滑雪軌跡', 'tours.ski.discipline': '項目', 'tours.ski.discipline.alpine': '高山滑雪', 'tours.ski.discipline.snowboard': '單板滑雪', 'tours.ski.discipline.cross_country': '越野滑雪', 'tours.ski.discipline.touring': '登山滑雪', + 'tours.ski.discipline.snowshoe': '雪鞋', + 'tours.ski.discipline.winter_hike': '冬季健行', 'tours.ski.technique': '技術', 'tours.ski.technique.classic': '傳統式', 'tours.ski.technique.skating': '自由式', 'tours.ski.technique.unknown': '未知', 'tours.ski.chooseDiscipline': '請選擇項目,以便將軌跡匯入為滑雪。', + 'tours.ski.chooseDisciplineToSave': '請選擇項目以儲存路線。', + 'tours.ski.drawnFreely': '每段都在你的點之間以直線繪製,不會與雪道、纜車或步道進行核對。', 'tours.ski.chooseTechnique': '請選擇技術,或選擇未知。', 'tours.ski.infoLabel': 'TREK 如何處理滑雪軌跡', 'tours.ski.info': - '保持記錄原樣,絕不重新規劃路線。距離和海拔來自檔案。TREK 不計算滑雪時間,軌跡和 3D 中的坡度顏色都不代表難度、積雪、雪崩或雪道是否開放。', + '規劃的路線中每段都在其點之間以直線繪製,匯入的軌跡保持記錄原樣。兩者都不會沿雪道、纜車或步道規劃路線。海拔來自地形或檔案。TREK 不計算滑雪時間,這裡的任何內容,包括 3D 中的坡度顏色,都不代表難度、積雪、雪崩或雪道是否開放。', 'tours.ski.time': '滑雪時間', 'tours.ski.timeUnknown': '未計算', + 'tours.ski.timeInfoLabel': '為什麼沒有滑雪時間', + 'tours.ski.timeInfo': 'TREK 不計算滑雪時間:各段以直線繪製,並未沿雪道、纜車或步道規劃。如有需要,請自行填寫計畫總時長。', 'tours.ski.classifyTitle': '為滑雪軌跡分類', 'tours.ski.saved': '分類已儲存', 'tours.ski.saveFailed': '無法儲存分類。', @@ -737,6 +736,7 @@ const tours: TranslationStrings = { 'tours.import.previewConfirm': '匯入 {count} 條路線', 'tours.conversion.introPaddle': '匯入的是一條記錄的軌跡。規劃一條可編輯的划槳行程:TREK 從中取出控制點,像繪製划槳行程那樣以直線相連,並顯示兩者有多接近。在你選擇之前不會有任何變更。', + 'tours.conversion.introSki': '匯入的是一條記錄的軌跡。規劃一條可編輯的滑雪行程:TREK 從中取出控制點,像繪製滑雪行程那樣以直線相連,並顯示兩者有多接近。在你選擇之前不會有任何變更。', 'tours.conversion.closePaddle': '建議緊貼軌跡。其各點以直線相連,因此並不是同一條線。', 'tours.planner.moreActions': '更多操作', 'tours.paddle.role.put_in': '下水點', @@ -1029,6 +1029,7 @@ const tours: TranslationStrings = { 'tourTypes.horse': '騎馬遊', 'tours.planner.tourTypeHint.horse': '騎馬,沿著有標示的騎馬路線', 'tours.planner.namePlaceholder.horse': '未命名的騎馬遊', + 'tours.planner.namePlaceholder.ski': '未命名的滑雪路線', 'tours.horse.time': '騎乘時間', 'tours.horse.timeInfoLabel': '為什麼沒有騎乘時間', 'tours.horse.timeInfo': 'TREK 不計算騎乘時間:節奏取決於馬和騎士。如有需要,請自行填寫計畫總時長。', diff --git a/shared/src/i18n/zh/tours.ts b/shared/src/i18n/zh/tours.ts index e9e87925c..88429fcf9 100644 --- a/shared/src/i18n/zh/tours.ts +++ b/shared/src/i18n/zh/tours.ts @@ -185,7 +185,7 @@ const tours: TranslationStrings = { 'tourTypes.hike': '徒步', 'tourTypes.bike': '骑行', 'tourTypes.run': '跑步', - 'tourTypes.ski': '滑雪', + 'tourTypes.ski': '滑雪与冬季', 'tourTypes.kayak': '划桨游', 'tourTypes.walk': '城市漫步 / 观光', 'tourTypes.food': '美食 / 葡萄酒之旅', @@ -362,31 +362,30 @@ const tours: TranslationStrings = { 'tours.planner.tourTypeHint.hike': '步行,在你设定的难度内', 'tours.planner.tourTypeHint.bike': '骑行,为旅行车、公路车、砾石车或山地车规划路线', 'tours.planner.tourTypeHint.kayak': '水上,皮划艇、独木舟或桨板', - 'tours.winter.title': '冬季活动', - 'tours.winter.infoLabel': '冬季标记的作用', - 'tours.winter.info': '只是徒步的标记。路线、坡度颜色和步行时间与任何徒步相同,不说明积雪、雪崩或道路冬季是否可通行。', - 'tours.winter.kind': '冬季活动类型', - 'tours.winter.activity.winter_hike': '冬季徒步', - 'tours.winter.activity.snowshoe': '雪鞋徒步', - 'tours.winter.labelOnly': '由旅行者标记。路线和时间按普通徒步规划,未针对冬季核查。', - 'tours.planner.tourTypeHint.ski': '来自 GPX 文件,保持记录原样', + 'tours.planner.tourTypeHint.ski': '滑雪、单板、雪鞋、冬季徒步', 'tours.ski.title': '滑雪轨迹', 'tours.ski.discipline': '项目', 'tours.ski.discipline.alpine': '高山滑雪', 'tours.ski.discipline.snowboard': '单板滑雪', 'tours.ski.discipline.cross_country': '越野滑雪', 'tours.ski.discipline.touring': '登山滑雪', + 'tours.ski.discipline.snowshoe': '雪鞋', + 'tours.ski.discipline.winter_hike': '冬季徒步', 'tours.ski.technique': '技术', 'tours.ski.technique.classic': '传统式', 'tours.ski.technique.skating': '自由式', 'tours.ski.technique.unknown': '未知', 'tours.ski.chooseDiscipline': '请选择项目,以便将轨迹导入为滑雪。', + 'tours.ski.chooseDisciplineToSave': '请选择项目以保存路线。', + 'tours.ski.drawnFreely': '每段都在你的点之间以直线绘制,不会与雪道、缆车或小径进行核对。', 'tours.ski.chooseTechnique': '请选择技术,或选择未知。', 'tours.ski.infoLabel': 'TREK 如何处理滑雪轨迹', 'tours.ski.info': - '保持记录原样,绝不重新规划路线。距离和海拔来自文件。TREK 不计算滑雪用时,轨迹和 3D 中的坡度颜色都不代表难度、积雪、雪崩或雪道是否开放。', + '计划的路线中每段都在其点之间以直线绘制,导入的轨迹保持记录原样。两者都不会沿雪道、缆车或小径规划路线。海拔来自地形或文件。TREK 不计算滑雪用时,这里的任何内容,包括 3D 中的坡度颜色,都不代表难度、积雪、雪崩或雪道是否开放。', 'tours.ski.time': '滑雪用时', 'tours.ski.timeUnknown': '未计算', + 'tours.ski.timeInfoLabel': '为什么没有滑雪用时', + 'tours.ski.timeInfo': 'TREK 不计算滑雪用时:各段以直线绘制,并未沿雪道、缆车或小径规划。如有需要,请自行填写计划总时长。', 'tours.ski.classifyTitle': '为滑雪轨迹分类', 'tours.ski.saved': '分类已保存', 'tours.ski.saveFailed': '无法保存分类。', @@ -738,6 +737,7 @@ const tours: TranslationStrings = { 'tours.import.previewConfirm': '导入 {count} 条路线', 'tours.conversion.introPaddle': '导入的是一条记录的轨迹。规划一条可编辑的划桨路线:TREK 从中提取控制点,像绘制划桨路线那样用直线相连,并显示两者有多接近。在你选择之前不会有任何更改。', + 'tours.conversion.introSki': '导入的是一条记录的轨迹。规划一条可编辑的滑雪路线:TREK 从中提取控制点,像绘制滑雪路线那样用直线相连,并显示两者有多接近。在你选择之前不会有任何更改。', 'tours.conversion.closePaddle': '建议紧贴轨迹。其各点以直线相连,因此并不是同一条线。', 'tours.planner.moreActions': '更多操作', 'tours.paddle.role.put_in': '下水点', @@ -1030,6 +1030,7 @@ const tours: TranslationStrings = { 'tourTypes.horse': '骑马游', 'tours.planner.tourTypeHint.horse': '骑马,沿着有标识的骑马路线', 'tours.planner.namePlaceholder.horse': '未命名的骑马游', + 'tours.planner.namePlaceholder.ski': '未命名的滑雪路线', 'tours.horse.time': '骑行时间', 'tours.horse.timeInfoLabel': '为什么没有骑行时间', 'tours.horse.timeInfo': 'TREK 不计算骑行时间:节奏取决于马和骑手。如有需要,请自行填写计划总时长。', diff --git a/shared/src/tours/collection-tour.schema.ts b/shared/src/tours/collection-tour.schema.ts index c58c70e50..d85022fd1 100644 --- a/shared/src/tours/collection-tour.schema.ts +++ b/shared/src/tours/collection-tour.schema.ts @@ -44,7 +44,7 @@ export type CollectionTourTakeOverRequest = z.infer { - it('SKI-SCHEMA-001: knows four disciplines and three cross-country techniques, unknown among them on purpose', () => { - expect(SKI_DISCIPLINES).toEqual(['alpine', 'snowboard', 'cross_country', 'touring']); +describe('ski and winter tours (SKI-02)', () => { + it('SKI-SCHEMA-001: knows six disciplines and three cross-country techniques, unknown among them on purpose', () => { + expect(SKI_DISCIPLINES).toEqual(['alpine', 'snowboard', 'cross_country', 'touring', 'snowshoe', 'winter_hike']); expect(SKI_TECHNIQUES).toEqual(['classic', 'skating', 'unknown']); }); - it('SKI-SCHEMA-002: ski is a type a track can be imported as, never one the planner plans', () => { - expect(importableTourTypeSchema.options).toEqual([...plannableTourTypeSchema.options, 'ski']); - expect(plannableTourTypeSchema.safeParse('ski').success).toBe(false); + it('SKI-SCHEMA-002: ski is a type the planner plans and a track can be imported as, never without its discipline', () => { + expect(plannableTourTypeSchema.options).toContain('ski'); + expect([...importableTourTypeSchema.options].sort()).toEqual([...plannableTourTypeSchema.options].sort()); const planned = { name: 'Valley run', tour_type: 'ski', @@ -30,16 +30,21 @@ describe('ski tracks (SKI-02)', () => { { lat: 47.01, lng: 11.01, role: 'end', sequence: 1 }, ], }; - expect(tourCreateRequestSchema.safeParse(planned).success).toBe(false); + expect(() => tourCreateRequestSchema.parse(planned)).toThrow(/a ski tour needs a discipline/); + expect(tourCreateRequestSchema.parse({ ...planned, ski_discipline: 'snowshoe' })).toMatchObject({ + tour_type: 'ski', + ski_discipline: 'snowshoe', + max_hiking_difficulty: null, + }); }); it('SKI-SCHEMA-003: a ski track names its discipline, a cross-country one its technique, and nothing else may', () => { expect(skiIssues(true, { ski_discipline: 'alpine' })).toEqual([]); expect(skiIssues(true, { ski_discipline: 'cross_country', ski_technique: 'unknown' })).toEqual([]); - expect(skiIssues(true, {})).toEqual([{ path: ['ski_discipline'], message: 'a ski track needs a discipline' }]); + expect(skiIssues(true, {})).toEqual([{ path: ['ski_discipline'], message: 'a ski tour needs a discipline' }]); expect(skiIssues(true, { ski_discipline: 'cross_country' })[0]?.path).toEqual(['ski_technique']); expect(skiIssues(true, { ski_discipline: 'touring', ski_technique: 'skating' })[0]?.message).toMatch( - /only a cross-country track/, + /only a cross-country tour/, ); expect(skiIssues(false, { ski_discipline: 'alpine', ski_technique: 'classic' }).map((i) => i.path)).toEqual([ ['ski_discipline'], @@ -111,4 +116,71 @@ describe('ski tracks (SKI-02)', () => { expect(tourListItemSchema.parse(listed).ski_discipline).toBeUndefined(); expect(tourListItemSchema.safeParse({ ...listed, ski_discipline: 'freeride' }).success).toBe(false); }); + + describe('a planned ski tour (SKI-02)', () => { + const point = (lat: number, lng: number, role: 'start' | 'via' | 'end', sequence: number) => ({ + lat, + lng, + role, + sequence, + }); + const planned = { + name: 'Hut to hut on snowshoes', + tour_type: 'ski' as const, + ski_discipline: 'touring' as const, + route_geometry: [ + [47, 11, 1200], + [47.01, 11.01, 1500], + ], + waypoints: [point(47, 11, 'start', 0), point(47.01, 11.01, 'end', 1)], + }; + + it('SKI-SCHEMA-007: saves every discipline, a cross-country tour only with its technique', () => { + for (const discipline of SKI_DISCIPLINES.filter((value) => value !== 'cross_country')) { + expect(tourCreateRequestSchema.safeParse({ ...planned, ski_discipline: discipline }).success).toBe(true); + } + expect(() => tourCreateRequestSchema.parse({ ...planned, ski_discipline: 'cross_country' })).toThrow( + /a cross-country tour needs a technique/, + ); + expect( + tourCreateRequestSchema.parse({ ...planned, ski_discipline: 'cross_country', ski_technique: 'skating' }), + ).toMatchObject({ ski_technique: 'skating' }); + expect(() => tourCreateRequestSchema.parse({ ...planned, ski_technique: 'classic' })).toThrow( + /only a cross-country tour has a technique/, + ); + }); + + it('SKI-SCHEMA-008: works out no ski time and takes nothing of another type, a free leg allowed', () => { + expect(() => tourCreateRequestSchema.parse({ ...planned, duration_seconds: 3600 })).toThrow( + /a ski tour has no ski time worked out/, + ); + expect(tourCreateRequestSchema.safeParse({ ...planned, duration_seconds: null }).success).toBe(true); + expect(() => tourCreateRequestSchema.parse({ ...planned, max_hiking_difficulty: 3 })).toThrow( + /a ski tour has no hiking difficulty/, + ); + expect(() => tourCreateRequestSchema.parse({ ...planned, hiking_preferences: { avoid_steps: true } })).toThrow( + /a ski tour has no hiking preferences/, + ); + expect(() => tourCreateRequestSchema.parse({ ...planned, bike_profile: 'mtb' })).toThrow(/bike tour/); + expect(() => tourCreateRequestSchema.parse({ ...planned, boat_type: 'sup' })).toThrow(/paddle tour/); + const [start, end] = planned.waypoints; + const line: Array<[number, number]> = [ + [47, 11], + [47.01, 11.01], + ]; + const withLeg = (fixed_leg: object) => ({ ...planned, waypoints: [{ ...start, fixed_leg }, end] }); + expect(tourCreateRequestSchema.safeParse(withLeg({ kind: 'free', geometry: line })).success).toBe(true); + const ferrata = withLeg({ kind: 'ferrata', ref: 'way/1', name: null, geometry: line }); + expect(() => tourCreateRequestSchema.parse(ferrata)).toThrow(/a ski tour has no via ferrata/); + }); + + it('SKI-SCHEMA-009: keeps the ski fields off every other type', () => { + const hike = { ...planned, tour_type: 'hike' as const, ski_discipline: 'winter_hike' as const }; + expect(() => tourCreateRequestSchema.parse(hike)).toThrow(/ski_discipline belongs to a ski tour/); + const { ski_discipline: _discipline, ...walk } = planned; + expect(() => tourCreateRequestSchema.parse({ ...walk, tour_type: 'walk', ski_technique: 'classic' })).toThrow( + /ski_technique belongs to a ski tour/, + ); + }); + }); }); diff --git a/shared/src/tours/ski.schema.ts b/shared/src/tours/ski.schema.ts index 100b4dd90..0622aa5c9 100644 --- a/shared/src/tours/ski.schema.ts +++ b/shared/src/tours/ski.schema.ts @@ -1,13 +1,16 @@ import { z } from 'zod'; /** - * SKI-02: a ski track comes in as a GPX file and is kept as it was recorded. - * TREK routes no ski track and works out no ski time: its distance and heights - * are the file's, and nothing is said about snow, avalanches or whether a run - * or a trail is open. What the skier names on purpose is the discipline, and - * for cross-country the technique. + * SKI-02: a ski and winter tour, planned in the planner or imported from a + * GPX file. A planned one has every leg drawn between its points (the piste + * router comes later), an imported one is kept as it was recorded. TREK works + * out no ski time, and nothing is said about snow, avalanches or whether a + * run or a trail is open. What the traveller names on purpose is the + * discipline: alpine, snowboard, cross-country, ski touring, snowshoeing or + * winter hiking (both of which live here and not on a hike), and for + * cross-country the technique. */ -export const SKI_DISCIPLINES = ['alpine', 'snowboard', 'cross_country', 'touring'] as const; +export const SKI_DISCIPLINES = ['alpine', 'snowboard', 'cross_country', 'touring', 'snowshoe', 'winter_hike'] as const; export const skiDisciplineSchema = z.enum(SKI_DISCIPLINES); export type SkiDiscipline = z.infer; @@ -23,9 +26,10 @@ export type SkiTechnique = z.infer; type SkiIssue = { path: string[]; message: string }; /** - * What a ski track names, and what nothing else may: a ski track its - * discipline, a cross-country track its technique as well; a technique on any - * other discipline, and either field on another type, is refused. + * What a ski tour names, and what nothing else may: a ski tour its + * discipline, a cross-country tour its technique as well; a technique on any + * other discipline, and either field on another type, is refused. Planned and + * imported ski tours are held to the same rule. */ export function skiIssues( isSki: boolean, @@ -34,19 +38,19 @@ export function skiIssues( if (!isSki) { return (['ski_discipline', 'ski_technique'] as const) .filter((field) => value[field] != null) - .map((field) => ({ path: [field], message: `${field} belongs to a ski track` })); + .map((field) => ({ path: [field], message: `${field} belongs to a ski tour` })); } - if (value.ski_discipline == null) return [{ path: ['ski_discipline'], message: 'a ski track needs a discipline' }]; + if (value.ski_discipline == null) return [{ path: ['ski_discipline'], message: 'a ski tour needs a discipline' }]; const crossCountry = value.ski_discipline === 'cross_country'; if (crossCountry && value.ski_technique == null) - return [{ path: ['ski_technique'], message: 'a cross-country track needs a technique' }]; + return [{ path: ['ski_technique'], message: 'a cross-country tour needs a technique' }]; if (!crossCountry && value.ski_technique != null) - return [{ path: ['ski_technique'], message: 'only a cross-country track has a technique' }]; + return [{ path: ['ski_technique'], message: 'only a cross-country tour has a technique' }]; return []; } /** - * A ski track classified again after its import: the discipline and the + * A ski tour classified again after its import: the discipline and the * technique, nothing of the track. Refused with a 409 once the tour moved on * past the revision the choice was made at. */ diff --git a/shared/src/tours/tour-poi-search.schema.spec.ts b/shared/src/tours/tour-poi-search.schema.spec.ts index 85dc83d95..40e82ecb5 100644 --- a/shared/src/tours/tour-poi-search.schema.spec.ts +++ b/shared/src/tours/tour-poi-search.schema.spec.ts @@ -20,6 +20,8 @@ describe('tour POI search contracts (PLN-02)', () => { expect(TOUR_POI_CATEGORIES_BY_TYPE.hike).toContain('viewpoint'); expect(TOUR_POI_CATEGORIES_BY_TYPE.kayak).toContain('slipway'); expect(TOUR_POI_CATEGORIES_BY_TYPE.hike).not.toContain('slipway'); + expect(TOUR_POI_CATEGORIES_BY_TYPE.ski).toContain('shelter'); + expect(TOUR_POI_CATEGORIES_BY_TYPE.ski).not.toContain('slipway'); for (const type of Object.values(TOUR_POI_CATEGORIES_BY_TYPE)) for (const category of type) expect(TOUR_POI_CATEGORIES).toContain(category); }); diff --git a/shared/src/tours/tour-poi-search.schema.ts b/shared/src/tours/tour-poi-search.schema.ts index 4c36d2079..d8ad4baf1 100644 --- a/shared/src/tours/tour-poi-search.schema.ts +++ b/shared/src/tours/tour-poi-search.schema.ts @@ -29,6 +29,8 @@ export const TOUR_POI_CATEGORIES_BY_TYPE: Record { }); }); -describe('winter activity of a hike (HIK-01)', () => { - const listedHike = { - place_id: 1, - tour_type: 'hike' as const, - distance: 8, - elevation_gain: 400, - elevation_loss: 400, - duration: 180, - difficulty: null, - wanderer_ref: null, - match_confidence: 1, - max_hiking_difficulty: 2, - name: 'Snowy ridge', - planned: false, - caution: false, - }; - - it('offers exactly winter hiking and snowshoeing, and a hike needs neither', () => { - expect(WINTER_ACTIVITIES).toEqual(['winter_hike', 'snowshoe']); - expect(tourCreateRequestSchema.parse(baseRequest).winter_activity).toBeUndefined(); - expect(tourCreateRequestSchema.parse({ ...baseRequest, winter_activity: null }).winter_activity).toBeNull(); - }); - - it('keeps the winter label a hike is saved with', () => { - for (const activity of WINTER_ACTIVITIES) { - expect(tourCreateRequestSchema.parse({ ...baseRequest, winter_activity: activity })).toMatchObject({ - tour_type: 'hike', - winter_activity: activity, - }); - } - }); - - it('refuses a label it does not know, and any label on another type', () => { - expect(tourCreateRequestSchema.safeParse({ ...baseRequest, winter_activity: 'ski_tour' }).success).toBe(false); - const bike = { ...baseRequest, tour_type: 'bike', bike_profile: 'mtb', winter_activity: 'snowshoe' }; - expect(() => tourCreateRequestSchema.parse(bike)).toThrow(/a bike tour has no winter activity/); - const walk = { ...baseRequest, tour_type: 'walk', winter_activity: 'winter_hike' }; - expect(() => tourCreateRequestSchema.parse(walk)).toThrow(/a city walk has no winter activity/); - const paddle = { - ...baseRequest, - tour_type: 'kayak', - boat_type: 'sup', - water_context: 'lake', - winter_activity: 'snowshoe', - }; - expect(() => tourCreateRequestSchema.parse(paddle)).toThrow(/a paddle tour has no winter activity/); - }); - - it('lists the label, and reads a list cached before it existed', () => { - expect(tourListItemSchema.parse({ ...listedHike, winter_activity: 'snowshoe' }).winter_activity).toBe('snowshoe'); - expect(tourListItemSchema.parse(listedHike).winter_activity).toBeUndefined(); - expect(tourListItemSchema.safeParse({ ...listedHike, winter_activity: 'ski_tour' }).success).toBe(false); +describe('a hike is summer only (HIK-01 withdrawn)', () => { + it('keeps no winter label on a hike: the field is not part of the contract and is dropped', () => { + const parsed = tourCreateRequestSchema.parse({ ...baseRequest, winter_activity: 'snowshoe' }); + expect(parsed).not.toHaveProperty('winter_activity'); + const listed = tourListItemSchema.parse({ + place_id: 1, + tour_type: 'hike', + distance: 8, + elevation_gain: 400, + elevation_loss: 400, + duration: 180, + difficulty: null, + wanderer_ref: null, + match_confidence: 1, + max_hiking_difficulty: 2, + name: 'Snowy ridge', + planned: false, + caution: false, + winter_activity: 'winter_hike', + }); + expect(listed).not.toHaveProperty('winter_activity'); }); }); @@ -641,7 +609,7 @@ describe('runs (RUN-01)', () => { expect(() => tourCreateRequestSchema.parse({ ...run, run_profile: null })).toThrow(/a run needs a run profile/); expect(tourCreateRequestSchema.safeParse({ ...run, run_profile: 'sprint' }).success).toBe(false); expect(() => tourCreateRequestSchema.parse({ ...run, max_hiking_difficulty: 2 })).toThrow(/a run has no hiking/); - expect(() => tourCreateRequestSchema.parse({ ...run, winter_activity: 'snowshoe' })).toThrow(/a run has no winter/); + expect(() => tourCreateRequestSchema.parse({ ...run, ski_discipline: 'alpine' })).toThrow(/belongs to a ski tour/); expect(() => tourCreateRequestSchema.parse({ ...run, bike_profile: 'road' })).toThrow(/bike tour/); expect(() => tourCreateRequestSchema.parse({ ...run, boat_type: 'sup' })).toThrow(/paddle tour/); const overFerrata = { ...run, waypoints: [{ ...run.waypoints[0], fixed_leg: ferrata }, run.waypoints[1]] }; diff --git a/shared/src/tours/tours.schema.ts b/shared/src/tours/tours.schema.ts index 57d3bcfaf..a8113a88e 100644 --- a/shared/src/tours/tours.schema.ts +++ b/shared/src/tours/tours.schema.ts @@ -7,7 +7,13 @@ import { trackMarksSchema, } from './paddle-plan.schema'; import { runPaceSecondsPerKmSchema } from './run-pace'; -import { skiDisciplineSchema, skiTechniqueSchema } from './ski.schema'; +import { + skiDisciplineSchema, + skiIssues, + skiTechniqueSchema, + type SkiDiscipline, + type SkiTechnique, +} from './ski.schema'; import { tourActivityRefSchema, tourActivitySchema } from './tour-activity.schema'; import { tourPackingLinkSchema } from './tour-packing-link.schema'; import { tourPoiCategorySchema } from './tour-poi-category'; @@ -40,15 +46,17 @@ export type TourTypeKey = z.infer; * walk (CW-01): stations joined on foot, without a hiking limit. `run` is a * run (RUN-01), planned on foot with the runner's own pace. `horse` is a * ride (HOR-01): sections of signposted horse routes and stretches the rider - * draws by hand, never routed. + * draws by hand, never routed. `ski` is a ski and winter tour (SKI-02): a + * discipline from alpine skiing to winter hiking, every leg drawn between + * its points until the piste router plans them, no ski time worked out. */ -export const plannableTourTypeSchema = z.enum(['hike', 'walk', 'run', 'bike', 'kayak', 'horse']); +export const plannableTourTypeSchema = z.enum(['hike', 'walk', 'run', 'bike', 'kayak', 'horse', 'ski']); export type PlannableTourType = z.infer; /** - * The types a tour can be imported and kept as: every type the planner plans, - * and ski (SKI-02), which TREK does not route. A ski track comes in as it was - * recorded and stays so; the planner never opens it as a draft. + * The types a tour can be imported and kept as: every type the planner plans. + * An imported track (a ski track among them, SKI-02) comes in as it was + * recorded and stays so until a tour is planned from it. */ export const importableTourTypeSchema = z.enum(['hike', 'walk', 'run', 'bike', 'kayak', 'horse', 'ski']); export type ImportableTourType = z.infer; @@ -87,16 +95,6 @@ export type PaddleWaterContext = z.infer; export const paddleLakeStyleSchema = z.enum(['direct', 'shore']); export type PaddleLakeStyle = z.infer; -/** - * HIK-01: a hike marked as one done in winter, walked or on snowshoes. It is - * optional and only a label: the route, its grade and its time are planned as - * for any hike, and the label says nothing about whether a way can be walked - * in winter, about snow or about avalanches. A hike without one keeps null. - */ -export const WINTER_ACTIVITIES = ['winter_hike', 'snowshoe'] as const; -export const winterActivitySchema = z.enum(WINTER_ACTIVITIES); -export type WinterActivity = z.infer; - /** The highest planning speed a paddler can enter, in km/h. */ export const MAX_PADDLE_SPEED_KMH = 20; export const paddleSpeedKmhSchema = z.number().finite().min(0.5).max(MAX_PADDLE_SPEED_KMH); @@ -214,15 +212,13 @@ export const tourSchema = z.object({ derived_from_place_id: z.number().int().positive().nullable().optional(), /** BIK-01: a bike tour's profile; null for every other type. Absent in a list cached before it existed. */ bike_profile: bikeProfileSchema.nullable().optional(), - /** HIK-01: a hike's winter label; null for none and for every other type. Absent in a list cached before it existed. */ - winter_activity: winterActivitySchema.nullable().optional(), /** RUN-01: a run's profile; null for every other type. Absent in a list cached before it existed. */ run_profile: runProfileSchema.nullable().optional(), /** RUN-01: the pace a run is planned at, seconds per km; null while none is entered and for every other type. */ run_pace_seconds_per_km: z.number().nullable().optional(), - /** SKI-02: a ski track's discipline; null for every other type. Absent in a list cached before it existed. */ + /** SKI-02: a ski tour's discipline; null for every other type. Absent in a list cached before it existed. */ ski_discipline: skiDisciplineSchema.nullable().optional(), - /** SKI-02: how a cross-country track is skied; null for every other discipline and type. */ + /** SKI-02: how a cross-country tour is skied; null for every other discipline and type. */ ski_technique: skiTechniqueSchema.nullable().optional(), /** A paddle tour's boat and water; null for every other type. Absent in a list cached before they existed. */ boat_type: paddleBoatTypeSchema.nullable().optional(), @@ -509,12 +505,13 @@ export const tourCreateRequestSchema = z hiking_preferences: hikingPreferencesSchema.nullable().optional(), /** BIK-01, a bike tour's only, and its one requirement. */ bike_profile: bikeProfileSchema.nullable().optional(), - /** HIK-01, a hike's only: its winter label, null to take it off; left out, an edit keeps the one it has. */ - winter_activity: winterActivitySchema.nullable().optional(), /** RUN-01, a run's only, and its one requirement. */ run_profile: runProfileSchema.nullable().optional(), /** RUN-01, a run's only: the pace it is planned at, seconds per km; null while none is entered. */ run_pace_seconds_per_km: runPaceSecondsPerKmSchema.nullable().optional(), + /** SKI-02, a ski tour's only, and its one requirement: the discipline, for cross-country the technique too. */ + ski_discipline: skiDisciplineSchema.nullable().optional(), + ski_technique: skiTechniqueSchema.nullable().optional(), boat_type: paddleBoatTypeSchema.nullable().optional(), water_context: paddleWaterContextSchema.nullable().optional(), paddle_speed_kmh: paddleSpeedKmhSchema.nullable().optional(), @@ -599,6 +596,7 @@ const TYPE_NAMED: Record = { run: 'a run', hike: 'a hike', horse: 'a horse tour', + ski: 'a ski tour', }; /** @@ -624,7 +622,9 @@ const PADDLE_FIELDS = [ * bike tour and a run exactly one profile each and nothing of a hike's or a * paddler's, a run also its pace; a paddle tour exactly one boat and its * water, and no hiking limit; a horse tour (HOR-01) only legs taken from a - * horse route or drawn by hand, and no riding time worked out for it. + * horse route or drawn by hand, and no riding time worked out for it; a ski + * tour (SKI-02) its discipline (and a cross-country technique), no via + * ferrata, and no ski time worked out for it. */ function tourTypeIssues(value: { tour_type: PlannableTourType; @@ -632,9 +632,10 @@ function tourTypeIssues(value: { max_hiking_difficulty?: number | null; hiking_preferences?: HikingPreferences | null; bike_profile?: BikeProfile | null; - winter_activity?: WinterActivity | null; run_profile?: RunProfile | null; run_pace_seconds_per_km?: number | null; + ski_discipline?: SkiDiscipline | null; + ski_technique?: SkiTechnique | null; boat_type?: PaddleBoatType | null; water_context?: PaddleWaterContext | null; paddle_speed_kmh?: number | null; @@ -668,17 +669,19 @@ function tourTypeIssues(value: { issues.push({ path: ['max_hiking_difficulty'], message: `${named} has no hiking difficulty` }); if (hikingPreferencesOf(value.hiking_preferences) !== null) issues.push({ path: ['hiking_preferences'], message: `${named} has no hiking preferences` }); - if (value.winter_activity != null) - issues.push({ path: ['winter_activity'], message: `${named} has no winter activity` }); } if (type === 'bike' && value.bike_profile == null) issues.push({ path: ['bike_profile'], message: 'a bike tour needs a bike profile' }); if (type !== 'bike' && value.bike_profile != null) issues.push({ path: ['bike_profile'], message: 'bike_profile belongs to a bike tour' }); issues.push(...runIssues(value)); + issues.push(...skiIssues(type === 'ski', value)); // No router plans a ride, so no time is worked out for it; a planned total the rider enters is theirs. if (type === 'horse' && value.duration_seconds != null) issues.push({ path: ['duration_seconds'], message: 'a horse tour has no riding time worked out' }); + // SKI-02: no ski time is worked out until the piste router plans the legs; a planned total stays the traveller's. + if (type === 'ski' && value.duration_seconds != null) + issues.push({ path: ['duration_seconds'], message: 'a ski tour has no ski time worked out' }); const points = value.waypoints ?? []; // A portage and a lock are a paddler's; a via ferrata is climbed, never ridden. points.forEach((point, index) => { @@ -687,7 +690,10 @@ function tourTypeIssues(value: { issues.push({ path: ['waypoints', index, 'fixed_leg'], message: 'a portage belongs to a paddle tour' }); if (type !== 'kayak' && point.lock != null) issues.push({ path: ['waypoints', index, 'lock'], message: 'a lock belongs to a paddle tour' }); - if ((type === 'bike' || type === 'walk' || type === 'run' || type === 'horse') && kind === 'ferrata') + if ( + (type === 'bike' || type === 'walk' || type === 'run' || type === 'horse' || type === 'ski') && + kind === 'ferrata' + ) issues.push({ path: ['waypoints', index, 'fixed_leg'], message: `${named} has no via ferrata` }); if (type !== 'horse' && kind === 'horse_route') issues.push({ diff --git a/wiki/MCP-Addon-Tools.md b/wiki/MCP-Addon-Tools.md index c4b2233b5..23078e584 100644 --- a/wiki/MCP-Addon-Tools.md +++ b/wiki/MCP-Addon-Tools.md @@ -237,8 +237,8 @@ The saved tours of a trip, as the Tours list and the tour detail show them, how | Tool | Description | Scope | |---|---|---| -| `list_tours` | The saved tours of a trip: name, type (`hike`, `walk` for a city walk, `run`, `bike`, `kayak` for a paddle tour, `horse` for a ride, which has no riding time worked out, or `ski` for a ski track), origin (`trek` for planned in TREK, `gpx` for a GPX import, `wanderer` for a wanderer snapshot), distance, ascent and descent (unknown for a paddle tour drawn in the planner), the stored routing limit of a hike on the SAC scale and its route choices (`hiking_preferences`), the winter label of a hike (`winter_activity`: `winter_hike` or `snowshoe`; a label only, the route and its time are planned as for any hike, never a statement about winter conditions), the classification of a ski track (`ski_discipline`: `alpine`, `snowboard`, `cross_country` or `touring`; `ski_technique` for cross-country only: `classic`, `skating` or `unknown`; a ski track is imported from a GPX file and kept as recorded, never routed, has no ski time worked out and says nothing about snow, avalanches or open runs), the boat, water and chosen speed of a paddle tour, the check bench of a paddle tour (`paddle_check`: how far the OpenStreetMap obstacle data covers its route and how many obstacles are open, to check again or decided; a planning status, never a safety or legal clearance), for a city walk how many stations it has (`station_count`), their stays as far as entered and how many have none, whether a tour still waits for its way to be planned (`route_pending`: saved with its points only, its distance and walking time unknown until someone opens it in the planner and saves it), for a multi-day tour how many stages its route is split into (`stage_count`, `null` for a tour of one day), the `revision` (raised by every saved change of the tour), the actual tracks linked to a tour (`activity_tracks`) and, for an actual track, the tour it belongs to (`activity_of`), when an imported track was recorded (`track_started_at`, `track_ended_at`; unknown when the file carried no times), and the days of the trip each tour is on | `trips:read` | -| `get_tour_plan` | One tour: its facts, its ordered control points, its provenance (where it came from, when, and whether it was changed in TREK since) and its days. A control point can carry a fixed leg to the next one: a via ferrata (`ferrata`), a stretch drawn by hand (`free`), which no router planned or checked, or on a ride a section of a signposted horse route (`horse_route`) in the direction chosen, with its signposting and the `horse=*` tags its ways carried; being on a route is no permission to ride it. A shaping point (`shaping: true`) only steers the route and is no stop. A stop with `stage_end: true` ends a stage of a multi-day tour and the next one begins there; the stages follow the route in its order. A paddle tour also carries its obstacle checklist (`obstacle_check`): the obstacles of the OpenStreetMap data along its route, what the travellers decided about each, and their own portages and locks. The stations of a city walk carry every field spelled out: `key` (the same across saves), `name`, `stay_minutes` (`0` for a point only passed, `null` when not known), `importance` (`optional`, `must`, or `null` for normal), `protected` (a station a proposed change has to keep where it is; the travellers can still edit it) and `opening_hours` (as OpenStreetMap writes them, `null` when none is known). Every leg from one control point to the next is listed in `legs` with its kind (`routed` for a leg the router planned, `track` for part of a recorded line no router planned, as for an import without control points of its own, `ferrata`, `free`, `portage` or `horse_route`) and, for a fixed leg, the length of its own line; a routed or track leg has no length of its own stored. `line` gives the saved line in brief: how many points it has, whether they carry heights, and a preview thinned to 200 points. Pass `dayId` with a city walk to read the visits of that day: each station then says `visit_status` (planned, visited or skipped) and `run` what is still open; without a day no visit is told. `derived` names every field worked out on reading rather than stored with the tour. The actual tracks linked to the tour (`activities`) each name their day, the recording (its tour in the trip, origin, file name, when it came in) and what that recording measures: distance, ascent and descent (unknown without heights) and the time from its first to its last time stamp, breaks included (unknown when the file carried no times, never estimated); the planned values stay apart and unchanged. Reading changes nothing, not even the revision. The full track geometry is not included | `trips:read` | +| `list_tours` | The saved tours of a trip: name, type (`hike`, `walk` for a city walk, `run`, `bike`, `kayak` for a paddle tour, `horse` for a ride, which has no riding time worked out, or `ski` for a ski and winter tour, which has no ski time worked out either), origin (`trek` for planned in TREK, `gpx` for a GPX import, `wanderer` for a wanderer snapshot), distance, ascent and descent (unknown for a paddle tour drawn in the planner), the stored routing limit of a hike on the SAC scale and its route choices (`hiking_preferences`), the discipline of a ski tour (`ski_discipline`: `alpine`, `snowboard`, `cross_country`, `touring`, `snowshoe` or `winter_hike`; snowshoeing and winter hiking are ski tours, a hike is summer only; `ski_technique` for cross-country only: `classic`, `skating` or `unknown`; a planned ski tour has every leg drawn straight between its points and an imported one is kept as recorded, neither is routed along pistes or trails, neither has a ski time worked out, and neither says anything about snow, avalanches or open runs), the boat, water and chosen speed of a paddle tour, the check bench of a paddle tour (`paddle_check`: how far the OpenStreetMap obstacle data covers its route and how many obstacles are open, to check again or decided; a planning status, never a safety or legal clearance), for a city walk how many stations it has (`station_count`), their stays as far as entered and how many have none, whether a tour still waits for its way to be planned (`route_pending`: saved with its points only, its distance and walking time unknown until someone opens it in the planner and saves it), for a multi-day tour how many stages its route is split into (`stage_count`, `null` for a tour of one day), the `revision` (raised by every saved change of the tour), the actual tracks linked to a tour (`activity_tracks`) and, for an actual track, the tour it belongs to (`activity_of`), when an imported track was recorded (`track_started_at`, `track_ended_at`; unknown when the file carried no times), and the days of the trip each tour is on | `trips:read` | +| `get_tour_plan` | One tour: its facts, its ordered control points, its provenance (where it came from, when, and whether it was changed in TREK since) and its days. A control point can carry a fixed leg to the next one: a via ferrata (`ferrata`), a stretch drawn by hand (`free`), which no router planned or checked, or on a ride a section of a signposted horse route (`horse_route`) in the direction chosen, with its signposting and the `horse=*` tags its ways carried; being on a route is no permission to ride it. A shaping point (`shaping: true`) only steers the route and is no stop. A stop with `stage_end: true` ends a stage of a multi-day tour and the next one begins there; the stages follow the route in its order. A paddle tour also carries its obstacle checklist (`obstacle_check`): the obstacles of the OpenStreetMap data along its route, what the travellers decided about each, and their own portages and locks. The stations of a city walk carry every field spelled out: `key` (the same across saves), `name`, `stay_minutes` (`0` for a point only passed, `null` when not known), `importance` (`optional`, `must`, or `null` for normal), `protected` (a station a proposed change has to keep where it is; the travellers can still edit it) and `opening_hours` (as OpenStreetMap writes them, `null` when none is known). Every leg from one control point to the next is listed in `legs` with its kind (`routed` for a leg the router planned, `track` for part of a recorded line no router planned, as for an import without control points of its own, `ferrata`, `free`, `portage` or `horse_route`; every leg of a planned ski tour is `free`, a straight line between its points) and, for a fixed leg or a ski tour's leg, the length of its own line; a routed or track leg has no length of its own stored. `line` gives the saved line in brief: how many points it has, whether they carry heights, and a preview thinned to 200 points. Pass `dayId` with a city walk to read the visits of that day: each station then says `visit_status` (planned, visited or skipped) and `run` what is still open; without a day no visit is told. `derived` names every field worked out on reading rather than stored with the tour. The actual tracks linked to the tour (`activities`) each name their day, the recording (its tour in the trip, origin, file name, when it came in) and what that recording measures: distance, ascent and descent (unknown without heights) and the time from its first to its last time stamp, breaks included (unknown when the file carried no times, never estimated); the planned values stay apart and unchanged. Reading changes nothing, not even the revision. The full track geometry is not included | `trips:read` | | `find_tours_for_day` | The tours put on one day of a trip, with the same facts as `list_tours` | `trips:read` | | `get_city_walk_progress` | How far a city walk got on one day it is on: every station in order with its key, its status that day (planned, visited or skipped, as checked off in the day plan), importance, protection, stay and opening hours, what is still open, and the walking time of the whole walk. Visits belong to that day only. Reading changes nothing | `trips:read` | | `export_tour_gpx` | One saved tour as a GPX file: its whole saved line as one track, with the heights it carries and none it lacks, and a file name. A tour without a line cannot be exported | `trips:read` |