mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user