feat(tours): import a ski track from GPX and keep it as recorded

SKI-02. Ski joins the types a GPX file can be imported as, offered in the
import preview's type field only: TREK routes no ski track, so the planner
never offers it. The preview asks for the discipline on purpose (alpine,
snowboard, cross-country or ski touring, none chosen at first) and, for
cross-country, the technique (classic, skating or unknown); Import waits
until both are named, and the technique stays in sight but dimmed for the
other disciplines. The track is stored exactly as recorded, with its file
name as provenance, its distance and heights from the file and no time:
no ski time is worked out and no hiking estimate stands in for one.

Opened in the planner, a ski track gets a rail card with its discipline on
a tinted tile, a cross-country technique as a badge beside it, the ski time
said as not worked out, and an info dot on what TREK does not say (snow,
avalanches, open runs, and that the 3D gradient colours are no difficulty
or avalanche rating). Change opens a dialog to classify the track again;
only the classification changes, through the offline core, with the
revision it was made at. Planning over the import is not offered for ski.
The existing 3D terrain view, the elevation profile and its focus work on
the track as for any import, with the standard map, topo and satellite and
no via ferratas. Lists, the day plan and the detail show the discipline as
a frosted corner badge on the tile and as a pill with the technique inside;
day assignment, copies and take-overs work as for any tour. The corner
badge is now one component that the winter label shares.

The server keeps tours.ski_discipline and tours.ski_technique (CHECK lists,
NULL on every other type), switches the ski type on without a routing
profile, refuses a ski import without its discipline, a technique outside
cross-country and ski fields on any other type, and takes a planned save's
classification off. PUT /api/trips/:tripId/tours/:placeId/ski classifies a
ski track again (place_edit, 409 on a stale revision, 400 for a tour that
is no ski track) and broadcasts tours:changed. GPX export names the
discipline (alpine_skiing, snowboarding, cross_country_skiing,
backcountry_skiing), and list_tours describes both fields with what a ski
track does not say. Ski is labelled plainly as Ski in every language, so
it no longer reads as ski touring.
This commit is contained in:
Maurice
2026-10-20 13:45:56 +02:00
parent 68907ec811
commit 0169be3d79
93 changed files with 2853 additions and 183 deletions
+12
View File
@@ -0,0 +1,12 @@
import type { TourDetailResponse, TourSkiClassificationRequest } from '@trek/shared';
import apiClient from './client';
/** SKI-02: a ski track classified again, its discipline and cross-country technique. */
export const tourSkiApi = {
classify: (
tripId: number | string,
placeId: number | string,
body: TourSkiClassificationRequest
): Promise<TourDetailResponse> =>
apiClient.put(`/trips/${tripId}/tours/${placeId}/ski`, body).then((r) => r.data),
};
@@ -0,0 +1,92 @@
import {
BIKE_PROFILES,
importableTourTypeSchema,
type BikeProfile,
type PaddleBoatType,
type PaddleWaterContext,
} from '@trek/shared';
import { Info } from 'lucide-react';
import { useId } from 'react';
import { useTranslation } from '../../i18n';
import { LABEL, Segmented } from '../shared/dialogParts';
import { DialogSection } from '../shared/DialogShell';
import type { GpxImportChoicesState } from './gpxImportChoices';
import { TourTypeSelect } from './planner/TourTypeSelect';
import { RunProfileField } from './run/RunFields';
import { SkiClassificationFields } from './ski/SkiClassificationFields';
import { TourNotice } from './tourParts';
const BOATS: readonly PaddleBoatType[] = ['kayak', 'canoe', 'sup', 'other'];
const WATERS: readonly PaddleWaterContext[] = ['river', 'lake'];
/** Every type a GPX file can come in as: the planner's, and ski, which is only kept (SKI-02). */
const IMPORT_TYPES = importableTourTypeSchema.options;
/** A paddle import's boat and water, side by side, each chosen on purpose. */
function PaddleImportFields({ choices }: { choices: GpxImportChoicesState }) {
const { t } = useTranslation();
const { chosen } = choices;
return (
<>
<div data-testid="gpx-preview-paddle" className="grid grid-cols-1 gap-3 sm:grid-cols-[3fr_2fr]">
<div className="min-w-0">
<span className={LABEL}>{t('tours.paddle.boat')}</span>
<Segmented<PaddleBoatType>
value={(chosen.boat ?? '') as PaddleBoatType}
onChange={choices.setBoat}
label={t('tours.paddle.boat')}
fill
options={BOATS.map((value) => ({ value, label: t(`tours.paddle.boat.${value}`) }))}
/>
</div>
<div className="min-w-0">
<span className={LABEL}>{t('tours.paddle.water')}</span>
<Segmented<PaddleWaterContext>
value={(chosen.water ?? '') as PaddleWaterContext}
onChange={choices.setWater}
label={t('tours.paddle.water')}
fill
options={WATERS.map((value) => ({ value, label: t(`tours.paddle.water.${value}`) }))}
/>
</div>
</div>
{(chosen.boat === null || chosen.water === null) && (
<TourNotice role="status" icon={<Info size={13} strokeWidth={2} />}>
{t('tours.paddle.chooseBoatImport')}
</TourNotice>
)}
</>
);
}
/**
* The preview's "import as" section: the type, and below it what that type
* names: a bike or run profile, a paddle tour's boat and water, a ski track's
* discipline and technique (SKI-02).
*/
export function GpxImportTypeSection({ choices }: { choices: GpxImportChoicesState }) {
const { t } = useTranslation();
const typeLabelId = useId();
const { chosen } = choices;
return (
<DialogSection label={<span id={typeLabelId}>{t('tours.import.previewAs')}</span>}>
<div className="flex flex-col gap-3">
<TourTypeSelect value={chosen.type} onChange={choices.setType} labelId={typeLabelId} types={IMPORT_TYPES} />
{chosen.type === 'bike' && (
<div data-testid="gpx-preview-bike" className="min-w-0">
<span className={LABEL}>{t('tours.bike.profile')}</span>
<Segmented<BikeProfile>
value={chosen.bikeProfile}
onChange={choices.setBikeProfile}
label={t('tours.bike.profile')}
fill
options={BIKE_PROFILES.map((value) => ({ value, label: t(`tours.bike.profile.${value}`) }))}
/>
</div>
)}
{chosen.type === 'run' && <RunProfileField value={chosen.runProfile} onChange={choices.setRunProfile} />}
{chosen.type === 'kayak' && <PaddleImportFields choices={choices} />}
{chosen.type === 'ski' && <SkiClassificationFields choice={chosen.ski} onChange={choices.setSki} />}
</div>
</DialogSection>
);
}
@@ -0,0 +1,36 @@
import type { LucideIcon } from 'lucide-react';
import { Tooltip } from '../shared/Tooltip';
/**
* A frosted badge on the top corner of a tour's round tile, carrying one
* icon: a hike's winter label (HIK-01), a ski track's discipline (SKI-02).
* Its tooltip and accessible name say what the icon stands for.
*/
export function TourCornerMark({
icon: Icon,
label,
size = 15,
testId,
}: {
icon: LucideIcon;
label: string;
/** The badge's diameter in pixels, about half the tile's. */
size?: number;
testId: string;
}) {
return (
<span className="absolute -end-1 -top-1 flex">
<Tooltip label={label}>
<span
data-testid={testId}
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 }}
>
<Icon size={Math.round(size * 0.64)} strokeWidth={2.4} aria-hidden="true" />
</span>
</Tooltip>
</span>
);
}
@@ -1,4 +1,4 @@
import { plannableTourTypeSchema, type TourListItem } from '@trek/shared';
import { importableTourTypeSchema, type TourListItem } from '@trek/shared';
import { ArrowUp, CheckCheck, Clock, Gauge, Pause, Ruler, type LucideIcon } from 'lucide-react';
import { createElement, useId, useState } from 'react';
import { useTranslation } from '../../i18n';
@@ -9,6 +9,7 @@ import { fs } from '../shared/DialogShell';
import { Tooltip } from '../shared/Tooltip';
import { paddleCheckFact } from './paddle/paddleCheckModel';
import { runPaceText } from './run/runPaceText';
import { SkiCornerMark } from './ski/skiMarks';
import { tourDayRowIcon } from './tourDayRowIcon';
import { movingTimeLabel } from './tourFacts';
import { formatPlannedTourDuration, plannedTotalText, tourPlannedTimes } from './tourPresentation';
@@ -16,9 +17,9 @@ import { useWalkProgress } from './walk/walkProgress';
import { walkStaysText } from './walk/walkTimeFields';
import { WinterCornerMark } from './winter/winterMarks';
/** Whether the app names this type: one it plans, not a key only the catalogue holds. */
/** Whether the app names this type: one it plans or keeps (a ski track, SKI-02), not a key only the catalogue holds. */
function knownType(type: string): boolean {
return plannableTourTypeSchema.safeParse(type).success;
return importableTourTypeSchema.safeParse(type).success;
}
/** The icon the time on the move goes by: a ride, a run or a paddle by its type's own, any walk by footprints. */
@@ -95,21 +96,26 @@ function Fact({
* A tour's tile in the day plan and in its detail: the T-scale grade in a round
* tile, the same tile the tour lists use, so a tour reads alike wherever it
* shows. A GPX import has no grade and shows its type's icon instead. A hike's
* winter label (HIK-01) sits on the tile's corner, as in the tour lists.
* winter label (HIK-01) or a ski track's discipline (SKI-02) sits on the
* tile's corner, as in the tour lists.
*/
export function TourDayRowAvatar({
tour,
size = 32,
}: {
tour: Pick<TourListItem, 'tour_type' | 'max_hiking_difficulty'> & Partial<Pick<TourListItem, 'winter_activity'>>;
tour: Pick<TourListItem, 'tour_type' | 'max_hiking_difficulty'> &
Partial<Pick<TourListItem, 'winter_activity' | 'ski_discipline' | 'ski_technique'>>;
size?: number;
}) {
const avatar = <TourGradeAvatar tour={tour} size={size} />;
if (!tour.winter_activity) return avatar;
const ski = tour.tour_type === 'ski' && !!tour.ski_discipline;
if (!tour.winter_activity && !ski) return avatar;
const markSize = Math.round(size * 0.44);
return (
<span className="relative flex flex-none">
{avatar}
<WinterCornerMark activity={tour.winter_activity} size={Math.round(size * 0.44)} />
<WinterCornerMark activity={tour.winter_activity} size={markSize} />
<SkiCornerMark tour={tour} size={markSize} />
</span>
);
}
@@ -52,6 +52,7 @@ import { PaddleCheckSection } from './paddle/PaddleCheckSection';
import { PaddleWindSection } from './paddle/PaddleWindCard';
import { TourProvenanceBlock } from './provenance/TourProvenanceSection';
import { RunHeadPills } from './run/RunHeadPills';
import { SkiHeadPill } from './ski/skiMarks';
import { tourDayRowIcon } from './tourDayRowIcon';
import { tourDaysOf } from './tourDays';
import { heightsUnknown, movingTimeLabel, withoutHeights } from './tourFacts';
@@ -210,7 +211,8 @@ function tourTimeFields(
}
/**
* The head's facts as pills: the type, a hike's winter label or a run's profile and pace, where it came from, the time (its parts
* The head's facts as pills: the type, a hike's winter label, a run's profile and pace or a ski track's
* discipline, where it came from, the time (its parts
* in the tooltip), distance, ascent, descent, and a caution.
*/
function TourHeadPills({
@@ -256,6 +258,8 @@ function TourHeadPills({
{tour.winter_activity && <WinterActivityPill activity={tour.winter_activity} className={HEAD_PILL} />}
{/* RUN-01: a run's profile and the pace its time is planned at. */}
<RunHeadPills tour={tour} className={HEAD_PILL} />
{/* SKI-02: a ski track's discipline, a cross-country technique inside it. */}
<SkiHeadPill tour={tour} 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">
@@ -1,14 +1,5 @@
import {
BIKE_PROFILES,
type BikeProfile,
type PaddleBoatType,
type PaddleWaterContext,
type PlannableTourType,
type RunProfile,
type TourGpxImportFields,
} from '@trek/shared';
import { AlertTriangle, FileDown, Info } from 'lucide-react';
import { useId, useMemo, useState } from 'react';
import { AlertTriangle, FileDown } from 'lucide-react';
import { useId, useMemo } from 'react';
import { OFM_POSITRON } from '../../constants/mapDefaults';
import { useTileUrl } from '../../hooks/useTileUrl';
import { useTranslation } from '../../i18n';
@@ -16,7 +7,6 @@ import { useSettingsStore } from '../../store/settingsStore';
import { formatDistance, formatElevation } from '../../utils/units';
import { MapViewAuto } from '../Map/MapViewAuto';
import { SoftPill } from '../Planner/planParts';
import { LABEL, Segmented } from '../shared/dialogParts';
import {
DialogButton,
DialogFooter,
@@ -28,13 +18,11 @@ import {
fs,
NEUTRAL_TINT,
} from '../shared/DialogShell';
import { TourTypeSelect } from './planner/TourTypeSelect';
import { RunProfileField } from './run/RunFields';
import { importFields, importMissing, useGpxImportChoices } from './gpxImportChoices';
import { GpxImportTypeSection } from './GpxImportTypeSection';
import { TourNotice } from './tourParts';
import type { GpxPreviewFile, TourGpxImport } from './useTourGpxImport';
const BOATS: readonly PaddleBoatType[] = ['kayak', 'canoe', 'sup', 'other'];
const WATERS: readonly PaddleWaterContext[] = ['river', 'lake'];
const BADGE =
'inline-flex flex-none items-center rounded-full bg-surface-secondary px-2 font-geist tabular-nums leading-[20px] text-content-secondary';
@@ -71,29 +59,12 @@ function PreviewTrackRow({ name, track }: { name: string; track: GpxPreviewFile[
);
}
/** What the dialog has chosen besides the type: a paddle import's boat and water, a bike or run import's profile. */
interface ImportChoices {
boat: PaddleBoatType | null;
water: PaddleWaterContext | null;
bikeProfile: BikeProfile;
runProfile: RunProfile;
}
/** What the import is asked to store the tracks as: the type and the fields that type names. */
function importFields(type: PlannableTourType, chosen: ImportChoices): TourGpxImportFields {
if (type === 'kayak' && chosen.boat && chosen.water)
return { tour_type: 'kayak', boat_type: chosen.boat, water_context: chosen.water };
if (type === 'bike') return { tour_type: 'bike', bike_profile: chosen.bikeProfile };
// RUN-01: a run comes in with its profile and no pace; its time stays unknown until one is planned from it.
if (type === 'run') return { tour_type: 'run', run_profile: chosen.runProfile };
return { tour_type: 'hike' };
}
/**
* PAD-02: a GPX file shown before anything is stored. The whole course on a
* map, every track with its measures, the tracks the trip already holds named
* as skipped, and the type to import them as; a paddle import names its boat
* and water, as a planned paddle tour does. Closing it stores nothing.
* and water, as a planned paddle tour does, a ski import its discipline
* (SKI-02). Closing it stores nothing.
*/
export default function TourGpxPreviewDialog({ gpxImport }: { gpxImport: TourGpxImport }) {
const { preview } = gpxImport;
@@ -104,13 +75,8 @@ export default function TourGpxPreviewDialog({ gpxImport }: { gpxImport: TourGpx
function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files: GpxPreviewFile[] }) {
const { t } = useTranslation();
const labelId = useId();
const typeLabelId = useId();
const tileUrl = useTileUrl(OFM_POSITRON);
const [type, setType] = useState<PlannableTourType>('hike');
const [boat, setBoat] = useState<PaddleBoatType | null>(null);
const [water, setWater] = useState<PaddleWaterContext | null>(null);
const [bikeProfile, setBikeProfile] = useState<BikeProfile>('touring');
const [runProfile, setRunProfile] = useState<RunProfile>('running');
const choices = useGpxImportChoices();
const tracks = files.flatMap((file) => file.tracks.map((track) => ({ file: file.file, track })));
const fresh = tracks.filter(({ track }) => !track.duplicate).length;
@@ -122,9 +88,8 @@ function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files:
[files]
);
const focusPoints = useMemo(() => lines.flat(), [lines]);
const paddleIncomplete = type === 'kayak' && (boat === null || water === null);
const canImport = fresh > 0 && !paddleIncomplete && !gpxImport.importing;
const fields = importFields(type, { boat, water, bikeProfile, runProfile });
const canImport = fresh > 0 && importMissing(choices.chosen) === null && !gpxImport.importing;
const fields = importFields(choices.chosen);
const title = files.length === 1 ? files[0].file.name : t('tours.import.previewFiles', { count: files.length });
return (
@@ -196,53 +161,7 @@ function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files:
)}
</DialogSection>
<DialogSection label={<span id={typeLabelId}>{t('tours.import.previewAs')}</span>}>
<div className="flex flex-col gap-3">
<TourTypeSelect value={type} onChange={setType} labelId={typeLabelId} />
{type === 'bike' && (
<div data-testid="gpx-preview-bike" className="min-w-0">
<span className={LABEL}>{t('tours.bike.profile')}</span>
<Segmented<BikeProfile>
value={bikeProfile}
onChange={setBikeProfile}
label={t('tours.bike.profile')}
fill
options={BIKE_PROFILES.map((value) => ({ value, label: t(`tours.bike.profile.${value}`) }))}
/>
</div>
)}
{type === 'run' && <RunProfileField value={runProfile} onChange={setRunProfile} />}
{type === 'kayak' && (
<div data-testid="gpx-preview-paddle" className="grid grid-cols-1 gap-3 sm:grid-cols-[3fr_2fr]">
<div className="min-w-0">
<span className={LABEL}>{t('tours.paddle.boat')}</span>
<Segmented<PaddleBoatType>
value={(boat ?? '') as PaddleBoatType}
onChange={setBoat}
label={t('tours.paddle.boat')}
fill
options={BOATS.map((value) => ({ value, label: t(`tours.paddle.boat.${value}`) }))}
/>
</div>
<div className="min-w-0">
<span className={LABEL}>{t('tours.paddle.water')}</span>
<Segmented<PaddleWaterContext>
value={(water ?? '') as PaddleWaterContext}
onChange={setWater}
label={t('tours.paddle.water')}
fill
options={WATERS.map((value) => ({ value, label: t(`tours.paddle.water.${value}`) }))}
/>
</div>
</div>
)}
{paddleIncomplete && (
<TourNotice role="status" icon={<Info size={13} strokeWidth={2} />}>
{t('tours.paddle.chooseBoatImport')}
</TourNotice>
)}
</div>
</DialogSection>
<GpxImportTypeSection choices={choices} />
</DialogShell>
);
}
@@ -5,6 +5,7 @@ import type { GpxConversion } from './conversion/useGpxConversion';
import type { PaddleChecklist } from './paddle/usePaddleChecklist';
import type { TrackPaddlePlan } from './paddle/useTrackPaddlePlan';
import type { TourPlannerController } from './planner/useTourPlanner';
import type { SkiClassification } from './ski/useSkiClassification';
import type { RecordedTrack } from './useRecordedTrack';
import type { WandererBrowser } from './wanderer/useWandererBrowser';
@@ -63,4 +64,12 @@ describe('TourWorkspaceRail', () => {
expect(seen.rail[seen.rail.length - 1]?.trackPaddlePlan).toBe(trackPaddlePlan);
expect(seen.rail[seen.rail.length - 1]?.paddleChecklist).toBe(paddleChecklist);
});
it('SKI-02: hands the classification of the ski track in view on to the editor rail', () => {
const skiClassification = { tour: null } as unknown as SkiClassification;
const parts = { ...workspace(wanderer({})), skiClassification };
seen.rail.length = 0;
render(<TourWorkspaceRail workspace={parts} canEdit canAssign />);
expect(seen.rail[seen.rail.length - 1]?.skiClassification).toBe(skiClassification);
});
});
@@ -3,6 +3,7 @@ import type { PaddleChecklist } from './paddle/usePaddleChecklist';
import type { TrackPaddlePlan } from './paddle/useTrackPaddlePlan';
import { TourPlannerRail } from './planner/TourPlannerPanels';
import type { TourPlannerController } from './planner/useTourPlanner';
import type { SkiClassification } from './ski/useSkiClassification';
import type { RecordedTrack } from './useRecordedTrack';
import WandererBrowserRail from './wanderer/WandererBrowserRail';
import type { WandererBrowser } from './wanderer/useWandererBrowser';
@@ -17,6 +18,8 @@ export interface TourWorkspaceRailParts {
trackPaddlePlan?: TrackPaddlePlan;
/** The obstacle checklist of the paddle tour open in the planner (PAD-06). */
paddleChecklist?: PaddleChecklist;
/** The classification of the ski track in view (SKI-02). */
skiClassification?: SkiClassification;
/** The trip's tours, never offered as a stop of another tour (PR1-10). */
tourPlaceIds?: ReadonlySet<number>;
}
@@ -38,6 +41,7 @@ export default function TourWorkspaceRail({
recordedTrack,
trackPaddlePlan,
paddleChecklist,
skiClassification,
tourPlaceIds,
} = workspace;
return wanderer.open ? (
@@ -51,6 +55,7 @@ export default function TourWorkspaceRail({
recordedTrack={recordedTrack}
trackPaddlePlan={trackPaddlePlan}
paddleChecklist={paddleChecklist}
skiClassification={skiClassification}
tourPlaceIds={tourPlaceIds}
onOpenWanderer={wanderer.enabled ? wanderer.openBrowser : undefined}
/>
@@ -363,6 +363,39 @@ describe('ToursSidebar', () => {
vi.restoreAllMocks()
})
it('SKI-02: a ski import waits for its discipline, and for a cross-country technique, then sends them along', async () => {
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack()] })
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValue({ tours: [tour], caution: false, skipped: 0 })
seedStore(useTripStore, { refreshPlaces: vi.fn().mockResolvedValue(undefined) })
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
const file = new File(['<gpx/>'], 'loipe.gpx', { type: 'application/gpx+xml' })
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [file])
await screen.findByRole('dialog')
fireEvent.click(screen.getByTestId('tour-type-select'))
fireEvent.click(screen.getByTestId('tour-type-option-ski'))
const confirm = screen.getByTestId('gpx-preview-import')
expect(confirm).toBeDisabled()
expect(screen.getByText('Choose a discipline to import the track as ski.')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Cross-country' }))
expect(confirm).toBeDisabled()
expect(screen.getByText('Choose the technique, or Unknown.')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Unknown' }))
expect(confirm).toBeEnabled()
fireEvent.click(confirm)
await waitFor(() =>
expect(importCall).toHaveBeenCalledWith(1, file, {
tour_type: 'ski',
ski_discipline: 'cross_country',
ski_technique: 'unknown',
})
)
vi.restoreAllMocks()
})
it('RUN-01: a run import names its profile, on purpose, and sends no pace', async () => {
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack()] })
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValue({ tours: [tour], caution: false, skipped: 0 })
@@ -215,6 +215,25 @@ describe('useGpxConversion', () => {
);
});
it('SKI-02: a ski track is never planned over: nothing is offered and no router is asked', () => {
const ski = {
...imported,
tour_type: 'ski' as const,
ski_discipline: 'touring' as const,
max_hiking_difficulty: null,
};
const controller = planner({
mode: { type: 'view-gpx', placeId: ski.place_id, tour: ski },
readOnlyGpxTour: { tour: ski, routeGeometry: null },
} as Partial<TourPlannerController>);
const { result } = renderHook(() => useGpxConversion(controller));
expect(result.current.available).toBe(false);
const { container } = render(<TourConversionSection conversion={result.current} />);
expect(container).toBeEmptyDOMElement();
expect(routing.route).not.toHaveBeenCalled();
expect(routing.run).not.toHaveBeenCalled();
});
it('PR1-05B: nothing is adopted before the suggestion is ready', () => {
const controller = planner();
const { result } = renderHook(() => useGpxConversion(controller));
@@ -125,8 +125,11 @@ export function useGpxConversion(planner: TourPlannerController) {
return useMemo(
() => ({
...current,
/** The import shown can be planned from: it has a track and the user may add tours. */
available: tour !== null && (track?.length ?? 0) >= 2 && planner.canEdit,
/**
* The import shown can be planned from: it has a track and the user may add tours. A ski
* track (SKI-02) never: TREK routes no ski track, and a hike's or a walk's route is no stand-in.
*/
available: tour !== null && tour.tour_type !== 'ski' && (track?.length ?? 0) >= 2 && planner.canEdit,
difficulty,
/** The import is a paddle tour: its version is drawn with straight legs, not routed. */
paddle,
@@ -0,0 +1,59 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { importFields, importMissing, useGpxImportChoices, type GpxImportChoices } from './gpxImportChoices';
// FE-TOUR-GPX-CHOICES-001 to FE-TOUR-GPX-CHOICES-003
const base: GpxImportChoices = {
type: 'hike',
boat: null,
water: null,
bikeProfile: 'touring',
runProfile: 'running',
ski: { discipline: null, technique: null },
};
describe('GPX import choices', () => {
it('FE-TOUR-GPX-CHOICES-001: each type names what it carries, and a hike nothing more', () => {
expect(importFields(base)).toEqual({ tour_type: 'hike' });
expect(importFields({ ...base, type: 'bike', bikeProfile: 'gravel' })).toEqual({
tour_type: 'bike',
bike_profile: 'gravel',
});
expect(importFields({ ...base, type: 'run', runProfile: 'trail' })).toEqual({
tour_type: 'run',
run_profile: 'trail',
});
expect(importFields({ ...base, type: 'kayak', boat: 'sup', water: 'lake' })).toEqual({
tour_type: 'kayak',
boat_type: 'sup',
water_context: 'lake',
});
// SKI-02: a ski track its discipline, and for cross-country its technique.
expect(importFields({ ...base, type: 'ski', ski: { discipline: 'touring', technique: null } })).toEqual({
tour_type: 'ski',
ski_discipline: 'touring',
});
expect(
importFields({ ...base, type: 'ski', ski: { discipline: 'cross_country', technique: 'unknown' } })
).toEqual({ tour_type: 'ski', ski_discipline: 'cross_country', ski_technique: 'unknown' });
});
it('FE-TOUR-GPX-CHOICES-002: holds the import back while a paddle tour or a ski track lacks what it must name', () => {
expect(importMissing(base)).toBeNull();
expect(importMissing({ ...base, type: 'kayak', boat: 'canoe' })).toBe('paddle');
expect(importMissing({ ...base, type: 'ski' })).toBe('ski');
const crossCountry = { discipline: 'cross_country' as const, technique: null };
expect(importMissing({ ...base, type: 'ski', ski: crossCountry })).toBe('ski');
expect(importMissing({ ...base, type: 'ski', ski: { discipline: 'alpine', technique: null } })).toBeNull();
});
it('FE-TOUR-GPX-CHOICES-003: starts as a hike and keeps each choice on its own', () => {
const { result } = renderHook(() => useGpxImportChoices());
expect(result.current.chosen).toEqual(base);
act(() => result.current.setType('ski'));
act(() => result.current.setSki({ discipline: 'snowboard', technique: null }));
act(() => result.current.setBoat('kayak'));
expect(result.current.chosen).toMatchObject({ type: 'ski', boat: 'kayak', ski: { discipline: 'snowboard' } });
});
});
@@ -0,0 +1,74 @@
import type {
BikeProfile,
ImportableTourType,
PaddleBoatType,
PaddleWaterContext,
RunProfile,
TourGpxImportFields,
} from '@trek/shared';
import { useState } from 'react';
import { NO_SKI_CHOICE, skiChoiceMissing, skiFieldsOf, type SkiChoice } from './ski/skiChoice';
/**
* What the GPX preview has chosen: the type to import as, and what that type
* names besides: a paddle import its boat and water, a bike or run import its
* profile, a ski import (SKI-02) its discipline and technique.
*/
export interface GpxImportChoices {
type: ImportableTourType;
boat: PaddleBoatType | null;
water: PaddleWaterContext | null;
bikeProfile: BikeProfile;
runProfile: RunProfile;
ski: SkiChoice;
}
const START: GpxImportChoices = {
type: 'hike',
boat: null,
water: null,
bikeProfile: 'touring',
runProfile: 'running',
ski: NO_SKI_CHOICE,
};
/** What the chosen type still lacks before anything can be imported; null when nothing does. */
export function importMissing(chosen: GpxImportChoices): 'paddle' | 'ski' | null {
if (chosen.type === 'kayak' && (chosen.boat === null || chosen.water === null)) return 'paddle';
if (chosen.type === 'ski' && skiChoiceMissing(chosen.ski)) return 'ski';
return null;
}
/** What the import is asked to store the tracks as: the type and the fields that type names. */
export function importFields(chosen: GpxImportChoices): TourGpxImportFields {
const { type } = chosen;
if (type === 'kayak' && chosen.boat && chosen.water)
return { tour_type: 'kayak', boat_type: chosen.boat, water_context: chosen.water };
if (type === 'bike') return { tour_type: 'bike', bike_profile: chosen.bikeProfile };
// RUN-01: a run comes in with its profile and no pace; its time stays unknown until one is planned from it.
if (type === 'run') return { tour_type: 'run', run_profile: chosen.runProfile };
// SKI-02: a ski track comes in with its discipline and, for cross-country, its technique, and no time.
const ski = type === 'ski' ? skiFieldsOf(chosen.ski) : null;
if (ski) return { tour_type: 'ski', ...ski };
return { tour_type: 'hike' };
}
/** The preview's choices, each field set on its own; a fresh preview starts as a hike. */
export function useGpxImportChoices() {
const [chosen, setChosen] = useState<GpxImportChoices>(START);
const set =
<K extends keyof GpxImportChoices>(key: K) =>
(value: GpxImportChoices[K]) =>
setChosen((current) => ({ ...current, [key]: value }));
return {
chosen,
setType: set('type'),
setBoat: set('boat'),
setWater: set('water'),
setBikeProfile: set('bikeProfile'),
setRunProfile: set('runProfile'),
setSki: set('ski'),
};
}
export type GpxImportChoicesState = ReturnType<typeof useGpxImportChoices>;
@@ -1,4 +1,4 @@
import { plannableTourTypeSchema, type TourLibraryItem, type TourTypeKey } from '@trek/shared';
import { importableTourTypeSchema, type TourLibraryItem, type TourTypeKey } from '@trek/shared';
import { AlertTriangle, ArrowUp, Clock, CopyPlus, Library, Loader2, Ruler, Search, WifiOff } from 'lucide-react';
import { useId, useMemo } from 'react';
import { OFM_POSITRON } from '../../../constants/mapDefaults';
@@ -142,7 +142,7 @@ function ListFilters({ library }: { library: TourLibrary }) {
];
const typeOptions = [
{ value: ALL, label: t('tours.library.allTypes') },
...plannableTourTypeSchema.options.map((type) => ({ value: type, label: t(`tourTypes.${type}`) })),
...importableTourTypeSchema.options.map((type) => ({ value: type, label: t(`tourTypes.${type}`) })),
];
return (
<div className="flex flex-col gap-2">
@@ -27,6 +27,8 @@ import { PaddleWindSection } from '../paddle/PaddleWindCard'
import { TrackPaddlePlanCards } from '../paddle/TrackPaddlePlanCard'
import type { PaddleChecklist } from '../paddle/usePaddleChecklist'
import type { TrackPaddlePlan } from '../paddle/useTrackPaddlePlan'
import { SkiTrackCard } from '../ski/SkiTrackCard'
import type { SkiClassification } from '../ski/useSkiClassification'
import { TourTypeFields } from './TourTypeFields'
import TourWaypointList from './TourWaypointList'
import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry'
@@ -273,12 +275,14 @@ function GpxViewRail({
conversion,
trackPaddlePlan,
paddleChecklist,
skiClassification,
}: {
planner: TourPlannerController
canEdit: boolean
conversion?: GpxConversion
trackPaddlePlan?: TrackPaddlePlan
paddleChecklist?: PaddleChecklist
skiClassification?: SkiClassification
}) {
const { t } = useTranslation()
const distanceUnit = useSettingsStore((state) => state.settings.distance_unit)
@@ -318,6 +322,7 @@ function GpxViewRail({
</div>
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-3 py-3">
{conversion && <TourConversionSection conversion={conversion} />}
{skiClassification && <SkiTrackCard ski={skiClassification} />}
{trackPaddlePlan && <TrackPaddlePlanCards plan={trackPaddlePlan} tour={tour} />}
{paddleChecklist && tour.tour_type === 'kayak' && <PaddleCheckCard checklist={paddleChecklist} />}
{tour.tour_type === 'kayak' && (
@@ -376,7 +381,7 @@ export function TourPlannerRail({ planner,
onOpenWanderer,
conversion,
paddleChecklist,
recordedTrack, trackPaddlePlan, tourPlaceIds, canEdit: editPermission, canAssign: assignPermission }: { planner: TourPlannerController
recordedTrack, trackPaddlePlan, skiClassification, tourPlaceIds, canEdit: editPermission, canAssign: assignPermission }: { planner: TourPlannerController
/** Planning an editable tour from the import in view. */
conversion?: GpxConversion
/** The way back to the recorded track of an import planned over in place. */
@@ -385,6 +390,8 @@ export function TourPlannerRail({ planner,
trackPaddlePlan?: TrackPaddlePlan
/** The obstacle checklist of the paddle tour open in the rail (PAD-06). */
paddleChecklist?: PaddleChecklist
/** The classification of the ski track in view (SKI-02). */
skiClassification?: SkiClassification
/** Browse the user's wanderer trails instead; offered while nothing is being edited. */
onOpenWanderer?: () => void
/** The trip's tours, never offered as a stop of another tour (PR1-10). */
@@ -413,7 +420,7 @@ export function TourPlannerRail({ planner,
/>
if (planner.mode.type === 'view-gpx')
return <GpxViewRail planner={planner} canEdit={canEdit} conversion={conversion} trackPaddlePlan={trackPaddlePlan}
paddleChecklist={paddleChecklist} />
paddleChecklist={paddleChecklist} skiClassification={skiClassification} />
const isSavedEdit = planner.mode.type === 'edit-saved'
@@ -39,6 +39,8 @@ describe('TourTypeFields (PAD-01)', () => {
expect(screen.getByTestId('tour-type-option-bike')).toHaveTextContent(
'By bike, routed for touring, road, gravel or MTB'
);
// SKI-02: ski is offered by the GPX import only; the planner routes no ski track.
expect(screen.queryByTestId('tour-type-option-ski')).toBeNull();
fireEvent.click(screen.getByTestId('tour-type-option-bike'));
expect(hike.setTourType).toHaveBeenCalledWith('bike');
expect(field).toHaveAttribute('aria-expanded', 'false');
@@ -1,4 +1,4 @@
import { plannableTourTypeSchema, type PlannableTourType } from '@trek/shared';
import { plannableTourTypeSchema, type ImportableTourType, type PlannableTourType } from '@trek/shared';
import { Check, ChevronDown } from 'lucide-react';
import { createElement, useId } from 'react';
import { useTranslation } from '../../../i18n';
@@ -10,7 +10,7 @@ import { tourDayRowIcon } from '../tourDayRowIcon';
/** The types the planner offers, in the order the contract lists them. */
const PLANNER_TOUR_TYPES = plannableTourTypeSchema.options;
function TypeTile({ type, active = false }: { type: PlannableTourType; active?: boolean }) {
function TypeTile({ type, active = false }: { type: ImportableTourType; active?: boolean }) {
return (
<span
aria-hidden="true"
@@ -25,26 +25,31 @@ function TypeTile({ type, active = false }: { type: PlannableTourType; active?:
* What the tour is, as one field that opens the types with what each one
* plans: a hike on footpaths within its limit, a bike tour on its profile, a
* paddle tour on the water. Room for the types still to come without the row
* of buttons it was growing too long for.
* of buttons it was growing too long for. The planner offers the types it
* plans; the GPX import offers ski as well (SKI-02), a track it only keeps.
*/
export function TourTypeSelect({
export function TourTypeSelect<T extends ImportableTourType = PlannableTourType>({
value,
onChange,
disabled = false,
labelId,
types,
}: {
value: PlannableTourType;
onChange: (type: PlannableTourType) => void;
value: T;
onChange: (type: T) => void;
disabled?: boolean;
/** The label over the field, read before the type chosen. */
labelId: string;
/** The types offered, in order; the planner's own when left out. */
types?: readonly T[];
}) {
const { t } = useTranslation();
const valueId = useId();
const list = usePopoverList(PLANNER_TOUR_TYPES.indexOf(value), { estimatedHeight: 270 });
const offered: readonly ImportableTourType[] = types ?? PLANNER_TOUR_TYPES;
const list = usePopoverList(offered.indexOf(value), { estimatedHeight: 50 * offered.length + 20 });
const { open, toggle, triggerRef, listId } = list;
const label = t('tours.planner.tourType');
const pick = (type: PlannableTourType) => {
const pick = (type: T) => {
list.close();
onChange(type);
};
@@ -78,7 +83,7 @@ export function TourTypeSelect({
/>
</button>
<PopoverListPanel list={list} label={label} className="flex min-w-[260px] flex-col gap-0.5">
{PLANNER_TOUR_TYPES.map((type) => {
{(offered as readonly T[]).map((type) => {
const active = type === value;
return (
<button
@@ -0,0 +1,54 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { useState } from 'react';
import { describe, expect, it } from 'vitest';
import { SkiClassificationFields } from './SkiClassificationFields';
import { NO_SKI_CHOICE, type SkiChoice } from './skiChoice';
// FE-TOUR-SKI-FIELDS-001 to FE-TOUR-SKI-FIELDS-003
const option = (name: string) => screen.getByRole('button', { name });
/** The fields over a form that keeps what it is given, as the import and the dialog do. */
function Stateful({ initial = NO_SKI_CHOICE }: { initial?: SkiChoice }) {
const [choice, setChoice] = useState<SkiChoice>(initial);
return (
<>
<SkiClassificationFields choice={choice} onChange={setChoice} />
<output data-testid="choice">{`${choice.discipline ?? 'none'}/${choice.technique ?? 'none'}`}</output>
</>
);
}
describe('SkiClassificationFields (SKI-02)', () => {
it('FE-TOUR-SKI-FIELDS-001: offers the four disciplines with none chosen, and asks for one', () => {
render(<Stateful />);
for (const discipline of ['alpine', 'snowboard', 'cross_country', 'touring']) {
expect(option(`tours.ski.discipline.${discipline}`)).toHaveAttribute('aria-pressed', 'false');
}
expect(screen.getByRole('status')).toHaveTextContent('tours.ski.chooseDiscipline');
expect(screen.getByRole('button', { name: 'tours.ski.infoLabel' })).toHaveAccessibleDescription('tours.ski.info');
fireEvent.click(option('tours.ski.discipline.touring'));
expect(screen.getByTestId('choice')).toHaveTextContent('touring/none');
expect(screen.queryByRole('status')).toBeNull();
});
it('FE-TOUR-SKI-FIELDS-002: the technique stays dimmed and out of reach until cross-country is chosen', () => {
render(<Stateful initial={{ discipline: 'alpine', technique: null }} />);
expect(screen.getByTestId('tour-ski-technique')).toHaveClass('opacity-50');
expect(option('tours.ski.technique.classic')).toBeDisabled();
fireEvent.click(option('tours.ski.discipline.cross_country'));
expect(screen.getByTestId('tour-ski-technique')).not.toHaveClass('opacity-50');
expect(screen.getByRole('status')).toHaveTextContent('tours.ski.chooseTechnique');
fireEvent.click(option('tours.ski.technique.unknown'));
expect(screen.getByTestId('choice')).toHaveTextContent('cross_country/unknown');
expect(option('tours.ski.technique.unknown')).toHaveAttribute('aria-pressed', 'true');
expect(screen.queryByRole('status')).toBeNull();
});
it('FE-TOUR-SKI-FIELDS-003: a technique left from cross-country shows as none chosen on another discipline', () => {
render(<Stateful initial={{ discipline: 'snowboard', technique: 'skating' }} />);
expect(option('tours.ski.technique.skating')).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(option('tours.ski.discipline.cross_country'));
expect(option('tours.ski.technique.skating')).toHaveAttribute('aria-pressed', 'true');
});
});
@@ -0,0 +1,72 @@
import { SKI_DISCIPLINES, SKI_TECHNIQUES, type SkiDiscipline, type SkiTechnique } from '@trek/shared';
import { Info } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { LABEL, Segmented } from '../../shared/dialogParts';
import { InfoDot, TourNotice } from '../tourParts';
import { skiChoiceMissing, type SkiChoice } from './skiChoice';
import { SKI_DISCIPLINE_ICON } from './skiMarks';
/**
* SKI-02: what a ski track is, chosen on purpose: the discipline, none until
* one is picked, and for cross-country the technique, Unknown among its
* answers. The technique stays in sight but dimmed while another discipline
* is chosen. The info dot says what TREK does with the track. The GPX import
* and the classification dialog share it.
*/
export function SkiClassificationFields({
choice,
onChange,
}: {
choice: SkiChoice;
onChange: (choice: SkiChoice) => void;
}) {
const { t } = useTranslation();
const crossCountry = choice.discipline === 'cross_country';
const missing = skiChoiceMissing(choice);
return (
<div data-testid="tour-ski-fields" className="flex min-w-0 flex-col gap-3">
<div className="min-w-0">
<div className="mb-[5px] flex min-h-5 min-w-0 items-center gap-1">
<span className={`${LABEL} !mb-0 min-w-0 truncate leading-5`}>{t('tours.ski.discipline')}</span>
<InfoDot label={t('tours.ski.infoLabel')} description={t('tours.ski.info')} />
</div>
<Segmented<SkiDiscipline>
value={(choice.discipline ?? '') as SkiDiscipline}
onChange={(discipline) => onChange({ ...choice, discipline })}
label={t('tours.ski.discipline')}
fill
options={SKI_DISCIPLINES.map((discipline) => {
const Icon = SKI_DISCIPLINE_ICON[discipline];
return {
value: discipline,
label: t(`tours.ski.discipline.${discipline}`),
icon: <Icon size={13} strokeWidth={2} aria-hidden="true" />,
};
})}
/>
</div>
<fieldset
data-testid="tour-ski-technique"
disabled={!crossCountry}
className={`m-0 min-w-0 border-0 p-0 transition-opacity ${crossCountry ? '' : 'opacity-50'}`}
>
<span className={LABEL}>{t('tours.ski.technique')}</span>
<Segmented<SkiTechnique>
value={(crossCountry ? (choice.technique ?? '') : '') as SkiTechnique}
onChange={(technique) => onChange({ ...choice, technique })}
label={t('tours.ski.technique')}
fill
options={SKI_TECHNIQUES.map((technique) => ({
value: technique,
label: t(`tours.ski.technique.${technique}`),
}))}
/>
</fieldset>
{missing && (
<TourNotice role="status" icon={<Info size={13} strokeWidth={2} />}>
{t(missing === 'discipline' ? 'tours.ski.chooseDiscipline' : 'tours.ski.chooseTechnique')}
</TourNotice>
)}
</div>
);
}
@@ -0,0 +1,66 @@
import { MountainSnow } from 'lucide-react';
import { useId } from 'react';
import { useTranslation } from '../../../i18n';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogShell,
DialogTile,
FooterSpacer,
NEUTRAL_TINT,
} from '../../shared/DialogShell';
import { SkiClassificationFields } from './SkiClassificationFields';
import type { SkiClassification } from './useSkiClassification';
/**
* SKI-02: the ski track classified again, in a dialog: the same fields as the
* import, starting from what the track has. Save stays off until the choice
* is complete and differs; Cancel and the backdrop change nothing.
*/
export function SkiClassifyDialog({ ski }: { ski: SkiClassification }) {
const { t } = useTranslation();
const labelId = useId();
const { tour, draft } = ski;
if (!tour || !draft) return null;
return (
<DialogShell
onClose={ski.close}
labelledBy={labelId}
width="detail"
blocked={ski.saving}
header={
<DialogHeader
tile={
<DialogTile>
<MountainSnow size={20} strokeWidth={2} className="text-content" />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={ski.close}
eyebrow={t('tours.ski.classifyTitle')}
title={tour.name}
/>
}
footer={
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={ski.close} disabled={ski.saving}>
{t('common.cancel')}
</DialogButton>
<DialogButton
variant="primary"
data-testid="ski-classify-save"
disabled={!ski.canSave || ski.saving}
onClick={() => void ski.save()}
>
{t('common.save')}
</DialogButton>
</DialogFooter>
}
>
<SkiClassificationFields choice={draft} onChange={ski.setDraft} />
</DialogShell>
);
}
@@ -0,0 +1,77 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type { TourListItem } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import { SkiTrackCard } from './SkiTrackCard';
import type { SkiClassification } from './useSkiClassification';
// FE-TOUR-SKI-CARD-001 to FE-TOUR-SKI-CARD-004
const tour = {
place_id: 21,
name: 'Up to the hut',
tour_type: 'ski',
ski_discipline: 'cross_country',
ski_technique: 'skating',
} as TourListItem;
function classification(overrides: Partial<SkiClassification> = {}): SkiClassification {
return {
tour,
canEdit: true,
saving: false,
draft: null,
setDraft: vi.fn(),
canSave: false,
open: vi.fn(),
close: vi.fn(),
save: vi.fn(),
...overrides,
};
}
describe('SkiTrackCard (SKI-02)', () => {
it('FE-TOUR-SKI-CARD-001: names the discipline, the technique as a badge, and the ski time as not worked out', () => {
render(<SkiTrackCard ski={classification()} />);
expect(screen.getByTestId('ski-track-discipline')).toHaveTextContent('tours.ski.discipline.cross_country');
expect(screen.getByTestId('ski-track-technique')).toHaveTextContent('tours.ski.technique.skating');
expect(screen.getByText('tours.ski.time')).toBeInTheDocument();
expect(screen.getByText('tours.ski.timeUnknown')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'tours.ski.infoLabel' })).toHaveAccessibleDescription('tours.ski.info');
expect(screen.queryByRole('dialog')).toBeNull();
});
it('FE-TOUR-SKI-CARD-002: Change opens the dialog, and is dimmed for someone who may not edit', () => {
const ski = classification();
const { rerender } = render(<SkiTrackCard ski={ski} />);
fireEvent.click(screen.getByRole('button', { name: 'common.change' }));
expect(ski.open).toHaveBeenCalledOnce();
rerender(<SkiTrackCard ski={classification({ canEdit: false })} />);
expect(screen.getByRole('button', { name: 'common.change' })).toBeDisabled();
});
it('FE-TOUR-SKI-CARD-003: the dialog saves only a complete change, and Cancel closes it', () => {
const ski = classification({ draft: { discipline: 'cross_country', technique: 'skating' } });
const { rerender } = render(<SkiTrackCard ski={ski} />);
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('tours.ski.classifyTitle')).toBeInTheDocument();
expect(screen.getByTestId('ski-classify-save')).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: 'tours.ski.discipline.touring' }));
expect(ski.setDraft).toHaveBeenCalledWith({ discipline: 'touring', technique: 'skating' });
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }));
expect(ski.close).toHaveBeenCalled();
const ready = classification({ draft: { discipline: 'touring', technique: null }, canSave: true });
rerender(<SkiTrackCard ski={ready} />);
fireEvent.click(screen.getByTestId('ski-classify-save'));
expect(ready.save).toHaveBeenCalledOnce();
});
it('FE-TOUR-SKI-CARD-004: shows nothing while no ski track is in view, and asks for a discipline a track lacks', () => {
const { container, rerender } = render(<SkiTrackCard ski={classification({ tour: null })} />);
expect(container).toBeEmptyDOMElement();
const unclassified = { ...tour, ski_discipline: null, ski_technique: null };
rerender(<SkiTrackCard ski={classification({ tour: unclassified })} />);
expect(screen.getByTestId('ski-track-discipline')).toHaveTextContent('tours.ski.chooseDiscipline');
expect(screen.queryByTestId('ski-track-technique')).toBeNull();
});
});
@@ -0,0 +1,65 @@
import { MountainSnow, Pencil } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { fs } from '../../shared/DialogShell';
import { FigureBadge } from '../paddle/FigureBadge';
import { CardAction, InfoDot, RailCard, RailFacts } from '../tourParts';
import { SkiClassifyDialog } from './SkiClassifyDialog';
import { SKI_DISCIPLINE_ICON, skiDisciplineOf, skiTechniqueOf } from './skiMarks';
import type { SkiClassification } from './useSkiClassification';
/**
* SKI-02: a ski track in the planner's rail: its discipline on a tinted tile,
* a cross-country technique as a badge beside it, and the ski time said as
* not worked out rather than left blank or filled with a hike's estimate. The
* classification changes in a dialog; a viewer who may not edit sees the
* action dimmed. Distance and heights are the inspector's below, as recorded.
*/
export function SkiTrackCard({ ski }: { ski: SkiClassification }) {
const { t } = useTranslation();
const { tour } = ski;
if (!tour) return null;
const discipline = skiDisciplineOf(tour);
const technique = skiTechniqueOf(tour);
const Icon = discipline ? SKI_DISCIPLINE_ICON[discipline] : MountainSnow;
return (
<>
<RailCard
icon={<MountainSnow size={14} strokeWidth={2} />}
title={t('tours.ski.title')}
aside={<InfoDot label={t('tours.ski.infoLabel')} description={t('tours.ski.info')} />}
>
<div data-testid="ski-track-card" className="flex flex-col gap-3">
<div className="flex min-w-0 items-center gap-2.5">
<span
aria-hidden="true"
className="grid h-8 w-8 flex-none place-items-center rounded-full bg-info-soft text-info ring-1 ring-inset ring-edge-faint"
>
<Icon size={15} strokeWidth={2} />
</span>
<span className="flex min-w-0 flex-1 flex-col items-start gap-1">
<span
data-testid="ski-track-discipline"
className={`block max-w-full truncate font-semibold ${discipline ? 'text-content' : 'text-content-muted'}`}
style={fs(13, 'body')}
>
{discipline ? t(`tours.ski.discipline.${discipline}`) : t('tours.ski.chooseDiscipline')}
</span>
{technique && (
<FigureBadge
label={t('tours.ski.technique')}
value={t(`tours.ski.technique.${technique}`)}
testId="ski-track-technique"
/>
)}
</span>
<CardAction icon={<Pencil size={12} strokeWidth={2.2} />} onClick={ski.open} disabled={!ski.canEdit}>
{t('common.change')}
</CardAction>
</div>
<RailFacts facts={[{ label: t('tours.ski.time'), value: t('tours.ski.timeUnknown') }]} />
</div>
</RailCard>
<SkiClassifyDialog ski={ski} />
</>
);
}
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { NO_SKI_CHOICE, skiChoiceChanged, skiChoiceMissing, skiChoiceOf, skiFieldsOf } from './skiChoice';
// FE-TOUR-SKI-CHOICE-001 to FE-TOUR-SKI-CHOICE-003
describe('ski choice (SKI-02)', () => {
it('FE-TOUR-SKI-CHOICE-001: asks for a discipline first, then for a cross-country technique, Unknown counting as one', () => {
expect(skiChoiceMissing(NO_SKI_CHOICE)).toBe('discipline');
expect(skiChoiceMissing({ discipline: 'cross_country', technique: null })).toBe('technique');
expect(skiChoiceMissing({ discipline: 'cross_country', technique: 'unknown' })).toBeNull();
expect(skiChoiceMissing({ discipline: 'alpine', technique: null })).toBeNull();
});
it('FE-TOUR-SKI-CHOICE-002: sends a technique for cross-country only, and nothing while incomplete', () => {
expect(skiFieldsOf(NO_SKI_CHOICE)).toBeNull();
expect(skiFieldsOf({ discipline: 'cross_country', technique: null })).toBeNull();
expect(skiFieldsOf({ discipline: 'cross_country', technique: 'classic' })).toEqual({
ski_discipline: 'cross_country',
ski_technique: 'classic',
});
// A technique tried on cross-country and left behind stays out of an alpine track.
expect(skiFieldsOf({ discipline: 'alpine', technique: 'classic' })).toEqual({ ski_discipline: 'alpine' });
});
it('FE-TOUR-SKI-CHOICE-003: opens from the saved track, and counts the technique as a change for cross-country only', () => {
const saved = skiChoiceOf({ ski_discipline: 'cross_country', ski_technique: 'skating' });
expect(saved).toEqual({ discipline: 'cross_country', technique: 'skating' });
expect(skiChoiceOf({})).toEqual(NO_SKI_CHOICE);
expect(skiChoiceChanged(saved, saved)).toBe(false);
expect(skiChoiceChanged({ ...saved, technique: 'classic' }, saved)).toBe(true);
expect(skiChoiceChanged({ discipline: 'touring', technique: 'skating' }, saved)).toBe(true);
const alpine = { discipline: 'alpine' as const, technique: null };
expect(skiChoiceChanged({ discipline: 'alpine', technique: 'classic' }, alpine)).toBe(false);
});
});
@@ -0,0 +1,40 @@
import type { SkiDiscipline, SkiTechnique, TourListItem, TourSkiClassificationRequest } from '@trek/shared';
/**
* SKI-02: a ski classification as a form holds it: nothing chosen until the
* skier picks, and a technique remembered while another discipline is tried,
* though only cross-country sends one.
*/
export interface SkiChoice {
discipline: SkiDiscipline | null;
technique: SkiTechnique | null;
}
export const NO_SKI_CHOICE: SkiChoice = { discipline: null, technique: null };
/** What a choice still lacks before it can be sent: the discipline, or a cross-country technique. */
export function skiChoiceMissing(choice: SkiChoice): 'discipline' | 'technique' | null {
if (choice.discipline === null) return 'discipline';
return choice.discipline === 'cross_country' && choice.technique === null ? 'technique' : null;
}
/** The fields a complete choice is sent as; null while something is missing. */
export function skiFieldsOf(
choice: SkiChoice,
): Pick<TourSkiClassificationRequest, 'ski_discipline' | 'ski_technique'> | null {
if (choice.discipline === null || skiChoiceMissing(choice)) return null;
return choice.discipline === 'cross_country'
? { ski_discipline: choice.discipline, ski_technique: choice.technique }
: { ski_discipline: choice.discipline };
}
/** The choice a saved ski track opens with. */
export function skiChoiceOf(tour: Partial<Pick<TourListItem, 'ski_discipline' | 'ski_technique'>>): SkiChoice {
return { discipline: tour.ski_discipline ?? null, technique: tour.ski_technique ?? null };
}
/** Whether a choice says something else than the saved track does; the technique counts for cross-country only. */
export function skiChoiceChanged(choice: SkiChoice, saved: SkiChoice): boolean {
if (choice.discipline !== saved.discipline) return true;
return choice.discipline === 'cross_country' && choice.technique !== saved.technique;
}
@@ -0,0 +1,71 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { TourDayRowAvatar } from '../TourDayRowFacts';
import { TourGradeTile } from '../tourParts';
import { SkiCornerMark, SkiHeadPill, skiClassificationText, skiDisciplineOf, skiTechniqueOf } from './skiMarks';
// FE-TOUR-SKI-MARK-001 to FE-TOUR-SKI-MARK-005
const t = (key: string) => key;
const skiTrack = {
tour_type: 'ski' as const,
max_hiking_difficulty: null,
wanderer_ref: null,
has_waypoints: false,
ski_discipline: 'cross_country' as const,
ski_technique: 'skating' as const,
};
describe('ski marks (SKI-02)', () => {
it('FE-TOUR-SKI-MARK-001: reads the discipline of a ski track only, and the technique of cross-country only', () => {
expect(skiDisciplineOf(skiTrack)).toBe('cross_country');
expect(skiTechniqueOf(skiTrack)).toBe('skating');
expect(skiTechniqueOf({ ...skiTrack, ski_discipline: 'alpine' })).toBeNull();
expect(skiDisciplineOf({ tour_type: 'hike', ski_discipline: 'alpine' })).toBeNull();
expect(skiDisciplineOf({ tour_type: 'ski' })).toBeNull();
expect(skiClassificationText(skiTrack, t)).toBe(
'tours.ski.discipline.cross_country, tours.ski.technique: tours.ski.technique.skating'
);
expect(skiClassificationText({ tour_type: 'ski', ski_discipline: 'touring' }, t)).toBe(
'tours.ski.discipline.touring'
);
expect(skiClassificationText({ tour_type: 'run' }, t)).toBeNull();
});
it('FE-TOUR-SKI-MARK-002: the corner mark names the classification, and any other tour shows none', () => {
const { container, rerender } = render(<SkiCornerMark tour={{ tour_type: 'hike' }} />);
expect(container).toBeEmptyDOMElement();
rerender(<SkiCornerMark tour={{ tour_type: 'ski', ski_discipline: 'snowboard' }} size={20} />);
const mark = screen.getByRole('img', { name: 'tours.ski.discipline.snowboard' });
expect(mark).toHaveAttribute('data-testid', 'tour-ski-mark');
expect(mark).toHaveStyle({ width: '20px', height: '20px' });
});
it('FE-TOUR-SKI-MARK-003: the head pill names the discipline with a cross-country technique inside it', () => {
const { container, rerender } = render(<SkiHeadPill tour={{ tour_type: 'bike' }} className="head-pill" />);
expect(container).toBeEmptyDOMElement();
rerender(<SkiHeadPill tour={skiTrack} className="head-pill" />);
const pill = screen.getByTestId('tour-detail-ski');
expect(pill).toHaveClass('head-pill');
expect(pill).toHaveTextContent('tours.ski.discipline.cross_country');
expect(screen.getByTestId('tour-detail-ski-technique')).toHaveTextContent('tours.ski.technique.skating');
rerender(<SkiHeadPill tour={{ ...skiTrack, ski_discipline: 'alpine' }} className="head-pill" />);
expect(screen.queryByTestId('tour-detail-ski-technique')).toBeNull();
});
it('FE-TOUR-SKI-MARK-004: a tour list tile shows the ski icon with the discipline at its corner', () => {
render(<TourGradeTile tour={skiTrack} />);
expect(screen.getByLabelText('tourTypes.ski')).toBeInTheDocument();
expect(screen.getByTestId('tour-ski-mark')).toBeInTheDocument();
});
it('FE-TOUR-SKI-MARK-005: the day plan tile names the type and carries the mark, a ski track without one stays plain', () => {
const { container, rerender } = render(
<TourDayRowAvatar tour={{ ...skiTrack, ski_discipline: null, ski_technique: null }} size={32} />
);
expect(container.firstElementChild).toHaveAttribute('data-testid', 'plan-tour-icon');
expect(screen.getByRole('img', { name: 'tourTypes.ski' })).toBeInTheDocument();
rerender(<TourDayRowAvatar tour={skiTrack} size={50} />);
expect(screen.getByTestId('tour-ski-mark')).toHaveStyle({ width: '22px' });
});
});
@@ -0,0 +1,82 @@
import type { SkiDiscipline, SkiTechnique, TourListItem } from '@trek/shared';
import { CableCar, Snowflake, Spline, TrendingUp, type LucideIcon } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { TourCornerMark } from '../TourCornerMark';
/**
* SKI-02: the icon each discipline goes by: a lift for alpine skiing, a
* snowflake for the board, a winding trail for cross-country and the climb on
* skins for ski touring.
*/
export const SKI_DISCIPLINE_ICON: Record<SkiDiscipline, LucideIcon> = {
alpine: CableCar,
snowboard: Snowflake,
cross_country: Spline,
touring: TrendingUp,
};
type SkiTour = Partial<Pick<TourListItem, 'tour_type' | 'ski_discipline' | 'ski_technique'>>;
/** The discipline of a ski track, or null for any other tour and a ski track listed before it had one. */
export function skiDisciplineOf(tour: SkiTour): SkiDiscipline | null {
return tour.tour_type === 'ski' ? (tour.ski_discipline ?? null) : null;
}
/** A cross-country track's technique; null for every other discipline and type. */
export function skiTechniqueOf(tour: SkiTour): SkiTechnique | null {
return skiDisciplineOf(tour) === 'cross_country' ? (tour.ski_technique ?? null) : null;
}
/** The classification in words, the discipline and a cross-country technique, as one tooltip reads it. */
export function skiClassificationText(tour: SkiTour, t: (key: string) => string): string | null {
const discipline = skiDisciplineOf(tour);
if (!discipline) return null;
const technique = skiTechniqueOf(tour);
const name = t(`tours.ski.discipline.${discipline}`);
return technique ? `${name}, ${t('tours.ski.technique')}: ${t(`tours.ski.technique.${technique}`)}` : name;
}
/**
* SKI-02: a ski track's discipline on the corner of its round tile, a frosted
* badge carrying the discipline's icon, the way a hike carries its winter
* label there. Its tooltip names the discipline and a cross-country technique.
*/
export function SkiCornerMark({ tour, size = 15 }: { tour: SkiTour; size?: number }) {
const { t } = useTranslation();
const discipline = skiDisciplineOf(tour);
const label = skiClassificationText(tour, t);
if (!discipline || !label) return null;
return <TourCornerMark icon={SKI_DISCIPLINE_ICON[discipline]} label={label} size={size} testId="tour-ski-mark" />;
}
/**
* SKI-02: a ski track's discipline as a pill of a tour's head, beside its
* type, a cross-country technique as a badge inside it. The tooltip says what
* TREK does with the track and what it does not say.
*/
export function SkiHeadPill({ tour, className }: { tour: SkiTour; className: string }) {
const { t } = useTranslation();
const discipline = skiDisciplineOf(tour);
if (!discipline) return null;
const technique = skiTechniqueOf(tour);
const Icon = SKI_DISCIPLINE_ICON[discipline];
return (
<Tooltip label={t('tours.ski.info')}>
<span data-testid="tour-detail-ski" className={className} style={fs(10.5)}>
<Icon size={11} strokeWidth={2} className="flex-none text-info" aria-hidden="true" />
{t(`tours.ski.discipline.${discipline}`)}
{technique && (
<span
data-testid="tour-detail-ski-technique"
className="ms-0.5 inline-flex h-[16px] items-center rounded-full bg-surface-secondary px-1.5 text-content"
>
<span className="sr-only">{t('tours.ski.technique')}:</span>
{t(`tours.ski.technique.${technique}`)}
</span>
)}
</span>
</Tooltip>
);
}
@@ -0,0 +1,141 @@
import { act, renderHook } from '@testing-library/react';
import { TOUR_REVISION_CONFLICT, type TourListItem } from '@trek/shared';
import { AxiosError, AxiosHeaders } from 'axios';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { tourRepo } from '../../../repo/tourRepo';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { useSkiClassification } from './useSkiClassification';
// FE-TOUR-SKI-HOOK-001 to FE-TOUR-SKI-HOOK-005
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { classifySki: vi.fn() } }));
const ski: TourListItem = {
place_id: 21,
name: 'Up to the hut',
tour_type: 'ski',
distance: 12,
elevation_gain: 980,
elevation_loss: 980,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: null,
planned: true,
caution: false,
has_waypoints: false,
revision: 3,
ski_discipline: 'cross_country',
ski_technique: 'unknown',
};
const viewing = (tour: TourListItem) =>
({ mode: { type: 'view-gpx', placeId: tour.place_id, tour } }) as Pick<TourPlannerController, 'mode'>;
function setup(overrides: { tour?: TourListItem; tours?: TourListItem[]; canEdit?: boolean } = {}) {
const onSaved = vi.fn();
const tour = overrides.tour ?? ski;
const hook = renderHook(() =>
useSkiClassification({
tripId: 5,
planner: viewing(tour),
tours: overrides.tours ?? [tour],
canEdit: overrides.canEdit ?? true,
onSaved,
})
);
return { ...hook, onSaved };
}
beforeEach(() => {
vi.clearAllMocks();
});
describe('useSkiClassification (SKI-02)', () => {
it('FE-TOUR-SKI-HOOK-001: knows the ski track in view, as the trip list has it now, and nothing for another tour', () => {
const newer = { ...ski, ski_technique: 'skating' as const, revision: 4 };
expect(setup({ tours: [newer] }).result.current.tour).toBe(newer);
expect(setup({ tour: { ...ski, tour_type: 'hike' } }).result.current.tour).toBeNull();
const planned = renderHook(() =>
useSkiClassification({
tripId: 5,
planner: { mode: { type: 'edit-saved', placeId: 21 } } as Pick<TourPlannerController, 'mode'>,
tours: [ski],
canEdit: true,
onSaved: vi.fn(),
})
);
expect(planned.result.current.tour).toBeNull();
});
it('FE-TOUR-SKI-HOOK-002: the dialog opens with the saved choice, saves only a complete change, and closes without one', () => {
const { result } = setup();
act(() => result.current.open());
expect(result.current.draft).toEqual({ discipline: 'cross_country', technique: 'unknown' });
expect(result.current.canSave).toBe(false);
act(() => result.current.setDraft({ discipline: 'cross_country', technique: null }));
expect(result.current.canSave).toBe(false);
act(() => result.current.setDraft({ discipline: 'touring', technique: null }));
expect(result.current.canSave).toBe(true);
act(() => result.current.close());
expect(result.current.draft).toBeNull();
expect(tourRepo.classifySki).not.toHaveBeenCalled();
});
it('FE-TOUR-SKI-HOOK-003: saves the classification with the revision it was made at, hands the tour on and closes', async () => {
const saved = { ...ski, ski_technique: 'classic' as const, revision: 4 };
vi.mocked(tourRepo.classifySki).mockResolvedValue({ tour: saved, waypoints: [] });
const { result, onSaved } = setup();
act(() => result.current.open());
act(() => result.current.setDraft({ discipline: 'cross_country', technique: 'classic' }));
await act(async () => {
await result.current.save();
});
expect(tourRepo.classifySki).toHaveBeenCalledWith(5, 21, {
ski_discipline: 'cross_country',
ski_technique: 'classic',
expected_revision: 3,
});
expect(onSaved).toHaveBeenCalledWith(saved);
expect(result.current.draft).toBeNull();
expect(toast.success).toHaveBeenCalledWith('tours.ski.saved');
});
it('FE-TOUR-SKI-HOOK-004: a refused save keeps the dialog and says why, a tour saved elsewhere as such', async () => {
const conflict = new AxiosError('conflict', '409', undefined, undefined, {
status: 409,
statusText: 'Conflict',
headers: {},
config: { headers: new AxiosHeaders() },
data: { error: 'changed', code: TOUR_REVISION_CONFLICT, revision: 5 },
});
vi.mocked(tourRepo.classifySki).mockRejectedValueOnce(conflict).mockRejectedValueOnce(new Error('offline'));
const { result, onSaved } = setup();
act(() => result.current.open());
act(() => result.current.setDraft({ discipline: 'alpine', technique: null }));
await act(async () => {
await result.current.save();
});
expect(toast.error).toHaveBeenLastCalledWith('tours.revision.conflictTitle');
await act(async () => {
await result.current.save();
});
expect(toast.error).toHaveBeenLastCalledWith('tours.ski.saveFailed');
expect(onSaved).not.toHaveBeenCalled();
expect(result.current.draft).toEqual({ discipline: 'alpine', technique: null });
expect(result.current.saving).toBe(false);
});
it('FE-TOUR-SKI-HOOK-005: someone who may not edit opens nothing and saves nothing', async () => {
const { result } = setup({ canEdit: false });
act(() => result.current.open());
expect(result.current.draft).toBeNull();
await act(async () => {
await result.current.save();
});
expect(tourRepo.classifySki).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,80 @@
import type { TourListItem } from '@trek/shared';
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from '../../../i18n';
import { tourRepo } from '../../../repo/tourRepo';
import { useToast } from '../../shared/Toast';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { revisionConflictOf } from '../planner/useTourRevisionGuard';
import { NO_SKI_CHOICE, skiChoiceChanged, skiChoiceOf, skiFieldsOf, type SkiChoice } from './skiChoice';
interface SkiClassificationOptions {
tripId: number;
planner: Pick<TourPlannerController, 'mode'>;
/** The trip's tours, which follow every save, so the card shows the classification as it stands. */
tours: readonly TourListItem[];
canEdit: boolean;
/** The tour as it stands after the save, for the lists and the map to take it in. */
onSaved: (tour: TourListItem) => Promise<void> | void;
}
/**
* SKI-02: the ski track open in the planner and its classification, changed
* on purpose in a dialog: the discipline, and for cross-country the
* technique. Saving goes through the offline core and changes nothing of the
* track; closing the dialog changes nothing at all.
*/
export function useSkiClassification({ tripId, planner, tours, canEdit, onSaved }: SkiClassificationOptions) {
const { t } = useTranslation();
const toast = useToast();
const [draft, setDraft] = useState<SkiChoice | null>(null);
const [saving, setSaving] = useState(false);
const viewed = planner.mode.type === 'view-gpx' ? planner.mode.tour : null;
const tour =
viewed?.tour_type === 'ski' ? (tours.find((item) => item.place_id === viewed.place_id) ?? viewed) : null;
const saved = useMemo(() => (tour ? skiChoiceOf(tour) : NO_SKI_CHOICE), [tour]);
const open = useCallback(() => {
if (tour && canEdit) setDraft(saved);
}, [canEdit, saved, tour]);
const close = useCallback(() => {
if (!saving) setDraft(null);
}, [saving]);
const save = useCallback(async () => {
const fields = draft ? skiFieldsOf(draft) : null;
if (!tour || !fields || saving || !canEdit) return;
setSaving(true);
try {
const result = await tourRepo.classifySki(tripId, tour.place_id, {
...fields,
expected_revision: tour.revision ?? null,
});
await onSaved(result.tour);
setDraft(null);
toast.success(t('tours.ski.saved'));
} catch (error: unknown) {
// Saved elsewhere meanwhile: the list brings the newer classification, the dialog stays to choose again.
toast.error(t(revisionConflictOf(error) === null ? 'tours.ski.saveFailed' : 'tours.revision.conflictTitle'));
} finally {
setSaving(false);
}
}, [canEdit, draft, onSaved, saving, t, toast, tour, tripId]);
return {
/** The ski track in view, or null while none is. */
tour,
canEdit,
saving,
/** The choice in the open dialog; null while it is closed. */
draft,
setDraft,
/** The dialog's choice is complete and says something else than the saved one. */
canSave: draft !== null && skiFieldsOf(draft) !== null && skiChoiceChanged(draft, saved),
open,
close,
save,
};
}
export type SkiClassification = ReturnType<typeof useSkiClassification>;
@@ -1,4 +1,4 @@
import { Activity, Bike, Footprints, Mountain, Route, Waves, type LucideIcon } from 'lucide-react';
import { Activity, Bike, Footprints, Mountain, MountainSnow, Route, Waves, type LucideIcon } from 'lucide-react';
export function tourDayRowIcon(type: string): LucideIcon {
switch (type) {
@@ -12,6 +12,8 @@ export function tourDayRowIcon(type: string): LucideIcon {
return Activity;
case 'kayak':
return Waves;
case 'ski':
return MountainSnow;
default:
return Route;
}
@@ -14,4 +14,13 @@ describe('tourMapLayersFor (MAP-01)', () => {
expect(baseLayerFor(tourMapLayersFor('hike', { hikingMap: true }), 'hiking')).toBe('hiking');
expect(baseLayerFor(tourMapLayersFor('hike'), 'hiking')).toBe('default');
});
it('SKI-02: a ski track gets the standard map, topo and satellite, and no via ferratas', () => {
expect(tourMapLayersFor('ski')).toEqual({
bases: ['default', 'topo', 'satellite'],
defaultBase: 'default',
overlays: [],
});
expect(baseLayerFor(tourMapLayersFor('ski'), 'hiking')).toBe('default');
});
});
@@ -18,6 +18,7 @@ import type { TourBaseLayer } from '../Map/MapLayerSwitcher';
* allows it) and satellite. No ferratas, a bike tour climbs none.
* - Run (RUN-01): the standard map, topo for the climbs and satellite. No ferratas, a run
* takes none.
* - Ski (SKI-02): the standard map, topo for the slopes and satellite. No ferratas.
* - Paddle: the standard map, whose water reads clearly, and satellite. No topo and
* no ferratas, those are hiking maps. Seamarks as an overlay.
* - Any other type (switched off, or one this build does not plan): the standard map
@@ -42,6 +43,7 @@ const HIKE_WITH_HIKING_MAP: TourMapLayerSet = { ...HIKE, bases: ['default', 'top
const BIKE: TourMapLayerSet = { bases: ['default', 'topo', 'satellite'], defaultBase: 'default', overlays: [] };
const BIKE_WITH_CYCLE_MAP: TourMapLayerSet = { ...BIKE, bases: ['default', 'topo', 'cycle', 'satellite'] };
const RUN: TourMapLayerSet = { bases: ['default', 'topo', 'satellite'], defaultBase: 'default', overlays: [] };
const SKI: TourMapLayerSet = { bases: ['default', 'topo', 'satellite'], defaultBase: 'default', overlays: [] };
const PADDLE: TourMapLayerSet = { bases: ['default', 'satellite'], defaultBase: 'default', overlays: ['seamarks'] };
const OTHER: TourMapLayerSet = { bases: ['default', 'satellite'], defaultBase: 'default', overlays: [] };
@@ -53,6 +55,7 @@ export function tourMapLayersFor(
if (type === 'hike') return hikingMap ? HIKE_WITH_HIKING_MAP : HIKE;
if (type === 'bike') return cycleMap ? BIKE_WITH_CYCLE_MAP : BIKE;
if (type === 'run') return RUN;
if (type === 'ski') return SKI;
if (type === 'kayak') return PADDLE;
return OTHER;
}
+7 -4
View File
@@ -17,19 +17,21 @@ import type { TourDayAssignment } from './useTourDayAssignment'
import { useRailFold } from './railFold'
import { tourDayRowIcon } from './tourDayRowIcon'
import { WinterCornerMark } from './winter/winterMarks'
import { SkiCornerMark } from './ski/skiMarks'
/**
* A tour's tile in a list: its T-scale grade in place of the mountain, named in
* full by its tooltip (a GPX import carries no grade and keeps the mountain),
* where it came from, when that was not TREK, as a small badge over its foot,
* and a hike's winter label (HIK-01) as a snowflake at its top corner.
* and a hike's winter label (HIK-01) or a ski track's discipline (SKI-02) at
* its top corner.
*/
export function TourGradeTile({ tour }: { tour: Pick<TourListItem, 'max_hiking_difficulty' | 'wanderer_ref' | 'has_waypoints'> & Partial<Pick<TourListItem, 'tour_type' | 'winter_activity'>> }) {
export function TourGradeTile({ tour }: { tour: Pick<TourListItem, 'max_hiking_difficulty' | 'wanderer_ref' | 'has_waypoints'> & Partial<Pick<TourListItem, 'tour_type' | 'winter_activity' | 'ski_discipline' | 'ski_technique'>> }) {
const { t } = useTranslation()
const level = tour.max_hiking_difficulty
const source = tourSource(tour)
// A tour without a grade (a paddle or a bike tour, a run, an import) names its type instead; a hike keeps the mountain.
const type = tour.tour_type === 'kayak' || tour.tour_type === 'bike' || tour.tour_type === 'run' ? tour.tour_type : 'hike'
// A tour without a grade (a paddle or a bike tour, a run, a ski track, an import) names its type instead; a hike keeps the mountain.
const type = tour.tour_type && ['kayak', 'bike', 'run', 'ski'].includes(tour.tour_type) ? tour.tour_type : 'hike'
const label = level != null ? t(`tours.planner.difficulty.t${level}`) : type !== 'hike' ? t(`tourTypes.${type}`) : null
return (
<span className="relative flex-none">
@@ -39,6 +41,7 @@ export function TourGradeTile({ tour }: { tour: Pick<TourListItem, 'max_hiking_d
</span>
</Tooltip>
<WinterCornerMark activity={tour.winter_activity} />
<SkiCornerMark tour={tour} />
{source !== 'trek' && (
<span className="absolute inset-x-0 -bottom-1 flex justify-center">
<Tooltip label={hikeSourceBadgeLabel(tour, t)}>
@@ -3,6 +3,7 @@ import { Footprints, Snowflake, type LucideIcon } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { TourCornerMark } from '../TourCornerMark';
/** 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> = {
@@ -25,21 +26,13 @@ export function WinterCornerMark({
}) {
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>
<TourCornerMark
icon={Snowflake}
label={t(`tours.winter.activity.${activity}`)}
size={size}
testId="tour-winter-mark"
/>
);
}
@@ -547,6 +547,27 @@ describe('TourPlannerMapStage', () => {
expect(tourPlanner.undo).not.toHaveBeenCalled();
});
it('FE-PAGE-TOURSTAGE-020: an imported ski track flies in 3D with its grade colours and its own maps, and the switch changes nothing of it (SKI-02)', async () => {
const samples = [0, 1, 2].map((i) => ({
distanceMeters: i * 100,
elevationMeters: 1200 + i * 40,
lat: 47 + i / 1000,
lng: 11,
}));
const tourPlanner = planner('view-gpx', {
mode: { type: 'view-gpx', placeId: 9, tour: { place_id: 9, tour_type: 'ski', ski_discipline: 'touring' } },
readOnlyGpxAnalysis: { distanceIndexedProfileSamples: samples },
});
renderStage(tourPlanner);
expect(stage.flatMap.props?.tourLayers?.bases).toEqual(['default', 'topo', 'satellite']);
expect(stage.flatMap.props?.ferrataLayer).toBeUndefined();
await openTerrain();
expect(screen.getByRole('button', { name: 'Grade mix' })).toBeInTheDocument();
// Seeing the track in 3D is a view: no point is set and no step of the planner is taken.
expect(tourPlanner.addWaypoint).not.toHaveBeenCalled();
expect(tourPlanner.undo).not.toHaveBeenCalled();
});
describe('map layers per tour type (P2B-06/07, PAD-04, PAD-10)', () => {
const flat = () => stage.flatMap.props as MapViewProps;
@@ -5,6 +5,7 @@ import { usePaddleChecklist } from '../../components/Tours/paddle/usePaddleCheck
import { useTrackPaddlePlan } from '../../components/Tours/paddle/useTrackPaddlePlan';
import { useTourPlanner } from '../../components/Tours/planner/useTourPlanner';
import { useTourRevisionWatch } from '../../components/Tours/planner/useTourRevisionWatch';
import { useSkiClassification } from '../../components/Tours/ski/useSkiClassification';
import { useRecordedTrack } from '../../components/Tours/useRecordedTrack';
import { useTourGpxImport } from '../../components/Tours/useTourGpxImport';
import { useWandererBrowser } from '../../components/Tours/wanderer/useWandererBrowser';
@@ -145,6 +146,15 @@ export function useTourWorkspace(
onSaved: takeInSavedTour,
});
// SKI-02: the discipline and technique of the ski track in view, classified again without touching the track.
const skiClassification = useSkiClassification({
tripId,
planner: tourPlanner,
tours,
canEdit: canPlaceEdit,
onSaved: takeInSavedTour,
});
return {
tourPlanner,
viewImportedTour,
@@ -154,6 +164,7 @@ export function useTourWorkspace(
recordedTrack,
trackPaddlePlan,
paddleChecklist,
skiClassification,
tourDetailActions: useTourDetailActions(planner),
};
}
@@ -29,7 +29,7 @@ export function useTripPlannerPage() {
const canPlaceEdit = can('place_edit', trip)
const canDayEdit = can('day_edit', trip)
const { tourPlanner, viewImportedTour, tourGpxImport, wandererBrowser, tourConversion, recordedTrack, trackPaddlePlan, paddleChecklist, tourDetailActions } =
const { tourPlanner, viewImportedTour, tourGpxImport, wandererBrowser, tourConversion, recordedTrack, trackPaddlePlan, paddleChecklist, skiClassification, tourDetailActions } =
useTourWorkspace(planner, canPlaceEdit, canDayEdit)
const tourDetailOpenerRef = useRef<{ placeId: number; element: HTMLElement } | null>(null)
@@ -118,6 +118,7 @@ export function useTripPlannerPage() {
recordedTrack,
trackPaddlePlan,
paddleChecklist,
skiClassification,
tourDetailActions,
tourMap: { route: drawn.route,
routeWalking: drawn.walking,
+43 -1
View File
@@ -1,4 +1,4 @@
// FE-REPO-TOUR-001 to FE-REPO-TOUR-014, FE-REPO-TOUR-042, FE-REPO-TOUR-043
// FE-REPO-TOUR-001 to FE-REPO-TOUR-014, FE-REPO-TOUR-042 to FE-REPO-TOUR-045
// 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'
@@ -327,6 +327,48 @@ describe('tourRepo writes and the revision (P2C-03)', () => {
})
})
describe('tourRepo.classifySki (SKI-02)', () => {
const skiTour = (overrides: Partial<TourListItem> = {}) =>
tour({ tour_type: 'ski', has_waypoints: false, duration: null, max_hiking_difficulty: null, ski_discipline: 'cross_country', ski_technique: 'unknown', ...overrides })
it('FE-REPO-TOUR-044: online, the classification goes to the server with its revision and the answer is cached', async () => {
let sentBody: Record<string, unknown> | null = null
server.use(http.put('/api/trips/3/tours/40/ski', async ({ request }) => {
sentBody = await request.json() as Record<string, unknown>
return HttpResponse.json({ tour: skiTour({ ski_technique: 'skating', revision: 2 }), waypoints: [] })
}))
const result = await tourRepo.classifySki(3, 40, { ski_discipline: 'cross_country', ski_technique: 'skating', expected_revision: 1 })
expect(sentBody).toEqual({ ski_discipline: 'cross_country', ski_technique: 'skating', expected_revision: 1 })
expect(result.tour).toMatchObject({ ski_technique: 'skating', revision: 2 })
expect(await offlineDb.tours.get(40)).toMatchObject({ trip_id: 3, ski_technique: 'skating' })
})
it('FE-REPO-TOUR-045: offline, the cached track takes the classification at once and the write waits without its revision', async () => {
setOnline(false)
const provenance = {
origin: 'gpx' as const,
createdAt: '2027-02-01 09:00:00',
source: { kind: 'gpx' as const, fileName: 'hut.gpx' },
derivedFrom: null,
plannedOver: false,
editedLocally: null,
}
await offlineDb.tours.put({ ...skiTour(), trip_id: 3, provenance })
const result = await tourRepo.classifySki(3, 40, { ski_discipline: 'alpine', expected_revision: 1 })
expect(result.tour).toMatchObject({ ski_discipline: 'alpine', ski_technique: null, distance: 8 })
expect(result.provenance).toEqual(provenance)
expect(await offlineDb.tours.get(40)).toMatchObject({ ski_discipline: 'alpine', ski_technique: null })
const [mutation] = await offlineDb.mutationQueue.toArray()
expect(mutation).toMatchObject({ method: 'PUT', url: '/trips/3/tours/40/ski', resource: 'tours', entityId: 40 })
expect(mutation.body).toEqual({ ski_discipline: 'alpine' })
await expect(tourRepo.classifySki(3, 99, { ski_discipline: 'alpine' })).rejects.toThrow('Tour not available offline')
})
})
describe('replaceTripTours', () => {
it('FE-REPO-TOUR-013: keeps control points only while the route reads the same, drops what the server no longer lists, keeps unsynced tours', async () => {
await offlineDb.tours.bulkPut([
+34 -1
View File
@@ -1,8 +1,9 @@
import { computeTourMetrics, estimatePaddleTime, paddleCheckSummaryOf, paddleLayoutOfTrack, paddleMovingSeconds, recommendedPaddleSpeedKmh, type GeometryPoint, type TourObstacleCheck, type TourObstacleReviewsRequest, type TourPaddlePlanRequest, type TourCreateRequest, type TourCreateResponse, type TourDetailResponse, type TourGpxImportFields, type TourGpxPreviewResponse, type TourImportGpxResponse, type TourListItem, type TourWaypoint, walkStationFacts } from '@trek/shared'
import { computeTourMetrics, estimatePaddleTime, paddleCheckSummaryOf, paddleLayoutOfTrack, paddleMovingSeconds, recommendedPaddleSpeedKmh, type GeometryPoint, type TourObstacleCheck, type TourObstacleReviewsRequest, type TourPaddlePlanRequest, type TourSkiClassificationRequest, type TourCreateRequest, type TourCreateResponse, type TourDetailResponse, type TourGpxImportFields, type TourGpxPreviewResponse, type TourImportGpxResponse, type TourListItem, type TourWaypoint, walkStationFacts } from '@trek/shared'
import { toursApi } from '../api/client'
import { tourGpxApi } from '../api/tourGpx'
import { recordedTrackApi } from '../api/recordedTrack'
import { tourPaddlePlanApi } from '../api/tourPaddlePlan'
import { tourSkiApi } from '../api/tourSki'
import { tourCopiesApi } from '../api/tourCopies'
import { tourGpxExportApi } from '../api/tourGpxExport'
import { tourObstacleReviewsApi } from '../api/tourObstacleReviews'
@@ -281,6 +282,38 @@ export const tourRepo = {
return result
},
/**
* SKI-02: a ski track classified again. Made offline, the cached tour takes
* the classification at once and the write waits in the queue; nothing of
* the track changes either way.
*/
async classifySki(tripId: number | string, placeId: number, body: TourSkiClassificationRequest): Promise<TourDetailResponse> {
if (await mutationQueue.mustQueue('tours', placeId)) {
const existing = await offlineDb.tours.get(placeId)
if (!existing) throw new Error('Tour not available offline')
const tour: CachedTour = { ...existing, ski_discipline: body.ski_discipline, ski_technique: body.ski_technique ?? null }
await offlineDb.tours.put(tour)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/tours/${placeId}/ski`,
body: withoutRevisionBase(body),
resource: 'tours',
entityId: placeId,
})
mutationQueue.sendSoon()
return {
tour: toListItem(tour),
waypoints: existing.waypoints ?? [],
...(existing.provenance ? { provenance: existing.provenance } : {}),
}
}
const result = await tourSkiApi.classify(tripId, placeId, body)
await cacheTourAnswer(Number(tripId), result)
return result
},
/** Online only, like the import: the recorded track is kept on the server, not in the cache. */
async restoreRecorded(tripId: number | string, placeId: number): Promise<TourDetailResponse> {
const result = await recordedTrackApi.restore(tripId, placeId)
+21
View File
@@ -42,6 +42,8 @@ export class Tours {
winter_activity?: TToursWinterActivity | null;
run_profile?: TToursRunProfile | null;
run_pace_seconds_per_km?: number | null;
ski_discipline?: TToursSkiDiscipline | null;
ski_technique?: TToursSkiTechnique | null;
tourTypeRef!: Ref<TourTypes>;
tour_obstacle_reviews_collection = new Collection<TourObstacleReviews>(this);
tour_sources: Ref<TourSources> | null = null;
@@ -102,6 +104,23 @@ export const ToursRunProfile = {
export type TToursRunProfile = (typeof ToursRunProfile)[keyof typeof ToursRunProfile];
export const ToursSkiDiscipline = {
ALPINE: 'alpine',
SNOWBOARD: 'snowboard',
CROSS_COUNTRY: 'cross_country',
TOURING: 'touring',
} as const;
export type TToursSkiDiscipline = (typeof ToursSkiDiscipline)[keyof typeof ToursSkiDiscipline];
export const ToursSkiTechnique = {
CLASSIC: 'classic',
SKATING: 'skating',
UNKNOWN: 'unknown',
} as const;
export type TToursSkiTechnique = (typeof ToursSkiTechnique)[keyof typeof ToursSkiTechnique];
export const ToursSchema = defineEntity({
class: Tours,
repository: () => ToursRepository,
@@ -153,6 +172,8 @@ export const ToursSchema = defineEntity({
winter_activity: p.enum(() => ToursWinterActivity).nullable(),
run_profile: p.enum(() => ToursRunProfile).nullable(),
run_pace_seconds_per_km: p.integer().nullable(),
ski_discipline: p.enum(() => ToursSkiDiscipline).nullable(),
ski_technique: p.enum(() => ToursSkiTechnique).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(),
+2
View File
@@ -34,4 +34,6 @@ export interface ToursTable {
winter_activity: InsertOptional<string | null>;
run_profile: InsertOptional<string | null>;
run_pace_seconds_per_km: InsertOptional<number | null>;
ski_discipline: InsertOptional<string | null>;
ski_technique: InsertOptional<string | null>;
}
@@ -0,0 +1,23 @@
import { Migration } from '@mikro-orm/migrations';
/**
* SKI-02: a tour can be a ski track, imported from a GPX file and kept as it
* was recorded. The `ski` key has been in `tour_types`, disabled and without
* a routing profile, since every contract type was seeded; it is switched on
* here and keeps no routing profile, since TREK routes no ski track. The
* discipline and, for cross-country, the technique are columns of the tour,
* NULL on every other type, so no existing tour changes its meaning.
*/
export class Migration20200101045520_a_tour_can_be_a_ski_track extends Migration {
override name = 'Migration20200101045520_a_tour_can_be_a_ski_track';
override up(): void {
this.addSql(
"ALTER TABLE tours ADD COLUMN ski_discipline TEXT CHECK (ski_discipline IS NULL OR ski_discipline IN ('alpine', 'snowboard', 'cross_country', 'touring'))",
);
this.addSql(
"ALTER TABLE tours ADD COLUMN ski_technique TEXT CHECK (ski_technique IS NULL OR ski_technique IN ('classic', 'skating', 'unknown'))",
);
this.addSql("UPDATE tour_types SET enabled = 1 WHERE key = 'ski'");
}
}
@@ -8,6 +8,8 @@ import type {
PaddleLakeStyle,
PaddleWaterContext,
RunProfile,
SkiDiscipline,
SkiTechnique,
WinterActivity,
} from '@trek/shared';
@@ -42,6 +44,9 @@ export interface TourListRow {
/** RUN-01: a run's profile and the pace it is planned at, seconds per km; NULL on every other type. */
run_profile: string | null;
run_pace_seconds_per_km: number | null;
/** SKI-02: a ski track's discipline and, for cross-country, its technique; NULL on every other type. */
ski_discipline: string | null;
ski_technique: string | null;
boat_type: string | null;
water_context: string | null;
paddle_speed_kmh: number | null;
@@ -112,6 +117,8 @@ export interface TourRow {
winter_activity: string | null;
run_profile: string | null;
run_pace_seconds_per_km: number | null;
ski_discipline: string | null;
ski_technique: string | null;
}
const _tourRowKeys: AssertRowKeys<TourRow, Tours> = true;
@@ -134,6 +141,9 @@ export interface TourInsert {
/** RUN-01: a run's profile and its pace, seconds per km; left out, NULL. */
run_profile?: RunProfile | null;
run_pace_seconds_per_km?: number | null;
/** SKI-02: a ski track's discipline and technique; left out, NULL. */
ski_discipline?: SkiDiscipline | null;
ski_technique?: SkiTechnique | null;
/** A paddle tour's boat, water and chosen speed; left out, a hike's NULLs. */
boat_type?: PaddleBoatType | null;
water_context?: PaddleWaterContext | null;
@@ -153,6 +163,8 @@ export type TourUpdate = Omit<
| 'bike_profile'
| 'run_profile'
| 'run_pace_seconds_per_km'
| 'ski_discipline'
| 'ski_technique'
| 'boat_type'
| 'water_context'
| 'paddle_speed_kmh'
@@ -164,6 +176,8 @@ export type TourUpdate = Omit<
bike_profile: BikeProfile | null;
run_profile: RunProfile | null;
run_pace_seconds_per_km: number | null;
ski_discipline: SkiDiscipline | null;
ski_technique: SkiTechnique | null;
boat_type: PaddleBoatType | null;
water_context: PaddleWaterContext | null;
paddle_speed_kmh: number | null;
@@ -171,6 +185,12 @@ export type TourUpdate = Omit<
paddle_lake_style: PaddleLakeStyle | null;
};
/** SKI-02: a ski track classified again: its discipline and, for cross-country, its technique. */
export interface TourSkiUpdate {
ski_discipline: SkiDiscipline;
ski_technique: SkiTechnique | null;
}
/** A recorded track's paddle plan (PAD-03, PAD-07): its speeds, its marks and the time they make. */
export interface TourPaddlePlanUpdate {
paddle_speed_kmh: number | null;
@@ -269,6 +289,8 @@ export class ToursRepository extends TrekRepository<Tours> {
't.winter_activity as winter_activity',
't.run_profile as run_profile',
't.run_pace_seconds_per_km as run_pace_seconds_per_km',
't.ski_discipline as ski_discipline',
't.ski_technique as ski_technique',
't.boat_type as boat_type',
't.water_context as water_context',
't.paddle_speed_kmh as paddle_speed_kmh',
@@ -637,6 +659,8 @@ export class ToursRepository extends TrekRepository<Tours> {
winter_activity: input.winter_activity ?? null,
run_profile: input.run_profile ?? null,
run_pace_seconds_per_km: input.run_pace_seconds_per_km ?? null,
ski_discipline: input.ski_discipline ?? null,
ski_technique: input.ski_technique ?? null,
boat_type: input.boat_type ?? null,
water_context: input.water_context ?? null,
paddle_speed_kmh: input.paddle_speed_kmh ?? null,
@@ -724,6 +748,8 @@ export class ToursRepository extends TrekRepository<Tours> {
bike_profile: input.bike_profile,
run_profile: input.run_profile,
run_pace_seconds_per_km: input.run_pace_seconds_per_km,
ski_discipline: input.ski_discipline,
ski_technique: input.ski_technique,
boat_type: input.boat_type,
water_context: input.water_context,
paddle_speed_kmh: input.paddle_speed_kmh,
@@ -758,6 +784,21 @@ export class ToursRepository extends TrekRepository<Tours> {
return Number(result.numUpdatedRows ?? 0) > 0;
}
/**
* SKI-02: a ski track of the trip classified again, its discipline and
* technique only; the track and everything else stay as they are. Returns
* whether a row of that trip was updated.
*/
async updateSkiInTrip(trip_id: number, place_id: number, input: TourSkiUpdate): Promise<boolean> {
const result = await this.db()
.updateTable('tours')
.set({ ski_discipline: input.ski_discipline, ski_technique: input.ski_technique })
.where('place_id', '=', place_id)
.where('place_id', 'in', (eb) => eb.selectFrom('places').select('id').where('trip_id', '=', trip_id))
.executeTakeFirst();
return Number(result.numUpdatedRows ?? 0) > 0;
}
/**
* Keeps an import's recorded line before it is planned over for the first
* time: copies the place's line into the tour while the tour has no
@@ -0,0 +1,5 @@
import { tourSkiClassificationRequestSchema } from '@trek/shared';
import { createZodDto } from 'nestjs-zod';
export class TourSkiClassificationDto extends createZodDto(tourSkiClassificationRequestSchema) {}
@@ -1,4 +1,4 @@
import type { BikeProfile, PaddleBoatType, RunProfile } from '@trek/shared';
import type { BikeProfile, PaddleBoatType, RunProfile, SkiDiscipline } from '@trek/shared';
/** What GPX calls the activity in a track's <type>; a paddle tour says what it is paddled in. */
const PADDLE_ACTIVITY: Record<PaddleBoatType, string> = {
@@ -22,22 +22,33 @@ const RUN_ACTIVITY: Record<RunProfile, string> = {
trail: 'trail_running',
};
/** SKI-02: a ski track says how it was skied, in the same words; without a known discipline it is skiing. */
const SKI_ACTIVITY: Record<SkiDiscipline, string> = {
alpine: 'alpine_skiing',
snowboard: 'snowboarding',
cross_country: 'cross_country_skiing',
touring: 'backcountry_skiing',
};
/**
* The activity a tour's track names in GPX: a paddle tour its boat, a bike
* tour and a run their profile, a hike on snowshoes snowshoeing (HIK-01) and any other
* hike hiking; whatever is missing falls back to the plainest.
* tour and a run their profile, a ski track its discipline (SKI-02), 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;
run_profile?: string | null;
ski_discipline?: string | null;
winter_activity?: string | null;
}): string {
const { tour_type: type, boat_type: boat, bike_profile: bike } = tour;
if (type === 'kayak') return PADDLE_ACTIVITY[(boat ?? 'other') as PaddleBoatType] ?? PADDLE_ACTIVITY.other;
if (type === 'bike') return BIKE_ACTIVITY[(bike ?? 'touring') as BikeProfile] ?? BIKE_ACTIVITY.touring;
if (type === 'run') return RUN_ACTIVITY[(tour.run_profile ?? 'running') as RunProfile] ?? RUN_ACTIVITY.running;
if (type === 'ski') return SKI_ACTIVITY[tour.ski_discipline as SkiDiscipline] ?? 'skiing';
if (type !== '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';
@@ -0,0 +1,34 @@
import { ADDON_IDS } from '../../addons';
import { AddonGuard } from '../addons/addon.guard';
import { RequireAddon } from '../addons/require-addon.decorator';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { ResponseContract } from '../common/response-contract';
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
import { TourSkiClassificationDto } from './dto/tour-ski.dto';
import { TourSkiService } from './tour-ski.service';
import { Body, Controller, Headers, Param, Put, UseGuards } from '@nestjs/common';
import { tourDetailResponseSchema } from '@trek/shared';
/**
* SKI-02: PUT /api/trips/:tripId/tours/:placeId/ski classifies a ski track
* again, its discipline and cross-country technique only, as someone who may
* edit the trip's places. Answers the tour's detail, as the editor reads it.
*/
@Controller('api/trips/:tripId/tours/:placeId/ski')
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
@RequireAddon(ADDON_IDS.TOURS, 'Tours')
export class TourSkiController {
constructor(private readonly ski: TourSkiService) {}
@RequirePermission('place_edit')
@Put()
@ResponseContract(tourDetailResponseSchema)
async classify(
@Param('tripId') tripId: string,
@Param('placeId') placeId: string,
@Body() body: TourSkiClassificationDto,
@Headers('x-socket-id') socketId?: string,
) {
return await this.ski.classify(tripId, placeId, body, socketId);
}
}
+57
View File
@@ -0,0 +1,57 @@
import { Tours } from '../../db/entities/Tours.entity';
import type { ToursRepository } from '../../db/repositories/Tours.repository';
import { DomainError } from '../common/domain-error';
import { toRowId } from '../common/row-id';
import { UnitOfWork } from '../database/unit-of-work';
import { advanceTourRevision } from './tour-revision';
import { ToursService } from './tours.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable, NotFoundException } from '@nestjs/common';
import type { TourDetailResponse, TourSkiClassificationRequest } from '@trek/shared';
/**
* SKI-02: a ski track classified again after its import, when the discipline
* or the cross-country technique chosen then was not the right one. Only the
* classification changes: the recorded track, its measures, its days and its
* provenance stay as they are, and nothing is routed. A tour that is no ski
* track has no classification to change.
*/
@Injectable()
export class TourSkiService {
constructor(
private readonly uow: UnitOfWork,
private readonly tours: ToursService,
@InjectRepository(Tours) private readonly toursRepo: ToursRepository,
) {}
async classify(
tripId: string,
placeId: string,
input: TourSkiClassificationRequest,
socketId?: string,
): Promise<TourDetailResponse> {
const tid = toRowId(tripId);
const pid = toRowId(placeId);
if (tid === null || pid === null) throw new NotFoundException('Tour not found');
await this.uow.transactional(async () => {
const row = await this.toursRepo.findInTrip(tid, pid);
if (!row) throw new NotFoundException('Tour not found');
if (row.tour_type !== 'ski') {
throw new DomainError(400, 'Only a ski track has a ski classification', {
code: 'not_a_ski_track',
details: { code: 'not_a_ski_track' },
});
}
await advanceTourRevision(this.toursRepo, pid, input.expected_revision);
await this.toursRepo.updateSkiInTrip(tid, pid, {
ski_discipline: input.ski_discipline,
ski_technique: input.ski_technique ?? null,
});
});
const result = await this.tours.getTour(tripId, placeId);
this.tours.broadcastToursChanged(tripId, [pid], socketId);
return result;
}
}
+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, runs, bike tours and paddle tours planned or imported in the Tours addon): name, type (hike, walk for a city walk, run, 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 profile and pace of a run (run_profile: running for runnable, mostly paved ways in towns and parks, trail for forest tracks and natural paths up to mountain paths (T2); a routing preference, never a statement that a way can be run; run_pace_seconds_per_km, the pace the runners planned with, null while they entered none; its duration is the distance at that pace and nothing else, no climb, descent or difficulty is in it, and it is a plan, never a recorded time), the 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, runs, bike tours, paddle tours and ski tracks planned or imported in the Tours addon): name, type (hike, walk for a city walk, run, bike, kayak for a paddle tour, or ski for a ski track), 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 profile and pace of a run (run_profile: running for runnable, mostly paved ways in towns and parks, trail for forest tracks and natural paths up to mountain paths (T2); a routing preference, never a statement that a way can be run; run_pace_seconds_per_km, the pace the runners planned with, null while they entered none; its duration is the distance at that pace and nothing else, no climb, descent or difficulty is in it, and it is a plan, never a recorded time), the classification of a ski track (ski_discipline: alpine, snowboard, cross_country or touring for ski touring; ski_technique for cross-country only: classic, skating or unknown when the travellers did not know; a ski track is imported from a GPX file and kept as recorded, never routed, its distance and heights come from the file, it has no ski time worked out, and nothing in it says anything about snow, avalanches or whether a run or trail is open), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), for a city walk the number of its stations (station_count: its points without the shaping points and the way back of a loop), their stays as far as entered (station_stay_minutes) and how many have none (station_stays_unknown), whether a tour still waits for its way to be planned (route_pending: saved with its points only, so its distance and walking time stay null until someone opens it in the planner and saves it), 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),
+4
View File
@@ -28,6 +28,8 @@ import { TourPaddleCheckService } from './tour-paddle-check.service';
import { TourPaddlePlanService } from './tour-paddle-plan.service';
import { TourPoiSearchController } from './tour-poi-search.controller';
import { TourPoiSearchService } from './tour-poi-search.service';
import { TourSkiController } from './tour-ski.controller';
import { TourSkiService } from './tour-ski.service';
import { TourTrailGradeController } from './tour-trail-grade.controller';
import { TourTrailGradeService } from './tour-trail-grade.service';
import { TourVisitsController } from './tour-visits.controller';
@@ -88,6 +90,7 @@ import { Module } from '@nestjs/common';
controllers: [
TourTakeOverController,
TourPoiSearchController,
TourSkiController,
TourTrailGradeController,
TourVisitsController,
TourWalkImportController,
@@ -106,6 +109,7 @@ import { Module } from '@nestjs/common';
ToursWandererService,
TourLibraryService,
TourPoiSearchService,
TourSkiService,
TourTrailGradeService,
TourVisitsService,
TourWalkImportService,
+16 -2
View File
@@ -48,6 +48,8 @@ import {
type PaddleLakeStyle,
type PaddleWaterContext,
type RunProfile,
type SkiDiscipline,
type SkiTechnique,
type TourCreateRequest,
type TourCreateResponse,
type TourDetailResponse,
@@ -179,6 +181,8 @@ export class ToursService {
winter_activity: r.winter_activity as WinterActivity | null,
run_profile: r.run_profile as RunProfile | null,
run_pace_seconds_per_km: r.run_pace_seconds_per_km,
ski_discipline: r.ski_discipline as SkiDiscipline | null,
ski_technique: r.ski_technique as SkiTechnique | null,
boat_type: r.boat_type as PaddleBoatType | null,
water_context: r.water_context as PaddleWaterContext | null,
paddle_speed_kmh: r.paddle_speed_kmh,
@@ -222,6 +226,9 @@ export class ToursService {
bike_profile: input.tour_type === 'bike' ? (input.bike_profile ?? null) : null,
winter_activity: winterField(input),
...runFields(input),
// SKI-02: TREK plans no ski track, so a planned save is never one and takes its classification off.
ski_discipline: null,
ski_technique: null,
boat_type: input.boat_type ?? null,
water_context: input.water_context ?? null,
paddle_speed_kmh: input.paddle_speed_kmh ?? null,
@@ -521,6 +528,8 @@ export class ToursService {
bike_profile: row.bike_profile as BikeProfile | null,
run_profile: row.run_profile as RunProfile | null,
run_pace_seconds_per_km: row.run_pace_seconds_per_km,
ski_discipline: row.ski_discipline as SkiDiscipline | null,
ski_technique: row.ski_technique as SkiTechnique | null,
boat_type: row.boat_type as PaddleBoatType | null,
water_context: row.water_context as PaddleWaterContext | null,
paddle_speed_kmh: row.paddle_speed_kmh,
@@ -632,6 +641,8 @@ export class ToursService {
winter_activity: row.winter_activity as WinterActivity | null,
run_profile: row.run_profile as RunProfile | null,
run_pace_seconds_per_km: row.run_pace_seconds_per_km,
ski_discipline: row.ski_discipline as SkiDiscipline | null,
ski_technique: row.ski_technique as SkiTechnique | null,
boat_type: row.boat_type as PaddleBoatType | null,
water_context: row.water_context as PaddleWaterContext | null,
paddle_speed_kmh: row.paddle_speed_kmh,
@@ -815,12 +826,15 @@ export class ToursService {
const rows = this.readGpxTracks(fileBuffer, fileName);
if (rows.length === 0) return null;
await this.assertTourTypeEnabled(fields.tour_type);
// What the type adds to each imported tour: a bike tour and a run their profile, a paddle tour its boat and water.
// A run comes in without a pace: its time stays unknown until one is planned from it.
// What the type adds to each imported tour: a bike tour and a run their profile, a paddle tour its boat and water,
// a ski track its discipline and technique (SKI-02). A run comes in without a pace and a ski track without a
// time: neither is worked out for them, so they stay unknown.
const typeFields = {
bike_profile: fields.bike_profile ?? null,
run_profile: fields.run_profile ?? null,
run_pace_seconds_per_km: null,
ski_discipline: fields.ski_discipline ?? null,
ski_technique: fields.ski_technique ?? null,
boat_type: fields.boat_type ?? null,
water_context: fields.water_context ?? null,
paddle_speed_kmh: null,
+322
View File
@@ -0,0 +1,322 @@
/**
* Tours e2e for ski tracks (SKI-02): a GPX file imported as a ski track with
* its discipline (and a cross-country technique), kept as recorded with no
* time worked out, listed, opened, exported, copied and classified again
* without touching the track, its days or its provenance, through the real
* JwtAuthGuard, AddonGuard and TripAccessGuard against a migrated and seeded
* temp SQLite db. The permission check is a container spy and the broadcast
* goes to a FakeRealtimeService, as in tours-run.e2e.test.ts.
*
* Fixtures: user 1 owns trip 5 and trip 6, 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;
// Up on skins to the hut and back down: heights recorded, no timestamps.
const GPX = `<?xml version="1.0"?>
<gpx version="1.1" creator="e2e"><trk><name>Up to the hut</name><trkseg>
<trkpt lat="47.2" lon="11.3"><ele>1200</ele></trkpt>
<trkpt lat="47.21" lon="11.31"><ele>1650</ele></trkpt>
<trkpt lat="47.22" lon="11.32"><ele>1980</ele></trkpt>
</trkseg></trk></gpx>`;
// A planned hike, the type a ski track is not.
const hikeBody = {
name: 'Ridge walk',
tour_type: 'hike',
route_geometry: [
[47.2, 11.3, 1200],
[47.22, 11.32, 1980],
],
waypoints: [
{ lat: 47.2, lng: 11.3, role: 'start', sequence: 0 },
{ lat: 47.22, lng: 11.32, role: 'end', sequence: 1 },
],
max_hiking_difficulty: 2,
duration_seconds: 3600,
};
describe('Ski tracks 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() })
.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 importGpx = (fields: Record<string, string>, tripId = 5) => {
let req = request(server).post(`/api/trips/${tripId}/tours/import/gpx`).set('Cookie', sessionCookie(OWNER));
for (const [key, value] of Object.entries(fields)) req = req.field(key, value);
return req.attach('file', Buffer.from(GPX), { filename: 'hut.gpx', contentType: 'application/gpx+xml' });
};
const importSki = async (fields: Record<string, string> = { ski_discipline: 'touring' }, tripId = 5) => {
const res = await importGpx({ tour_type: 'ski', ...fields }, tripId);
expect(res.status).toBe(201);
return res.body.tours[0].place_id as number;
};
const classify = (placeId: number, body: Record<string, unknown>, userId = OWNER, tripId = 5) =>
request(server)
.put(`/api/trips/${tripId}/tours/${placeId}/ski`)
.set('Cookie', sessionCookie(userId))
.set('X-Socket-Id', 'sock-9')
.send(body);
const detail = async (placeId: number) =>
(await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER))).body;
beforeAll(async () => {
orm = await createTestOrm(db);
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: 'Ski week' },
{ id: 6, user: 1, title: 'Other trip' },
]);
await addTripMember(orm, 5, 2);
await insertRows(orm, Days, [{ id: 3, trip: 5, day_number: 1, date: '2027-02-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-SKI-E2E-001: imports a ski track with its discipline, kept as recorded, with its heights and no time', async () => {
const placeId = await importSki();
expect(await findRow(orm, Tours, { place: placeId })).toMatchObject({
tour_type: 'ski',
ski_discipline: 'touring',
ski_technique: null,
duration: null,
max_hiking_difficulty: null,
origin: 'gpx',
origin_file_name: 'hut.gpx',
});
const list = await request(server).get('/api/trips/5/tours').set('Cookie', sessionCookie(VIEWER));
expect(list.status).toBe(200);
const listed = list.body.tours[0];
expect(listed).toMatchObject({ place_id: placeId, tour_type: 'ski', ski_discipline: 'touring', duration: null });
expect(listed.elevation_gain).toBeGreaterThan(0);
expect(listed.distance).toBeGreaterThan(0);
const opened = await detail(placeId);
expect(opened.tour).toMatchObject({ tour_type: 'ski', ski_discipline: 'touring', has_waypoints: false });
expect(opened.provenance).toMatchObject({ origin: 'gpx', source: { kind: 'gpx', fileName: 'hut.gpx' } });
});
it('TOURS-SKI-E2E-002: a cross-country track keeps its technique, unknown included', async () => {
for (const technique of ['classic', 'skating', 'unknown']) {
const placeId = await importSki({ ski_discipline: 'cross_country', ski_technique: technique });
expect(await findRow(orm, Tours, { place: placeId })).toMatchObject({
ski_discipline: 'cross_country',
ski_technique: technique,
});
await deleteRows(orm, Places);
}
});
it('TOURS-SKI-E2E-003: refuses a ski import without its discipline or with a technique it has not, and ski fields on a hike', async () => {
const refused = await Promise.all(
[
{ tour_type: 'ski' },
{ tour_type: 'ski', ski_discipline: 'cross_country' },
{ tour_type: 'ski', ski_discipline: 'alpine', ski_technique: 'classic' },
{ tour_type: 'ski', ski_discipline: 'freeride' },
{ tour_type: 'hike', ski_discipline: 'alpine' },
].map((fields) => importGpx(fields)),
);
expect(refused.map((res) => res.status)).toEqual([400, 400, 400, 400, 400]);
expect(await countRows(orm, Places)).toBe(0);
});
it('TOURS-SKI-E2E-004: a ski track is never planned: a planned save of one is refused', async () => {
const res = await request(server)
.post('/api/trips/5/tours')
.set('Cookie', sessionCookie(OWNER))
.send({ ...hikeBody, tour_type: 'ski', max_hiking_difficulty: null });
expect(res.status).toBe(400);
expect(await countRows(orm, Tours)).toBe(0);
});
it('TOURS-SKI-E2E-005: classified again, only the classification changes: track, measures, days and provenance stay', async () => {
const placeId = await importSki({ ski_discipline: 'cross_country', ski_technique: 'unknown' });
await insertRows(orm, DayAssignments, [{ day: 3, place: placeId, order_index: 0 }]);
const before = await findRow(orm, Places, { id: placeId });
const measured = await findRow(orm, Tours, { place: placeId });
const res = await classify(placeId, {
ski_discipline: 'cross_country',
ski_technique: 'skating',
expected_revision: 1,
});
expect(res.status).toBe(200);
expect(res.body.tour).toMatchObject({
ski_discipline: 'cross_country',
ski_technique: 'skating',
revision: 2,
planned: true,
distance: measured?.distance,
elevation_gain: measured?.elevation_gain,
});
expect(res.body.provenance).toMatchObject({ origin: 'gpx', source: { kind: 'gpx', fileName: 'hut.gpx' } });
expect((await findRow(orm, Places, { id: placeId }))?.route_geometry).toBe(before?.route_geometry);
expect(await countRows(orm, DayAssignments, { place: placeId })).toBe(1);
expect(realtime.broadcastMock).toHaveBeenCalledWith('5', 'tours:changed', { placeIds: [placeId] }, 'sock-9');
// Another discipline takes the technique off.
const alpine = await classify(placeId, { ski_discipline: 'alpine' });
expect(alpine.status).toBe(200);
expect(alpine.body.tour).toMatchObject({ ski_discipline: 'alpine', ski_technique: null, revision: 3 });
});
it('TOURS-SKI-E2E-006: a classification against a tour that moved on is a 409 and changes nothing', async () => {
const placeId = await importSki({ ski_discipline: 'alpine' });
expect((await classify(placeId, { ski_discipline: 'snowboard' })).status).toBe(200);
const stale = await classify(placeId, { ski_discipline: 'touring', expected_revision: 1 });
expect(stale.status).toBe(409);
expect(stale.body).toMatchObject({ code: 'tour_revision_conflict', revision: 2 });
expect(await findRow(orm, Tours, { place: placeId })).toMatchObject({ ski_discipline: 'snowboard', revision: 2 });
});
it('TOURS-SKI-E2E-007: refuses a classification that is no ski classification, or of a tour that is no ski track', async () => {
const placeId = await importSki({ ski_discipline: 'alpine' });
const invalid = await Promise.all(
[
{},
{ ski_discipline: 'cross_country' },
{ ski_discipline: 'touring', ski_technique: 'classic' },
{ ski_discipline: 'monoski' },
].map((body) => classify(placeId, body)),
);
expect(invalid.map((res) => res.status)).toEqual([400, 400, 400, 400]);
const hike = await request(server).post('/api/trips/5/tours').set('Cookie', sessionCookie(OWNER)).send(hikeBody);
expect(hike.status).toBe(201);
const notSki = await classify(hike.body.tour.place_id as number, { ski_discipline: 'alpine' });
expect(notSki.status).toBe(400);
expect(notSki.body).toMatchObject({ error: 'Only a ski track has a ski classification' });
expect(await findRow(orm, Tours, { place: placeId })).toMatchObject({ ski_discipline: 'alpine', revision: 1 });
});
it('TOURS-SKI-E2E-008: a member who may only read cannot classify, and a ski track of another trip is not found', async () => {
const placeId = await importSki({ ski_discipline: 'alpine' });
expect((await classify(placeId, { ski_discipline: 'snowboard' }, VIEWER)).status).toBe(403);
const elsewhere = await importSki({ ski_discipline: 'alpine' }, 6);
expect((await classify(elsewhere, { ski_discipline: 'snowboard' })).status).toBe(404);
expect((await classify(999999, { ski_discipline: 'snowboard' })).status).toBe(404);
expect(await findRow(orm, Tours, { place: elsewhere })).toMatchObject({ ski_discipline: 'alpine' });
});
it('TOURS-SKI-E2E-009: exports a ski track as its discipline, and a copy keeps the classification', async () => {
const placeId = await importSki();
const res = await request(server)
.get(`/api/trips/5/tours/${placeId}/export.gpx`)
.set('Cookie', sessionCookie(OWNER))
.buffer(true)
.parse((response, done) => {
let text = '';
response.setEncoding('utf8');
response.on('data', (chunk: string) => (text += chunk));
response.on('end', () => done(null, text));
});
expect(/<type>([^<]+)<\/type>/.exec(String(res.body))?.[1]).toBe('backcountry_skiing');
const copy = await request(server)
.post(`/api/trips/5/tours/${placeId}/copy`)
.set('Cookie', sessionCookie(OWNER))
.send({ name: 'Again' });
expect(copy.status).toBe(201);
expect(copy.body.tour).toMatchObject({ tour_type: 'ski', ski_discipline: 'touring', ski_technique: null });
});
});
+1 -1
View File
@@ -1021,7 +1021,7 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
[
{ tour_type: 'kayak', water_context: 'lake' },
{ tour_type: 'hike', boat_type: 'sup' },
{ tour_type: 'ski' },
{ tour_type: 'food' },
].map((fields) => importGpx(OWNER, TRACK_GPX, 5, fields)),
);
for (const res of refused) {
@@ -68,6 +68,7 @@ const UNNUMBERED = [
'Migration20200101045400_a_tour_waypoint_remembers_the_kind_of_place_it_was_found_as',
'Migration20200101045500_a_hike_can_be_marked_as_a_winter_activity',
'Migration20200101045510_a_tour_can_be_a_run',
'Migration20200101045520_a_tour_can_be_a_ski_track',
];
// The last step the positional runner ever had; planLegacyBaseline refuses a
// schema_version past it.
@@ -106,10 +106,10 @@ describe('ToursRepository reads', () => {
hiking_preferences: null,
bike_profile: null,
winter_activity: null,
run_profile: null,
run_pace_seconds_per_km: null,
run_profile: null,
run_pace_seconds_per_km: null,
ski_discipline: null,
ski_technique: null,
},
{
place_id: older.id,
@@ -143,10 +143,10 @@ describe('ToursRepository reads', () => {
hiking_preferences: null,
bike_profile: null,
winter_activity: null,
run_profile: null,
run_pace_seconds_per_km: null,
run_profile: null,
run_pace_seconds_per_km: null,
ski_discipline: null,
ski_technique: null,
},
]);
expect((await repo.listForTrip(trip.id)).map((r) => r.place_id)).not.toContain(plain.id);
@@ -263,6 +263,8 @@ describe('ToursRepository writes', () => {
winter_activity: null,
run_profile: null,
run_pace_seconds_per_km: null,
ski_discipline: null,
ski_technique: null,
});
});
@@ -272,6 +274,8 @@ describe('ToursRepository writes', () => {
bike_profile: null,
run_profile: null,
run_pace_seconds_per_km: null,
ski_discipline: null,
ski_technique: null,
boat_type: null,
water_context: null,
paddle_speed_kmh: null,
@@ -333,6 +337,33 @@ describe('ToursRepository writes', () => {
expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({ run_profile: null, run_pace_seconds_per_km: null });
});
it('TOURREPO-018: a ski track keeps its classification, is classified again only in its own trip, and a route edit takes it off (SKI-02)', async () => {
const { trip, other } = fixture();
const place = createPlace(testDb, trip.id, { name: 'Valley loop' });
const ski = { ...METRICS, tour_type: 'ski', max_hiking_difficulty: null, duration: null };
await repo.insertTour({ place_id: place.id, ...ski, ski_discipline: 'cross_country', ski_technique: 'classic' });
expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({
tour_type: 'ski',
ski_discipline: 'cross_country',
ski_technique: 'classic',
duration: null,
});
const touring = { ski_discipline: 'touring' as const, ski_technique: null };
expect(await repo.updateSkiInTrip(other.id, place.id, touring)).toBe(false);
expect((await repo.findInTrip(trip.id, place.id))?.ski_discipline).toBe('cross_country');
expect(await repo.updateSkiInTrip(trip.id, place.id, touring)).toBe(true);
expect((await repo.listForTrip(trip.id))[0]).toMatchObject({
ski_discipline: 'touring',
ski_technique: null,
distance: METRICS.distance,
elevation_gain: METRICS.elevation_gain,
});
expect(await repo.updateInTrip(trip.id, place.id, HIKE_UPDATE)).toBe(true);
expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({ ski_discipline: null, ski_technique: null });
});
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);
@@ -379,10 +410,10 @@ describe('ToursRepository writes', () => {
hiking_preferences: null,
bike_profile: null,
winter_activity: null,
run_profile: null,
run_pace_seconds_per_km: null,
run_profile: null,
run_pace_seconds_per_km: null,
ski_discipline: null,
ski_technique: null,
taken_over_from_trip_title: null,
taken_over_from_tour_name: null,
},
@@ -0,0 +1,69 @@
/**
* SKI-02: `Migration20200101045520_a_tour_can_be_a_ski_track` switches the ski
* type on, without a routing profile, and gives a tour its ski discipline and
* cross-country technique. Both columns hold a CHECK list the entity carries
* as its enum and the contract holds too: a value one side takes and the
* other refuses would be a ski track the import accepts and the database
* cannot store.
*/
import { ToursSkiDiscipline, ToursSkiTechnique } from '../../../src/db/entities/Tours.entity';
import { createMigrationOrm, migrateTo, pendingNames, rawExec, rawQuery } from '../../helpers/migration-step';
import type { MikroORM } from '@mikro-orm/sqlite';
import { SKI_DISCIPLINES, SKI_TECHNIQUES } from '@trek/shared';
import { describe, expect, it } from 'vitest';
const TARGET = 'Migration20200101045520_a_tour_can_be_a_ski_track';
async function ormBeforeTarget(): Promise<MikroORM> {
const orm = await createMigrationOrm();
const names = await pendingNames(orm);
const idx = names.indexOf(TARGET);
expect(idx).toBeGreaterThan(0);
await migrateTo(orm, names[idx - 1]);
return orm;
}
describe('ski columns match the contract', () => {
it('TOURSKIMIG-001: stores exactly the disciplines and techniques the contract knows', () => {
expect([...Object.values(ToursSkiDiscipline)].sort()).toEqual([...SKI_DISCIPLINES].sort());
expect([...Object.values(ToursSkiTechnique)].sort()).toEqual([...SKI_TECHNIQUES].sort());
});
it('TOURSKIMIG-002: switches the ski type on without a router, leaves every tour as it was and refuses an unknown value', async () => {
const orm = await ormBeforeTarget();
try {
await rawExec(
orm,
"INSERT INTO users (id, username, email, password_hash) VALUES (1, 'owner', 'owner@test', 'x')",
);
await rawExec(orm, "INSERT INTO trips (id, user_id, title) VALUES (10, 1, 'Alps')");
await rawExec(
orm,
"INSERT INTO places (id, trip_id, name) VALUES (100, 10, 'Ridge walk'), (101, 10, 'Up to the hut')",
);
await rawExec(orm, "INSERT INTO tours (place_id, tour_type, distance) VALUES (100, 'hike', 12.5)");
await migrateTo(orm, TARGET);
expect(await rawQuery(orm, "SELECT enabled, routing_profile FROM tour_types WHERE key = 'ski'")).toEqual([
{ enabled: 1, routing_profile: null },
]);
expect(await rawQuery(orm, 'SELECT ski_discipline, ski_technique FROM tours')).toEqual([
{ ski_discipline: null, ski_technique: null },
]);
await rawExec(
orm,
"INSERT INTO tours (place_id, tour_type, ski_discipline, ski_technique) VALUES (101, 'ski', 'cross_country', 'unknown')",
);
await expect(rawExec(orm, "UPDATE tours SET ski_discipline = 'freeride' WHERE place_id = 101")).rejects.toThrow(
/CHECK/,
);
await expect(rawExec(orm, "UPDATE tours SET ski_technique = 'diagonal' WHERE place_id = 101")).rejects.toThrow(
/CHECK/,
);
} finally {
await orm.close(true);
}
}, 30000);
});
@@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest';
// TOUR-GPX-ACTIVITY-001 to TOUR-GPX-ACTIVITY-005
describe('gpxActivityOf (PR1-06A, BIK-01, HIK-01, RUN-01)', () => {
describe('gpxActivityOf (PR1-06A, BIK-01, HIK-01, RUN-01, SKI-02)', () => {
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');
@@ -35,4 +35,13 @@ describe('gpxActivityOf (PR1-06A, BIK-01, HIK-01, RUN-01)', () => {
expect(gpxActivityOf({ tour_type: 'run', run_profile: 'sprint' })).toBe('running');
expect(gpxActivityOf({ tour_type: 'run', run_profile: 'trail' })).toBe('trail_running');
});
it('TOUR-GPX-ACTIVITY-006: a ski track names its discipline, and skiing without a known one (SKI-02)', () => {
expect(gpxActivityOf({ tour_type: 'ski', ski_discipline: 'alpine' })).toBe('alpine_skiing');
expect(gpxActivityOf({ tour_type: 'ski', ski_discipline: 'snowboard' })).toBe('snowboarding');
expect(gpxActivityOf({ tour_type: 'ski', ski_discipline: 'cross_country' })).toBe('cross_country_skiing');
expect(gpxActivityOf({ tour_type: 'ski', ski_discipline: 'touring' })).toBe('backcountry_skiing');
expect(gpxActivityOf({ tour_type: 'ski', ski_discipline: null })).toBe('skiing');
expect(gpxActivityOf({ tour_type: 'ski', ski_discipline: 'freeride' })).toBe('skiing');
});
});
@@ -0,0 +1,46 @@
/**
* TourSkiController (SKI-02): the route metadata of classifying a ski track
* again, and its delegation to TourSkiService with the validated body and the
* socket id.
*/
import 'reflect-metadata';
import { AddonGuard } from '../../../src/nest/addons/addon.guard';
import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator';
import { JwtAuthGuard } from '../../../src/nest/auth-core/jwt-auth.guard';
import { RESPONSE_CONTRACT_KEY } from '../../../src/nest/common/response-contract';
import { TRIP_PERMISSION_KEY, TripAccessGuard } from '../../../src/nest/permissions/trip-access.guard';
import { TourSkiController } from '../../../src/nest/tours/tour-ski.controller';
import type { TourSkiService } from '../../../src/nest/tours/tour-ski.service';
import { RequestMethod } from '@nestjs/common';
import { GUARDS_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
import { tourDetailResponseSchema, type TourDetailResponse } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
describe('TourSkiController', () => {
it('TOUR-SKI-CTL-001: sits behind the addon guard before auth and trip access, and needs place_edit', () => {
expect(Reflect.getMetadata(PATH_METADATA, TourSkiController)).toBe('api/trips/:tripId/tours/:placeId/ski');
expect(Reflect.getMetadata(GUARDS_METADATA, TourSkiController)).toEqual([
AddonGuard,
JwtAuthGuard,
TripAccessGuard,
]);
expect(Reflect.getMetadata(REQUIRE_ADDON, TourSkiController)).toEqual({ addonId: 'tours', label: 'Tours' });
const handler = TourSkiController.prototype.classify;
expect(Reflect.getMetadata(METHOD_METADATA, handler)).toBe(RequestMethod.PUT);
expect(Reflect.getMetadata(PATH_METADATA, handler)).toBe('/');
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, handler)).toBe('place_edit');
expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, handler)).toBe(tourDetailResponseSchema);
});
it('TOUR-SKI-CTL-002: hands the trip, tour, classification and socket id to the service and answers what it answers', async () => {
const answer = { tour: { place_id: 9 }, waypoints: [] } as unknown as TourDetailResponse;
const service = { classify: vi.fn(async () => answer) };
const controller = new TourSkiController(service as unknown as TourSkiService);
const body = { ski_discipline: 'cross_country' as const, ski_technique: 'skating' as const, expected_revision: 2 };
expect(await controller.classify('7', '9', body, 'sock-1')).toBe(answer);
expect(service.classify).toHaveBeenCalledExactlyOnceWith('7', '9', body, 'sock-1');
});
});
+26
View File
@@ -411,4 +411,30 @@ describe('ToursMcp', () => {
expect(description).toContain('trail for forest tracks and natural paths up to mountain paths (T2)');
expect(description).toContain('never a statement that a way can be run');
});
it('TOURS-MCP-018: list_tours names a ski track with its discipline and technique, and says it is kept as recorded (SKI-02)', async () => {
const { user, trip, ridge, gorge } = fixture();
const orm = await createTestOrm(db);
await updateRows(
orm,
Tours,
{ place: ridge.id },
{ tourTypeRef: 'ski', max_hiking_difficulty: null, ski_discipline: 'cross_country', ski_technique: 'skating' },
);
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: 'ski',
ski_discipline: 'cross_country',
ski_technique: 'skating',
});
expect(byId.get(gorge.id)).toMatchObject({ ski_discipline: null, ski_technique: null });
const entry = getEntry(ToursMcp as unknown as ClassRef, 'listTours');
const description = entry?.kind === 'tool' ? (entry.options.description ?? '') : '';
expect(description).toMatch(/ski_discipline: alpine, snowboard, cross_country or touring/);
expect(description).toContain('never routed');
expect(description).toContain('it has no ski time worked out');
expect(description).toContain('nothing in it says anything about snow, avalanches');
});
});
@@ -195,6 +195,8 @@ describe('ToursService planner contracts (mock-only; SQLite rollback semantics a
bike_profile: null,
run_profile: null,
run_pace_seconds_per_km: null,
ski_discipline: null,
ski_technique: null,
});
expect((setup.toursRepo.insertTour.mock.calls[0][0] as { distance: number }).distance).toBeGreaterThan(0);
expect(setup.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, keyed(request.waypoints));
@@ -315,6 +317,8 @@ describe('ToursService planner contracts (mock-only; SQLite rollback semantics a
bike_profile: null,
run_profile: null,
run_pace_seconds_per_km: null,
ski_discipline: null,
ski_technique: null,
});
expect(setup.waypointsRepo.deleteForPlace).toHaveBeenCalledExactlyOnceWith(42);
expect(setup.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, keyed(update.waypoints));
+22 -1
View File
@@ -216,7 +216,7 @@ const tours: TranslationStrings = {
'tourTypes.hike': 'مشي طويل',
'tourTypes.bike': 'جولة بالدراجة',
'tourTypes.run': 'جري',
'tourTypes.ski': 'جولة تزلج',
'tourTypes.ski': 'تزلج',
'tourTypes.kayak': 'جولة تجديف',
'tourTypes.walk': 'مشي في المدينة / مشاهدة المعالم',
'tourTypes.food': 'جولة طعام / نبيذ',
@@ -414,6 +414,27 @@ const tours: TranslationStrings = {
'tours.winter.activity.winter_hike': 'مشي شتوي',
'tours.winter.activity.snowshoe': 'مشي بأحذية الثلج',
'tours.winter.labelOnly': 'وضعها المسافرون. يُخطَّط المسار والوقت كما في أي نزهة، دون فحص لظروف الشتاء.',
'tours.planner.tourTypeHint.ski': 'من ملف GPX كما سُجّل',
'tours.ski.title': 'مسار تزلج',
'tours.ski.discipline': 'النوع',
'tours.ski.discipline.alpine': 'تزلج ألبي',
'tours.ski.discipline.snowboard': 'تزلج على اللوح',
'tours.ski.discipline.cross_country': 'تزلج ريفي',
'tours.ski.discipline.touring': 'تزلج جبلي',
'tours.ski.technique': 'الأسلوب',
'tours.ski.technique.classic': 'كلاسيكي',
'tours.ski.technique.skating': 'تزحلق',
'tours.ski.technique.unknown': 'غير معروف',
'tours.ski.chooseDiscipline': 'اختر نوعًا لاستيراد المسار كتزلج.',
'tours.ski.chooseTechnique': 'اختر الأسلوب، أو غير معروف.',
'tours.ski.infoLabel': 'ما يفعله TREK بمسار التزلج',
'tours.ski.info':
'يبقى كما سُجّل ولا يُعاد حسابه أبدًا. المسافة والارتفاعات من الملف. لا يحسب TREK وقتًا للتزلج، ولا يقول المسار ولا ألوان الانحدار في العرض ثلاثي الأبعاد شيئًا عن الصعوبة أو الثلج أو الانهيارات أو ما إذا كانت المسارات مفتوحة.',
'tours.ski.time': 'وقت التزلج',
'tours.ski.timeUnknown': 'غير محسوب',
'tours.ski.classifyTitle': 'تصنيف مسار التزلج',
'tours.ski.saved': 'تم حفظ التصنيف',
'tours.ski.saveFailed': 'تعذّر حفظ التصنيف.',
'tours.planner.tourTypeHint.run': 'سيرًا على الأقدام بإيقاعك الخاص، على طرق مناسبة للجري',
'tours.planner.namePlaceholder.run': 'جري بلا عنوان',
'tours.planner.unreachable.run':
+22 -1
View File
@@ -200,7 +200,7 @@ const tours: TranslationStrings = {
'tourTypes.hike': 'Yürüş',
'tourTypes.bike': 'Velosiped turu',
'tourTypes.run': 'Qaçış',
'tourTypes.ski': 'Xizək turu',
'tourTypes.ski': 'Xizək',
'tourTypes.kayak': 'Avarlı tur',
'tourTypes.walk': 'Şəhər / görməli yerlər gəzintisi',
'tourTypes.food': 'Yemək/şərab turu',
@@ -404,6 +404,27 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Qar ayaqqabısı ilə yürüş',
'tours.winter.labelOnly':
'Səyahətçilər tərəfindən qeyd edilib. Marşrut və vaxt istənilən yürüşdəki kimi planlaşdırılır, qış şəraiti üçün yoxlanılmır.',
'tours.planner.tourTypeHint.ski': 'GPX faylından, qeydə alındığı kimi',
'tours.ski.title': 'Xizək izi',
'tours.ski.discipline': 'Növ',
'tours.ski.discipline.alpine': 'Alp xizəyi',
'tours.ski.discipline.snowboard': 'Snoubord',
'tours.ski.discipline.cross_country': 'Xizək qaçışı',
'tours.ski.discipline.touring': 'Dağ xizəyi turu',
'tours.ski.technique': 'Texnika',
'tours.ski.technique.classic': 'Klassik',
'tours.ski.technique.skating': 'Konki üslubu',
'tours.ski.technique.unknown': 'Naməlum',
'tours.ski.chooseDiscipline': 'İzi xizək kimi idxal etmək üçün növ seç.',
'tours.ski.chooseTechnique': 'Texnikanı və ya Naməlum seç.',
'tours.ski.infoLabel': 'TREK xizək izi ilə nə edir',
'tours.ski.info':
'Qeydə alındığı kimi qalır və heç vaxt marşrutlanmır. Məsafə və hündürlüklər fayldan gəlir. TREK xizək vaxtı hesablamır, nə iz, nə də 3D-dəki meyil rəngləri çətinlik, qar, uçqun və ya trassaların açıq olub-olmaması barədə heç nə demir.',
'tours.ski.time': 'Xizək vaxtı',
'tours.ski.timeUnknown': 'Hesablanmayıb',
'tours.ski.classifyTitle': 'Xizək izini təsnif et',
'tours.ski.saved': 'Təsnifat saxlanıldı',
'tours.ski.saveFailed': 'Təsnifatı saxlamaq alınmadı.',
'tours.planner.tourTypeHint.run': 'Piyada öz tempinizlə, qaçış üçün rahat yollarla',
'tours.planner.namePlaceholder.run': 'Adsız qaçış',
'tours.planner.unreachable.run':
+22 -1
View File
@@ -205,7 +205,7 @@ const tours: TranslationStrings = {
'tourTypes.hike': 'Trilha',
'tourTypes.bike': 'Passeio de bicicleta',
'tourTypes.run': 'Corrida',
'tourTypes.ski': 'Passeio de esqui',
'tourTypes.ski': 'Esqui',
'tourTypes.kayak': 'Passeio de remo',
'tourTypes.walk': 'Caminhada urbana / turística',
'tourTypes.food': 'Passeio gastronômico / enológico',
@@ -410,6 +410,27 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Caminhada com raquetes de neve',
'tours.winter.labelOnly':
'Marcado pelos viajantes. Rota e tempo são planejados como em qualquer caminhada, sem verificação para o inverno.',
'tours.planner.tourTypeHint.ski': 'De um arquivo GPX, como foi gravado',
'tours.ski.title': 'Trilha de esqui',
'tours.ski.discipline': 'Modalidade',
'tours.ski.discipline.alpine': 'Alpino',
'tours.ski.discipline.snowboard': 'Snowboard', // same-as-en
'tours.ski.discipline.cross_country': 'Esqui cross-country',
'tours.ski.discipline.touring': 'Esqui de travessia',
'tours.ski.technique': 'Técnica',
'tours.ski.technique.classic': 'Clássica',
'tours.ski.technique.skating': 'Skating', // same-as-en
'tours.ski.technique.unknown': 'Desconhecida',
'tours.ski.chooseDiscipline': 'Escolha uma modalidade para importar a trilha como esqui.',
'tours.ski.chooseTechnique': 'Escolha a técnica, ou Desconhecida.',
'tours.ski.infoLabel': 'O que o TREK faz com uma trilha de esqui',
'tours.ski.info':
'Fica como foi gravada e nunca é roteada. Distância e altitudes vêm do arquivo. O TREK não calcula tempo de esqui, e nem a trilha nem as cores de inclinação em 3D dizem algo sobre dificuldade, neve, avalanches ou se pistas e trilhas estão abertas.',
'tours.ski.time': 'Tempo de esqui',
'tours.ski.timeUnknown': 'Não calculado',
'tours.ski.classifyTitle': 'Classificar trilha de esqui',
'tours.ski.saved': 'Classificação salva',
'tours.ski.saveFailed': 'Não foi possível salvar a classificação.',
'tours.planner.tourTypeHint.run': 'A pé no seu ritmo, por caminhos bons para correr',
'tours.planner.namePlaceholder.run': 'Corrida sem título',
'tours.planner.unreachable.run':
+22 -1
View File
@@ -205,7 +205,7 @@ const tours: TranslationStrings = {
'tourTypes.hike': 'Senderisme',
'tourTypes.bike': 'Ruta en bicicleta',
'tourTypes.run': 'Córrer',
'tourTypes.ski': 'Esquí de muntanya',
'tourTypes.ski': 'Esquí',
'tourTypes.kayak': 'Ruta en piragua',
'tourTypes.walk': 'Passeig urbà / turístic',
'tourTypes.food': 'Ruta gastronòmica / enològica',
@@ -411,6 +411,27 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Excursió amb raquetes de neu',
'tours.winter.labelOnly':
'Marcat pels viatgers. La ruta i el temps es planifiquen com en qualsevol excursió, sense comprovar l’hivern.',
'tours.planner.tourTypeHint.ski': 'D’un fitxer GPX, tal com es va gravar',
'tours.ski.title': 'Traça d’esquí',
'tours.ski.discipline': 'Disciplina',
'tours.ski.discipline.alpine': 'Alpí',
'tours.ski.discipline.snowboard': 'Surf de neu',
'tours.ski.discipline.cross_country': 'Esquí de fons',
'tours.ski.discipline.touring': 'Esquí de muntanya',
'tours.ski.technique': 'Tècnica',
'tours.ski.technique.classic': 'Clàssica',
'tours.ski.technique.skating': 'Patinador',
'tours.ski.technique.unknown': 'Desconeguda',
'tours.ski.chooseDiscipline': 'Tria una disciplina per importar la traça com a esquí.',
'tours.ski.chooseTechnique': 'Tria la tècnica, o Desconeguda.',
'tours.ski.infoLabel': 'Què fa TREK amb una traça d’esquí',
'tours.ski.info':
'Es manté tal com es va gravar i mai no s’enruta. La distància i les altituds vénen del fitxer. TREK no calcula cap temps d’esquí, i ni la traça ni els colors de pendent en 3D diuen res de dificultat, neu, allaus o si les pistes estan obertes.',
'tours.ski.time': 'Temps d’esquí',
'tours.ski.timeUnknown': 'Sense calcular',
'tours.ski.classifyTitle': 'Classificar la traça d’esquí',
'tours.ski.saved': 'Classificació desada',
'tours.ski.saveFailed': 'No s’ha pogut desar la classificació.',
'tours.planner.tourTypeHint.run': 'A peu al teu ritme, per camins bons per córrer',
'tours.planner.namePlaceholder.run': 'Cursa sense títol',
'tours.planner.unreachable.run':
+22 -1
View File
@@ -203,7 +203,7 @@ const tours: TranslationStrings = {
'tourTypes.hike': 'Pěší túra',
'tourTypes.bike': 'Cyklovýlet',
'tourTypes.run': 'Běh',
'tourTypes.ski': 'Skialpinistická túra',
'tourTypes.ski': 'Lyže',
'tourTypes.kayak': 'Vodácká túra',
'tourTypes.walk': 'Procházka městem / po památkách',
'tourTypes.food': 'Gastronomický / vinařský výlet',
@@ -403,6 +403,27 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Túra na sněžnicích',
'tours.winter.labelOnly':
'Označeno cestovateli. Trasa a čas se plánují jako u každé túry, bez kontroly zimních podmínek.',
'tours.planner.tourTypeHint.ski': 'Ze souboru GPX, tak jak byla zaznamenána',
'tours.ski.title': 'Lyžařská trasa',
'tours.ski.discipline': 'Disciplína',
'tours.ski.discipline.alpine': 'Sjezd',
'tours.ski.discipline.snowboard': 'Snowboard', // same-as-en
'tours.ski.discipline.cross_country': 'Běh na lyžích',
'tours.ski.discipline.touring': 'Skialpinismus',
'tours.ski.technique': 'Technika',
'tours.ski.technique.classic': 'Klasika',
'tours.ski.technique.skating': 'Bruslení',
'tours.ski.technique.unknown': 'Neznámá',
'tours.ski.chooseDiscipline': 'Vyberte disciplínu pro import trasy jako lyže.',
'tours.ski.chooseTechnique': 'Vyberte techniku, nebo Neznámá.',
'tours.ski.infoLabel': 'Co TREK dělá s lyžařskou trasou',
'tours.ski.info':
'Zůstává tak, jak byla zaznamenána, a nikdy se nepřepočítává. Vzdálenost a výšky pocházejí ze souboru. TREK nepočítá žádný čas na lyžích a ani trasa, ani barvy sklonu ve 3D neříkají nic o obtížnosti, sněhu, lavinách ani o tom, zda jsou sjezdovky a stopy otevřené.',
'tours.ski.time': 'Čas na lyžích',
'tours.ski.timeUnknown': 'Nepočítá se',
'tours.ski.classifyTitle': 'Zařadit lyžařskou trasu',
'tours.ski.saved': 'Zařazení uloženo',
'tours.ski.saveFailed': 'Zařazení se nepodařilo uložit.',
'tours.planner.tourTypeHint.run': 'Pěšky vlastním tempem, po dobře běhatelných cestách',
'tours.planner.namePlaceholder.run': 'Běh bez názvu',
'tours.planner.unreachable.run':
+22 -1
View File
@@ -206,7 +206,7 @@ const tours: TranslationStrings = {
'tourTypes.hike': 'Wanderung',
'tourTypes.bike': 'Radtour',
'tourTypes.run': 'Lauf',
'tourTypes.ski': 'Skitour',
'tourTypes.ski': 'Ski', // same-as-en
'tourTypes.kayak': 'Paddeltour',
'tourTypes.walk': 'Stadt- und Spaziergang',
'tourTypes.food': 'Genusstour',
@@ -413,6 +413,27 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Schneeschuhwandern',
'tours.winter.labelOnly':
'Von den Reisenden gekennzeichnet. Route und Zeit sind wie bei jeder Wanderung geplant, nicht für den Winter geprüft.',
'tours.planner.tourTypeHint.ski': 'Aus einer GPX-Datei, wie aufgezeichnet',
'tours.ski.title': 'Skistrecke',
'tours.ski.discipline': 'Disziplin',
'tours.ski.discipline.alpine': 'Alpin',
'tours.ski.discipline.snowboard': 'Snowboard', // same-as-en
'tours.ski.discipline.cross_country': 'Langlauf',
'tours.ski.discipline.touring': 'Skitour',
'tours.ski.technique': 'Technik',
'tours.ski.technique.classic': 'Klassisch',
'tours.ski.technique.skating': 'Skating', // same-as-en
'tours.ski.technique.unknown': 'Unbekannt',
'tours.ski.chooseDiscipline': 'Wähle eine Disziplin, um die Strecke als Ski zu importieren.',
'tours.ski.chooseTechnique': 'Wähle die Technik oder Unbekannt.',
'tours.ski.infoLabel': 'Was TREK mit einer Skistrecke macht',
'tours.ski.info':
'Bleibt wie aufgezeichnet und wird nie geroutet. Distanz und Höhen stammen aus der Datei. TREK berechnet keine Skizeit, und weder die Strecke noch die Steigungsfarben in 3D sagen etwas über Schwierigkeit, Schnee, Lawinen oder geöffnete Pisten und Loipen.',
'tours.ski.time': 'Skizeit',
'tours.ski.timeUnknown': 'Nicht berechnet',
'tours.ski.classifyTitle': 'Skistrecke einordnen',
'tours.ski.saved': 'Einordnung gespeichert',
'tours.ski.saveFailed': 'Die Einordnung konnte nicht gespeichert werden.',
'tours.planner.tourTypeHint.run': 'Zu Fuß in deiner Pace, auf gut laufbaren Wegen',
'tours.planner.namePlaceholder.run': 'Unbenannter Lauf',
'tours.planner.unreachable.run':
+22 -1
View File
@@ -199,7 +199,7 @@ const tours: TranslationStrings = {
'tourTypes.hike': 'Hike',
'tourTypes.bike': 'Bike tour',
'tourTypes.run': 'Run',
'tourTypes.ski': 'Ski tour',
'tourTypes.ski': 'Ski',
'tourTypes.kayak': 'Paddle tour',
'tourTypes.walk': 'City / sightseeing walk',
'tourTypes.food': 'Food/wine tour',
@@ -403,6 +403,27 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Snowshoe hike',
'tours.winter.labelOnly':
'Marked by the travellers. Route and time are planned as for any hike, not checked for winter.',
'tours.planner.tourTypeHint.ski': 'From a GPX file, kept as recorded',
'tours.ski.title': 'Ski track',
'tours.ski.discipline': 'Discipline',
'tours.ski.discipline.alpine': 'Alpine',
'tours.ski.discipline.snowboard': 'Snowboard',
'tours.ski.discipline.cross_country': 'Cross-country',
'tours.ski.discipline.touring': 'Ski touring',
'tours.ski.technique': 'Technique',
'tours.ski.technique.classic': 'Classic',
'tours.ski.technique.skating': 'Skating',
'tours.ski.technique.unknown': 'Unknown',
'tours.ski.chooseDiscipline': 'Choose a discipline to import the track as ski.',
'tours.ski.chooseTechnique': 'Choose the technique, or Unknown.',
'tours.ski.infoLabel': 'What TREK does with a ski track',
'tours.ski.info':
'Kept as recorded and never routed. Distance and heights come from the file. TREK works out no ski time, and neither the track nor the gradient colours in 3D say anything about difficulty, snow, avalanches or whether runs and trails are open.',
'tours.ski.time': 'Ski time',
'tours.ski.timeUnknown': 'Not worked out',
'tours.ski.classifyTitle': 'Classify ski track',
'tours.ski.saved': 'Classification saved',
'tours.ski.saveFailed': 'The classification could not be saved.',
'tours.planner.tourTypeHint.run': 'On foot at your own pace, on runnable ways',
'tours.planner.namePlaceholder.run': 'Untitled run',
'tours.planner.unreachable.run':
+22 -1
View File
@@ -205,7 +205,7 @@ const tours: TranslationStrings = {
'tourTypes.hike': 'Senderismo',
'tourTypes.bike': 'Ruta en bicicleta',
'tourTypes.run': 'Carrera a pie',
'tourTypes.ski': 'Esquí de travesía',
'tourTypes.ski': 'Esquí',
'tourTypes.kayak': 'Ruta en piragua',
'tourTypes.walk': 'Paseo urbano / turístico',
'tourTypes.food': 'Ruta gastronómica / enológica',
@@ -410,6 +410,27 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Ruta con raquetas de nieve',
'tours.winter.labelOnly':
'Marcado por los viajeros. Ruta y tiempo se planifican como en cualquier ruta, sin comprobación invernal.',
'tours.planner.tourTypeHint.ski': 'Desde un archivo GPX, tal como se grabó',
'tours.ski.title': 'Ruta de esquí',
'tours.ski.discipline': 'Disciplina',
'tours.ski.discipline.alpine': 'Alpino',
'tours.ski.discipline.snowboard': 'Snowboard', // same-as-en
'tours.ski.discipline.cross_country': 'Esquí de fondo',
'tours.ski.discipline.touring': 'Esquí de travesía',
'tours.ski.technique': 'Técnica',
'tours.ski.technique.classic': 'Clásica',
'tours.ski.technique.skating': 'Skating', // same-as-en
'tours.ski.technique.unknown': 'Desconocida',
'tours.ski.chooseDiscipline': 'Elige una disciplina para importar la ruta como esquí.',
'tours.ski.chooseTechnique': 'Elige la técnica, o Desconocida.',
'tours.ski.infoLabel': 'Qué hace TREK con una ruta de esquí',
'tours.ski.info':
'Se conserva tal como se grabó y nunca se enruta. La distancia y las alturas vienen del archivo. TREK no calcula ningún tiempo de esquí, y ni la ruta ni los colores de pendiente en 3D dicen nada sobre dificultad, nieve, aludes o si las pistas están abiertas.',
'tours.ski.time': 'Tiempo de esquí',
'tours.ski.timeUnknown': 'Sin calcular',
'tours.ski.classifyTitle': 'Clasificar ruta de esquí',
'tours.ski.saved': 'Clasificación guardada',
'tours.ski.saveFailed': 'No se pudo guardar la clasificación.',
'tours.planner.tourTypeHint.run': 'A pie a tu ritmo, por caminos buenos para correr',
'tours.planner.namePlaceholder.run': 'Carrera sin título',
'tours.planner.unreachable.run':
+22 -1
View File
@@ -202,7 +202,7 @@ const tours: TranslationStrings = {
'tourTypes.hike': 'Matk',
'tourTypes.bike': 'Rattaretk',
'tourTypes.run': 'Jooks',
'tourTypes.ski': 'Suusaretk',
'tourTypes.ski': 'Suusad',
'tourTypes.kayak': 'Aerutamismatk',
'tourTypes.walk': 'Linna- / vaatamisväärsuste jalutuskäik',
'tourTypes.food': 'Toidu- / veiniretk',
@@ -405,6 +405,27 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Räätsamatk',
'tours.winter.labelOnly':
'Reisijate märgitud. Marsruut ja aeg on kavandatud nagu igal matkal, talve jaoks kontrollimata.',
'tours.planner.tourTypeHint.ski': 'GPX-failist, nagu salvestati',
'tours.ski.title': 'Suusarada',
'tours.ski.discipline': 'Ala',
'tours.ski.discipline.alpine': 'Mäesuusatamine',
'tours.ski.discipline.snowboard': 'Lumelaud',
'tours.ski.discipline.cross_country': 'Murdmaasuusatamine',
'tours.ski.discipline.touring': 'Matkasuusatamine',
'tours.ski.technique': 'Tehnika',
'tours.ski.technique.classic': 'Klassika',
'tours.ski.technique.skating': 'Uisutehnika',
'tours.ski.technique.unknown': 'Teadmata',
'tours.ski.chooseDiscipline': 'Vali ala, et importida rada suusatamisena.',
'tours.ski.chooseTechnique': 'Vali tehnika või Teadmata.',
'tours.ski.infoLabel': 'Mida TREK suusarajaga teeb',
'tours.ski.info':
'Jääb nii, nagu salvestati, ja seda ei marsruudita kunagi. Pikkus ja kõrgused tulevad failist. TREK ei arvuta suusaaega ning ei rada ega 3D kallakuvärvid ütle midagi raskuse, lume, laviinide ega nõlvade ja radade avatuse kohta.',
'tours.ski.time': 'Suusaaeg',
'tours.ski.timeUnknown': 'Arvutamata',
'tours.ski.classifyTitle': 'Liigita suusarada',
'tours.ski.saved': 'Liigitus salvestatud',
'tours.ski.saveFailed': 'Liigitust ei õnnestunud salvestada.',
'tours.planner.tourTypeHint.run': 'Jalgsi omas tempos, hästi joostavatel teedel',
'tours.planner.namePlaceholder.run': 'Nimetu jooks',
'tours.planner.unreachable.run':
+22 -1
View File
@@ -208,7 +208,7 @@ const tours: TranslationStrings = {
'tourTypes.hike': 'Randonnée',
'tourTypes.bike': 'Sortie à vélo',
'tourTypes.run': 'Course à pied',
'tourTypes.ski': 'Ski de randonnée',
'tourTypes.ski': 'Ski', // same-as-en
'tourTypes.kayak': 'Sortie en pagaie',
'tourTypes.walk': 'Balade urbaine / touristique',
'tourTypes.food': 'Circuit gastronomique / œnologique',
@@ -416,6 +416,27 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Randonnée en raquettes',
'tours.winter.labelOnly':
'Marqué par les voyageurs. Itinéraire et durée sont planifiés comme pour toute randonnée, sans vérification hivernale.',
'tours.planner.tourTypeHint.ski': 'Depuis un fichier GPX, tel qu’enregistré',
'tours.ski.title': 'Trace de ski',
'tours.ski.discipline': 'Discipline', // same-as-en
'tours.ski.discipline.alpine': 'Alpin',
'tours.ski.discipline.snowboard': 'Snowboard', // same-as-en
'tours.ski.discipline.cross_country': 'Ski de fond',
'tours.ski.discipline.touring': 'Ski de randonnée',
'tours.ski.technique': 'Technique', // same-as-en
'tours.ski.technique.classic': 'Classique',
'tours.ski.technique.skating': 'Skating', // same-as-en
'tours.ski.technique.unknown': 'Inconnue',
'tours.ski.chooseDiscipline': 'Choisissez une discipline pour importer la trace en ski.',
'tours.ski.chooseTechnique': 'Choisissez la technique, ou Inconnue.',
'tours.ski.infoLabel': 'Ce que TREK fait d’une trace de ski',
'tours.ski.info':
'Conservée telle qu’enregistrée et jamais calculée par un routeur. Distance et altitudes viennent du fichier. TREK ne calcule aucun temps de ski, et ni la trace ni les couleurs de pente en 3D ne disent quoi que ce soit sur la difficulté, la neige, les avalanches ou l’ouverture des pistes.',
'tours.ski.time': 'Temps de ski',
'tours.ski.timeUnknown': 'Non calculé',
'tours.ski.classifyTitle': 'Classer la trace de ski',
'tours.ski.saved': 'Classement enregistré',
'tours.ski.saveFailed': 'Le classement n’a pas pu être enregistré.',
'tours.planner.tourTypeHint.run': 'À pied, à ton allure, sur des chemins où l’on court bien',
'tours.planner.namePlaceholder.run': 'Course sans titre',
'tours.planner.unreachable.run':
+22 -1
View File
@@ -209,7 +209,7 @@ const tours: TranslationStrings = {
'tourTypes.hike': 'Πεζοπορία',
'tourTypes.bike': 'Ποδηλατική εκδρομή',
'tourTypes.run': 'Τρέξιμο',
'tourTypes.ski': 'Ορειβατικό σκι',
'tourTypes.ski': 'Σκι',
'tourTypes.kayak': 'Διαδρομή με κουπί',
'tourTypes.walk': 'Περίπατος στην πόλη / στα αξιοθέατα',
'tourTypes.food': 'Γαστρονομική / οινική εκδρομή',
@@ -418,6 +418,27 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Πεζοπορία με χιονοπέδιλα',
'tours.winter.labelOnly':
'Σημειώθηκε από τους ταξιδιώτες. Η διαδρομή και ο χρόνος σχεδιάζονται όπως σε κάθε πεζοπορία, χωρίς έλεγχο για τον χειμώνα.',
'tours.planner.tourTypeHint.ski': 'Από αρχείο GPX, όπως καταγράφηκε',
'tours.ski.title': 'Διαδρομή σκι',
'tours.ski.discipline': 'Αγώνισμα',
'tours.ski.discipline.alpine': 'Αλπικό',
'tours.ski.discipline.snowboard': 'Σνόουμπορντ',
'tours.ski.discipline.cross_country': 'Σκι αντοχής',
'tours.ski.discipline.touring': 'Ορειβατικό σκι',
'tours.ski.technique': 'Τεχνική',
'tours.ski.technique.classic': 'Κλασική',
'tours.ski.technique.skating': 'Ελεύθερη',
'tours.ski.technique.unknown': 'Άγνωστη',
'tours.ski.chooseDiscipline': 'Επιλέξτε αγώνισμα για εισαγωγή της διαδρομής ως σκι.',
'tours.ski.chooseTechnique': 'Επιλέξτε τεχνική ή Άγνωστη.',
'tours.ski.infoLabel': 'Τι κάνει το TREK με μια διαδρομή σκι',
'tours.ski.info':
'Μένει όπως καταγράφηκε και δεν δρομολογείται ποτέ. Απόσταση και υψόμετρα προέρχονται από το αρχείο. Το TREK δεν υπολογίζει χρόνο σκι, και ούτε η διαδρομή ούτε τα χρώματα κλίσης στο 3D λένε κάτι για δυσκολία, χιόνι, χιονοστιβάδες ή αν οι πίστες είναι ανοιχτές.',
'tours.ski.time': 'Χρόνος σκι',
'tours.ski.timeUnknown': 'Δεν υπολογίστηκε',
'tours.ski.classifyTitle': 'Κατάταξη διαδρομής σκι',
'tours.ski.saved': 'Η κατάταξη αποθηκεύτηκε',
'tours.ski.saveFailed': 'Η κατάταξη δεν αποθηκεύτηκε.',
'tours.planner.tourTypeHint.run': 'Με τα πόδια, με τον δικό σου ρυθμό, σε δρόμους κατάλληλους για τρέξιμο',
'tours.planner.namePlaceholder.run': 'Τρέξιμο χωρίς τίτλο',
'tours.planner.unreachable.run':
+22 -1
View File
@@ -204,7 +204,7 @@ const tours: TranslationStrings = {
'tourTypes.hike': 'Gyalogtúra',
'tourTypes.bike': 'Kerékpártúra',
'tourTypes.run': 'Futás',
'tourTypes.ski': 'Sítúra',
'tourTypes.ski': 'Sí',
'tourTypes.kayak': 'Evezős túra',
'tourTypes.walk': 'Városi / városnéző séta',
'tourTypes.food': 'Gasztro- / bortúra',
@@ -410,6 +410,27 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Hótalpas túra',
'tours.winter.labelOnly':
'Az utazók jelölték meg. Az útvonal és az idő úgy van tervezve, mint bármely túránál, téli szempontból nincs ellenőrizve.',
'tours.planner.tourTypeHint.ski': 'GPX-fájlból, ahogy rögzítették',
'tours.ski.title': 'Sínyomvonal',
'tours.ski.discipline': 'Szakág',
'tours.ski.discipline.alpine': 'Alpesi',
'tours.ski.discipline.snowboard': 'Snowboard', // same-as-en
'tours.ski.discipline.cross_country': 'Sífutás',
'tours.ski.discipline.touring': 'Sítúra',
'tours.ski.technique': 'Technika',
'tours.ski.technique.classic': 'Klasszikus',
'tours.ski.technique.skating': 'Korcsolyázó',
'tours.ski.technique.unknown': 'Ismeretlen',
'tours.ski.chooseDiscipline': 'Válassz szakágat, hogy sínyomvonalként importáld.',
'tours.ski.chooseTechnique': 'Válaszd ki a technikát, vagy az Ismeretlent.',
'tours.ski.infoLabel': 'Mit kezd a TREK egy sínyomvonallal',
'tours.ski.info':
'Úgy marad, ahogy rögzítették, útvonaltervező soha nem számolja újra. A táv és a magasságok a fájlból származnak. A TREK nem számol síidőt, és sem a nyomvonal, sem a 3D lejtőszínei nem mondanak semmit a nehézségről, a hóról, a lavinákról vagy arról, hogy nyitva vannak-e a pályák.',
'tours.ski.time': 'Síidő',
'tours.ski.timeUnknown': 'Nincs kiszámolva',
'tours.ski.classifyTitle': 'Sínyomvonal besorolása',
'tours.ski.saved': 'Besorolás mentve',
'tours.ski.saveFailed': 'A besorolást nem sikerült menteni.',
'tours.planner.tourTypeHint.run': 'Gyalog a saját tempódban, jól futható utakon',
'tours.planner.namePlaceholder.run': 'Névtelen futás',
'tours.planner.unreachable.run':
+22 -1
View File
@@ -198,7 +198,7 @@ const tours: TranslationStrings = {
'tourTypes.hike': 'Pendakian',
'tourTypes.bike': 'Tur sepeda',
'tourTypes.run': 'Lari',
'tourTypes.ski': 'Tur ski',
'tourTypes.ski': 'Ski', // same-as-en
'tourTypes.kayak': 'Tur dayung',
'tourTypes.walk': 'Jalan-jalan kota / wisata',
'tourTypes.food': 'Tur kuliner / anggur',
@@ -405,6 +405,27 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Pendakian dengan sepatu salju',
'tours.winter.labelOnly':
'Ditandai oleh para pelancong. Rute dan waktu direncanakan seperti pendakian lain, tidak diperiksa untuk musim dingin.',
'tours.planner.tourTypeHint.ski': 'Dari berkas GPX, seperti yang direkam',
'tours.ski.title': 'Jalur ski',
'tours.ski.discipline': 'Disiplin',
'tours.ski.discipline.alpine': 'Alpen',
'tours.ski.discipline.snowboard': 'Papan seluncur salju',
'tours.ski.discipline.cross_country': 'Ski lintas alam',
'tours.ski.discipline.touring': 'Tur ski',
'tours.ski.technique': 'Teknik',
'tours.ski.technique.classic': 'Klasik',
'tours.ski.technique.skating': 'Gaya bebas',
'tours.ski.technique.unknown': 'Tidak diketahui',
'tours.ski.chooseDiscipline': 'Pilih disiplin untuk mengimpor jalur sebagai ski.',
'tours.ski.chooseTechnique': 'Pilih teknik, atau Tidak diketahui.',
'tours.ski.infoLabel': 'Apa yang TREK lakukan dengan jalur ski',
'tours.ski.info':
'Disimpan seperti yang direkam dan tidak pernah dirutekan. Jarak dan ketinggian berasal dari berkas. TREK tidak menghitung waktu ski, dan baik jalur maupun warna kemiringan dalam 3D tidak mengatakan apa pun tentang kesulitan, salju, longsoran, atau apakah lintasan sedang dibuka.',
'tours.ski.time': 'Waktu ski',
'tours.ski.timeUnknown': 'Tidak dihitung',
'tours.ski.classifyTitle': 'Klasifikasikan jalur ski',
'tours.ski.saved': 'Klasifikasi disimpan',
'tours.ski.saveFailed': 'Klasifikasi tidak dapat disimpan.',
'tours.planner.tourTypeHint.run': 'Berjalan kaki dengan tempomu sendiri, di jalan yang enak untuk lari',
'tours.planner.namePlaceholder.run': 'Lari tanpa judul',
'tours.planner.unreachable.run':
+22 -1
View File
@@ -204,7 +204,7 @@ const tours: TranslationStrings = {
'tourTypes.hike': 'Escursione a piedi',
'tourTypes.bike': 'Escursione in bici',
'tourTypes.run': 'Corsa',
'tourTypes.ski': 'Escursione con gli sci',
'tourTypes.ski': 'Sci',
'tourTypes.kayak': 'Uscita in pagaia',
'tourTypes.walk': 'Passeggiata urbana / turistica',
'tourTypes.food': 'Tour enogastronomico',
@@ -413,6 +413,27 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Ciaspolata',
'tours.winter.labelOnly':
'Contrassegnato dai viaggiatori. Percorso e tempi sono pianificati come per ogni escursione, non verificati per l’inverno.',
'tours.planner.tourTypeHint.ski': 'Da un file GPX, così come registrato',
'tours.ski.title': 'Traccia di sci',
'tours.ski.discipline': 'Disciplina',
'tours.ski.discipline.alpine': 'Alpino',
'tours.ski.discipline.snowboard': 'Snowboard', // same-as-en
'tours.ski.discipline.cross_country': 'Sci di fondo',
'tours.ski.discipline.touring': 'Scialpinismo',
'tours.ski.technique': 'Tecnica',
'tours.ski.technique.classic': 'Classica',
'tours.ski.technique.skating': 'Skating', // same-as-en
'tours.ski.technique.unknown': 'Sconosciuta',
'tours.ski.chooseDiscipline': 'Scegli una disciplina per importare la traccia come sci.',
'tours.ski.chooseTechnique': 'Scegli la tecnica, oppure Sconosciuta.',
'tours.ski.infoLabel': 'Cosa fa TREK con una traccia di sci',
'tours.ski.info':
'Resta come registrata e non viene mai ricalcolata. Distanza e quote vengono dal file. TREK non calcola alcun tempo di sci, e né la traccia né i colori della pendenza in 3D dicono nulla su difficoltà, neve, valanghe o apertura di piste e anelli.',
'tours.ski.time': 'Tempo di sci',
'tours.ski.timeUnknown': 'Non calcolato',
'tours.ski.classifyTitle': 'Classifica traccia di sci',
'tours.ski.saved': 'Classificazione salvata',
'tours.ski.saveFailed': 'Impossibile salvare la classificazione.',
'tours.planner.tourTypeHint.run': 'A piedi al tuo passo, su percorsi adatti alla corsa',
'tours.planner.namePlaceholder.run': 'Corsa senza titolo',
'tours.planner.unreachable.run':
+22 -1
View File
@@ -195,7 +195,7 @@ const tours: TranslationStrings = {
'tourTypes.hike': 'ハイキング',
'tourTypes.bike': 'サイクリング',
'tourTypes.run': 'ランニング',
'tourTypes.ski': 'スキーツアー',
'tourTypes.ski': 'スキー',
'tourTypes.kayak': 'パドルツアー',
'tourTypes.walk': '街歩き・観光',
'tourTypes.food': 'グルメ・ワインツアー',
@@ -395,6 +395,27 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'スノーシューハイキング',
'tours.winter.labelOnly':
'旅行者が付けたラベルです。ルートと時間は通常のハイキングと同じく計画され、冬向けには確認されていません。',
'tours.planner.tourTypeHint.ski': 'GPX ファイルから、記録されたまま',
'tours.ski.title': 'スキーのトラック',
'tours.ski.discipline': '種目',
'tours.ski.discipline.alpine': 'アルペン',
'tours.ski.discipline.snowboard': 'スノーボード',
'tours.ski.discipline.cross_country': 'クロスカントリー',
'tours.ski.discipline.touring': '山スキー',
'tours.ski.technique': '走法',
'tours.ski.technique.classic': 'クラシカル',
'tours.ski.technique.skating': 'スケーティング',
'tours.ski.technique.unknown': '不明',
'tours.ski.chooseDiscipline': 'スキーとして取り込むには種目を選んでください。',
'tours.ski.chooseTechnique': '走法を選ぶか、不明を選んでください。',
'tours.ski.infoLabel': 'TREK がスキーのトラックで行うこと',
'tours.ski.info':
'記録されたまま保持され、ルート検索されることはありません。距離と標高はファイルの値です。TREK はスキーの所要時間を計算せず、トラックも 3D の勾配の色も、難易度、積雪、雪崩、コースの営業状況について何も示しません。',
'tours.ski.time': 'スキー時間',
'tours.ski.timeUnknown': '計算なし',
'tours.ski.classifyTitle': 'スキーのトラックを分類',
'tours.ski.saved': '分類を保存しました',
'tours.ski.saveFailed': '分類を保存できませんでした。',
'tours.planner.tourTypeHint.run': '自分のペースで、走りやすい道を',
'tours.planner.namePlaceholder.run': '無題のラン',
'tours.planner.unreachable.run':
+22 -1
View File
@@ -193,7 +193,7 @@ const tours: TranslationStrings = {
'tourTypes.hike': '하이킹',
'tourTypes.bike': '자전거 투어',
'tourTypes.run': '달리기',
'tourTypes.ski': '스키 투어',
'tourTypes.ski': '스키',
'tourTypes.kayak': '패들 투어',
'tourTypes.walk': '도심 산책 / 관광',
'tourTypes.food': '미식 / 와인 투어',
@@ -393,6 +393,27 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': '스노슈 하이킹',
'tours.winter.labelOnly':
'여행자가 표시했습니다. 경로와 시간은 다른 하이킹과 같이 계획되며 겨울 조건은 확인하지 않았습니다.',
'tours.planner.tourTypeHint.ski': 'GPX 파일에서, 기록된 그대로',
'tours.ski.title': '스키 트랙',
'tours.ski.discipline': '종목',
'tours.ski.discipline.alpine': '알파인',
'tours.ski.discipline.snowboard': '스노보드',
'tours.ski.discipline.cross_country': '크로스컨트리',
'tours.ski.discipline.touring': '산악 스키',
'tours.ski.technique': '주법',
'tours.ski.technique.classic': '클래식',
'tours.ski.technique.skating': '스케이팅',
'tours.ski.technique.unknown': '알 수 없음',
'tours.ski.chooseDiscipline': '트랙을 스키로 가져오려면 종목을 선택하세요.',
'tours.ski.chooseTechnique': '주법을 선택하거나 알 수 없음을 선택하세요.',
'tours.ski.infoLabel': 'TREK이 스키 트랙으로 하는 일',
'tours.ski.info':
'기록된 그대로 유지되며 경로를 다시 계산하지 않습니다. 거리와 고도는 파일에서 가져옵니다. TREK은 스키 시간을 계산하지 않으며, 트랙도 3D 경사 색상도 난이도, 눈, 눈사태, 슬로프 운영 여부에 대해 아무것도 알려 주지 않습니다.',
'tours.ski.time': '스키 시간',
'tours.ski.timeUnknown': '계산 안 함',
'tours.ski.classifyTitle': '스키 트랙 분류',
'tours.ski.saved': '분류를 저장했습니다',
'tours.ski.saveFailed': '분류를 저장하지 못했습니다.',
'tours.planner.tourTypeHint.run': '내 페이스로 걸어서, 달리기 좋은 길로',
'tours.planner.namePlaceholder.run': '이름 없는 달리기',
'tours.planner.unreachable.run':
+22 -1
View File
@@ -204,7 +204,7 @@ const tours: TranslationStrings = {
'tourTypes.hike': 'Wandeling',
'tourTypes.bike': 'Fietstocht',
'tourTypes.run': 'Hardlooptocht',
'tourTypes.ski': 'Skitocht',
'tourTypes.ski': 'Ski', // same-as-en
'tourTypes.kayak': 'Peddeltocht',
'tourTypes.walk': 'Stadswandeling / bezienswaardigheden',
'tourTypes.food': 'Culinaire tocht / wijntocht',
@@ -412,6 +412,27 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Sneeuwschoenwandeling',
'tours.winter.labelOnly':
'Gemarkeerd door de reizigers. Route en tijd worden gepland als bij elke wandeling, niet gecontroleerd op de winter.',
'tours.planner.tourTypeHint.ski': 'Uit een GPX-bestand, zoals opgenomen',
'tours.ski.title': 'Skispoor',
'tours.ski.discipline': 'Discipline', // same-as-en
'tours.ski.discipline.alpine': 'Alpine', // same-as-en
'tours.ski.discipline.snowboard': 'Snowboard', // same-as-en
'tours.ski.discipline.cross_country': 'Langlaufen',
'tours.ski.discipline.touring': 'Toerskiën',
'tours.ski.technique': 'Techniek',
'tours.ski.technique.classic': 'Klassiek',
'tours.ski.technique.skating': 'Skating', // same-as-en
'tours.ski.technique.unknown': 'Onbekend',
'tours.ski.chooseDiscipline': 'Kies een discipline om het spoor als ski te importeren.',
'tours.ski.chooseTechnique': 'Kies de techniek, of Onbekend.',
'tours.ski.infoLabel': 'Wat TREK met een skispoor doet',
'tours.ski.info':
'Blijft zoals opgenomen en wordt nooit gerouteerd. Afstand en hoogtes komen uit het bestand. TREK berekent geen skitijd, en noch het spoor noch de hellingkleuren in 3D zeggen iets over moeilijkheid, sneeuw, lawines of of pistes en loipes open zijn.',
'tours.ski.time': 'Skitijd',
'tours.ski.timeUnknown': 'Niet berekend',
'tours.ski.classifyTitle': 'Skispoor indelen',
'tours.ski.saved': 'Indeling opgeslagen',
'tours.ski.saveFailed': 'De indeling kon niet worden opgeslagen.',
'tours.planner.tourTypeHint.run': 'Te voet in je eigen tempo, over goed beloopbare wegen',
'tours.planner.namePlaceholder.run': 'Naamloze looptocht',
'tours.planner.unreachable.run':
+22 -1
View File
@@ -212,7 +212,7 @@ const tours: TranslationStrings = {
'tourTypes.hike': 'Wędrówka piesza',
'tourTypes.bike': 'Wycieczka rowerowa',
'tourTypes.run': 'Bieg',
'tourTypes.ski': 'Wycieczka skiturowa',
'tourTypes.ski': 'Narty',
'tourTypes.kayak': 'Spływ',
'tourTypes.walk': 'Spacer po mieście / zwiedzanie',
'tourTypes.food': 'Wycieczka kulinarna / winiarska',
@@ -419,6 +419,27 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Wędrówka na rakietach śnieżnych',
'tours.winter.labelOnly':
'Oznaczone przez podróżnych. Trasa i czas są planowane jak przy każdej wędrówce, bez sprawdzania pod kątem zimy.',
'tours.planner.tourTypeHint.ski': 'Z pliku GPX, tak jak został nagrany',
'tours.ski.title': 'Ślad narciarski',
'tours.ski.discipline': 'Dyscyplina',
'tours.ski.discipline.alpine': 'Zjazdowe',
'tours.ski.discipline.snowboard': 'Snowboard', // same-as-en
'tours.ski.discipline.cross_country': 'Biegowe',
'tours.ski.discipline.touring': 'Skitury',
'tours.ski.technique': 'Technika',
'tours.ski.technique.classic': 'Klasyczna',
'tours.ski.technique.skating': 'Łyżwowa',
'tours.ski.technique.unknown': 'Nieznana',
'tours.ski.chooseDiscipline': 'Wybierz dyscyplinę, aby zaimportować ślad jako narty.',
'tours.ski.chooseTechnique': 'Wybierz technikę lub Nieznana.',
'tours.ski.infoLabel': 'Co TREK robi ze śladem narciarskim',
'tours.ski.info':
'Pozostaje taki, jak został nagrany, i nigdy nie jest wyznaczany na nowo. Dystans i wysokości pochodzą z pliku. TREK nie wylicza czasu na nartach, a ani ślad, ani kolory nachylenia w 3D nie mówią nic o trudności, śniegu, lawinach ani o tym, czy trasy są otwarte.',
'tours.ski.time': 'Czas na nartach',
'tours.ski.timeUnknown': 'Nie wyliczono',
'tours.ski.classifyTitle': 'Sklasyfikuj ślad narciarski',
'tours.ski.saved': 'Klasyfikację zapisano',
'tours.ski.saveFailed': 'Nie udało się zapisać klasyfikacji.',
'tours.planner.tourTypeHint.run': 'Pieszo we własnym tempie, po drogach dobrych do biegania',
'tours.planner.namePlaceholder.run': 'Bieg bez nazwy',
'tours.planner.unreachable.run':
+22 -1
View File
@@ -214,7 +214,7 @@ const tours: TranslationStrings = {
'tourTypes.hike': 'Пеший поход',
'tourTypes.bike': 'Велотур',
'tourTypes.run': 'Пробежка',
'tourTypes.ski': 'Лыжный тур',
'tourTypes.ski': 'Лыжи',
'tourTypes.kayak': 'Сплав',
'tourTypes.walk': 'Прогулка по городу / достопримечательностям',
'tourTypes.food': 'Гастрономический / винный тур',
@@ -423,6 +423,27 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Поход на снегоступах',
'tours.winter.labelOnly':
'Отмечено путешественниками. Маршрут и время рассчитаны как для любого похода, без проверки на зимние условия.',
'tours.planner.tourTypeHint.ski': 'Из файла GPX, как записано',
'tours.ski.title': 'Лыжный трек',
'tours.ski.discipline': 'Дисциплина',
'tours.ski.discipline.alpine': 'Горные лыжи',
'tours.ski.discipline.snowboard': 'Сноуборд',
'tours.ski.discipline.cross_country': 'Беговые лыжи',
'tours.ski.discipline.touring': 'Ски-тур',
'tours.ski.technique': 'Техника',
'tours.ski.technique.classic': 'Классика',
'tours.ski.technique.skating': 'Конёк',
'tours.ski.technique.unknown': 'Неизвестно',
'tours.ski.chooseDiscipline': 'Выберите дисциплину, чтобы импортировать трек как лыжи.',
'tours.ski.chooseTechnique': 'Выберите технику или «Неизвестно».',
'tours.ski.infoLabel': 'Что TREK делает с лыжным треком',
'tours.ski.info':
'Остаётся таким, как записан, и никогда не прокладывается заново. Расстояние и высоты взяты из файла. TREK не рассчитывает время на лыжах, а ни трек, ни цвета уклона в 3D ничего не говорят о сложности, снеге, лавинах или о том, открыты ли трассы и лыжни.',
'tours.ski.time': 'Время на лыжах',
'tours.ski.timeUnknown': 'Не рассчитано',
'tours.ski.classifyTitle': 'Классифицировать лыжный трек',
'tours.ski.saved': 'Классификация сохранена',
'tours.ski.saveFailed': 'Не удалось сохранить классификацию.',
'tours.planner.tourTypeHint.run': 'Пешком в своём темпе, по удобным для бега дорогам',
'tours.planner.namePlaceholder.run': 'Пробежка без названия',
'tours.planner.unreachable.run':
+22 -1
View File
@@ -204,7 +204,7 @@ const tours: TranslationStrings = {
'tourTypes.hike': 'Pešia túra',
'tourTypes.bike': 'Cyklotúra',
'tourTypes.run': 'Beh',
'tourTypes.ski': 'Lyžiarska túra',
'tourTypes.ski': 'Lyže',
'tourTypes.kayak': 'Vodácka túra',
'tourTypes.walk': 'Prechádzka mestom / po pamiatkach',
'tourTypes.food': 'Gastronomická / vínna túra',
@@ -404,6 +404,27 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Túra na snežniciach',
'tours.winter.labelOnly':
'Označené cestovateľmi. Trasa a čas sa plánujú ako pri každej túre, bez kontroly zimných podmienok.',
'tours.planner.tourTypeHint.ski': 'Zo súboru GPX, tak ako bola zaznamenaná',
'tours.ski.title': 'Lyžiarska trasa',
'tours.ski.discipline': 'Disciplína',
'tours.ski.discipline.alpine': 'Zjazd',
'tours.ski.discipline.snowboard': 'Snowboard', // same-as-en
'tours.ski.discipline.cross_country': 'Beh na lyžiach',
'tours.ski.discipline.touring': 'Skialpinizmus',
'tours.ski.technique': 'Technika',
'tours.ski.technique.classic': 'Klasika',
'tours.ski.technique.skating': 'Korčuľovanie',
'tours.ski.technique.unknown': 'Neznáma',
'tours.ski.chooseDiscipline': 'Vyberte disciplínu na import trasy ako lyže.',
'tours.ski.chooseTechnique': 'Vyberte techniku, alebo Neznáma.',
'tours.ski.infoLabel': 'Čo TREK robí s lyžiarskou trasou',
'tours.ski.info':
'Zostáva tak, ako bola zaznamenaná, a nikdy sa neprepočítava. Vzdialenosť a výšky pochádzajú zo súboru. TREK nepočíta žiadny čas na lyžiach a ani trasa, ani farby sklonu v 3D nehovoria nič o náročnosti, snehu, lavínach ani o tom, či sú zjazdovky a stopy otvorené.',
'tours.ski.time': 'Čas na lyžiach',
'tours.ski.timeUnknown': 'Nepočíta sa',
'tours.ski.classifyTitle': 'Zaradiť lyžiarsku trasu',
'tours.ski.saved': 'Zaradenie uložené',
'tours.ski.saveFailed': 'Zaradenie sa nepodarilo uložiť.',
'tours.planner.tourTypeHint.run': 'Pešo vlastným tempom, po dobre bežateľných cestách',
'tours.planner.namePlaceholder.run': 'Beh bez názvu',
'tours.planner.unreachable.run':
+22 -1
View File
@@ -199,7 +199,7 @@ const tours: TranslationStrings = {
'tourTypes.hike': 'Vandring',
'tourTypes.bike': 'Cykeltur',
'tourTypes.run': 'Löptur',
'tourTypes.ski': 'Skidtur',
'tourTypes.ski': 'Skidor',
'tourTypes.kayak': 'Paddeltur',
'tourTypes.walk': 'Stadspromenad / sightseeing',
'tourTypes.food': 'Mat- och vintur',
@@ -402,6 +402,27 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Snöskovandring',
'tours.winter.labelOnly':
'Märkt av resenärerna. Rutt och tid planeras som för alla vandringar, inte kontrollerade för vinter.',
'tours.planner.tourTypeHint.ski': 'Från en GPX-fil, som den spelades in',
'tours.ski.title': 'Skidspår',
'tours.ski.discipline': 'Gren',
'tours.ski.discipline.alpine': 'Utförsåkning',
'tours.ski.discipline.snowboard': 'Snowboard', // same-as-en
'tours.ski.discipline.cross_country': 'Längdskidor',
'tours.ski.discipline.touring': 'Topptur',
'tours.ski.technique': 'Teknik',
'tours.ski.technique.classic': 'Klassisk',
'tours.ski.technique.skating': 'Skate',
'tours.ski.technique.unknown': 'Okänd',
'tours.ski.chooseDiscipline': 'Välj en gren för att importera spåret som skidor.',
'tours.ski.chooseTechnique': 'Välj teknik, eller Okänd.',
'tours.ski.infoLabel': 'Vad TREK gör med ett skidspår',
'tours.ski.info':
'Behålls som det spelades in och ruttplaneras aldrig. Sträcka och höjder kommer från filen. TREK räknar inte ut någon skidtid, och varken spåret eller lutningsfärgerna i 3D säger något om svårighet, snö, laviner eller om backar och spår är öppna.',
'tours.ski.time': 'Skidtid',
'tours.ski.timeUnknown': 'Inte uträknad',
'tours.ski.classifyTitle': 'Klassa skidspår',
'tours.ski.saved': 'Klassning sparad',
'tours.ski.saveFailed': 'Klassningen kunde inte sparas.',
'tours.planner.tourTypeHint.run': 'Till fots i din egen takt, på bra löpvägar',
'tours.planner.namePlaceholder.run': 'Namnlös löptur',
'tours.planner.unreachable.run':
+22 -1
View File
@@ -191,7 +191,7 @@ const tours: TranslationStrings = {
'tourTypes.hike': 'เดินป่า',
'tourTypes.bike': 'ทัวร์จักรยาน',
'tourTypes.run': 'วิ่ง',
'tourTypes.ski': 'ทัวร์สกี',
'tourTypes.ski': 'สกี',
'tourTypes.kayak': 'ทริปพายเรือ',
'tourTypes.walk': 'เดินชมเมือง / สถานที่ท่องเที่ยว',
'tourTypes.food': 'ทัวร์อาหาร/ไวน์',
@@ -384,6 +384,27 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'เดินป่าด้วยรองเท้าลุยหิมะ',
'tours.winter.labelOnly':
'ผู้เดินทางเป็นผู้ทำเครื่องหมาย เส้นทางและเวลาวางแผนเหมือนการเดินป่าทั่วไป ไม่ได้ตรวจสอบสำหรับฤดูหนาว',
'tours.planner.tourTypeHint.ski': 'จากไฟล์ GPX ตามที่บันทึกไว้',
'tours.ski.title': 'เส้นทางสกี',
'tours.ski.discipline': 'ประเภท',
'tours.ski.discipline.alpine': 'อัลไพน์',
'tours.ski.discipline.snowboard': 'สโนว์บอร์ด',
'tours.ski.discipline.cross_country': 'สกีทางเรียบ',
'tours.ski.discipline.touring': 'สกีปีนเขา',
'tours.ski.technique': 'เทคนิค',
'tours.ski.technique.classic': 'คลาสสิก',
'tours.ski.technique.skating': 'สเก็ตติ้ง',
'tours.ski.technique.unknown': 'ไม่ทราบ',
'tours.ski.chooseDiscipline': 'เลือกประเภทเพื่อนำเข้าเส้นทางเป็นสกี',
'tours.ski.chooseTechnique': 'เลือกเทคนิค หรือไม่ทราบ',
'tours.ski.infoLabel': 'TREK ทำอะไรกับเส้นทางสกี',
'tours.ski.info':
'เก็บไว้ตามที่บันทึกและไม่คำนวณเส้นทางใหม่ ระยะทางและความสูงมาจากไฟล์ TREK ไม่คำนวณเวลาเล่นสกี และทั้งเส้นทางและสีความชันในมุมมอง 3D ไม่ได้บอกอะไรเกี่ยวกับความยาก หิมะ หิมะถล่ม หรือว่าลานสกีเปิดอยู่หรือไม่',
'tours.ski.time': 'เวลาเล่นสกี',
'tours.ski.timeUnknown': 'ไม่ได้คำนวณ',
'tours.ski.classifyTitle': 'จัดประเภทเส้นทางสกี',
'tours.ski.saved': 'บันทึกการจัดประเภทแล้ว',
'tours.ski.saveFailed': 'บันทึกการจัดประเภทไม่สำเร็จ',
'tours.planner.tourTypeHint.run': 'เดินเท้าตามจังหวะของคุณ บนทางที่วิ่งได้สะดวก',
'tours.planner.namePlaceholder.run': 'การวิ่งที่ไม่มีชื่อ',
'tours.planner.unreachable.run':
+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 = 861;
const GENERAL_KEY_COUNT = 881;
function literalKeys(source: string): string[] {
const tree = ts.createSourceFile('locale.ts', source, ts.ScriptTarget.Latest, true);
+22 -1
View File
@@ -200,7 +200,7 @@ const tours: TranslationStrings = {
'tourTypes.hike': 'Doğa yürüyüşü',
'tourTypes.bike': 'Bisiklet turu',
'tourTypes.run': 'Koşu',
'tourTypes.ski': 'Kayak turu',
'tourTypes.ski': 'Kayak',
'tourTypes.kayak': 'Kürek turu',
'tourTypes.walk': 'Şehir / gezi yürüyüşü',
'tourTypes.food': 'Yemek / şarap turu',
@@ -404,6 +404,27 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Kar ayakkabısıyla yürüyüş',
'tours.winter.labelOnly':
'Gezginler tarafından işaretlendi. Rota ve süre her yürüyüşteki gibi planlanır, kış koşullarına göre denetlenmez.',
'tours.planner.tourTypeHint.ski': 'Bir GPX dosyasından, kaydedildiği gibi',
'tours.ski.title': 'Kayak izi',
'tours.ski.discipline': 'Branş',
'tours.ski.discipline.alpine': 'Alp disiplini',
'tours.ski.discipline.snowboard': 'Snowboard', // same-as-en
'tours.ski.discipline.cross_country': 'Kayaklı koşu',
'tours.ski.discipline.touring': 'Dağ kayağı',
'tours.ski.technique': 'Teknik',
'tours.ski.technique.classic': 'Klasik',
'tours.ski.technique.skating': 'Serbest stil',
'tours.ski.technique.unknown': 'Bilinmiyor',
'tours.ski.chooseDiscipline': 'İzi kayak olarak içe aktarmak için bir branş seç.',
'tours.ski.chooseTechnique': 'Tekniği ya da Bilinmiyor seçeneğini seç.',
'tours.ski.infoLabel': 'TREK bir kayak iziyle ne yapar',
'tours.ski.info':
'Kaydedildiği gibi kalır ve hiçbir zaman yeniden rotalanmaz. Mesafe ve yükseklikler dosyadan gelir. TREK kayak süresi hesaplamaz; ne iz ne de 3D eğim renkleri zorluk, kar, çığ ya da pist ve parkurların açık olup olmadığı hakkında bir şey söyler.',
'tours.ski.time': 'Kayak süresi',
'tours.ski.timeUnknown': 'Hesaplanmadı',
'tours.ski.classifyTitle': 'Kayak izini sınıflandır',
'tours.ski.saved': 'Sınıflandırma kaydedildi',
'tours.ski.saveFailed': 'Sınıflandırma kaydedilemedi.',
'tours.planner.tourTypeHint.run': 'Yürüyerek kendi temponda, koşmaya uygun yollarda',
'tours.planner.namePlaceholder.run': 'Adsız koşu',
'tours.planner.unreachable.run':
+22 -1
View File
@@ -211,7 +211,7 @@ const tours: TranslationStrings = {
'tourTypes.hike': 'Піший похід',
'tourTypes.bike': 'Велотур',
'tourTypes.run': 'Пробіжка',
'tourTypes.ski': 'Лижний тур',
'tourTypes.ski': 'Лижі',
'tourTypes.kayak': 'Сплав',
'tourTypes.walk': 'Прогулянка містом / визначними місцями',
'tourTypes.food': 'Гастрономічний / винний тур',
@@ -418,6 +418,27 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Похід на снігоступах',
'tours.winter.labelOnly':
'Позначено мандрівниками. Маршрут і час сплановано як для будь-якого походу, без перевірки на зимові умови.',
'tours.planner.tourTypeHint.ski': 'З файлу GPX, як записано',
'tours.ski.title': 'Лижний трек',
'tours.ski.discipline': 'Дисципліна',
'tours.ski.discipline.alpine': 'Гірські лижі',
'tours.ski.discipline.snowboard': 'Сноуборд',
'tours.ski.discipline.cross_country': 'Бігові лижі',
'tours.ski.discipline.touring': 'Скітур',
'tours.ski.technique': 'Техніка',
'tours.ski.technique.classic': 'Класика',
'tours.ski.technique.skating': 'Ковзанярський хід',
'tours.ski.technique.unknown': 'Невідомо',
'tours.ski.chooseDiscipline': 'Виберіть дисципліну, щоб імпортувати трек як лижі.',
'tours.ski.chooseTechnique': 'Виберіть техніку або «Невідомо».',
'tours.ski.infoLabel': 'Що TREK робить з лижним треком',
'tours.ski.info':
'Залишається таким, як записано, і ніколи не прокладається заново. Відстань і висоти взято з файлу. TREK не розраховує час на лижах, а ні трек, ні кольори нахилу в 3D нічого не кажуть про складність, сніг, лавини чи про те, чи відкриті траси й лижні.',
'tours.ski.time': 'Час на лижах',
'tours.ski.timeUnknown': 'Не розраховано',
'tours.ski.classifyTitle': 'Класифікувати лижний трек',
'tours.ski.saved': 'Класифікацію збережено',
'tours.ski.saveFailed': 'Не вдалося зберегти класифікацію.',
'tours.planner.tourTypeHint.run': 'Пішки у власному темпі, дорогами, зручними для бігу',
'tours.planner.namePlaceholder.run': 'Пробіжка без назви',
'tours.planner.unreachable.run':
+22 -1
View File
@@ -200,7 +200,7 @@ const tours: TranslationStrings = {
'tourTypes.hike': 'Đi bộ đường dài',
'tourTypes.bike': 'Chuyến đạp xe',
'tourTypes.run': 'Chạy bộ',
'tourTypes.ski': 'Chuyến trượt tuyết',
'tourTypes.ski': 'Trượt tuyết',
'tourTypes.kayak': 'Chuyến chèo thuyền',
'tourTypes.walk': 'Đi bộ trong thành phố / ngắm cảnh',
'tourTypes.food': 'Chuyến khám phá ẩm thực / rượu vang',
@@ -402,6 +402,27 @@ const tours: TranslationStrings = {
'tours.winter.activity.snowshoe': 'Đi bộ bằng giày tuyết',
'tours.winter.labelOnly':
'Do du khách đánh dấu. Tuyến đường và thời gian được lập như mọi chuyến đi bộ khác, không kiểm tra cho mùa đông.',
'tours.planner.tourTypeHint.ski': 'Từ tệp GPX, giữ nguyên như đã ghi',
'tours.ski.title': 'Đường trượt tuyết',
'tours.ski.discipline': 'Bộ môn',
'tours.ski.discipline.alpine': 'Trượt đổ dốc',
'tours.ski.discipline.snowboard': 'Trượt ván tuyết',
'tours.ski.discipline.cross_country': 'Trượt tuyết băng đồng',
'tours.ski.discipline.touring': 'Trượt tuyết leo núi',
'tours.ski.technique': 'Kỹ thuật',
'tours.ski.technique.classic': 'Cổ điển',
'tours.ski.technique.skating': 'Trượt kiểu lướt',
'tours.ski.technique.unknown': 'Không rõ',
'tours.ski.chooseDiscipline': 'Chọn bộ môn để nhập đường đi thành trượt tuyết.',
'tours.ski.chooseTechnique': 'Chọn kỹ thuật, hoặc Không rõ.',
'tours.ski.infoLabel': 'TREK làm gì với một đường trượt tuyết',
'tours.ski.info':
'Được giữ nguyên như đã ghi và không bao giờ được định tuyến. Quãng đường và độ cao lấy từ tệp. TREK không tính thời gian trượt, và cả đường đi lẫn màu độ dốc trong 3D đều không nói gì về độ khó, tuyết, tuyết lở hay việc đường trượt có mở hay không.',
'tours.ski.time': 'Thời gian trượt',
'tours.ski.timeUnknown': 'Không tính',
'tours.ski.classifyTitle': 'Phân loại đường trượt tuyết',
'tours.ski.saved': 'Đã lưu phân loại',
'tours.ski.saveFailed': 'Không thể lưu phân loại.',
'tours.planner.tourTypeHint.run': 'Đi bộ theo nhịp của bạn, trên những con đường dễ chạy',
'tours.planner.namePlaceholder.run': 'Buổi chạy chưa đặt tên',
'tours.planner.unreachable.run':
+21 -1
View File
@@ -184,7 +184,7 @@ const tours: TranslationStrings = {
'tourTypes.hike': '健行',
'tourTypes.bike': '單車遊程',
'tourTypes.run': '跑步',
'tourTypes.ski': '滑雪登山',
'tourTypes.ski': '滑雪',
'tourTypes.kayak': '划槳行程',
'tourTypes.walk': '城市散步 / 觀光',
'tourTypes.food': '美食 / 葡萄酒之旅',
@@ -367,6 +367,26 @@ const tours: TranslationStrings = {
'tours.winter.activity.winter_hike': '冬季健行',
'tours.winter.activity.snowshoe': '雪鞋健行',
'tours.winter.labelOnly': '由旅行者標記。路線與時間按一般健行規劃,未針對冬季查核。',
'tours.planner.tourTypeHint.ski': '來自 GPX 檔案,保持記錄原樣',
'tours.ski.title': '滑雪軌跡',
'tours.ski.discipline': '項目',
'tours.ski.discipline.alpine': '高山滑雪',
'tours.ski.discipline.snowboard': '單板滑雪',
'tours.ski.discipline.cross_country': '越野滑雪',
'tours.ski.discipline.touring': '登山滑雪',
'tours.ski.technique': '技術',
'tours.ski.technique.classic': '傳統式',
'tours.ski.technique.skating': '自由式',
'tours.ski.technique.unknown': '未知',
'tours.ski.chooseDiscipline': '請選擇項目,以便將軌跡匯入為滑雪。',
'tours.ski.chooseTechnique': '請選擇技術,或選擇未知。',
'tours.ski.infoLabel': 'TREK 如何處理滑雪軌跡',
'tours.ski.info': '保持記錄原樣,絕不重新規劃路線。距離和海拔來自檔案。TREK 不計算滑雪時間,軌跡和 3D 中的坡度顏色都不代表難度、積雪、雪崩或雪道是否開放。',
'tours.ski.time': '滑雪時間',
'tours.ski.timeUnknown': '未計算',
'tours.ski.classifyTitle': '為滑雪軌跡分類',
'tours.ski.saved': '分類已儲存',
'tours.ski.saveFailed': '無法儲存分類。',
'tours.planner.tourTypeHint.run': '依你自己的配速步行,走適合跑步的道路',
'tours.planner.namePlaceholder.run': '未命名的跑步',
'tours.planner.unreachable.run': '沒有適合跑步的道路能到達第 {point} 個點:路由器所知最近的道路在 {distance} 之外。請把該點移到道路上,或手動繪製到那裡的路段。',
+21 -1
View File
@@ -185,7 +185,7 @@ const tours: TranslationStrings = {
'tourTypes.hike': '徒步',
'tourTypes.bike': '骑行',
'tourTypes.run': '跑步',
'tourTypes.ski': '滑雪穿越',
'tourTypes.ski': '滑雪',
'tourTypes.kayak': '划桨游',
'tourTypes.walk': '城市漫步 / 观光',
'tourTypes.food': '美食 / 葡萄酒之旅',
@@ -368,6 +368,26 @@ const tours: TranslationStrings = {
'tours.winter.activity.winter_hike': '冬季徒步',
'tours.winter.activity.snowshoe': '雪鞋徒步',
'tours.winter.labelOnly': '由旅行者标记。路线和时间按普通徒步规划,未针对冬季核查。',
'tours.planner.tourTypeHint.ski': '来自 GPX 文件,保持记录原样',
'tours.ski.title': '滑雪轨迹',
'tours.ski.discipline': '项目',
'tours.ski.discipline.alpine': '高山滑雪',
'tours.ski.discipline.snowboard': '单板滑雪',
'tours.ski.discipline.cross_country': '越野滑雪',
'tours.ski.discipline.touring': '登山滑雪',
'tours.ski.technique': '技术',
'tours.ski.technique.classic': '传统式',
'tours.ski.technique.skating': '自由式',
'tours.ski.technique.unknown': '未知',
'tours.ski.chooseDiscipline': '请选择项目,以便将轨迹导入为滑雪。',
'tours.ski.chooseTechnique': '请选择技术,或选择未知。',
'tours.ski.infoLabel': 'TREK 如何处理滑雪轨迹',
'tours.ski.info': '保持记录原样,绝不重新规划路线。距离和海拔来自文件。TREK 不计算滑雪用时,轨迹和 3D 中的坡度颜色都不代表难度、积雪、雪崩或雪道是否开放。',
'tours.ski.time': '滑雪用时',
'tours.ski.timeUnknown': '未计算',
'tours.ski.classifyTitle': '为滑雪轨迹分类',
'tours.ski.saved': '分类已保存',
'tours.ski.saveFailed': '无法保存分类。',
'tours.planner.tourTypeHint.run': '按你自己的配速步行,走适合跑步的道路',
'tours.planner.namePlaceholder.run': '未命名的跑步',
'tours.planner.unreachable.run': '没有适合跑步的道路能到达第 {point} 个点:路由器知道的最近道路在 {distance} 之外。请把该点移到道路上,或手动绘制到那里的路段。',
+1
View File
@@ -56,6 +56,7 @@ export * from './roadtrip/roadtrip.schema';
export * from './place/track-colors';
export * from './tours/tours.schema';
export * from './tours/run-pace';
export * from './tours/ski.schema';
export * from './tours/tour-provenance.schema';
export * from './tours/tour-library.schema';
export * from './tours/tour-poi-search.schema';
+114
View File
@@ -0,0 +1,114 @@
import { SKI_DISCIPLINES, SKI_TECHNIQUES, skiIssues, tourSkiClassificationRequestSchema } from './ski.schema';
import { tourGpxImportFieldsSchema } from './tour-gpx-import.schema';
import {
importableTourTypeSchema,
plannableTourTypeSchema,
tourCreateRequestSchema,
tourListItemSchema,
} from './tours.schema';
import { describe, expect, it } from 'vitest';
describe('ski tracks (SKI-02)', () => {
it('SKI-SCHEMA-001: knows four disciplines and three cross-country techniques, unknown among them on purpose', () => {
expect(SKI_DISCIPLINES).toEqual(['alpine', 'snowboard', 'cross_country', 'touring']);
expect(SKI_TECHNIQUES).toEqual(['classic', 'skating', 'unknown']);
});
it('SKI-SCHEMA-002: ski is a type a track can be imported as, never one the planner plans', () => {
expect(importableTourTypeSchema.options).toEqual([...plannableTourTypeSchema.options, 'ski']);
expect(plannableTourTypeSchema.safeParse('ski').success).toBe(false);
const planned = {
name: 'Valley run',
tour_type: 'ski',
route_geometry: [
[47, 11, 1200],
[47.01, 11.01, 1100],
],
waypoints: [
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
{ lat: 47.01, lng: 11.01, role: 'end', sequence: 1 },
],
};
expect(tourCreateRequestSchema.safeParse(planned).success).toBe(false);
});
it('SKI-SCHEMA-003: a ski track names its discipline, a cross-country one its technique, and nothing else may', () => {
expect(skiIssues(true, { ski_discipline: 'alpine' })).toEqual([]);
expect(skiIssues(true, { ski_discipline: 'cross_country', ski_technique: 'unknown' })).toEqual([]);
expect(skiIssues(true, {})).toEqual([{ path: ['ski_discipline'], message: 'a ski track needs a discipline' }]);
expect(skiIssues(true, { ski_discipline: 'cross_country' })[0]?.path).toEqual(['ski_technique']);
expect(skiIssues(true, { ski_discipline: 'touring', ski_technique: 'skating' })[0]?.message).toMatch(
/only a cross-country track/,
);
expect(skiIssues(false, { ski_discipline: 'alpine', ski_technique: 'classic' }).map((i) => i.path)).toEqual([
['ski_discipline'],
['ski_technique'],
]);
expect(skiIssues(false, { ski_discipline: null, ski_technique: null })).toEqual([]);
});
it('SKI-SCHEMA-004: imports a GPX file as ski only with its discipline, and ski fields only for ski', () => {
expect(tourGpxImportFieldsSchema.parse({ tour_type: 'ski', ski_discipline: 'touring' })).toEqual({
tour_type: 'ski',
ski_discipline: 'touring',
});
expect(
tourGpxImportFieldsSchema.parse({ tour_type: 'ski', ski_discipline: 'cross_country', ski_technique: 'skating' }),
).toMatchObject({ ski_technique: 'skating' });
for (const fields of [
{ tour_type: 'ski' },
{ tour_type: 'ski', ski_discipline: 'cross_country' },
{ tour_type: 'ski', ski_discipline: 'alpine', ski_technique: 'classic' },
{ tour_type: 'ski', ski_discipline: 'slalom' },
{ tour_type: 'ski', ski_discipline: 'alpine', run_profile: 'running' },
{ tour_type: 'hike', ski_discipline: 'alpine' },
{ tour_type: 'run', run_profile: 'trail', ski_technique: 'classic' },
]) {
expect(tourGpxImportFieldsSchema.safeParse(fields).success).toBe(false);
}
});
it('SKI-SCHEMA-005: a classification names a discipline, a technique only for cross-country, and an optional revision', () => {
expect(tourSkiClassificationRequestSchema.parse({ ski_discipline: 'snowboard', expected_revision: 3 })).toEqual({
ski_discipline: 'snowboard',
expected_revision: 3,
});
expect(
tourSkiClassificationRequestSchema.parse({ ski_discipline: 'cross_country', ski_technique: 'classic' }),
).toMatchObject({ ski_technique: 'classic' });
for (const body of [
{},
{ ski_discipline: 'cross_country' },
{ ski_discipline: 'cross_country', ski_technique: null },
{ ski_discipline: 'alpine', ski_technique: 'skating' },
{ ski_discipline: 'alpine', expected_revision: 0 },
]) {
expect(tourSkiClassificationRequestSchema.safeParse(body).success).toBe(false);
}
});
it('SKI-SCHEMA-006: lists the discipline and technique, and reads a list cached before they existed', () => {
const listed = {
place_id: 4,
tour_type: 'ski',
distance: 12.4,
elevation_gain: 980,
elevation_loss: 990,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: null,
name: 'Up to the hut',
planned: false,
caution: false,
};
expect(tourListItemSchema.parse({ ...listed, ski_discipline: 'touring', ski_technique: null })).toMatchObject({
ski_discipline: 'touring',
ski_technique: null,
});
expect(tourListItemSchema.parse(listed).ski_discipline).toBeUndefined();
expect(tourListItemSchema.safeParse({ ...listed, ski_discipline: 'freeride' }).success).toBe(false);
});
});
+63
View File
@@ -0,0 +1,63 @@
import { z } from 'zod';
/**
* SKI-02: a ski track comes in as a GPX file and is kept as it was recorded.
* TREK routes no ski track and works out no ski time: its distance and heights
* are the file's, and nothing is said about snow, avalanches or whether a run
* or a trail is open. What the skier names on purpose is the discipline, and
* for cross-country the technique.
*/
export const SKI_DISCIPLINES = ['alpine', 'snowboard', 'cross_country', 'touring'] as const;
export const skiDisciplineSchema = z.enum(SKI_DISCIPLINES);
export type SkiDiscipline = z.infer<typeof skiDisciplineSchema>;
/**
* How a cross-country track is skied. `unknown` is an answer the skier gives
* on purpose (a borrowed track, a mixed day), not a gap TREK fills in; every
* other discipline has no technique at all.
*/
export const SKI_TECHNIQUES = ['classic', 'skating', 'unknown'] as const;
export const skiTechniqueSchema = z.enum(SKI_TECHNIQUES);
export type SkiTechnique = z.infer<typeof skiTechniqueSchema>;
type SkiIssue = { path: string[]; message: string };
/**
* What a ski track names, and what nothing else may: a ski track its
* discipline, a cross-country track its technique as well; a technique on any
* other discipline, and either field on another type, is refused.
*/
export function skiIssues(
isSki: boolean,
value: { ski_discipline?: SkiDiscipline | null; ski_technique?: SkiTechnique | null },
): SkiIssue[] {
if (!isSki) {
return (['ski_discipline', 'ski_technique'] as const)
.filter((field) => value[field] != null)
.map((field) => ({ path: [field], message: `${field} belongs to a ski track` }));
}
if (value.ski_discipline == null) return [{ path: ['ski_discipline'], message: 'a ski track needs a discipline' }];
const crossCountry = value.ski_discipline === 'cross_country';
if (crossCountry && value.ski_technique == null)
return [{ path: ['ski_technique'], message: 'a cross-country track needs a technique' }];
if (!crossCountry && value.ski_technique != null)
return [{ path: ['ski_technique'], message: 'only a cross-country track has a technique' }];
return [];
}
/**
* A ski track classified again after its import: the discipline and the
* technique, nothing of the track. Refused with a 409 once the tour moved on
* past the revision the choice was made at.
*/
export const tourSkiClassificationRequestSchema = z
.object({
ski_discipline: skiDisciplineSchema,
ski_technique: skiTechniqueSchema.nullable().optional(),
/** P2C-03: the revision the classification was made at. */
expected_revision: z.number().int().min(1).nullable().optional(),
})
.superRefine((value, ctx) =>
skiIssues(true, value).forEach((issue) => ctx.addIssue({ code: z.ZodIssueCode.custom, ...issue })),
);
export type TourSkiClassificationRequest = z.infer<typeof tourSkiClassificationRequestSchema>;
@@ -18,7 +18,7 @@ describe('Tours GPX import fields', () => {
it('refuses a paddle import without a boat, and a hike that names one', () => {
expect(() => tourGpxImportFieldsSchema.parse({ tour_type: 'kayak', water_context: 'lake' })).toThrow(/boat_type/);
expect(() => tourGpxImportFieldsSchema.parse({ tour_type: 'hike', boat_type: 'sup' })).toThrow(/paddle import/);
expect(() => tourGpxImportFieldsSchema.parse({ tour_type: 'ski' })).toThrow();
expect(() => tourGpxImportFieldsSchema.parse({ tour_type: 'food' })).toThrow();
});
});
+11 -3
View File
@@ -1,8 +1,9 @@
import { skiDisciplineSchema, skiIssues, skiTechniqueSchema } from './ski.schema';
import {
bikeProfileSchema,
importableTourTypeSchema,
paddleBoatTypeSchema,
paddleWaterContextSchema,
plannableTourTypeSchema,
runProfileSchema,
} from './tours.schema';
@@ -41,17 +42,24 @@ export type TourGpxPreviewResponse = z.infer<typeof tourGpxPreviewResponseSchema
* form sends them. Left out, the import is a hike, as it always was; a paddle
* import names its boat and water, and a bike import and a run import (RUN-01)
* their profile, like a planned tour of that type does. A run import has no
* pace: its time stays unknown until a run is planned from it.
* pace: its time stays unknown until a run is planned from it. A ski import
* (SKI-02) names its discipline, a cross-country one its technique too; the
* track is kept as recorded, with no time worked out for it.
*/
export const tourGpxImportFieldsSchema = z
.object({
tour_type: plannableTourTypeSchema.default('hike'),
tour_type: importableTourTypeSchema.default('hike'),
bike_profile: bikeProfileSchema.optional(),
run_profile: runProfileSchema.optional(),
boat_type: paddleBoatTypeSchema.optional(),
water_context: paddleWaterContextSchema.optional(),
ski_discipline: skiDisciplineSchema.optional(),
ski_technique: skiTechniqueSchema.optional(),
})
.superRefine((value, ctx) => {
skiIssues(value.tour_type === 'ski', value).forEach((issue) =>
ctx.addIssue({ code: z.ZodIssueCode.custom, ...issue }),
);
const owners = { bike_profile: 'bike', run_profile: 'run', boat_type: 'kayak', water_context: 'kayak' } as const;
const names = { bike: 'a bike import', run: 'a run import', kayak: 'a paddle import' } as const;
for (const [field, owner] of Object.entries(owners) as Array<[keyof typeof owners, keyof typeof names]>) {
+13
View File
@@ -6,6 +6,7 @@ import {
trackMarksSchema,
} from './paddle-plan.schema';
import { runPaceSecondsPerKmSchema } from './run-pace';
import { skiDisciplineSchema, skiTechniqueSchema } from './ski.schema';
import { tourPoiCategorySchema } from './tour-poi-category';
import { tourOriginSchema, tourProvenanceSchema } from './tour-provenance.schema';
@@ -37,6 +38,14 @@ export type TourTypeKey = z.infer<typeof tourTypeKeySchema>;
export const plannableTourTypeSchema = z.enum(['hike', 'walk', 'run', 'bike', 'kayak']);
export type PlannableTourType = z.infer<typeof plannableTourTypeSchema>;
/**
* The types a tour can be imported and kept as: every type the planner plans,
* and ski (SKI-02), which TREK does not route. A ski track comes in as it was
* recorded and stays so; the planner never opens it as a draft.
*/
export const importableTourTypeSchema = z.enum(['hike', 'walk', 'run', 'bike', 'kayak', 'ski']);
export type ImportableTourType = z.infer<typeof importableTourTypeSchema>;
/**
* BIK-01 to BIK-04: what a bike tour is ridden on, the one profile it is routed
* for. Touring/city keeps to paved ways, cycle paths and quiet roads; road
@@ -194,6 +203,10 @@ export const tourSchema = z.object({
run_profile: runProfileSchema.nullable().optional(),
/** RUN-01: the pace a run is planned at, seconds per km; null while none is entered and for every other type. */
run_pace_seconds_per_km: z.number().nullable().optional(),
/** SKI-02: a ski track's discipline; null for every other type. Absent in a list cached before it existed. */
ski_discipline: skiDisciplineSchema.nullable().optional(),
/** SKI-02: how a cross-country track is skied; null for every other discipline and type. */
ski_technique: skiTechniqueSchema.nullable().optional(),
/** A paddle tour's boat and water; null for every other type. Absent in a list cached before they existed. */
boat_type: paddleBoatTypeSchema.nullable().optional(),
water_context: paddleWaterContextSchema.nullable().optional(),
+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 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` |
| `list_tours` | The saved tours of a trip: name, type (`hike`, `walk` for a city walk, `run`, `bike`, `kayak` for a paddle tour, or `ski` for a ski track), origin (`trek` for planned in TREK, `gpx` for a GPX import, `wanderer` for a wanderer snapshot), distance, ascent and descent (unknown for a paddle tour drawn in the planner), the stored routing limit of a hike on the SAC scale and its route choices (`hiking_preferences`), the winter label of a hike (`winter_activity`: `winter_hike` or `snowshoe`; a label only, the route and its time are planned as for any hike, never a statement about winter conditions), the classification of a ski track (`ski_discipline`: `alpine`, `snowboard`, `cross_country` or `touring`; `ski_technique` for cross-country only: `classic`, `skating` or `unknown`; a ski track is imported from a GPX file and kept as recorded, never routed, has no ski time worked out and says nothing about snow, avalanches or open runs), the boat, water and chosen speed of a paddle tour, the check bench of a paddle tour (`paddle_check`: how far the OpenStreetMap obstacle data covers its route and how many obstacles are open, to check again or decided; a planning status, never a safety or legal clearance), for a city walk how many stations it has (`station_count`), their stays as far as entered and how many have none, whether a tour still waits for its way to be planned (`route_pending`: saved with its points only, its distance and walking time unknown until someone opens it in the planner and saves it), 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` |