feat(tours): a hike can be marked as a winter hike or a snowshoe hike

HIK-01. The details of the hiking planner gain an optional Winter activity
switch, off by default, so the normal hiking flow asks for nothing more.
Switched on, the hike is a winter hike or a snowshoe hike; the choice stays
dimmed and out of reach while the switch is off. The label is saved with the
tour, comes back when it is opened, survives a draft reload, a copy and a
take-over, and switching it off takes only the label away: the route, its
grade and the days stay as they were.

The label is a label only. Routing, the walking time and the 3D terrain view
work as for any hike, and the info dot and the head pill's tooltip say that
nothing was checked for snow, avalanches or whether a path can be walked in
winter. The saved label shows as a frosted snowflake on the corner of the
tour's tile in the tour list and the day plan, and as a pill beside the type
in the tour's detail.

The server keeps it in tours.winter_activity (CHECK list, NULL for every tour
saved before and for every other type), refuses it on a bike tour, a city walk
and a paddle tour, and keeps the stored label when an older client saves a
hike without the field. A snowshoe hike exports to GPX as snowshoeing.
list_tours names the field, with the same words on what it does not say.
This commit is contained in:
Maurice
2026-10-20 12:20:33 +02:00
parent 71fe69c1f1
commit cd407cf80c
61 changed files with 1266 additions and 24 deletions
@@ -13,6 +13,7 @@ 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';
export function TourDayRowIcon({ type, size = 27 }: { type: string; size?: number }) {
const { t } = useTranslation();
@@ -82,14 +83,32 @@ 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.
* shows. A GPX import has no grade and shows its type's icon instead. A hike's
* winter label (HIK-01) sits on the tile's corner, as in the tour lists.
*/
export function TourDayRowAvatar({
tour,
size = 32,
}: {
tour: Pick<TourListItem, 'tour_type' | 'max_hiking_difficulty'>;
tour: Pick<TourListItem, 'tour_type' | 'max_hiking_difficulty'> & Partial<Pick<TourListItem, 'winter_activity'>>;
size?: number;
}) {
const avatar = <TourGradeAvatar tour={tour} size={size} />;
if (!tour.winter_activity) return avatar;
return (
<span className="relative flex flex-none">
{avatar}
<WinterCornerMark activity={tour.winter_activity} size={Math.round(size * 0.44)} />
</span>
);
}
function TourGradeAvatar({
tour,
size,
}: {
tour: Pick<TourListItem, 'tour_type' | 'max_hiking_difficulty'>;
size: number;
}) {
const { t } = useTranslation();
const level = tour.max_hiking_difficulty;
@@ -304,6 +304,18 @@ 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', () => {
const { unmount } = render(<TourDetailDialog {...defaultProps} />)
expect(screen.queryByTestId('tour-detail-winter')).not.toBeInTheDocument()
expect(screen.queryByTestId('tour-winter-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)
})
it('shows the grade on the tile and no second difficulty scale', () => {
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, difficulty: 'easy', max_hiking_difficulty: 1 }} />)
@@ -69,6 +69,7 @@ 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;
@@ -208,7 +209,7 @@ function tourTimeFields(
}
/**
* The head's facts as pills: the type, where it came from, the time (its parts
* The head's facts as pills: the type and a hike's winter label, where it came from, the time (its parts
* in the tooltip), distance, ascent, descent, and a caution.
*/
function TourHeadPills({
@@ -250,6 +251,8 @@ 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} />}
{/* A paddle tour names its boat and its water, each a badge of its own. */}
{tour.boat_type && (
<span className={HEAD_PILL} style={fs(10.5)} data-testid="tour-detail-boat">
@@ -7,6 +7,7 @@ import { INPUT, LABEL } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
import { FoldButton, InfoDot, TourNotice, TourSection } from '../tourParts';
import { HikingPreferencesField } from './HikingPreferencesField';
import { WinterActivityField } from './WinterActivityField';
import { TOUR_DURATION_INPUT_MAX_LENGTH, formatTourDuration, parseTourDuration } from './tourDurationInput';
import type { TourPlannerController } from './useTourPlanner';
@@ -324,8 +325,8 @@ function TourDifficultyField({
}
/**
* Description, website, the difficulty cap 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, 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
* the planner refuses, so the reason Save is held back is never out of sight.
*/
export function TourDetailsSection({
@@ -364,6 +365,7 @@ 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} />
@@ -0,0 +1,70 @@
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-004
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, as the real one does. */
function Stateful({ initial = null }: { initial?: WinterActivity | null }) {
const [winterActivity, setWinterActivity] = useState<WinterActivity | null>(initial);
return (
<>
<WinterActivityField planner={{ winterActivity, setWinterActivity }} locked={false} />
<output data-testid="saved">{winterActivity ?? 'none'}</output>
</>
);
}
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={{ winterActivity: 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={{ winterActivity: '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={{ winterActivity: 'winter_hike', setWinterActivity }} locked />);
fireEvent.click(toggle());
fireEvent.click(option('snowshoe'));
expect(setWinterActivity).not.toHaveBeenCalled();
expect(option('snowshoe')).toBeDisabled();
});
});
@@ -0,0 +1,87 @@
import { WINTER_ACTIVITIES, type WinterActivity } from '@trek/shared';
import { Snowflake } from 'lucide-react';
import { useState } from '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/winterMarks';
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 info dot says it
* is a label only: it plans and times nothing differently.
*/
export function WinterActivityField({
planner,
locked,
}: {
planner: Pick<TourPlannerController, 'winterActivity' | 'setWinterActivity'>;
locked: boolean;
}) {
const { t } = useTranslation();
const current = planner.winterActivity ?? null;
const [lastChoice, setLastChoice] = useState<WinterActivity>(current ?? 'winter_hike');
const on = current !== null;
const toggle = () => {
if (locked) return;
if (current === null) {
planner.setWinterActivity(lastChoice);
return;
}
setLastChoice(current);
planner.setWinterActivity(null);
};
const choose = (next: WinterActivity) => {
if (locked || !on) return;
setLastChoice(next);
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 ?? lastChoice}
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>
);
}
@@ -4,10 +4,12 @@ import {
MAX_PLANNED_TOUR_DURATION_MINUTES,
paddleLockSchema,
plannableTourTypeSchema,
winterActivitySchema,
type BikeProfile,
type HikingPreferences,
type PlannableTourType,
type TourMaxHikingDifficulty,
type WinterActivity,
} from '@trek/shared';
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
import type { PaddleFieldValues } from './usePaddleFields';
@@ -43,6 +45,8 @@ 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;
}
const DRAFT_VERSIONS: ReadonlyArray<DraftPayload['version']> = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
@@ -83,12 +87,13 @@ function validDraftWaypoint(point: TourPlannerWaypoint): boolean {
);
}
/** The type of a draft and what its route is planned by, each one absent in a draft older than it. */
/** The type of a draft, what its route is planned by and its winter label, each one absent in a draft older than it. */
function validRouteChoices(draft: Partial<DraftPayload>): boolean {
return (
(draft.tourType === undefined || plannableTourTypeSchema.safeParse(draft.tourType).success) &&
(draft.bikeProfile === undefined || bikeProfileSchema.safeParse(draft.bikeProfile).success) &&
(draft.hikingPreferences == null || hikingPreferencesSchema.safeParse(draft.hikingPreferences).success) &&
(draft.winterActivity == null || winterActivitySchema.safeParse(draft.winterActivity).success) &&
optionalDifficulty(draft.maxHikingDifficulty)
);
}
@@ -10,6 +10,7 @@ import {
type PlannableTourType,
type TourListItem,
type TourMaxHikingDifficulty,
type WinterActivity,
} from '@trek/shared';
import type { RouteGeometryAnalysis } from '../../../utils/routeGeometry';
import { hikingTimeMinutes } from '../hikingTime';
@@ -28,6 +29,7 @@ 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,
};
}
@@ -39,6 +41,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;
website: string;
plannedDurationMinutes: number | null;
breakAdditionalMinutes: number | null;
@@ -92,7 +96,10 @@ function walkTimesFor(input: TourPlannerFactsInput, walkingMinutes: number | nul
return walkTimesOf({ stayMinutes: 0, unknownStays: 0, stations: 0, ...input.stays, walkingMinutes });
}
/** What the type adds to a save: a hike its routing limit, a bike tour its profile, a paddle tour its boat, water and speed. */
/**
* What the type adds to a save: a hike its routing limit and its winter label,
* a bike tour its profile, a paddle tour its boat, water and speed.
*/
function typeFieldsOf(input: TourPlannerFactsInput) {
const isBike = input.tourType === 'bike';
if (input.tourType === 'kayak') {
@@ -107,6 +114,7 @@ function typeFieldsOf(input: TourPlannerFactsInput) {
portage_speed_kmh: portageSpeedKmh,
paddle_lake_style: lakeStyle,
hiking_preferences: null,
winter_activity: null,
};
}
const isHike = input.tourType === 'hike';
@@ -120,6 +128,7 @@ function typeFieldsOf(input: TourPlannerFactsInput) {
portage_speed_kmh: null,
paddle_lake_style: null,
hiking_preferences: isHike ? input.hikingPreferences : null,
winter_activity: isHike ? input.winterActivity : null,
};
}
@@ -9,6 +9,7 @@ const hike = (overrides: Partial<TourPlannerFactsInput> = {}): TourPlannerFactsI
maxHikingDifficulty: 3,
hikingPreferences: null,
bikeProfile: 'touring',
winterActivity: null,
website: '',
plannedDurationMinutes: null,
breakAdditionalMinutes: null,
@@ -56,6 +57,26 @@ 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', () => {
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' })
);
expect(winter.typeFields).toMatchObject({
tour_type: 'hike',
winter_activity: 'snowshoe',
max_hiking_difficulty: 3,
});
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();
}
});
it('holds the fields to their bounds, and a total that runs past a day without an override', () => {
const facts = tourPlannerFacts(
hike({ website: 'http://plain.example', plannedDurationMinutes: 1.5, breakAdditionalMinutes: -1 })
@@ -205,4 +226,15 @@ describe('useTourDraftStorage', () => {
act(() => first.result.current.forget());
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', () => {
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' }));
expect(renderHook(() => useTourDraftStorage(5)).result.current.restored).toMatchObject({
winterActivity: 'snowshoe',
});
localStorage.setItem('tour-draft-6', JSON.stringify({ ...draft, winterActivity: 'ski_tour' }));
expect(renderHook(() => useTourDraftStorage(6)).result.current.restored).toBeNull();
});
});
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { hikingPreferencesOf, isClosedLoop, paddleSpeedKmhSchema, tourWebsiteSchema, type BikeProfile, type HikingPreferences, type PlannableTourType, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourMaxHikingDifficulty } from '@trek/shared'
import { hikingPreferencesOf, isClosedLoop, paddleSpeedKmhSchema, tourWebsiteSchema, type BikeProfile, type HikingPreferences, type PlannableTourType, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourMaxHikingDifficulty, type WinterActivity } from '@trek/shared'
import { paddleRouteRepo } from '../../../repo/paddleRouteRepo'
import { tourRepo } from '../../../repo/tourRepo'
import { randomId } from '../../../utils/randomId'
@@ -123,6 +123,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
const [maxHikingDifficulty, setMaxHikingDifficultyState] = useState<TourMaxHikingDifficulty>(restored?.maxHikingDifficulty ?? 2)
const [hikingPreferences, setHikingPreferencesState] = useState<HikingPreferences | null>(restored?.hikingPreferences ?? null)
const [bikeProfile, setBikeProfileState] = useState<BikeProfile>(restored?.bikeProfile ?? 'touring')
const [winterActivity, setWinterActivityState] = useState<WinterActivity | null>(restored?.winterActivity ?? null)
const [tourType, setTourTypeState] = useState<PlannableTourType>(restored?.tourType ?? 'hike')
const [selectedWaypointId, setSelectedWaypointId] = useState<string | null>(restored?.waypoints[0]?.id ?? null)
const history = usePlannerHistory<HistoryEntry>()
@@ -466,6 +467,11 @@ 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()) setWinterActivityState(value)
}, [markEdited, winterActivity])
/**
* PAD-01, BIK-01: hike, bike or paddle tour, as one step of the history with
@@ -503,11 +509,11 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
// CW-02: a city walk's stays, what of them is known.
const stays = useMemo(() => (tourType === 'walk' ? walkTimes(waypoints, null) : null), [tourType, waypoints])
const facts = useMemo(() => tourPlannerFacts({
tourType, maxHikingDifficulty, hikingPreferences, bikeProfile, website, plannedDurationMinutes, breakAdditionalMinutes,
tourType, maxHikingDifficulty, hikingPreferences, bikeProfile, winterActivity, website, plannedDurationMinutes, breakAdditionalMinutes,
paddle: { boatType, waterContext, speedKmh, portageSpeedKmh, lakeStyle },
paddleSpeedInvalid, paddleEstimate, routeAnalysis, durationSeconds, stays,
}), [bikeProfile, boatType, breakAdditionalMinutes, durationSeconds, hikingPreferences, lakeStyle, maxHikingDifficulty, paddleEstimate, paddleSpeedInvalid,
plannedDurationMinutes, portageSpeedKmh, routeAnalysis, speedKmh, stays, tourType, waterContext, website])
plannedDurationMinutes, portageSpeedKmh, routeAnalysis, speedKmh, stays, tourType, waterContext, website, winterActivity])
// 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 } = facts
@@ -535,6 +541,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setHikingPreferencesState(null)
bikeProfileRef.current = 'touring'
setBikeProfileState('touring')
setWinterActivityState(null)
tourTypeRef.current = 'hike'
setSelectedWaypointId(null)
clearHistory()
@@ -705,6 +712,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setHikingPreferencesState(opened.preferences)
bikeProfileRef.current = opened.bikeProfile
setBikeProfileState(opened.bikeProfile)
setWinterActivityState(opened.winterActivity)
tourTypeRef.current = opened.tourType
setSelectedWaypointId(nextWaypoints[0]?.id ?? null)
clearHistory()
@@ -764,14 +772,14 @@ 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,
derivedFromPlaceId, draftSource, tourType, baseRevision: revision.base, hikingPreferences, bikeProfile, winterActivity,
paddle: { boatType: paddle.boatType, waterContext: paddle.waterContext, speedKmh: paddle.speedKmh, portageSpeedKmh: paddle.portageSpeedKmh, lakeStyle: paddle.lakeStyle },
}
keepDraft(draft)
}, [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])
bikeProfile, winterActivity])
useEffect(() => {
if (!hasUnsavedChanges) return
@@ -871,7 +879,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',
bikeProfile, setBikeProfile, isBike: tourType === 'bike', winterActivity, setWinterActivity,
tourType, setTourType, isPaddle, paddle, paddleSpeedInvalid, paddleIncomplete,
description, setDescription, website, setWebsite, websiteInvalid,
plannedDurationMinutes, setPlannedDurationMinutes, plannedDurationInvalid,
@@ -0,0 +1,187 @@
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-005
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 })
);
});
});
+5 -2
View File
@@ -16,13 +16,15 @@ import { tourMetricFacts } from './tourFacts'
import type { TourDayAssignment } from './useTourDayAssignment'
import { useRailFold } from './railFold'
import { tourDayRowIcon } from './tourDayRowIcon'
import { WinterCornerMark } from './winter/winterMarks'
/**
* 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),
* and where it came from, when that was not TREK, as a small badge over its foot.
* where it came from, when that was not TREK, as a small badge over its foot,
* and a hike's winter label (HIK-01) as a snowflake at its top corner.
*/
export function TourGradeTile({ tour }: { tour: Pick<TourListItem, 'max_hiking_difficulty' | 'wanderer_ref' | 'has_waypoints'> & Partial<Pick<TourListItem, 'tour_type'>> }) {
export function TourGradeTile({ tour }: { tour: Pick<TourListItem, 'max_hiking_difficulty' | 'wanderer_ref' | 'has_waypoints'> & Partial<Pick<TourListItem, 'tour_type' | 'winter_activity'>> }) {
const { t } = useTranslation()
const level = tour.max_hiking_difficulty
const source = tourSource(tour)
@@ -36,6 +38,7 @@ 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} />
{source !== 'trek' && (
<span className="absolute inset-x-0 -bottom-1 flex justify-center">
<Tooltip label={hikeSourceBadgeLabel(tour, t)}>
@@ -0,0 +1,43 @@
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-004
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' });
});
});
@@ -0,0 +1,62 @@
import type { WinterActivity } from '@trek/shared';
import { Footprints, Snowflake, type LucideIcon } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
/** 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,
};
/**
* HIK-01: a hike's winter label on its round tile, a frosted snowflake badge
* at the tile's top corner, 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;
const label = t(`tours.winter.activity.${activity}`);
return (
<span className="absolute -end-1 -top-1 flex">
<Tooltip label={label}>
<span
data-testid="tour-winter-mark"
role="img"
aria-label={label}
className="grid place-items-center rounded-full bg-surface-card text-info shadow-sm ring-1 ring-inset ring-edge-faint"
style={{ width: size, height: size }}
>
<Snowflake size={Math.round(size * 0.64)} strokeWidth={2.4} aria-hidden="true" />
</span>
</Tooltip>
</span>
);
}
/**
* 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>
);
}
+14 -1
View File
@@ -1,4 +1,4 @@
// FE-REPO-TOUR-001 to FE-REPO-TOUR-014
// FE-REPO-TOUR-001 to FE-REPO-TOUR-014, FE-REPO-TOUR-042
// Tours through the offline core: read-through to Dexie, offline create and
// edit written there and queued, and the online paths keeping the copy fresh.
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
@@ -196,6 +196,19 @@ 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 () => {
setOnline(false)
await offlineDb.tours.put({ ...tour({ winter_activity: 'snowshoe' }), trip_id: 3 })
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()
})
it('FE-REPO-TOUR-012: online edits and GPX imports refresh the cache', async () => {
server.use(
http.put('/api/trips/3/tours/40', () => HttpResponse.json({ tour: tour({ name: 'Edited' }), waypoints: body.waypoints })),
+10
View File
@@ -58,6 +58,15 @@ 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.
*/
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 }
}
/**
* What the server will derive from a create or edit, worked out here for the
* copy shown until the queued write replays: the same metrics, the duration
@@ -82,6 +91,7 @@ function routeFields(body: TourCreateRequest) {
match_confidence: 1,
caution: false,
max_hiking_difficulty: body.max_hiking_difficulty,
...winterField(body),
has_waypoints: true,
...walkStationFacts(body.tour_type, body.waypoints),
...(body.planned_duration_minutes !== undefined ? { planned_duration_minutes: body.planned_duration_minutes } : {}),
+3
View File
@@ -13,6 +13,9 @@ export function hikePlannerDefaults() {
isBike: 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(),
unreachable: null,
paddle: {
boatType: null,
+9
View File
@@ -39,6 +39,7 @@ 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;
tourTypeRef!: Ref<TourTypes>;
tour_obstacle_reviews_collection = new Collection<TourObstacleReviews>(this);
tour_sources: Ref<TourSources> | null = null;
@@ -85,6 +86,13 @@ 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 ToursSchema = defineEntity({
class: Tours,
repository: () => ToursRepository,
@@ -127,6 +135,7 @@ 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(),
tourTypeRef: () => p.manyToOne(TourTypes).ref().joinColumn('tour_type').hidden().index('idx_tours_tour_type'),
tour_obstacle_reviews_collection: () => p.oneToMany(TourObstacleReviews).mappedBy('place').hidden(),
tour_sources: () => p.oneToOne(TourSources).ref().mappedBy('place').hidden(),
+1
View File
@@ -31,4 +31,5 @@ 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>;
}
@@ -0,0 +1,16 @@
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'))",
);
}
}
@@ -2,7 +2,7 @@ import { type Tours, type TToursOrigin, ToursOrigin } from '../entities/Tours.en
import type { DB } from '../kysely/db';
import type { AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
import type { BikeProfile, PaddleBoatType, PaddleLakeStyle, PaddleWaterContext } from '@trek/shared';
import type { BikeProfile, PaddleBoatType, PaddleLakeStyle, PaddleWaterContext, WinterActivity } from '@trek/shared';
/**
* A tour as the Tours list and detail read it: the `tours` facet joined with
@@ -30,6 +30,8 @@ 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;
boat_type: string | null;
water_context: string | null;
paddle_speed_kmh: number | null;
@@ -97,6 +99,7 @@ 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;
}
const _tourRowKeys: AssertRowKeys<TourRow, Tours> = true;
@@ -114,6 +117,8 @@ 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;
/** A paddle tour's boat, water and chosen speed; left out, a hike's NULLs. */
boat_type?: PaddleBoatType | null;
water_context?: PaddleWaterContext | null;
@@ -242,6 +247,7 @@ 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.boat_type as boat_type',
't.water_context as water_context',
't.paddle_speed_kmh as paddle_speed_kmh',
@@ -607,6 +613,7 @@ 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,
boat_type: input.boat_type ?? null,
water_context: input.water_context ?? null,
paddle_speed_kmh: input.paddle_speed_kmh ?? null,
@@ -698,6 +705,7 @@ 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))
@@ -16,14 +16,21 @@ const BIKE_ACTIVITY: Record<BikeProfile, string> = {
mtb: 'mountain_biking',
};
/** The activity a tour's track names in GPX: a paddle tour its boat, a bike tour its profile, a hike hiking; whatever is missing falls back to the plainest. */
/**
* The activity a tour's track names in GPX: a paddle tour its boat, a bike
* tour its profile, a hike on snowshoes snowshoeing (HIK-01) and any other
* hike hiking; whatever is missing falls back to the plainest.
*/
export function gpxActivityOf(tour: {
tour_type: string;
boat_type?: string | null;
bike_profile?: 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;
if (type === 'bike') return BIKE_ACTIVITY[(bike ?? 'touring') as BikeProfile] ?? BIKE_ACTIVITY.touring;
return type === 'hike' ? 'hiking' : type;
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';
}
+1 -1
View File
@@ -85,7 +85,7 @@ export class ToursMcp {
@Tool({
name: 'list_tours',
description:
'List the saved tours of a trip (hikes, city walks, bike tours and paddle tours planned or imported in the Tours addon): name, type (hike, walk for a city walk, bike, or kayak for a paddle tour), where it came from (origin: trek, gpx 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 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), the revision (raised by every saved change of the tour), and the days of the trip each tour is on.',
'List the saved tours of a trip (hikes, city walks, bike tours and paddle tours planned or imported in the Tours addon): name, type (hike, walk for a city walk, bike, or kayak for a paddle tour), where it came from (origin: trek, gpx 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 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), the revision (raised by every saved change of the tour), and the days of the trip each tour is on.',
inputSchema: { tripId: idSchema },
annotations: TOOL_ANNOTATIONS_READONLY,
when: addonGate(ADDON_IDS.TOURS),
+14
View File
@@ -58,6 +58,7 @@ import {
type TourProvenanceSource,
type TourWaypoint,
type WandererDifficulty,
type WinterActivity,
walkStationFacts,
walkStationsOf,
wandererDifficultyToSac,
@@ -103,6 +104,15 @@ function hikingFields(input: TourCreateCommand): string | null {
return preferences ? encodeJson(TOUR_HIKING_PREFERENCES, preferences) : 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.
*/
function winterField(input: TourCreateCommand): WinterActivity | null | undefined {
return input.tour_type === 'hike' ? input.winter_activity : null;
}
/**
* CW-03: every point with its key, kept from the write where it came with one,
* so the visits of a city walk's stations outlast a reorder or a new point. A
@@ -155,6 +165,7 @@ export class ToursService {
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,
boat_type: r.boat_type as PaddleBoatType | null,
water_context: r.water_context as PaddleWaterContext | null,
paddle_speed_kmh: r.paddle_speed_kmh,
@@ -196,6 +207,7 @@ export class ToursService {
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),
boat_type: input.boat_type ?? null,
water_context: input.water_context ?? null,
paddle_speed_kmh: input.paddle_speed_kmh ?? null,
@@ -601,6 +613,7 @@ export class ToursService {
match_confidence: row.match_confidence,
max_hiking_difficulty: row.max_hiking_difficulty,
bike_profile: row.bike_profile as BikeProfile | null,
winter_activity: row.winter_activity as WinterActivity | null,
boat_type: row.boat_type as PaddleBoatType | null,
water_context: row.water_context as PaddleWaterContext | null,
paddle_speed_kmh: row.paddle_speed_kmh,
@@ -843,6 +856,7 @@ export class ToursService {
route_missing: 0,
revision: 1,
hiking_preferences: null,
winter_activity: null,
}),
);
}
+248
View File
@@ -0,0 +1,248 @@
/**
* 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,6 +66,7 @@ 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',
];
// The last step the positional runner ever had; planLegacyBaseline refuses a
// schema_version past it.
@@ -105,6 +105,7 @@ describe('ToursRepository reads', () => {
revision: 1,
hiking_preferences: null,
bike_profile: null,
winter_activity: null,
},
{
place_id: older.id,
@@ -137,6 +138,7 @@ describe('ToursRepository reads', () => {
revision: 1,
hiking_preferences: null,
bike_profile: null,
winter_activity: null,
},
]);
expect((await repo.listForTrip(trip.id)).map((r) => r.place_id)).not.toContain(plain.id);
@@ -250,6 +252,7 @@ describe('ToursRepository writes', () => {
revision: 1,
hiking_preferences: null,
bike_profile: null,
winter_activity: null,
});
});
@@ -287,6 +290,22 @@ describe('ToursRepository writes', () => {
});
});
it('TOURREPO-016: a hike keeps its winter label through an edit that leaves it out, and loses it only when told (HIK-01)', 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');
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();
});
it('TOURREPO-008: listRowsForTrip and insertCopy carry every column, created_at included, onto the copied place', async () => {
const { trip, other } = fixture();
const source = createPlace(testDb, trip.id);
@@ -332,6 +351,7 @@ describe('ToursRepository writes', () => {
revision: 1,
hiking_preferences: null,
bike_profile: null,
winter_activity: null,
taken_over_from_trip_title: null,
taken_over_from_tour_name: null,
},
@@ -0,0 +1,16 @@
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());
});
});
@@ -2,9 +2,9 @@ import { gpxActivityOf } from '../../../src/nest/tours/tour-gpx-export.helpers';
import { describe, expect, it } from 'vitest';
// TOUR-GPX-ACTIVITY-001 to TOUR-GPX-ACTIVITY-003
// TOUR-GPX-ACTIVITY-001 to TOUR-GPX-ACTIVITY-004
describe('gpxActivityOf (PR1-06A, BIK-01)', () => {
describe('gpxActivityOf (PR1-06A, BIK-01, HIK-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');
@@ -21,4 +21,11 @@ describe('gpxActivityOf (PR1-06A, BIK-01)', () => {
expect(gpxActivityOf({ tour_type: 'hike' })).toBe('hiking');
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');
});
});
+16
View File
@@ -7,6 +7,7 @@
import { Places } from '../../../src/db/entities/Places.entity';
import { TourStationVisits } from '../../../src/db/entities/TourStationVisits.entity';
import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
import { Tours } from '../../../src/db/entities/Tours.entity';
import type { McpContext } from '../../../src/nest-mcp';
import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata';
import { PlacesMcp } from '../../../src/nest/places/places.mcp';
@@ -355,4 +356,19 @@ describe('ToursMcp', () => {
);
}
});
it('TOURS-MCP-015: list_tours names the winter label of a hike, and says it is only a label (HIK-01)', async () => {
const { user, trip, ridge, gorge } = fixture();
const orm = await createTestOrm(db);
await updateRows(orm, Tours, { place: ridge.id }, { winter_activity: '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 });
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');
});
});
+8
View File
@@ -406,6 +406,14 @@ 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.bike.profile': 'الملف',
'tours.bike.profile.touring': 'تجوال',
'tours.bike.profile.road': 'طريق',
+9
View File
@@ -395,6 +395,15 @@ 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.bike.profile': 'Profil',
'tours.bike.profile.touring': 'Turinq',
'tours.bike.profile.road': 'Yol',
+9
View File
@@ -401,6 +401,15 @@ 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.bike.profile': 'Perfil',
'tours.bike.profile.touring': 'Cicloturismo',
'tours.bike.profile.road': 'Estrada',
+9
View File
@@ -402,6 +402,15 @@ 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.bike.profile': 'Perfil',
'tours.bike.profile.touring': 'Cicloturisme',
'tours.bike.profile.road': 'Carretera',
+9
View File
@@ -394,6 +394,15 @@ 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.bike.profile': 'Profil',
'tours.bike.profile.touring': 'Treking',
'tours.bike.profile.road': 'Silnice',
+9
View File
@@ -404,6 +404,15 @@ 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.bike.profile': 'Profil',
'tours.bike.profile.touring': 'Touring', // same-as-en
'tours.bike.profile.road': 'Rennrad',
+9
View File
@@ -394,6 +394,15 @@ 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.bike.profile': 'Profile',
'tours.bike.profile.touring': 'Touring',
'tours.bike.profile.road': 'Road',
+9
View File
@@ -401,6 +401,15 @@ 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.bike.profile': 'Perfil',
'tours.bike.profile.touring': 'Cicloturismo',
'tours.bike.profile.road': 'Carretera',
+9
View File
@@ -396,6 +396,15 @@ 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.bike.profile': 'Profiil',
'tours.bike.profile.touring': 'Matk',
'tours.bike.profile.road': 'Maantee',
+9
View File
@@ -407,6 +407,15 @@ 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.bike.profile': 'Profil',
'tours.bike.profile.touring': 'Voyage',
'tours.bike.profile.road': 'Route',
+9
View File
@@ -409,6 +409,15 @@ 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.bike.profile': 'Προφίλ',
'tours.bike.profile.touring': 'Περιήγηση',
'tours.bike.profile.road': 'Δρόμου',
+9
View File
@@ -401,6 +401,15 @@ 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.bike.profile': 'Profil',
'tours.bike.profile.touring': 'Túra',
'tours.bike.profile.road': 'Országúti',
+9
View File
@@ -396,6 +396,15 @@ 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.bike.profile': 'Profil',
'tours.bike.profile.touring': 'Touring', // same-as-en
'tours.bike.profile.road': 'Road', // same-as-en
+9
View File
@@ -404,6 +404,15 @@ 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.bike.profile': 'Profilo',
'tours.bike.profile.touring': 'Turismo',
'tours.bike.profile.road': 'Strada',
+9
View File
@@ -386,6 +386,15 @@ 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.bike.profile': 'プロファイル',
'tours.bike.profile.touring': 'ツーリング',
'tours.bike.profile.road': 'ロード',
+9
View File
@@ -384,6 +384,15 @@ 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.bike.profile': '프로필',
'tours.bike.profile.touring': '투어링',
'tours.bike.profile.road': '로드',
+9
View File
@@ -403,6 +403,15 @@ 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.bike.profile': 'Profiel',
'tours.bike.profile.touring': 'Toer',
'tours.bike.profile.road': 'Racefiets',
+9
View File
@@ -410,6 +410,15 @@ 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.bike.profile': 'Profil',
'tours.bike.profile.touring': 'Turystyka',
'tours.bike.profile.road': 'Szosa',
+9
View File
@@ -414,6 +414,15 @@ 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.bike.profile': 'Профиль',
'tours.bike.profile.touring': 'Туризм',
'tours.bike.profile.road': 'Шоссе',
+9
View File
@@ -395,6 +395,15 @@ 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.bike.profile': 'Profil',
'tours.bike.profile.touring': 'Trekking',
'tours.bike.profile.road': 'Cestný',
+9
View File
@@ -393,6 +393,15 @@ const tours: TranslationStrings = {
'tours.planner.tourTypeHint.hike': 'Till fots, inom en svårighet du väljer',
'tours.planner.tourTypeHint.bike': 'Med cykel, rutt för tur, landsväg, grus eller MTB',
'tours.planner.tourTypeHint.kayak': 'På vattnet, med kajak, kanot eller SUP',
'tours.winter.title': 'Vinteraktivitet',
'tours.winter.infoLabel': 'Vad vintermärkningen gör',
'tours.winter.info':
'Bara en märkning av vandringen. Rutt, lutningsfärger och gångtid är desamma som för alla vandringar och säger inget om snö, laviner eller om en stig går att gå på vintern.',
'tours.winter.kind': 'Typ av vinteraktivitet',
'tours.winter.activity.winter_hike': 'Vintervandring',
'tours.winter.activity.snowshoe': 'Snöskovandring',
'tours.winter.labelOnly':
'Märkt av resenärerna. Rutt och tid planeras som för alla vandringar, inte kontrollerade för vinter.',
'tours.bike.profile': 'Profil',
'tours.bike.profile.touring': 'Tur',
'tours.bike.profile.road': 'Landsväg',
+9
View File
@@ -375,6 +375,15 @@ 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.bike.profile': 'โปรไฟล์',
'tours.bike.profile.touring': 'ทัวริ่ง',
'tours.bike.profile.road': 'เสือหมอบ',
+1 -1
View File
@@ -20,7 +20,7 @@ const tokens = (value: string) => [...value.matchAll(/\{[^{}]+\}/g)].map((match)
// placeholder spec; here only the general keys are.
const groups = pluralGroups(Object.keys(english));
const generalKeys = (keys: string[]) => keys.filter((key) => !pluralFormOf(key, groups));
const GENERAL_KEY_COUNT = 825;
const GENERAL_KEY_COUNT = 832;
function literalKeys(source: string): string[] {
const tree = ts.createSourceFile('locale.ts', source, ts.ScriptTarget.Latest, true);
+9
View File
@@ -395,6 +395,15 @@ const tours: TranslationStrings = {
'tours.planner.tourTypeHint.hike': 'Yürüyerek, belirlediğin zorluk içinde',
'tours.planner.tourTypeHint.bike': 'Bisikletle, tur, yol, gravel veya MTB için rota',
'tours.planner.tourTypeHint.kayak': 'Suda, kano, kanoe veya SUP ile',
'tours.winter.title': 'Kış etkinliği',
'tours.winter.infoLabel': 'Kış etiketi ne işe yarar',
'tours.winter.info':
'Yalnızca yürüyüş için bir etiket. Rota, eğim renkleri ve yürüme süresi her yürüyüşteki gibi kalır; kar, çığ ya da bir yolun kışın yürünebilir olup olmadığı hakkında hiçbir şey söylemez.',
'tours.winter.kind': 'Kış etkinliği türü',
'tours.winter.activity.winter_hike': 'Kış yürüyüşü',
'tours.winter.activity.snowshoe': 'Kar ayakkabısıyla yürüyüş',
'tours.winter.labelOnly':
'Gezginler tarafından işaretlendi. Rota ve süre her yürüyüşteki gibi planlanır, kış koşullarına göre denetlenmez.',
'tours.bike.profile': 'Profil',
'tours.bike.profile.touring': 'Tur',
'tours.bike.profile.road': 'Yol',
+9
View File
@@ -409,6 +409,15 @@ 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.bike.profile': 'Профіль',
'tours.bike.profile.touring': 'Туризм',
'tours.bike.profile.road': 'Шосе',
+9
View File
@@ -393,6 +393,15 @@ const tours: TranslationStrings = {
'tours.planner.tourTypeHint.hike': 'Đi bộ, trong mức độ khó bạn chọn',
'tours.planner.tourTypeHint.bike': 'Bằng xe đạp, lộ trình cho touring, đường trường, gravel hoặc MTB',
'tours.planner.tourTypeHint.kayak': 'Trên nước, bằng kayak, xuồng canoe hoặc SUP',
'tours.winter.title': 'Hoạt động mùa đông',
'tours.winter.infoLabel': 'Nhãn mùa đông có tác dụng gì',
'tours.winter.info':
'Chỉ là nhãn cho chuyến đi bộ. Tuyến đường, màu độ dốc và thời gian đi bộ vẫn như mọi chuyến đi bộ khác và không cho biết gì về tuyết, tuyết lở hay việc lối đi có đi được vào mùa đông không.',
'tours.winter.kind': 'Loại hoạt động mùa đông',
'tours.winter.activity.winter_hike': 'Đi bộ mùa đông',
'tours.winter.activity.snowshoe': 'Đi bộ bằng giày tuyết',
'tours.winter.labelOnly':
'Do du khách đánh dấu. Tuyến đường và thời gian được lập như mọi chuyến đi bộ khác, không kiểm tra cho mùa đông.',
'tours.bike.profile': 'Hồ sơ',
'tours.bike.profile.touring': 'Du lịch',
'tours.bike.profile.road': 'Đường trường',
+7
View File
@@ -360,6 +360,13 @@ 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.bike.profile': '類型',
'tours.bike.profile.touring': '旅行',
'tours.bike.profile.road': '公路',
+7
View File
@@ -361,6 +361,13 @@ 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.bike.profile': '类型',
'tours.bike.profile.touring': '旅行',
'tours.bike.profile.road': '公路',
+56
View File
@@ -16,6 +16,7 @@ import {
tourMaxHikingDifficultySchema,
tourNameSchema,
tourWebsiteSchema,
WINTER_ACTIVITIES,
} from './tours.schema';
import { describe, expect, it } from 'vitest';
@@ -534,3 +535,58 @@ describe('city walks (CW-01 to CW-03, CW-10)', () => {
expect(tourDescriptionSchema.safeParse('x'.repeat(2001)).success).toBe(false);
});
});
describe('winter activity of a hike (HIK-01)', () => {
const listedHike = {
place_id: 1,
tour_type: 'hike' as const,
distance: 8,
elevation_gain: 400,
elevation_loss: 400,
duration: 180,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: 2,
name: 'Snowy ridge',
planned: false,
caution: false,
};
it('offers exactly winter hiking and snowshoeing, and a hike needs neither', () => {
expect(WINTER_ACTIVITIES).toEqual(['winter_hike', 'snowshoe']);
expect(tourCreateRequestSchema.parse(baseRequest).winter_activity).toBeUndefined();
expect(tourCreateRequestSchema.parse({ ...baseRequest, winter_activity: null }).winter_activity).toBeNull();
});
it('keeps the winter label a hike is saved with', () => {
for (const activity of WINTER_ACTIVITIES) {
expect(tourCreateRequestSchema.parse({ ...baseRequest, winter_activity: activity })).toMatchObject({
tour_type: 'hike',
winter_activity: activity,
});
}
});
it('refuses a label it does not know, and any label on another type', () => {
expect(tourCreateRequestSchema.safeParse({ ...baseRequest, winter_activity: 'ski_tour' }).success).toBe(false);
const bike = { ...baseRequest, tour_type: 'bike', bike_profile: 'mtb', winter_activity: 'snowshoe' };
expect(() => tourCreateRequestSchema.parse(bike)).toThrow(/a bike tour has no winter activity/);
const walk = { ...baseRequest, tour_type: 'walk', winter_activity: 'winter_hike' };
expect(() => tourCreateRequestSchema.parse(walk)).toThrow(/a city walk has no winter activity/);
const paddle = {
...baseRequest,
tour_type: 'kayak',
boat_type: 'sup',
water_context: 'lake',
winter_activity: 'snowshoe',
};
expect(() => tourCreateRequestSchema.parse(paddle)).toThrow(/a paddle tour has no winter activity/);
});
it('lists the label, and reads a list cached before it existed', () => {
expect(tourListItemSchema.parse({ ...listedHike, winter_activity: 'snowshoe' }).winter_activity).toBe('snowshoe');
expect(tourListItemSchema.parse(listedHike).winter_activity).toBeUndefined();
expect(tourListItemSchema.safeParse({ ...listedHike, winter_activity: 'ski_tour' }).success).toBe(false);
});
});
+17
View File
@@ -58,6 +58,16 @@ export type PaddleWaterContext = z.infer<typeof paddleWaterContextSchema>;
export const paddleLakeStyleSchema = z.enum(['direct', 'shore']);
export type PaddleLakeStyle = z.infer<typeof paddleLakeStyleSchema>;
/**
* HIK-01: a hike marked as one done in winter, walked or on snowshoes. It is
* optional and only a label: the route, its grade and its time are planned as
* for any hike, and the label says nothing about whether a way can be walked
* in winter, about snow or about avalanches. A hike without one keeps null.
*/
export const WINTER_ACTIVITIES = ['winter_hike', 'snowshoe'] as const;
export const winterActivitySchema = z.enum(WINTER_ACTIVITIES);
export type WinterActivity = z.infer<typeof winterActivitySchema>;
/** The highest planning speed a paddler can enter, in km/h. */
export const MAX_PADDLE_SPEED_KMH = 20;
export const paddleSpeedKmhSchema = z.number().finite().min(0.5).max(MAX_PADDLE_SPEED_KMH);
@@ -165,6 +175,8 @@ export const tourSchema = z.object({
derived_from_place_id: z.number().int().positive().nullable().optional(),
/** BIK-01: a bike tour's profile; null for every other type. Absent in a list cached before it existed. */
bike_profile: bikeProfileSchema.nullable().optional(),
/** HIK-01: a hike's winter label; null for none and for every other type. Absent in a list cached before it existed. */
winter_activity: winterActivitySchema.nullable().optional(),
/** A paddle tour's boat and water; null for every other type. Absent in a list cached before they existed. */
boat_type: paddleBoatTypeSchema.nullable().optional(),
water_context: paddleWaterContextSchema.nullable().optional(),
@@ -401,6 +413,8 @@ export const tourCreateRequestSchema = z
hiking_preferences: hikingPreferencesSchema.nullable().optional(),
/** BIK-01, a bike tour's only, and its one requirement. */
bike_profile: bikeProfileSchema.nullable().optional(),
/** HIK-01, a hike's only: its winter label, null to take it off; left out, an edit keeps the one it has. */
winter_activity: winterActivitySchema.nullable().optional(),
boat_type: paddleBoatTypeSchema.nullable().optional(),
water_context: paddleWaterContextSchema.nullable().optional(),
paddle_speed_kmh: paddleSpeedKmhSchema.nullable().optional(),
@@ -493,6 +507,7 @@ function tourTypeIssues(value: {
max_hiking_difficulty?: number | null;
hiking_preferences?: HikingPreferences | null;
bike_profile?: BikeProfile | null;
winter_activity?: WinterActivity | null;
boat_type?: PaddleBoatType | null;
water_context?: PaddleWaterContext | null;
paddle_speed_kmh?: number | null;
@@ -526,6 +541,8 @@ function tourTypeIssues(value: {
issues.push({ path: ['max_hiking_difficulty'], message: `${named} has no hiking difficulty` });
if (hikingPreferencesOf(value.hiking_preferences) !== null)
issues.push({ path: ['hiking_preferences'], message: `${named} has no hiking preferences` });
if (value.winter_activity != null)
issues.push({ path: ['winter_activity'], message: `${named} has no winter activity` });
}
if (type === 'bike' && value.bike_profile == null)
issues.push({ path: ['bike_profile'], message: 'a bike tour needs a bike profile' });
+1 -1
View File
@@ -237,7 +237,7 @@ The saved tours of a trip, as the Tours list and the tour detail show them, how
| Tool | Description | Scope |
|---|---|---|
| `list_tours` | The saved tours of a trip: name, type (`hike`, `walk` for a city walk, `bike`, or `kayak` for a paddle tour), origin (`trek` for planned in TREK, `gpx` for a GPX import, `wanderer` for a wanderer snapshot), distance, ascent and descent (unknown for a paddle tour drawn in the planner), the stored routing limit of a hike on the SAC scale and its route choices (`hiking_preferences`), the boat, water and chosen speed of a paddle tour, the check bench of a paddle tour (`paddle_check`: how far the OpenStreetMap obstacle data covers its route and how many obstacles are open, to check again or decided; a planning status, never a safety or legal clearance), for a city walk how many stations it has (`station_count`), their stays as far as entered and how many have none, whether a tour still waits for its way to be planned (`route_pending`: saved with its points only, its distance and walking time unknown until someone opens it in the planner and saves it), the `revision` (raised by every saved change of the tour), and the days of the trip each tour is on | `trips:read` |
| `list_tours` | The saved tours of a trip: name, type (`hike`, `walk` for a city walk, `bike`, or `kayak` for a paddle tour), origin (`trek` for planned in TREK, `gpx` for a GPX import, `wanderer` for a wanderer snapshot), distance, ascent and descent (unknown for a paddle tour drawn in the planner), the stored routing limit of a hike on the SAC scale and its route choices (`hiking_preferences`), the winter label of a hike (`winter_activity`: `winter_hike` or `snowshoe`; a label only, the route and its time are planned as for any hike, never a statement about winter conditions), the boat, water and chosen speed of a paddle tour, the check bench of a paddle tour (`paddle_check`: how far the OpenStreetMap obstacle data covers its route and how many obstacles are open, to check again or decided; a planning status, never a safety or legal clearance), for a city walk how many stations it has (`station_count`), their stays as far as entered and how many have none, whether a tour still waits for its way to be planned (`route_pending`: saved with its points only, its distance and walking time unknown until someone opens it in the planner and saves it), the `revision` (raised by every saved change of the tour), and the days of the trip each tour is on | `trips:read` |
| `get_tour_plan` | One tour: its facts, its ordered control points, its provenance (where it came from, when, and whether it was changed in TREK since) and its days. A control point can carry a fixed leg to the next one: a via ferrata (`ferrata`) or a stretch drawn by hand (`free`), which no router planned or checked. A shaping point (`shaping: true`) only steers the route and is no stop. A paddle tour also carries its obstacle checklist (`obstacle_check`): the obstacles of the OpenStreetMap data along its route, what the travellers decided about each, and their own portages and locks. The stations of a city walk carry every field spelled out: `key` (the same across saves), `name`, `stay_minutes` (`0` for a point only passed, `null` when not known), `importance` (`optional`, `must`, or `null` for normal), `protected` (a station a proposed change has to keep where it is; the travellers can still edit it) and `opening_hours` (as OpenStreetMap writes them, `null` when none is known). The full track geometry is not included | `trips:read` |
| `find_tours_for_day` | The tours put on one day of a trip, with the same facts as `list_tours` | `trips:read` |
| `get_city_walk_progress` | How far a city walk got on one day it is on: every station in order with its key, its status that day (planned, visited or skipped, as checked off in the day plan), importance, protection, stay and opening hours, what is still open, and the walking time of the whole walk. Visits belong to that day only. Reading changes nothing | `trips:read` |