feat(tours): plan ski and winter tours, snowshoeing and winter hiking among them

Ski becomes "Ski & winter", a type the planner plans with six disciplines
(alpine, snowboard, cross-country, ski touring, snowshoeing, winter hiking),
nothing preselected; saving needs a discipline, and a technique for
cross-country. Every leg is drawn straight between the points through
planner/skiRouting.ts, the one place the piste router will plug in, with its
heights from the elevation source and no ski time worked out.

The hike's winter label goes entirely: its migration, column, field, marks,
GPX rule, MCP field and strings. A hike is summer only; a ski tour with the
snowshoe discipline exports as snowshoeing. The unreleased ski migration takes
the six disciplines and the collection migration loses the winter column.

The pistes are offered for a ski tour, shown until switched off, and with no
tour open, never for a hike. Export, copy, collections, stages (untimed),
actual tracks, the atlas, list and day marks (one glyph per discipline), 3D,
planning over a ski import and list_tours/get_tour_plan know the planned ski
tour.
This commit is contained in:
Maurice
2026-10-20 18:27:53 +02:00
parent 204ee2ef86
commit 164a3633ac
116 changed files with 1675 additions and 1576 deletions
@@ -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']);
});
});
@@ -209,7 +209,7 @@ interface OverlayLook {
const OVERLAY_LOOK: Record<string, OverlayLook> = {
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',
@@ -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({
+10 -11
View File
@@ -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<TourListItem, 'tour_type' | 'max_hiking_difficulty'> &
Partial<Pick<TourListItem, 'winter_activity' | 'ski_discipline' | 'ski_technique'>>;
Partial<Pick<TourListItem, 'ski_discipline' | 'ski_technique'>>;
size?: number;
}) {
const avatar = <TourGradeAvatar tour={tour} size={size} />;
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 (
<span className="relative flex flex-none">
{avatar}
<WinterCornerMark activity={tour.winter_activity} size={markSize} />
<SkiCornerMark tour={tour} size={markSize} />
</span>
);
@@ -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(<TourDetailDialog {...defaultProps} />)
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(<TourDetailDialog {...defaultProps} tour={{ ...tour, winter_activity: 'snowshoe' }} />)
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(<TourDetailDialog {...defaultProps} tour={snowshoe} />)
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', () => {
@@ -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}`)}
</span>
{/* HIK-01: a hike's winter label, the travellers' own, beside its type. */}
{tour.winter_activity && <WinterActivityPill activity={tour.winter_activity} className={HEAD_PILL} />}
{/* RUN-01: a run's profile and the pace its time is planned at. */}
<RunHeadPills tour={tour} className={HEAD_PILL} />
{/* 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. */}
<SkiHeadPill tour={tour} className={HEAD_PILL} />
{/* A paddle tour names its boat and its water, each a badge of its own. */}
{tour.boat_type && (
@@ -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', () => {
@@ -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(<CollectionToursSection collectionId={3} canRemove={false} />);
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();
@@ -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<TourPlannerController>);
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<TourPlannerController>);
const { result } = renderHook(() => useGpxConversion(controller));
expect(result.current.available).toBe(false);
const { container } = render(<TourConversionSection conversion={result.current} />);
expect(container).toBeEmptyDOMElement();
@@ -77,7 +77,13 @@ export default function TourConversionSection({ conversion }: { conversion: GpxC
return card(
<div className="flex flex-col items-start gap-2.5">
<p className="m-0 leading-snug text-content-secondary" style={fs(12, 'body')}>
{t(conversion.paddle ? 'tours.conversion.introPaddle' : 'tours.conversion.intro')}
{t(
conversion.ski
? 'tours.conversion.introSki'
: conversion.paddle
? 'tours.conversion.introPaddle'
: 'tours.conversion.intro'
)}
</p>
<CardAction onClick={() => void conversion.analyze()} icon={<Route size={12} strokeWidth={2.2} />}>
{t('tours.conversion.analyze')}
@@ -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]
);
}
@@ -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({
<>
<TourDifficultyField planner={planner} locked={locked} onDifficultyChange={onDifficultyChange} />
<HikingPreferencesField planner={planner} locked={locked} />
<WinterActivityField planner={planner} locked={locked} />
</>
)}
<TourTimeFields planner={planner} locked={locked} />
@@ -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<PlannableTourType, 'hike' | 'bike' | 'run'> = {
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),
},
{
@@ -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<string, unknown> = {}) =>
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(<TourTypeFields planner={ski} locked={false} />);
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(<TourTypeFields planner={crossCountry} locked={false} />);
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(<TourTypeFields planner={alpine} locked={false} />);
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(<TourTypeFields planner={planner({ tourType: 'hike', isPaddle: false })} locked={false} />);
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' });
@@ -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 (
<div data-testid="tour-planner-ski-fields" className="flex min-w-0 flex-col gap-1.5">
<SkiClassificationFields choice={planner.ski.choice} onChange={planner.ski.change} planned />
<span className="block text-content-faint" style={fs(11, 'body')}>
{t('tours.ski.drawnFreely')}
</span>
</div>
);
}
/**
* 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 && <RunFields planner={planner} locked={locked} />}
{planner.isSki && <SkiFields planner={planner} />}
{planner.isPaddle && (
<div data-testid="tour-planner-paddle-fields" className="flex min-w-0 flex-col gap-3">
<div className="min-w-0">
@@ -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<T extends ImportableTourType = PlannableTourType>({
value,
@@ -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 },
@@ -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<WinterActivity | null>(initial);
const [winterActivityChoice, setChoice] = useState<WinterActivity>(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 (
<>
<WinterActivityField planner={{ winterActivity, setWinterActivity, winterActivityChoice }} locked={false} />
<output data-testid="saved">{winterActivity ?? 'none'}</output>
<button type="button" onClick={() => load(null)}>
reset
</button>
<button type="button" onClick={() => load('snowshoe')}>
open snowshoe
</button>
</>
);
}
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(<WinterActivityField planner={field(null, setWinterActivity)} locked={false} />);
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(<WinterActivityField planner={field('snowshoe', setWinterActivity)} locked={false} />);
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(<Stateful initial="snowshoe" />);
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(<WinterActivityField planner={field('winter_hike', setWinterActivity)} locked />);
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(<Stateful initial="snowshoe" />);
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(<WinterActivityField planner={field(null, setWinterActivity, 'snowshoe')} locked={false} />);
expect(option('snowshoe')).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(toggle());
expect(setWinterActivity).toHaveBeenCalledWith('snowshoe');
});
});
@@ -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<TourPlannerController, 'winterActivity' | 'setWinterActivity' | 'winterActivityChoice'>;
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 (
<div data-testid="tour-winter-activity" className={`${BOX} min-w-0`}>
<div className="flex items-center gap-2.5 px-3 py-2">
<span
aria-hidden="true"
className={`grid h-7 w-7 flex-none place-items-center rounded-full transition-colors ${on ? 'bg-info-soft text-info' : 'bg-surface-tertiary text-content-faint'}`}
>
<Snowflake size={14} strokeWidth={2} />
</span>
<span className="flex min-w-0 flex-1 items-center gap-1">
<span className="min-w-0 truncate font-semibold text-content" style={fs(12.5, 'body')}>
{t('tours.winter.title')}
</span>
<InfoDot label={t('tours.winter.infoLabel')} description={t('tours.winter.info')} />
</span>
<span className={locked ? 'pointer-events-none opacity-50' : undefined}>
<ToggleSwitch on={on} onToggle={toggle} label={t('tours.winter.title')} />
</span>
</div>
<fieldset
data-testid="tour-winter-kind"
disabled={locked || !on}
className={`m-0 min-w-0 border-0 border-t border-solid border-edge-faint px-3 py-2 transition-opacity ${on ? '' : 'opacity-50'}`}
>
<Segmented<WinterActivity>
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: <Icon size={13} strokeWidth={2} aria-hidden="true" />,
};
})}
/>
</fieldset>
</div>
);
}
@@ -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);
});
});
@@ -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<TourFixedLeg[]> {
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<TourRouteResult | null> {
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 })));
}
@@ -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<DraftPayload['version']> = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const DRAFT_VERSIONS: ReadonlyArray<DraftPayload['version']> = [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<DraftPayload>): boolean {
return (
(draft.tourType === undefined || plannableTourTypeSchema.safeParse(draft.tourType).success) &&
@@ -109,7 +120,7 @@ function validRouteChoices(draft: Partial<DraftPayload>): 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)
);
}
@@ -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,
@@ -9,7 +9,6 @@ const hike = (overrides: Partial<TourPlannerFactsInput> = {}): 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<typeof openedTourFields>[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<typeof openedTourFields>[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', () => {
@@ -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
@@ -1,21 +1,40 @@
import { useCallback, useState } from 'react';
import type { TourBaseLayer } from '../../Map/MapLayerSwitcher';
interface OverlayChoice {
/** Switched on by hand. */
on: ReadonlySet<string>;
/** Switched off by hand: an overlay on by default for a type (the pistes on a ski tour, SKI-01) stays off. */
off: ReadonlySet<string>;
}
/**
* 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<TourBaseLayer>('default');
const [mapOverlays, setMapOverlays] = useState<ReadonlySet<string>>(() => 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<OverlayChoice>(() => ({ 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 };
}
@@ -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,
})
);
@@ -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<typeof import('./tourRouting')>()),
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 });
});
});
@@ -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<WinterActivity | null>(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<WinterActivity>(restored?.winterActivity ?? 'winter_hike')
const [tourType, setTourTypeState] = useState<PlannableTourType>(restored?.tourType ?? 'hike')
const [selectedWaypointId, setSelectedWaypointId] = useState<string | null>(restored?.waypoints[0]?.id ?? null)
const history = usePlannerHistory<HistoryEntry>()
@@ -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,
@@ -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');
});
});
@@ -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,
]);
@@ -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: <Icon size={13} strokeWidth={2} aria-hidden="true" />,
// Two columns in a narrow rail: a long name ends in an ellipsis rather than spilling out.
label: <span className="min-w-0 truncate">{t(`tours.ski.discipline.${discipline}`)}</span>,
icon: <Icon size={13} strokeWidth={2} aria-hidden="true" className="flex-none" />,
};
})}
/>
@@ -64,7 +69,13 @@ export function SkiClassificationFields({
</fieldset>
{missing && (
<TourNotice role="status" icon={<Info size={13} strokeWidth={2} />}>
{t(missing === 'discipline' ? 'tours.ski.chooseDiscipline' : 'tours.ski.chooseTechnique')}
{t(
missing === 'technique'
? 'tours.ski.chooseTechnique'
: planned
? 'tours.ski.chooseDisciplineToSave'
: 'tours.ski.chooseDiscipline'
)}
</TourNotice>
)}
</div>
@@ -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<SkiDiscipline, LucideIcon> = {
alpine: CableCar,
snowboard: Snowboard,
cross_country: Spline,
touring: TrendingUp,
snowshoe: Footprints,
winter_hike: Snowflake,
};
export type SkiTour = Partial<Pick<TourListItem, 'tour_type' | 'ski_discipline' | 'ski_technique'>>;
/** 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;
}
@@ -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(<SkiCornerMark tour={{ tour_type: 'ski', ski_discipline: 'snowshoe' }} />);
const snowshoe = screen.getByRole('img', { name: 'tours.ski.discipline.snowshoe' });
expect(snowshoe.querySelector('svg.lucide-footprints')).not.toBeNull();
rerender(<SkiCornerMark tour={{ tour_type: 'ski', ski_discipline: 'winter_hike' }} />);
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(<TourGradeTile tour={hike} />);
expect(screen.queryAllByTestId('tour-ski-mark')).toHaveLength(1);
});
});
+4 -4
View File
@@ -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.
*/
@@ -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();
});
});
@@ -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<SkiChoice>(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,
};
}
@@ -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));
@@ -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);
+5 -3
View File
@@ -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;
}
+4 -3
View File
@@ -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: [] };
+3 -6
View File
@@ -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<TourListItem, 'max_hiking_difficulty' | 'wanderer_ref' | 'has_waypoints'> & Partial<Pick<TourListItem, 'tour_type' | 'winter_activity' | 'ski_discipline' | 'ski_technique'>> }) {
export function TourGradeTile({ tour }: { tour: Pick<TourListItem, 'max_hiking_difficulty' | 'wanderer_ref' | 'has_waypoints'> & Partial<Pick<TourListItem, 'tour_type' | 'ski_discipline' | 'ski_technique'>> }) {
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<TourListItem, 'max_hiking_d
{level != null ? `T${level}` : createElement(tourDayRowIcon(type), { size: 15, strokeWidth: 1.9, className: 'text-content-secondary' })}
</span>
</Tooltip>
<WinterCornerMark activity={tour.winter_activity} />
<SkiCornerMark tour={tour} />
{source !== 'trek' && (
<span className="absolute inset-x-0 -bottom-1 flex justify-center">
@@ -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<WinterActivity, LucideIcon> = {
winter_hike: Snowflake,
snowshoe: Footprints,
};
@@ -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(<WinterCornerMark activity={null} />);
expect(container).toBeEmptyDOMElement();
rerender(<WinterCornerMark activity="snowshoe" size={20} />);
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(<WinterActivityPill activity="winter_hike" className="head-pill" />);
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(<TourGradeTile tour={hike} />);
expect(screen.queryByTestId('tour-winter-mark')).toBeNull();
rerender(<TourGradeTile tour={{ ...hike, winter_activity: 'winter_hike' }} />);
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(<TourDayRowAvatar tour={hike} size={32} />);
expect(container.firstElementChild).toHaveAttribute('data-testid', 'plan-tour-icon');
rerender(<TourDayRowAvatar tour={{ ...hike, winter_activity: 'snowshoe' }} size={50} />);
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(<WinterCornerMark activity={activity} />);
expect(mark.container.querySelector(`svg.${icon}`)).not.toBeNull();
mark.unmount();
const pill = render(<WinterActivityPill activity={activity} className="head-pill" />);
expect(pill.container.querySelector(`svg.${icon}`)).not.toBeNull();
pill.unmount();
}
});
});
@@ -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 (
<TourCornerMark
icon={WINTER_ACTIVITY_ICON[activity]}
label={t(`tours.winter.activity.${activity}`)}
size={size}
testId="tour-winter-mark"
/>
);
}
/**
* 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 (
<Tooltip label={t('tours.winter.labelOnly')}>
<span data-testid="tour-detail-winter" className={className} style={fs(10.5)}>
<Icon size={11} strokeWidth={2} className="flex-none text-info" aria-hidden="true" />
{t(`tours.winter.activity.${activity}`)}
</span>
</Tooltip>
);
}
+9 -3
View File
@@ -57,8 +57,11 @@ export interface SegmentedOption<T extends string> {
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<T extends string>({ value, options, onChange, label, accent = false, fill = false }: {
export function Segmented<T extends string>({ value, options, onChange, label, accent = false, fill = false, columns }: {
value: T
options: readonly SegmentedOption<T>[]
onChange: (value: T) => void
@@ -68,15 +71,18 @@ export function Segmented<T extends string>({ 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 (
<div role="group" aria-label={label} className={`${fill ? 'flex w-full' : 'inline-flex'} flex-none gap-0.5 rounded-[10px] bg-surface-tertiary p-[3px]`}>
<div role="group" aria-label={label} className={`${track} flex-none gap-0.5 rounded-[10px] bg-surface-tertiary p-[3px]`}>
{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 (
<button key={o.value} type="button" onClick={() => onChange(o.value)} aria-pressed={on}
className={`${fill ? 'flex-1' : ''} inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-[8px] px-3 py-1.5 font-medium ${look}`}
className={`${fill ? 'flex-1' : ''} ${columns ? 'min-w-0' : ''} inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-[8px] px-3 py-1.5 font-medium ${look}`}
style={fs(12.5, 'body')}>
{o.icon}{o.label}
</button>
@@ -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 <TourPlannerMapStage {...stageProps(planner(mode, view))} />;
return <TourPlannerMapStage {...stageProps(planner(mode, { ...view, tourType: 'ski' }))} />;
}
const { rerender } = render(<NeutralStage mode="neutral" />);
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(<NeutralStage mode="new-draft" />);
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<string, unknown>) =>
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 <TourPlannerMapStage {...stageProps(planner('edit-saved', { ...view, tourType: 'ski', mode }))} />;
}
const { rerender } = render(<SkiStage placeId={4} />);
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(<SkiStage placeId={5} />);
expect(pistes()?.status).toBe('off');
act(() => pistes()?.onToggle());
expect(pistes()?.status).toBe('on');
});
});
});
@@ -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();
});
@@ -20,12 +20,16 @@ type FerrataLayerState = ReturnType<typeof useFerrataMapLayer>;
* 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<TourPlannerController, 'mode' | 'tourType' | 'mapBaseLayer' | 'mapOverlays' | 'toggleMapOverlay'>,
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
);
+10 -9
View File
@@ -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 () => {
+7 -6
View File
@@ -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<TourListItem, 'winter_activity'> {
if (body.tour_type !== 'hike') return { winter_activity: null }
return body.winter_activity === undefined ? {} : { winter_activity: body.winter_activity }
function skiFields(body: TourCreateRequest): Pick<TourListItem, 'ski_discipline' | 'ski_technique'> {
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),
+5 -4
View File
@@ -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<string>(),
mapOverlaysOff: new Set<string>(),
toggleMapOverlay: vi.fn(),
tripId: 1,
editWaypoints: vi.fn(),
@@ -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(),
+2 -9
View File
@@ -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(),
@@ -23,7 +23,6 @@ export interface CollectionToursTable {
match_confidence: InsertOptional<number | null>;
max_hiking_difficulty: InsertOptional<number | null>;
bike_profile: InsertOptional<string | null>;
winter_activity: InsertOptional<string | null>;
run_profile: InsertOptional<string | null>;
run_pace_seconds_per_km: InsertOptional<number | null>;
ski_discipline: InsertOptional<string | null>;
-1
View File
@@ -31,7 +31,6 @@ export interface ToursTable {
taken_over_from_trip_title: InsertOptional<string | null>;
taken_over_from_tour_name: InsertOptional<string | null>;
bike_profile: InsertOptional<string | null>;
winter_activity: InsertOptional<string | null>;
run_profile: InsertOptional<string | null>;
run_pace_seconds_per_km: InsertOptional<number | null>;
ski_discipline: InsertOptional<string | 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'))",
);
}
}
@@ -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'))",
@@ -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,
@@ -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;
+2 -11
View File
@@ -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<Tours> {
'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<Tours> {
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<Tours> {
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))
@@ -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<CollectionTourRow, 'waypoints'>): 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,
@@ -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,
@@ -22,12 +22,18 @@ const RUN_ACTIVITY: Record<RunProfile, string> = {
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<SkiDiscipline, string> = {
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;
}
@@ -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,
+23 -6
View File
@@ -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,
};
});
}
+10 -10
View File
@@ -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<TourUpdate, 'ski_discipline' | 'ski_technique'> {
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,
+4 -3
View File
@@ -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'),
-1
View File
@@ -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,
}),
+1 -1
View File
@@ -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'.
+61 -7
View File
@@ -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 = `<?xml version="1.0"?>
<trkpt lat="47.22" lon="11.32"><ele>1980</ele></trkpt>
</trkseg></trk></gpx>`;
// 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>([^<]+)<\/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 () => {
-248
View File
@@ -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<string, unknown> = {}) => ({
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<ReturnType<typeof build>>;
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<string, unknown>, userId = OWNER) =>
request(server)[method](`/api/trips/5/tours${path}`).set('Cookie', sessionCookie(userId)).send(body);
const createViaApi = async (body: Record<string, unknown> = 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>([^<]+)<\/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');
});
});
@@ -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',
@@ -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,
@@ -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/,
);
@@ -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());
});
});
@@ -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: [
@@ -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)', () => {
@@ -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 }),
]);
});
});
+38 -9
View File
@@ -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);
});
});
+14 -11
View File
@@ -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':
+14 -12
View File
@@ -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':
+14 -12
View File
@@ -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':
+14 -12
View File
@@ -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':
+14 -12
View File
@@ -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':
+14 -12
View File
@@ -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':
+14 -12
View File
@@ -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':
+14 -12
View File
@@ -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':
+14 -12
View File
@@ -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':
+14 -12
View File
@@ -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':
+14 -12
View File
@@ -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':
+14 -12
View File
@@ -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':
+14 -12
View File
@@ -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':
+14 -12
View File
@@ -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':
+12 -12
View File
@@ -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':
+12 -12
View File
@@ -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':
+14 -12
View File
@@ -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':
+14 -12
View File
@@ -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':
+14 -12
View File
@@ -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':
+14 -12
View File
@@ -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':

Some files were not shown because too many files have changed in this diff Show More