mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
feat(tours): plan ski and winter tours, snowshoeing and winter hiking among them
Ski becomes "Ski & winter", a type the planner plans with six disciplines (alpine, snowboard, cross-country, ski touring, snowshoeing, winter hiking), nothing preselected; saving needs a discipline, and a technique for cross-country. Every leg is drawn straight between the points through planner/skiRouting.ts, the one place the piste router will plug in, with its heights from the elevation source and no ski time worked out. The hike's winter label goes entirely: its migration, column, field, marks, GPX rule, MCP field and strings. A hike is summer only; a ski tour with the snowshoe discipline exports as snowshoeing. The unreleased ski migration takes the six disciplines and the collection migration loses the winter column. The pistes are offered for a ski tour, shown until switched off, and with no tour open, never for a hike. Export, copy, collections, stages (untimed), actual tracks, the atlas, list and day marks (one glyph per discipline), 3D, planning over a ski import and list_tours/get_tour_plan know the planned ski tour.
This commit is contained in:
@@ -89,4 +89,9 @@ describe('tourActivityStatsModel', () => {
|
||||
]);
|
||||
expect(typeOptions([entry({ tour_type: 'walk', date: TODAY })], TODAY)).toEqual(['all']);
|
||||
});
|
||||
|
||||
it('FE-STA2-MODEL-008: a planned ski tour is a type of its own, after the types planned before it', () => {
|
||||
const entries = [entry({ tour_type: 'ski' }), entry({ tour_type: 'horse' }), entry({ tour_type: 'hike' })];
|
||||
expect(typeOptions(entries, TODAY)).toEqual(['all', 'hike', 'horse', 'ski']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -209,7 +209,7 @@ interface OverlayLook {
|
||||
|
||||
const OVERLAY_LOOK: Record<string, OverlayLook> = {
|
||||
seamarks: { Icon: Anchor, labelKey: 'tours.map.seamarks', downKey: 'tours.map.seamarksDown' },
|
||||
// SKI-01: a hike's row already holds four maps, the ferratas and 3D, so the pistes go by their icon.
|
||||
// SKI-01: the row already holds the maps and 3D, so the pistes go by their icon.
|
||||
pistes: {
|
||||
Icon: MountainSnow,
|
||||
labelKey: 'tours.map.pistes',
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Tooltip } from '../shared/Tooltip';
|
||||
|
||||
/**
|
||||
* A frosted badge on the top corner of a tour's round tile, carrying one
|
||||
* icon: a hike's winter label (HIK-01), a ski track's discipline (SKI-02).
|
||||
* icon: a ski tour's discipline (SKI-02).
|
||||
* Its tooltip and accessible name say what the icon stands for.
|
||||
*/
|
||||
export function TourCornerMark({
|
||||
|
||||
@@ -15,16 +15,18 @@ import { movingTimeLabel } from './tourFacts';
|
||||
import { formatPlannedTourDuration, plannedTotalText, tourPlannedTimes } from './tourPresentation';
|
||||
import { useWalkProgress } from './walk/walkProgress';
|
||||
import { walkStaysText } from './walk/walkTimeFields';
|
||||
import { WinterCornerMark } from './winter/winterMarks';
|
||||
|
||||
/** Whether the app names this type: one it plans or keeps (a ski track, SKI-02), not a key only the catalogue holds. */
|
||||
/** Whether the app names this type: one it plans or keeps, not a key only the catalogue holds. */
|
||||
function knownType(type: string): boolean {
|
||||
return importableTourTypeSchema.safeParse(type).success;
|
||||
}
|
||||
|
||||
/** The icon the time on the move goes by: a bike tour, a ride on horseback, a run or a paddle by its type's own, any walk by footprints. */
|
||||
/**
|
||||
* The icon the time on the move goes by: a bike tour, a ride on horseback, a
|
||||
* run, a paddle or a ski tour by its type's own, any walk by footprints.
|
||||
*/
|
||||
function movingIconType(type: string): string {
|
||||
return type === 'kayak' || type === 'bike' || type === 'run' || type === 'horse' ? type : 'walk';
|
||||
return type === 'kayak' || type === 'bike' || type === 'run' || type === 'horse' || type === 'ski' ? type : 'walk';
|
||||
}
|
||||
|
||||
export function TourDayRowIcon({ type, size = 27 }: { type: string; size?: number }) {
|
||||
@@ -95,26 +97,23 @@ function Fact({
|
||||
/**
|
||||
* A tour's tile in the day plan and in its detail: the T-scale grade in a round
|
||||
* tile, the same tile the tour lists use, so a tour reads alike wherever it
|
||||
* shows. A GPX import has no grade and shows its type's icon instead. A hike's
|
||||
* winter label (HIK-01) or a ski track's discipline (SKI-02) sits on the
|
||||
* tile's corner, as in the tour lists.
|
||||
* shows. A GPX import has no grade and shows its type's icon instead. A ski
|
||||
* tour's discipline (SKI-02) sits on the tile's corner, as in the tour lists.
|
||||
*/
|
||||
export function TourDayRowAvatar({
|
||||
tour,
|
||||
size = 32,
|
||||
}: {
|
||||
tour: Pick<TourListItem, 'tour_type' | 'max_hiking_difficulty'> &
|
||||
Partial<Pick<TourListItem, 'winter_activity' | 'ski_discipline' | 'ski_technique'>>;
|
||||
Partial<Pick<TourListItem, 'ski_discipline' | 'ski_technique'>>;
|
||||
size?: number;
|
||||
}) {
|
||||
const avatar = <TourGradeAvatar tour={tour} size={size} />;
|
||||
const ski = tour.tour_type === 'ski' && !!tour.ski_discipline;
|
||||
if (!tour.winter_activity && !ski) return avatar;
|
||||
if (tour.tour_type !== 'ski' || !tour.ski_discipline) return avatar;
|
||||
const markSize = Math.round(size * 0.44);
|
||||
return (
|
||||
<span className="relative flex flex-none">
|
||||
{avatar}
|
||||
<WinterCornerMark activity={tour.winter_activity} size={markSize} />
|
||||
<SkiCornerMark tour={tour} size={markSize} />
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -306,16 +306,16 @@ describe('TourDetailDialog', () => {
|
||||
expect(screen.queryByText(/Based on/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('HIK-01: names the winter label of a hike in its head and on its tile, and nothing for a hike without one', () => {
|
||||
it('SKI-02: names a snowshoe tour by its discipline in its head and on its tile, and a hike by none', () => {
|
||||
const { unmount } = render(<TourDetailDialog {...defaultProps} />)
|
||||
expect(screen.queryByTestId('tour-detail-winter')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('tour-winter-mark')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('tour-detail-ski')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('tour-ski-mark')).not.toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, winter_activity: 'snowshoe' }} />)
|
||||
expect(screen.getByTestId('tour-detail-winter')).toHaveTextContent('Snowshoe hike')
|
||||
expect(screen.getByRole('img', { name: 'Snowshoe hike' })).toBeInTheDocument()
|
||||
expect(screen.getAllByText('T2').length).toBeGreaterThan(0)
|
||||
const snowshoe = { ...tour, tour_type: 'ski' as const, max_hiking_difficulty: null, ski_discipline: 'snowshoe' as const }
|
||||
render(<TourDetailDialog {...defaultProps} tour={snowshoe} />)
|
||||
expect(screen.getByTestId('tour-detail-ski')).toHaveTextContent('Snowshoeing')
|
||||
expect(screen.getByRole('img', { name: 'Snowshoeing' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ACT-01: a recording that is the actual track of a tour names that tour in its head', () => {
|
||||
|
||||
@@ -76,7 +76,6 @@ import { useTourPermissions, type TourPermissionProps } from './useTourPermissio
|
||||
import { WalkRoutePendingHint } from './walk/WalkRoutePending';
|
||||
import { WalkStationsCard } from './walk/WalkStations';
|
||||
import { walkTimeFields } from './walk/walkTimeFields';
|
||||
import { WinterActivityPill } from './winter/winterMarks';
|
||||
|
||||
interface TourDetailDialogProps extends TourPermissionProps {
|
||||
tour: TourListItem;
|
||||
@@ -220,7 +219,7 @@ function tourTimeFields(
|
||||
}
|
||||
|
||||
/**
|
||||
* The head's facts as pills: the type, a hike's winter label, a run's profile and pace or a ski track's
|
||||
* The head's facts as pills: the type, a run's profile and pace or a ski tour's
|
||||
* discipline, where it came from, the time (its parts
|
||||
* in the tooltip), distance, ascent, descent, and a caution.
|
||||
*/
|
||||
@@ -267,11 +266,9 @@ function TourHeadPills({
|
||||
})}
|
||||
{t(`tourTypes.${tour.tour_type}`)}
|
||||
</span>
|
||||
{/* HIK-01: a hike's winter label, the travellers' own, beside its type. */}
|
||||
{tour.winter_activity && <WinterActivityPill activity={tour.winter_activity} className={HEAD_PILL} />}
|
||||
{/* RUN-01: a run's profile and the pace its time is planned at. */}
|
||||
<RunHeadPills tour={tour} className={HEAD_PILL} />
|
||||
{/* SKI-02: a ski track's discipline, a cross-country technique inside it. */}
|
||||
{/* SKI-02: a ski tour's discipline, a cross-country technique inside it. */}
|
||||
<SkiHeadPill tour={tour} className={HEAD_PILL} />
|
||||
{/* A paddle tour names its boat and its water, each a badge of its own. */}
|
||||
{tour.boat_type && (
|
||||
|
||||
@@ -141,6 +141,15 @@ describe('the GPX preset', () => {
|
||||
});
|
||||
expect(importFieldsFor(tour({ tour_type: 'horse' }))).toEqual({ tour_type: 'horse' });
|
||||
});
|
||||
|
||||
it('FE-ACT-MODEL-010: the track of a planned snowshoe or winter hike tour comes in as ski with that discipline (SKI-02)', () => {
|
||||
for (const discipline of ['snowshoe', 'winter_hike'] as const) {
|
||||
expect(importFieldsFor(tour({ tour_type: 'ski', ski_discipline: discipline, ski_technique: null }))).toEqual({
|
||||
tour_type: 'ski',
|
||||
ski_discipline: discipline,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('measures', () => {
|
||||
|
||||
@@ -370,10 +370,10 @@ describe('CollectionToursSection', () => {
|
||||
await waitFor(() => expect(screen.queryByTestId('collection-tours')).toBeNull());
|
||||
});
|
||||
|
||||
it('FE-COLLECTION-TOURS-015: a kept tour looks as it did in its trip list: winter mark, run pace and stages', async () => {
|
||||
it('FE-COLLECTION-TOURS-015: a kept tour looks as it did in its trip list: ski mark, run pace and stages', async () => {
|
||||
vi.spyOn(collectionToursRepo, 'list').mockResolvedValue({
|
||||
tours: [
|
||||
kept({ id: 5, winter_activity: 'snowshoe', stage_count: 3 }),
|
||||
kept({ id: 5, tour_type: 'ski', max_hiking_difficulty: null, ski_discipline: 'snowshoe', stage_count: 3 }),
|
||||
kept({
|
||||
id: 6,
|
||||
tour_type: 'run',
|
||||
@@ -384,10 +384,10 @@ describe('CollectionToursSection', () => {
|
||||
],
|
||||
});
|
||||
render(<CollectionToursSection collectionId={3} canRemove={false} />);
|
||||
const hike = await screen.findByTestId('kept-tour-5');
|
||||
expect(within(hike).getByTestId('tour-winter-mark')).toBeInTheDocument();
|
||||
expect(within(hike).getByTestId('kept-tour-stages')).toHaveTextContent('3 stages');
|
||||
expect(within(hike).queryByTestId('kept-tour-pace')).toBeNull();
|
||||
const ski = await screen.findByTestId('kept-tour-5');
|
||||
expect(within(ski).getByTestId('tour-ski-mark')).toBeInTheDocument();
|
||||
expect(within(ski).getByTestId('kept-tour-stages')).toHaveTextContent('3 stages');
|
||||
expect(within(ski).queryByTestId('kept-tour-pace')).toBeNull();
|
||||
const run = screen.getByTestId('kept-tour-6');
|
||||
expect(within(run).getByTestId('kept-tour-pace')).toHaveTextContent('6:00');
|
||||
expect(within(run).queryByTestId('kept-tour-stages')).toBeNull();
|
||||
|
||||
@@ -215,11 +215,12 @@ describe('useGpxConversion', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('SKI-02: a ski track is never planned over: nothing is offered and no router is asked', () => {
|
||||
it('SKI-02: a ski track is planned over as a ski tour of its discipline, its legs drawn straight', async () => {
|
||||
const ski = {
|
||||
...imported,
|
||||
tour_type: 'ski' as const,
|
||||
ski_discipline: 'touring' as const,
|
||||
ski_discipline: 'cross_country' as const,
|
||||
ski_technique: 'classic' as const,
|
||||
max_hiking_difficulty: null,
|
||||
};
|
||||
const controller = planner({
|
||||
@@ -227,6 +228,29 @@ describe('useGpxConversion', () => {
|
||||
readOnlyGpxTour: { tour: ski, routeGeometry: null },
|
||||
} as Partial<TourPlannerController>);
|
||||
const { result } = renderHook(() => useGpxConversion(controller));
|
||||
expect(result.current.available).toBe(true);
|
||||
expect(result.current.paddle).toBe(true);
|
||||
await act(async () => {
|
||||
await result.current.analyze();
|
||||
});
|
||||
const legs = routing.route.mock.calls[0]?.[0] as Array<{ fixedLeg: { kind: string } | null }>;
|
||||
expect(legs.slice(0, -1).every((point) => point.fixedLeg?.kind === 'free')).toBe(true);
|
||||
expect(routing.run).not.toHaveBeenCalled();
|
||||
act(() => {
|
||||
result.current.adopt('new');
|
||||
});
|
||||
expect(controller.startDerivedTour).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tourType: 'ski', ski: { discipline: 'cross_country', technique: 'classic' } })
|
||||
);
|
||||
});
|
||||
|
||||
it('SKI-02: a ski track that names no discipline is not planned over: nothing is offered and no router is asked', () => {
|
||||
const ski = { ...imported, tour_type: 'ski' as const, ski_discipline: null, max_hiking_difficulty: null };
|
||||
const controller = planner({
|
||||
mode: { type: 'view-gpx', placeId: ski.place_id, tour: ski },
|
||||
readOnlyGpxTour: { tour: ski, routeGeometry: null },
|
||||
} as Partial<TourPlannerController>);
|
||||
const { result } = renderHook(() => useGpxConversion(controller));
|
||||
expect(result.current.available).toBe(false);
|
||||
const { container } = render(<TourConversionSection conversion={result.current} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
|
||||
@@ -77,7 +77,13 @@ export default function TourConversionSection({ conversion }: { conversion: GpxC
|
||||
return card(
|
||||
<div className="flex flex-col items-start gap-2.5">
|
||||
<p className="m-0 leading-snug text-content-secondary" style={fs(12, 'body')}>
|
||||
{t(conversion.paddle ? 'tours.conversion.introPaddle' : 'tours.conversion.intro')}
|
||||
{t(
|
||||
conversion.ski
|
||||
? 'tours.conversion.introSki'
|
||||
: conversion.paddle
|
||||
? 'tours.conversion.introPaddle'
|
||||
: 'tours.conversion.intro'
|
||||
)}
|
||||
</p>
|
||||
<CardAction onClick={() => void conversion.analyze()} icon={<Route size={12} strokeWidth={2.2} />}>
|
||||
{t('tours.conversion.analyze')}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { isTourRoute } from '../planner/tourRouteOutcome';
|
||||
import { routeBikeTour, routeRun, routeWalkingTour } from '../planner/tourRouting';
|
||||
import { paddleFieldsOf } from '../planner/usePaddleFields';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { skiChoiceMissing, skiChoiceOf } from '../ski/skiChoice';
|
||||
import { compareRoutes, deriveControlPoints, type RouteComparison, type TrackPoint } from './gpxConversion';
|
||||
|
||||
/** The grade a planned version routes with when the import carries none: the paths a T3 walk may take. */
|
||||
@@ -47,6 +48,9 @@ export function useGpxConversion(planner: TourPlannerController) {
|
||||
// RUN-01: a run import is planned as a run for its profile, at the pace it holds; a fresh
|
||||
// import holds none, so its pace is the runner's to enter.
|
||||
const runProfile = tour?.tour_type === 'run' ? (tour.run_profile ?? 'running') : null;
|
||||
// SKI-02: a ski import is planned as a ski tour of its discipline, its legs drawn as the planner draws them.
|
||||
const ski = useMemo(() => (tour?.tour_type === 'ski' ? skiChoiceOf(tour) : null), [tour]);
|
||||
const drawn = paddle || ski !== null;
|
||||
|
||||
useEffect(() => () => abort.current?.abort(), []);
|
||||
|
||||
@@ -63,7 +67,7 @@ export function useGpxConversion(planner: TourPlannerController) {
|
||||
return;
|
||||
}
|
||||
setState({ ...base, status: 'analyzing' });
|
||||
const legs = paddle
|
||||
const legs = drawn
|
||||
? points.map((point, index) => ({
|
||||
...point,
|
||||
fixedLeg: index < points.length - 1 ? freeLegBetween(point, points[index + 1]) : null,
|
||||
@@ -86,7 +90,7 @@ export function useGpxConversion(planner: TourPlannerController) {
|
||||
line: routed.coordinates,
|
||||
comparison: compareRoutes(trackPoints, suggestion),
|
||||
});
|
||||
}, [bikeProfile, difficulty, paddle, runProfile, tour, track]);
|
||||
}, [bikeProfile, difficulty, drawn, runProfile, tour, track]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
abort.current?.abort();
|
||||
@@ -115,11 +119,12 @@ export function useGpxConversion(planner: TourPlannerController) {
|
||||
...(runProfile
|
||||
? { tourType: 'run' as const, runProfile, runPaceSecondsPerKm: tour.run_pace_seconds_per_km ?? null }
|
||||
: {}),
|
||||
...(ski ? { tourType: 'ski' as const, ski } : {}),
|
||||
});
|
||||
if (started) setState(IDLE);
|
||||
return started;
|
||||
},
|
||||
[bikeProfile, current.points, current.status, difficulty, paddle, runProfile, startDerivedTour, tour]
|
||||
[bikeProfile, current.points, current.status, difficulty, paddle, runProfile, ski, startDerivedTour, tour]
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
@@ -127,23 +132,25 @@ export function useGpxConversion(planner: TourPlannerController) {
|
||||
...current,
|
||||
/**
|
||||
* The import shown can be planned from: it has a track and the user may add tours. A ski
|
||||
* track (SKI-02) never: TREK routes no ski track, and a hike's or a walk's route is no stand-in.
|
||||
* A ride (HOR-01) neither: no router plans one, and walking it again on foot would be no ride.
|
||||
* track (SKI-02) once it names its discipline, planned as a ski tour with its legs drawn.
|
||||
* A ride (HOR-01) never: no router plans one, and walking it again on foot would be no ride.
|
||||
*/
|
||||
available:
|
||||
tour !== null &&
|
||||
tour.tour_type !== 'ski' &&
|
||||
(ski === null || skiChoiceMissing(ski) === null) &&
|
||||
tour.tour_type !== 'horse' &&
|
||||
(track?.length ?? 0) >= 2 &&
|
||||
planner.canEdit,
|
||||
difficulty,
|
||||
/** The import is a paddle tour: its version is drawn with straight legs, not routed. */
|
||||
paddle,
|
||||
/** The import is a paddle or a ski tour: its version is drawn with straight legs, not routed. */
|
||||
paddle: drawn,
|
||||
/** SKI-02: the import is a ski track, planned as a ski tour of its discipline. */
|
||||
ski: ski !== null,
|
||||
analyze,
|
||||
cancel,
|
||||
adopt,
|
||||
}),
|
||||
[adopt, analyze, cancel, current, difficulty, paddle, planner.canEdit, tour, track]
|
||||
[adopt, analyze, cancel, current, difficulty, drawn, planner.canEdit, ski, tour, track]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import { FoldButton, InfoDot, TourNotice, TourSection } from '../tourParts';
|
||||
import { HikingPreferencesField } from './HikingPreferencesField';
|
||||
import { TOUR_DURATION_INPUT_MAX_LENGTH, formatTourDuration, parseTourDuration } from './tourDurationInput';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
import { WinterActivityField } from './WinterActivityField';
|
||||
|
||||
type Difficulty = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
@@ -83,6 +82,7 @@ export function TourNameField({ planner, locked }: TourFieldProps) {
|
||||
walk: t('tours.planner.namePlaceholder.walk'),
|
||||
run: t('tours.planner.namePlaceholder.run'),
|
||||
horse: t('tours.planner.namePlaceholder.horse'),
|
||||
ski: t('tours.planner.namePlaceholder.ski'),
|
||||
}[planner.tourType]
|
||||
}
|
||||
className={INPUT}
|
||||
@@ -327,8 +327,7 @@ function TourDifficultyField({
|
||||
}
|
||||
|
||||
/**
|
||||
* Description, website, a hike's difficulty cap, route choice and winter label
|
||||
* (HIK-01), and the time, folded away with the chevron. The section opens by itself while one of its fields holds a value
|
||||
* Description, website, a hike's difficulty cap and route choice, and the time, folded away with the chevron. The section opens by itself while one of its fields holds a value
|
||||
* the planner refuses, so the reason Save is held back is never out of sight.
|
||||
*/
|
||||
export function TourDetailsSection({
|
||||
@@ -367,7 +366,6 @@ export function TourDetailsSection({
|
||||
<>
|
||||
<TourDifficultyField planner={planner} locked={locked} onDifficultyChange={onDifficultyChange} />
|
||||
<HikingPreferencesField planner={planner} locked={locked} />
|
||||
<WinterActivityField planner={planner} locked={locked} />
|
||||
</>
|
||||
)}
|
||||
<TourTimeFields planner={planner} locked={locked} />
|
||||
|
||||
@@ -670,6 +670,8 @@ const ESTIMATE_KEYS = {
|
||||
run: { labelKey: 'tours.run.timeEstimated', shortKey: 'tours.run.timeEstimatedShort', infoLabelKey: 'tours.run.timeEstimatedInfoLabel', infoKey: 'tours.run.timeEstimatedInfo' },
|
||||
// HOR-01: a ride has no estimate; the field says why, and the planned total is the rider's own.
|
||||
horse: { labelKey: 'tours.horse.time', shortKey: 'tours.horse.time', infoLabelKey: 'tours.horse.timeInfoLabel', infoKey: 'tours.horse.timeInfo' },
|
||||
// SKI-02: nor has a ski tour, whose legs are drawn; the field says so.
|
||||
ski: { labelKey: 'tours.ski.time', shortKey: 'tours.ski.time', infoLabelKey: 'tours.ski.timeInfoLabel', infoKey: 'tours.ski.timeInfo' },
|
||||
} as const
|
||||
|
||||
/** Which ways a point is out of reach of: paths for hikes, city walks and rides, ways for bikes, runnable ways. */
|
||||
@@ -678,6 +680,7 @@ const UNREACHABLE_WAYS: Record<PlannableTourType, 'hike' | 'bike' | 'run'> = {
|
||||
walk: 'hike',
|
||||
kayak: 'hike',
|
||||
horse: 'hike',
|
||||
ski: 'hike',
|
||||
bike: 'bike',
|
||||
run: 'run',
|
||||
}
|
||||
@@ -730,7 +733,7 @@ function plannerTimeFields(planner: TourPlannerController, t: Translate): TourMe
|
||||
{
|
||||
...estimate,
|
||||
value: planner.walkingDurationMinutes == null
|
||||
? t('tours.planner.walkingTimeUnknown')
|
||||
? t(planner.isSki ? 'tours.ski.timeUnknown' : 'tours.planner.walkingTimeUnknown')
|
||||
: formatPlannedTourDuration(planner.walkingDurationMinutes),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
|
||||
import { hikePlannerDefaults } from '../../../../tests/helpers/tourPlannerStub';
|
||||
import { TourTypeFields } from './TourTypeFields';
|
||||
@@ -39,8 +39,8 @@ describe('TourTypeFields (PAD-01)', () => {
|
||||
expect(screen.getByTestId('tour-type-option-bike')).toHaveTextContent(
|
||||
'By bike, routed for touring, road, gravel or MTB'
|
||||
);
|
||||
// SKI-02: ski is offered by the GPX import only; the planner routes no ski track.
|
||||
expect(screen.queryByTestId('tour-type-option-ski')).toBeNull();
|
||||
// SKI-02: ski and winter tours are planned too, under the type's new name.
|
||||
expect(screen.getByTestId('tour-type-option-ski')).toHaveTextContent('Ski & winter');
|
||||
fireEvent.click(screen.getByTestId('tour-type-option-bike'));
|
||||
expect(hike.setTourType).toHaveBeenCalledWith('bike');
|
||||
expect(field).toHaveAttribute('aria-expanded', 'false');
|
||||
@@ -124,6 +124,52 @@ describe('TourTypeFields (PAD-01)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('TourTypeFields ski and winter (SKI-02)', () => {
|
||||
const skiPlanner = (overrides: Record<string, unknown> = {}) =>
|
||||
planner({ tourType: 'ski', isPaddle: false, isSki: true, skiMissing: 'discipline', ...overrides });
|
||||
|
||||
it('offers six disciplines with nothing chosen, says what is missing and how the legs are drawn', () => {
|
||||
const ski = skiPlanner();
|
||||
render(<TourTypeFields planner={ski} locked={false} />);
|
||||
const fields = screen.getByTestId('tour-planner-ski-fields');
|
||||
for (const name of ['Alpine', 'Snowboard', 'Cross-country', 'Ski touring', 'Snowshoeing', 'Winter hiking']) {
|
||||
expect(screen.getByRole('button', { name })).toHaveAttribute('aria-pressed', 'false');
|
||||
}
|
||||
expect(fields).toHaveTextContent('Choose a discipline to save the tour.');
|
||||
expect(fields).toHaveTextContent('Each leg is drawn straight between your points.');
|
||||
expect(screen.queryByTestId('tour-planner-paddle-fields')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Snowshoeing' }));
|
||||
expect(ski.ski.change).toHaveBeenCalledWith({ discipline: 'snowshoe', technique: null });
|
||||
});
|
||||
|
||||
it('asks a cross-country tour for its technique and keeps it dimmed for any other discipline', () => {
|
||||
const crossCountry = skiPlanner({
|
||||
ski: { choice: { discipline: 'cross_country', technique: null }, change: vi.fn(), load: vi.fn() },
|
||||
skiMissing: 'technique',
|
||||
});
|
||||
const { unmount } = render(<TourTypeFields planner={crossCountry} locked={false} />);
|
||||
expect(screen.getByTestId('tour-ski-technique')).not.toBeDisabled();
|
||||
expect(screen.getByTestId('tour-planner-ski-fields')).toHaveTextContent('Choose the technique, or Unknown.');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Skating' }));
|
||||
expect(crossCountry.ski.change).toHaveBeenCalledWith({ discipline: 'cross_country', technique: 'skating' });
|
||||
unmount();
|
||||
|
||||
const alpine = skiPlanner({
|
||||
ski: { choice: { discipline: 'alpine', technique: null }, change: vi.fn(), load: vi.fn() },
|
||||
skiMissing: null,
|
||||
});
|
||||
render(<TourTypeFields planner={alpine} locked={false} />);
|
||||
expect(screen.getByTestId('tour-ski-technique')).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Alpine' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no ski fields on any other tour', () => {
|
||||
render(<TourTypeFields planner={planner({ tourType: 'hike', isPaddle: false })} locked={false} />);
|
||||
expect(screen.queryByTestId('tour-planner-ski-fields')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TourTypeFields lake style (PAD-11)', () => {
|
||||
it('asks how to cross lakes on lake water or a route over a lake, straight over by default, and hands a change on', () => {
|
||||
const river = planner({}, { waterContext: 'river' });
|
||||
|
||||
@@ -14,6 +14,7 @@ import { fs } from '../../shared/DialogShell';
|
||||
import { crossesLake } from '../paddle/lakeStyleAdvice';
|
||||
import { PaddleLakeStyleField } from '../paddle/PaddleLakeStyleField';
|
||||
import { RunFields } from '../run/RunFields';
|
||||
import { SkiClassificationFields } from '../ski/SkiClassificationFields';
|
||||
import { TourNotice } from '../tourParts';
|
||||
import { TourTypeSelect } from './TourTypeSelect';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
@@ -105,11 +106,28 @@ function BikeProfileField({ planner }: { planner: TourPlannerController }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-01, BIK-01, RUN-01: what the tour is. A hike keeps its fields where they
|
||||
* were; a bike tour names its profile; a run its profile and the runner's pace;
|
||||
* a paddle tour asks for exactly one boat and the water it is on, and offers
|
||||
* the boat's planning speed beside the paddler's own. Nothing here puts the
|
||||
* tour on a day.
|
||||
* SKI-02: what a ski tour is, chosen on purpose and nothing preselected: the
|
||||
* discipline and, for cross-country, the technique. A short line says how its
|
||||
* legs are laid: straight between the points, nothing checked against pistes.
|
||||
*/
|
||||
function SkiFields({ planner }: { planner: TourPlannerController }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div data-testid="tour-planner-ski-fields" className="flex min-w-0 flex-col gap-1.5">
|
||||
<SkiClassificationFields choice={planner.ski.choice} onChange={planner.ski.change} planned />
|
||||
<span className="block text-content-faint" style={fs(11, 'body')}>
|
||||
{t('tours.ski.drawnFreely')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-01, BIK-01, RUN-01, SKI-02: what the tour is. A hike keeps its fields
|
||||
* where they were; a bike tour names its profile; a run its profile and the
|
||||
* runner's pace; a ski tour its discipline; a paddle tour asks for exactly one
|
||||
* boat and the water it is on, and offers the boat's planning speed beside the
|
||||
* paddler's own. Nothing here puts the tour on a day.
|
||||
*/
|
||||
export function TourTypeFields({ planner, locked }: TourTypeFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -134,6 +152,8 @@ export function TourTypeFields({ planner, locked }: TourTypeFieldsProps) {
|
||||
|
||||
{planner.isRun && <RunFields planner={planner} locked={locked} />}
|
||||
|
||||
{planner.isSki && <SkiFields planner={planner} />}
|
||||
|
||||
{planner.isPaddle && (
|
||||
<div data-testid="tour-planner-paddle-fields" className="flex min-w-0 flex-col gap-3">
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -24,10 +24,10 @@ function TypeTile({ type, active = false }: { type: ImportableTourType; active?:
|
||||
/**
|
||||
* What the tour is, as one field that opens the types with what each one
|
||||
* plans: a hike on footpaths within its limit, a bike tour on its profile, a
|
||||
* paddle tour on the water, a ride along horse routes. Room for the types
|
||||
* still to come without the row of buttons it was growing too long for. The
|
||||
* planner offers the types it plans; the GPX import offers ski as well
|
||||
* (SKI-02), a track it only keeps.
|
||||
* paddle tour on the water, a ride along horse routes, a ski and winter tour
|
||||
* (SKI-02) drawn point to point. Room for the types still to come without the
|
||||
* row of buttons it was growing too long for. The planner offers the types it
|
||||
* plans, the GPX import the types a track can be kept as.
|
||||
*/
|
||||
export function TourTypeSelect<T extends ImportableTourType = PlannableTourType>({
|
||||
value,
|
||||
|
||||
@@ -370,7 +370,8 @@ type WaypointPoint = TourPlannerController['waypoints'][number];
|
||||
* tour: on the water), planned again (P2A-05), other ways for it compared on
|
||||
* a hike (RTE-02), and the point a stop or a shaping point (P2A-03; never the
|
||||
* first or the last). A ride (HOR-01) has none: no router plans any of its
|
||||
* legs, so there is nothing to switch.
|
||||
* legs, so there is nothing to switch. Nor has a ski tour's leg (SKI-02),
|
||||
* drawn straight until the piste router plans it; its points can still shape.
|
||||
*/
|
||||
function routeMenuItems(
|
||||
planner: TourPlannerController,
|
||||
@@ -382,9 +383,9 @@ function routeMenuItems(
|
||||
if (planner.isHorse) return [];
|
||||
const hasLeg = index < lastIndex;
|
||||
const free = point.fixedLeg?.kind === 'free';
|
||||
const routed = hasLeg && !point.fixedLeg;
|
||||
const routed = hasLeg && !point.fixedLeg && !planner.isSki;
|
||||
const items: MenuItems = [];
|
||||
if (free || routed) {
|
||||
if ((free || routed) && !planner.isSki) {
|
||||
const routeAgain = planner.isPaddle ? t('tours.paddle.routeOnWater') : t('tours.planner.routeLeg');
|
||||
items.push(
|
||||
{ divider: true },
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { WinterActivity } from '@trek/shared';
|
||||
import { useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { WinterActivityField } from './WinterActivityField';
|
||||
|
||||
// FE-TOUR-WINTER-FIELD-001 to FE-TOUR-WINTER-FIELD-006
|
||||
|
||||
const toggle = () => screen.getByRole('button', { name: 'tours.winter.title' });
|
||||
const option = (activity: WinterActivity) => screen.getByRole('button', { name: `tours.winter.activity.${activity}` });
|
||||
|
||||
/**
|
||||
* The field over a planner that keeps what it is given and the label the
|
||||
* switch brings back, as the real one does. Reset and open stand for the
|
||||
* planner starting a new tour and opening a saved one.
|
||||
*/
|
||||
function Stateful({ initial = null }: { initial?: WinterActivity | null }) {
|
||||
const [winterActivity, setActivity] = useState<WinterActivity | null>(initial);
|
||||
const [winterActivityChoice, setChoice] = useState<WinterActivity>(initial ?? 'winter_hike');
|
||||
const setWinterActivity = (value: WinterActivity | null) => {
|
||||
setActivity(value);
|
||||
if (value) setChoice(value);
|
||||
};
|
||||
const load = (value: WinterActivity | null) => {
|
||||
setActivity(value);
|
||||
setChoice(value ?? 'winter_hike');
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<WinterActivityField planner={{ winterActivity, setWinterActivity, winterActivityChoice }} locked={false} />
|
||||
<output data-testid="saved">{winterActivity ?? 'none'}</output>
|
||||
<button type="button" onClick={() => load(null)}>
|
||||
reset
|
||||
</button>
|
||||
<button type="button" onClick={() => load('snowshoe')}>
|
||||
open snowshoe
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const field = (winterActivity: WinterActivity | null, setWinterActivity = vi.fn(), choice?: WinterActivity) => ({
|
||||
winterActivity,
|
||||
setWinterActivity,
|
||||
winterActivityChoice: choice ?? winterActivity ?? 'winter_hike',
|
||||
});
|
||||
|
||||
describe('WinterActivityField (HIK-01)', () => {
|
||||
it('FE-TOUR-WINTER-FIELD-001: is off by default, its choice dimmed and out of reach until it is switched on', () => {
|
||||
const setWinterActivity = vi.fn();
|
||||
render(<WinterActivityField planner={field(null, setWinterActivity)} locked={false} />);
|
||||
expect(toggle()).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.getByTestId('tour-winter-kind')).toHaveClass('opacity-50');
|
||||
expect(option('winter_hike')).toBeDisabled();
|
||||
expect(option('snowshoe')).toBeDisabled();
|
||||
fireEvent.click(option('snowshoe'));
|
||||
expect(setWinterActivity).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('button', { name: 'tours.winter.infoLabel' })).toHaveAccessibleDescription(
|
||||
'tours.winter.info'
|
||||
);
|
||||
fireEvent.click(toggle());
|
||||
expect(setWinterActivity).toHaveBeenCalledWith('winter_hike');
|
||||
});
|
||||
|
||||
it('FE-TOUR-WINTER-FIELD-002: switched on, it names the label chosen and takes the other one', () => {
|
||||
const setWinterActivity = vi.fn();
|
||||
render(<WinterActivityField planner={field('snowshoe', setWinterActivity)} locked={false} />);
|
||||
expect(toggle()).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(option('snowshoe')).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(option('winter_hike')).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.getByRole('group', { name: 'tours.winter.kind' })).toBeInTheDocument();
|
||||
fireEvent.click(option('winter_hike'));
|
||||
expect(setWinterActivity).toHaveBeenLastCalledWith('winter_hike');
|
||||
fireEvent.click(toggle());
|
||||
expect(setWinterActivity).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it('FE-TOUR-WINTER-FIELD-003: switched off and on again, it comes back with the label it had', () => {
|
||||
render(<Stateful initial="snowshoe" />);
|
||||
fireEvent.click(toggle());
|
||||
expect(screen.getByTestId('saved')).toHaveTextContent('none');
|
||||
expect(option('snowshoe')).toHaveAttribute('aria-pressed', 'true');
|
||||
fireEvent.click(toggle());
|
||||
expect(screen.getByTestId('saved')).toHaveTextContent('snowshoe');
|
||||
});
|
||||
|
||||
it('FE-TOUR-WINTER-FIELD-004: changes nothing while the tour is locked', () => {
|
||||
const setWinterActivity = vi.fn();
|
||||
render(<WinterActivityField planner={field('winter_hike', setWinterActivity)} locked />);
|
||||
fireEvent.click(toggle());
|
||||
fireEvent.click(option('snowshoe'));
|
||||
expect(setWinterActivity).not.toHaveBeenCalled();
|
||||
expect(option('snowshoe')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-TOUR-WINTER-FIELD-005: keeps no choice of its own, so the next tour switches on as winter hiking', () => {
|
||||
render(<Stateful initial="snowshoe" />);
|
||||
fireEvent.click(toggle());
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reset' }));
|
||||
expect(option('winter_hike')).toHaveAttribute('aria-pressed', 'true');
|
||||
fireEvent.click(toggle());
|
||||
expect(screen.getByTestId('saved')).toHaveTextContent('winter_hike');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'open snowshoe' }));
|
||||
fireEvent.click(toggle());
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reset' }));
|
||||
fireEvent.click(toggle());
|
||||
expect(screen.getByTestId('saved')).toHaveTextContent('winter_hike');
|
||||
});
|
||||
|
||||
it('FE-TOUR-WINTER-FIELD-006: switched on, it brings back the label the planner kept for the tour', () => {
|
||||
const setWinterActivity = vi.fn();
|
||||
render(<WinterActivityField planner={field(null, setWinterActivity, 'snowshoe')} locked={false} />);
|
||||
expect(option('snowshoe')).toHaveAttribute('aria-pressed', 'true');
|
||||
fireEvent.click(toggle());
|
||||
expect(setWinterActivity).toHaveBeenCalledWith('snowshoe');
|
||||
});
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
import { WINTER_ACTIVITIES, type WinterActivity } from '@trek/shared';
|
||||
import { Snowflake } from 'lucide-react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { BOX } from '../../Planner/bookings/bookingParts';
|
||||
import ToggleSwitch from '../../Settings/ToggleSwitch';
|
||||
import { Segmented } from '../../shared/dialogParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { InfoDot } from '../tourParts';
|
||||
import { WINTER_ACTIVITY_ICON } from '../winter/winterActivityIcon';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
|
||||
/**
|
||||
* HIK-01: a hike marked as one done in winter, off by default among the
|
||||
* details, so a hike asks for nothing more. Switched on, it is winter hiking
|
||||
* until snowshoeing is picked; switched off, the label goes and the route and
|
||||
* the days stay. The choice stays in sight but dimmed while the switch is off,
|
||||
* and comes back as it was when it is switched on again. The planner keeps
|
||||
* that choice with the tour, so a tour saved, closed or opened after a
|
||||
* snowshoe hike starts again from winter hiking. The info dot says it is a
|
||||
* label only: it plans and times nothing differently.
|
||||
*/
|
||||
export function WinterActivityField({
|
||||
planner,
|
||||
locked,
|
||||
}: {
|
||||
planner: Pick<TourPlannerController, 'winterActivity' | 'setWinterActivity' | 'winterActivityChoice'>;
|
||||
locked: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const current = planner.winterActivity ?? null;
|
||||
const on = current !== null;
|
||||
const toggle = () => {
|
||||
if (locked) return;
|
||||
planner.setWinterActivity(on ? null : planner.winterActivityChoice);
|
||||
};
|
||||
const choose = (next: WinterActivity) => {
|
||||
if (locked || !on) return;
|
||||
planner.setWinterActivity(next);
|
||||
};
|
||||
return (
|
||||
<div data-testid="tour-winter-activity" className={`${BOX} min-w-0`}>
|
||||
<div className="flex items-center gap-2.5 px-3 py-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`grid h-7 w-7 flex-none place-items-center rounded-full transition-colors ${on ? 'bg-info-soft text-info' : 'bg-surface-tertiary text-content-faint'}`}
|
||||
>
|
||||
<Snowflake size={14} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1">
|
||||
<span className="min-w-0 truncate font-semibold text-content" style={fs(12.5, 'body')}>
|
||||
{t('tours.winter.title')}
|
||||
</span>
|
||||
<InfoDot label={t('tours.winter.infoLabel')} description={t('tours.winter.info')} />
|
||||
</span>
|
||||
<span className={locked ? 'pointer-events-none opacity-50' : undefined}>
|
||||
<ToggleSwitch on={on} onToggle={toggle} label={t('tours.winter.title')} />
|
||||
</span>
|
||||
</div>
|
||||
<fieldset
|
||||
data-testid="tour-winter-kind"
|
||||
disabled={locked || !on}
|
||||
className={`m-0 min-w-0 border-0 border-t border-solid border-edge-faint px-3 py-2 transition-opacity ${on ? '' : 'opacity-50'}`}
|
||||
>
|
||||
<Segmented<WinterActivity>
|
||||
value={current ?? planner.winterActivityChoice}
|
||||
onChange={choose}
|
||||
label={t('tours.winter.kind')}
|
||||
fill
|
||||
options={WINTER_ACTIVITIES.map((activity) => {
|
||||
const Icon = WINTER_ACTIVITY_ICON[activity];
|
||||
return {
|
||||
value: activity,
|
||||
label: t(`tours.winter.activity.${activity}`),
|
||||
icon: <Icon size={13} strokeWidth={2} aria-hidden="true" />,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { routeSkiTour, skiLegs, skiRouteKey } from './skiRouting';
|
||||
import { lineLengthM } from './tourFixedLegs';
|
||||
|
||||
// FE-TOUR-SKI-ROUTE-001 to FE-TOUR-SKI-ROUTE-004
|
||||
|
||||
const start = { lat: 47, lng: 11 };
|
||||
const col = { lat: 47.01, lng: 11 };
|
||||
const hut = { lat: 47.01, lng: 11.01 };
|
||||
const touring = { discipline: 'touring' as const, technique: null };
|
||||
|
||||
describe('skiRouting (SKI-02)', () => {
|
||||
it('FE-TOUR-SKI-ROUTE-001: lays a straight free leg between every two points, and keeps a leg the traveller drew', async () => {
|
||||
const drawn = {
|
||||
kind: 'free' as const,
|
||||
geometry: [
|
||||
[47.01, 11],
|
||||
[47.02, 11.005],
|
||||
[47.01, 11.01],
|
||||
] as [number, number][],
|
||||
};
|
||||
const legs = await skiLegs([start, { ...col, fixedLeg: drawn }, hut], touring);
|
||||
expect(legs).toEqual([
|
||||
{
|
||||
kind: 'free',
|
||||
geometry: [
|
||||
[47, 11],
|
||||
[47.01, 11],
|
||||
],
|
||||
},
|
||||
drawn,
|
||||
]);
|
||||
expect(await skiLegs([start], touring)).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-TOUR-SKI-ROUTE-002: the line is drawn whole, sampled along each leg for its heights, with no time worked out', async () => {
|
||||
const route = await routeSkiTour([start, col, hut], touring);
|
||||
expect(route).not.toBeNull();
|
||||
expect(route?.durationSeconds).toBeNull();
|
||||
expect(route?.parts).toHaveLength(1);
|
||||
expect(route?.parts[0]?.free).toBe(true);
|
||||
const coordinates = route?.coordinates ?? [];
|
||||
expect(coordinates[0]).toEqual([47, 11]);
|
||||
expect(coordinates[coordinates.length - 1]).toEqual([47.01, 11.01]);
|
||||
// Every 25 m a point, so the heights follow the ground between the two ends of each leg.
|
||||
expect(coordinates.length).toBeGreaterThan(40);
|
||||
const straight = lineLengthM([
|
||||
[47, 11],
|
||||
[47.01, 11],
|
||||
[47.01, 11.01],
|
||||
]);
|
||||
expect(route?.distanceMeters ?? 0).toBeCloseTo(straight, 0);
|
||||
});
|
||||
|
||||
it('FE-TOUR-SKI-ROUTE-003: fewer than two points, or a request called off, draw nothing', async () => {
|
||||
expect(await routeSkiTour([start], touring)).toBeNull();
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
expect(await routeSkiTour([start, col], touring, controller.signal)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TOUR-SKI-ROUTE-004: no choice of the discipline changes the legs, so none plans the tour again', () => {
|
||||
const keys = [
|
||||
skiRouteKey({ discipline: null, technique: null }),
|
||||
skiRouteKey({ discipline: 'alpine', technique: null }),
|
||||
skiRouteKey({ discipline: 'cross_country', technique: 'skating' }),
|
||||
];
|
||||
expect(new Set(keys).size).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { TourFixedLeg } from '@trek/shared';
|
||||
import type { SkiChoice } from '../ski/skiChoice';
|
||||
import { freeLegBetween } from './tourRouteActions';
|
||||
import { drawnTour, type TourRouteResult } from './tourRouting';
|
||||
|
||||
/**
|
||||
* SKI-02: how the legs of a planned ski tour are laid. There is no piste
|
||||
* router yet, so every leg is drawn straight between its two points, as a leg
|
||||
* drawn by hand is, and its heights are read off the ground like any free
|
||||
* leg's. No ski time is worked out from it.
|
||||
*
|
||||
* This file is the one place the piste router plugs in: `skiLegs` will ask it
|
||||
* for the legs between the fixed ones and keep a straight leg wherever it
|
||||
* finds none, and `skiRouteKey` will name what of the discipline, technique
|
||||
* and options the legs were planned for, so a change of them plans the tour
|
||||
* again. Nothing else of the planner has to change for it.
|
||||
*/
|
||||
|
||||
interface SkiLegPoint {
|
||||
lat: number;
|
||||
lng: number;
|
||||
fixedLeg?: TourFixedLeg | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What of the ski choice the legs are laid for. A straight leg depends on its
|
||||
* two points only, so nothing of the choice yet: a discipline changed plans
|
||||
* nothing again.
|
||||
*/
|
||||
export function skiRouteKey(_choice: SkiChoice): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* One leg for each point but the last: the line the point carries (a leg the
|
||||
* traveller drew), else a straight line to the next point.
|
||||
*/
|
||||
export async function skiLegs(
|
||||
points: readonly SkiLegPoint[],
|
||||
_choice: SkiChoice,
|
||||
_signal?: AbortSignal
|
||||
): Promise<TourFixedLeg[]> {
|
||||
return points.slice(0, -1).map((point, index) => point.fixedLeg ?? freeLegBetween(point, points[index + 1]));
|
||||
}
|
||||
|
||||
/**
|
||||
* A ski tour's line from its legs, drawn and never routed: each leg is sampled
|
||||
* along its length so the heights read for it follow the ground. Null for
|
||||
* fewer than two points or a request called off.
|
||||
*/
|
||||
export async function routeSkiTour(
|
||||
points: readonly SkiLegPoint[],
|
||||
choice: SkiChoice,
|
||||
signal?: AbortSignal
|
||||
): Promise<TourRouteResult | null> {
|
||||
if (points.length < 2) return null;
|
||||
const legs = await skiLegs(points, choice, signal);
|
||||
if (signal?.aborted) return null;
|
||||
return drawnTour(points.map((point, index) => ({ ...point, fixedLeg: legs[index] ?? null })));
|
||||
}
|
||||
@@ -5,14 +5,15 @@ import {
|
||||
paddleLockSchema,
|
||||
plannableTourTypeSchema,
|
||||
runProfileSchema,
|
||||
winterActivitySchema,
|
||||
skiDisciplineSchema,
|
||||
skiTechniqueSchema,
|
||||
type BikeProfile,
|
||||
type HikingPreferences,
|
||||
type PlannableTourType,
|
||||
type RunProfile,
|
||||
type TourMaxHikingDifficulty,
|
||||
type WinterActivity,
|
||||
} from '@trek/shared';
|
||||
import type { SkiChoice } from '../ski/skiChoice';
|
||||
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||
import type { PaddleFieldValues } from './usePaddleFields';
|
||||
|
||||
@@ -26,7 +27,7 @@ export type DraftSource = 'google_maps';
|
||||
* half read.
|
||||
*/
|
||||
export interface DraftPayload {
|
||||
version: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10;
|
||||
version: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11;
|
||||
name: string;
|
||||
description?: string;
|
||||
website?: string;
|
||||
@@ -47,14 +48,14 @@ export interface DraftPayload {
|
||||
hikingPreferences?: HikingPreferences | null;
|
||||
/** BIK-01: the profile a bike tour is routed for. */
|
||||
bikeProfile?: BikeProfile;
|
||||
/** HIK-01: the hike's winter label; absent in a draft older than it. */
|
||||
winterActivity?: WinterActivity | null;
|
||||
/** SKI-02: a ski tour's discipline and cross-country technique; absent in a draft older than it. */
|
||||
ski?: SkiChoice;
|
||||
/** RUN-01: the profile a run is routed for, and its pace in seconds per km; absent in a draft older than them. */
|
||||
runProfile?: RunProfile;
|
||||
runPaceSecondsPerKm?: number | null;
|
||||
}
|
||||
|
||||
const DRAFT_VERSIONS: ReadonlyArray<DraftPayload['version']> = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
const DRAFT_VERSIONS: ReadonlyArray<DraftPayload['version']> = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
|
||||
|
||||
function optionalString(value: unknown): boolean {
|
||||
return value === undefined || typeof value === 'string';
|
||||
@@ -101,7 +102,17 @@ function optionalPace(value: number | null | undefined): boolean {
|
||||
return value == null || (Number.isInteger(value) && value > 0);
|
||||
}
|
||||
|
||||
/** The type of a draft, what its route is planned by and its winter label, each one absent in a draft older than it. */
|
||||
/** SKI-02: a ski choice as a draft keeps it, each half nothing chosen or a value the contract knows. */
|
||||
function optionalSki(value: SkiChoice | undefined): boolean {
|
||||
if (value === undefined) return true;
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
return (
|
||||
(value.discipline === null || skiDisciplineSchema.safeParse(value.discipline).success) &&
|
||||
(value.technique === null || skiTechniqueSchema.safeParse(value.technique).success)
|
||||
);
|
||||
}
|
||||
|
||||
/** The type of a draft and what its route is planned by, each one absent in a draft older than it. */
|
||||
function validRouteChoices(draft: Partial<DraftPayload>): boolean {
|
||||
return (
|
||||
(draft.tourType === undefined || plannableTourTypeSchema.safeParse(draft.tourType).success) &&
|
||||
@@ -109,7 +120,7 @@ function validRouteChoices(draft: Partial<DraftPayload>): boolean {
|
||||
(draft.runProfile === undefined || runProfileSchema.safeParse(draft.runProfile).success) &&
|
||||
optionalPace(draft.runPaceSecondsPerKm) &&
|
||||
(draft.hikingPreferences == null || hikingPreferencesSchema.safeParse(draft.hikingPreferences).success) &&
|
||||
(draft.winterActivity == null || winterActivitySchema.safeParse(draft.winterActivity).success) &&
|
||||
optionalSki(draft.ski) &&
|
||||
optionalDifficulty(draft.maxHikingDifficulty)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,11 +13,11 @@ import {
|
||||
type RunProfile,
|
||||
type TourListItem,
|
||||
type TourMaxHikingDifficulty,
|
||||
type WinterActivity,
|
||||
} from '@trek/shared';
|
||||
import type { RouteGeometryAnalysis } from '../../../utils/routeGeometry';
|
||||
import { hikingTimeMinutes } from '../hikingTime';
|
||||
import { runPaceInvalid } from '../run/runPace';
|
||||
import { NO_SKI_CHOICE, skiChoiceMissing, skiChoiceOf, skiFieldsOf, type SkiChoice } from '../ski/skiChoice';
|
||||
import { walkTimesOf, type WalkTimes } from './stationModel';
|
||||
|
||||
/** The editable fields of a saved tour as the planner opens them, each with its empty value. */
|
||||
@@ -33,9 +33,8 @@ export function openedTourFields(tour: TourListItem) {
|
||||
// A type nobody can plan (an import of another kind) opens as a hike, as it always has.
|
||||
tourType: plannableTourTypeSchema.catch('hike').parse(tour.tour_type) as PlannableTourType,
|
||||
bikeProfile: (tour.bike_profile ?? 'touring') as BikeProfile,
|
||||
winterActivity: tour.winter_activity ?? null,
|
||||
/** HIK-01: the label the winter switch brings back, the tour's own or winter hiking. */
|
||||
winterActivityChoice: tour.winter_activity ?? ('winter_hike' satisfies WinterActivity),
|
||||
/** SKI-02: a ski tour's discipline and technique as saved; nothing chosen on any other tour. */
|
||||
ski: skiChoiceOf(tour.tour_type === 'ski' ? tour : {}),
|
||||
runProfile: (tour.run_profile ?? 'running') as RunProfile,
|
||||
runPaceSecondsPerKm: tour.run_pace_seconds_per_km ?? null,
|
||||
};
|
||||
@@ -49,8 +48,8 @@ export interface TourPlannerFactsInput {
|
||||
hikingPreferences: HikingPreferences | null;
|
||||
/** BIK-01: the profile a bike tour is routed for. */
|
||||
bikeProfile: BikeProfile;
|
||||
/** HIK-01: a hike's winter label, none by default; it changes nothing the planner works out. */
|
||||
winterActivity: WinterActivity | null;
|
||||
/** SKI-02: a ski tour's discipline and cross-country technique, nothing chosen until the traveller picks. */
|
||||
ski?: SkiChoice;
|
||||
/** RUN-01: the profile a run is routed for, and the pace it is planned at (null while none is entered). */
|
||||
runProfile: RunProfile;
|
||||
runPaceSecondsPerKm: number | null;
|
||||
@@ -99,10 +98,11 @@ function runMinutes({ runPaceSecondsPerKm: pace, routeAnalysis }: TourPlannerFac
|
||||
* weighs the climbs, a hike by the signposts' rule over distance, climb and
|
||||
* descent once the route has its heights, and by the router's own time before that.
|
||||
* A ride (HOR-01) has none: its pace is the horse's and the rider's, so nothing is made up.
|
||||
* Nor has a ski tour (SKI-02): its legs are drawn, not planned along pistes and lifts.
|
||||
*/
|
||||
function movingMinutes(input: TourPlannerFactsInput, routerMinutes: number | null): number | null {
|
||||
const { paddleEstimate, routeAnalysis } = input;
|
||||
if (input.tourType === 'horse') return null;
|
||||
if (input.tourType === 'horse' || input.tourType === 'ski') return null;
|
||||
if (paddleEstimate) return paddleEstimate.waterMinutes === null ? null : paddleEstimate.estimateMinutes;
|
||||
if (input.tourType === 'run') return runMinutes(input);
|
||||
// A bike tour is timed by the router for its profile, a city walk by the router on foot.
|
||||
@@ -123,9 +123,9 @@ function walkTimesFor(input: TourPlannerFactsInput, walkingMinutes: number | nul
|
||||
}
|
||||
|
||||
/**
|
||||
* What the type adds to a save: a hike its routing limit and its winter label,
|
||||
* a bike tour its profile, a run its profile and pace, a paddle tour its boat,
|
||||
* water and speed.
|
||||
* What the type adds to a save: a hike its routing limit, a bike tour its
|
||||
* profile, a run its profile and pace, a ski tour its discipline (and a
|
||||
* cross-country technique), a paddle tour its boat, water and speed.
|
||||
*/
|
||||
function typeFieldsOf(input: TourPlannerFactsInput) {
|
||||
const isBike = input.tourType === 'bike';
|
||||
@@ -144,10 +144,12 @@ function typeFieldsOf(input: TourPlannerFactsInput) {
|
||||
portage_speed_kmh: portageSpeedKmh,
|
||||
paddle_lake_style: lakeStyle,
|
||||
hiking_preferences: null,
|
||||
winter_activity: null,
|
||||
ski_discipline: null,
|
||||
ski_technique: null,
|
||||
};
|
||||
}
|
||||
const isHike = input.tourType === 'hike';
|
||||
const ski = input.tourType === 'ski' && input.ski ? skiFieldsOf(input.ski) : null;
|
||||
return {
|
||||
tour_type: input.tourType,
|
||||
max_hiking_difficulty: isHike ? input.maxHikingDifficulty : null,
|
||||
@@ -160,7 +162,8 @@ function typeFieldsOf(input: TourPlannerFactsInput) {
|
||||
portage_speed_kmh: null,
|
||||
paddle_lake_style: null,
|
||||
hiking_preferences: isHike ? input.hikingPreferences : null,
|
||||
winter_activity: isHike ? input.winterActivity : null,
|
||||
ski_discipline: ski?.ski_discipline ?? null,
|
||||
ski_technique: ski?.ski_technique ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -185,6 +188,8 @@ export function tourPlannerFacts(input: TourPlannerFactsInput) {
|
||||
// A city walk sends no breaks (CW-02), so one left from another type cannot hold its save up.
|
||||
breakAdditionalInvalid: input.tourType !== 'walk' && invalidMinutes(breakAdditionalMinutes),
|
||||
paddleIncomplete: isPaddle && (boatType === null || waterContext === null),
|
||||
/** SKI-02: a ski tour is saved with its discipline, a cross-country one with its technique too. */
|
||||
skiMissing: input.tourType === 'ski' ? skiChoiceMissing(input.ski ?? NO_SKI_CHOICE) : null,
|
||||
/** RUN-01: a run is planned with a pace; without one Save waits, and a pace that is none is marked. */
|
||||
runPaceMissing: isRun && input.runPaceSecondsPerKm === null,
|
||||
runPaceInvalid: isRun && runPaceInvalid(input.runPaceSecondsPerKm),
|
||||
@@ -199,10 +204,11 @@ export function tourPlannerFacts(input: TourPlannerFactsInput) {
|
||||
automaticPlannedTotalMinutes > maxTourMinutesFor(input.stageCount ?? 1),
|
||||
// A city walk's stops are its stays: it saves no breaks, so a cafe never counts as a stay and a break at once.
|
||||
savedBreakAdditionalMinutes: input.tourType === 'walk' ? null : breakAdditionalMinutes,
|
||||
// The router's own time stands in for a hike without heights; a paddle tour has only its speed, a run its pace, a ride none.
|
||||
// The router's own time stands in for a hike without heights; a paddle tour has only its speed, a run its pace,
|
||||
// a ride and a ski tour none.
|
||||
savedDurationSeconds:
|
||||
walkingDurationMinutes === null
|
||||
? isPaddle || isRun || input.tourType === 'horse'
|
||||
? isPaddle || isRun || input.tourType === 'horse' || input.tourType === 'ski'
|
||||
? null
|
||||
: durationSeconds
|
||||
: walkingDurationMinutes * 60,
|
||||
|
||||
@@ -9,7 +9,6 @@ const hike = (overrides: Partial<TourPlannerFactsInput> = {}): TourPlannerFactsI
|
||||
maxHikingDifficulty: 3,
|
||||
hikingPreferences: null,
|
||||
bikeProfile: 'touring',
|
||||
winterActivity: null,
|
||||
runProfile: 'running',
|
||||
runPaceSecondsPerKm: null,
|
||||
website: '',
|
||||
@@ -59,26 +58,46 @@ describe('tourPlannerFacts', () => {
|
||||
expect(facts.typeFields).toMatchObject({ tour_type: 'hike', max_hiking_difficulty: 3, boat_type: null });
|
||||
});
|
||||
|
||||
it('HIK-01: saves a hike with its winter label and the same times as without, and no other type with one', () => {
|
||||
it('SKI-02: saves a ski tour with its discipline, untimed, and asks for a discipline and a cross-country technique', () => {
|
||||
const analysis = { distanceKm: 8, gain: 600, loss: 600 } as TourPlannerFactsInput['routeAnalysis'];
|
||||
const summer = tourPlannerFacts(hike({ routeAnalysis: analysis, breakAdditionalMinutes: 30 }));
|
||||
const winter = tourPlannerFacts(
|
||||
hike({ routeAnalysis: analysis, breakAdditionalMinutes: 30, winterActivity: 'snowshoe' })
|
||||
const snowshoe = tourPlannerFacts(
|
||||
hike({
|
||||
tourType: 'ski',
|
||||
ski: { discipline: 'snowshoe', technique: 'classic' },
|
||||
routeAnalysis: analysis,
|
||||
breakAdditionalMinutes: 30,
|
||||
})
|
||||
);
|
||||
expect(winter.typeFields).toMatchObject({
|
||||
tour_type: 'hike',
|
||||
winter_activity: 'snowshoe',
|
||||
max_hiking_difficulty: 3,
|
||||
expect(snowshoe.typeFields).toMatchObject({
|
||||
tour_type: 'ski',
|
||||
ski_discipline: 'snowshoe',
|
||||
ski_technique: null,
|
||||
max_hiking_difficulty: null,
|
||||
hiking_preferences: null,
|
||||
});
|
||||
expect(summer.typeFields).toMatchObject({ winter_activity: null });
|
||||
// A label only: the time is the signposts' rule, as for any hike.
|
||||
expect(winter.walkingDurationMinutes).toBe(summer.walkingDurationMinutes);
|
||||
expect(winter.plannedTotalMinutes).toBe(summer.plannedTotalMinutes);
|
||||
for (const tourType of ['bike', 'walk', 'kayak'] as const) {
|
||||
expect(tourPlannerFacts(hike({ tourType, winterActivity: 'winter_hike' })).typeFields.winter_activity).toBeNull();
|
||||
expect(snowshoe.skiMissing).toBeNull();
|
||||
// No ski time is worked out: no signposts' rule, no router time, nothing saved.
|
||||
expect(snowshoe.walkingDurationMinutes).toBeNull();
|
||||
expect(snowshoe.plannedTotalMinutes).toBeNull();
|
||||
expect(snowshoe.savedDurationSeconds).toBeNull();
|
||||
expect(tourPlannerFacts(hike({ tourType: 'ski', durationSeconds: 3_600 })).savedDurationSeconds).toBeNull();
|
||||
|
||||
expect(tourPlannerFacts(hike({ tourType: 'ski' })).skiMissing).toBe('discipline');
|
||||
const crossCountry = hike({ tourType: 'ski', ski: { discipline: 'cross_country', technique: null } });
|
||||
expect(tourPlannerFacts(crossCountry).skiMissing).toBe('technique');
|
||||
const skating = tourPlannerFacts({ ...crossCountry, ski: { discipline: 'cross_country', technique: 'skating' } });
|
||||
expect(skating.typeFields).toMatchObject({ ski_discipline: 'cross_country', ski_technique: 'skating' });
|
||||
for (const tourType of ['hike', 'bike', 'walk', 'kayak'] as const) {
|
||||
const other = tourPlannerFacts(hike({ tourType, ski: { discipline: 'alpine', technique: null } }));
|
||||
expect(other.typeFields).toMatchObject({ ski_discipline: null, ski_technique: null });
|
||||
expect(other.skiMissing).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('HIK-01 withdrawn: a hike saves no winter label at all', () => {
|
||||
expect(tourPlannerFacts(hike()).typeFields).not.toHaveProperty('winter_activity');
|
||||
});
|
||||
|
||||
it('RUN-01: times a run as its distance at the pace, nothing else, and saves its profile and pace', () => {
|
||||
// 8 km with 600 m up and down at 6:00 min/km: 48 minutes, the climb left out.
|
||||
const analysis = { distanceKm: 8, gain: 600, loss: 600 } as TourPlannerFactsInput['routeAnalysis'];
|
||||
@@ -96,7 +115,7 @@ describe('tourPlannerFacts', () => {
|
||||
run_pace_seconds_per_km: 360,
|
||||
max_hiking_difficulty: null,
|
||||
hiking_preferences: null,
|
||||
winter_activity: null,
|
||||
ski_discipline: null,
|
||||
bike_profile: null,
|
||||
});
|
||||
const withBreak = hike({
|
||||
@@ -140,8 +159,20 @@ describe('tourPlannerFacts', () => {
|
||||
expect(hikeOpened).toMatchObject({
|
||||
runProfile: 'running',
|
||||
runPaceSecondsPerKm: null,
|
||||
winterActivity: null,
|
||||
winterActivityChoice: 'winter_hike',
|
||||
ski: { discipline: null, technique: null },
|
||||
});
|
||||
});
|
||||
|
||||
it('SKI-02: opens a saved ski tour with its discipline and technique, and any other tour with none chosen', () => {
|
||||
const ski = { name: 'Loipe', tour_type: 'ski', ski_discipline: 'cross_country', ski_technique: 'classic' };
|
||||
expect(openedTourFields(ski as Parameters<typeof openedTourFields>[0])).toMatchObject({
|
||||
tourType: 'ski',
|
||||
ski: { discipline: 'cross_country', technique: 'classic' },
|
||||
});
|
||||
const stray = { name: 'Ridge', tour_type: 'hike', ski_discipline: 'alpine' };
|
||||
expect(openedTourFields(stray as Parameters<typeof openedTourFields>[0]).ski).toEqual({
|
||||
discipline: null,
|
||||
technique: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -304,15 +335,20 @@ describe('useTourDraftStorage', () => {
|
||||
expect(localStorage.getItem('tour-draft-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('HIK-01: gives back a draft with its winter label, and drops one whose label it does not know', () => {
|
||||
it('SKI-02: gives back a draft with its ski choice, and drops one whose discipline it does not know', () => {
|
||||
localStorage.clear();
|
||||
const draft = { version: 10, name: 'Snowy ridge', waypoints: [{ id: 'a', lat: 47, lng: 11, role: 'start' }] };
|
||||
localStorage.setItem('tour-draft-5', JSON.stringify({ ...draft, winterActivity: 'snowshoe' }));
|
||||
const draft = { version: 11, name: 'Snowy ridge', waypoints: [{ id: 'a', lat: 47, lng: 11, role: 'start' }] };
|
||||
const ski = { ...draft, tourType: 'ski', ski: { discipline: 'winter_hike', technique: null } };
|
||||
localStorage.setItem('tour-draft-5', JSON.stringify(ski));
|
||||
expect(renderHook(() => useTourDraftStorage(5)).result.current.restored).toMatchObject({
|
||||
winterActivity: 'snowshoe',
|
||||
tourType: 'ski',
|
||||
ski: { discipline: 'winter_hike', technique: null },
|
||||
});
|
||||
localStorage.setItem('tour-draft-6', JSON.stringify({ ...draft, winterActivity: 'ski_tour' }));
|
||||
localStorage.setItem('tour-draft-6', JSON.stringify({ ...ski, ski: { discipline: 'freeride', technique: null } }));
|
||||
expect(renderHook(() => useTourDraftStorage(6)).result.current.restored).toBeNull();
|
||||
// A draft from before the label went keeps the rest of itself; the winter label is not read.
|
||||
localStorage.setItem('tour-draft-11', JSON.stringify({ ...draft, version: 10, winterActivity: 'snowshoe' }));
|
||||
expect(renderHook(() => useTourDraftStorage(11)).result.current.restored).toMatchObject({ name: 'Snowy ridge' });
|
||||
});
|
||||
|
||||
it('RUN-01: gives back a draft with its run profile and pace, and drops one it cannot read', () => {
|
||||
|
||||
@@ -143,6 +143,16 @@ export async function routeBikeTour(
|
||||
* of its own, which a ride never keeps.
|
||||
*/
|
||||
export function rideTour(waypoints: LandWaypoint[]): TourRouteResult | null {
|
||||
return drawnTour(waypoints)
|
||||
}
|
||||
|
||||
/**
|
||||
* A tour put together from the lines its legs carry, with no router asked and
|
||||
* no time worked out: a ride (HOR-01) and a ski tour (SKI-02) are. A section of
|
||||
* a horse route keeps its own line; any other leg is sampled along its length
|
||||
* so its heights follow the ground. Null while a leg has no line of its own.
|
||||
*/
|
||||
export function drawnTour(waypoints: LandWaypoint[]): TourRouteResult | null {
|
||||
const lines: TourRoutePart[] = []
|
||||
for (const point of waypoints.slice(0, -1)) {
|
||||
const leg = point.fixedLeg
|
||||
|
||||
@@ -1,21 +1,40 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { TourBaseLayer } from '../../Map/MapLayerSwitcher';
|
||||
|
||||
interface OverlayChoice {
|
||||
/** Switched on by hand. */
|
||||
on: ReadonlySet<string>;
|
||||
/** Switched off by hand: an overlay on by default for a type (the pistes on a ski tour, SKI-01) stays off. */
|
||||
off: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* How the planner's map looks, kept with the planner so it lasts while tours
|
||||
* are opened, saved and closed: the base layer and the overlays switched on.
|
||||
* Planner-local by design (P2B-07): nothing here touches the global map
|
||||
* setting, and none of it changes a tour.
|
||||
* are opened, saved and closed: the base layer and the overlays switched on
|
||||
* or off by hand. Planner-local by design (P2B-07): nothing here touches the
|
||||
* global map setting, and none of it changes a tour.
|
||||
*/
|
||||
export function useTourMapView() {
|
||||
const [mapBaseLayer, setMapBaseLayer] = useState<TourBaseLayer>('default');
|
||||
const [mapOverlays, setMapOverlays] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const toggleMapOverlay = useCallback((id: string) => {
|
||||
setMapOverlays((current) => {
|
||||
const next = new Set(current);
|
||||
if (!next.delete(id)) next.add(id);
|
||||
return next;
|
||||
const [overlays, setOverlays] = useState<OverlayChoice>(() => ({ on: new Set(), off: new Set() }));
|
||||
/**
|
||||
* An overlay switched by hand. `shown` is whether it shows now, for an
|
||||
* overlay that can show without being switched on; left out, it shows
|
||||
* while it is switched on.
|
||||
*/
|
||||
const toggleMapOverlay = useCallback((id: string, shown?: boolean) => {
|
||||
setOverlays(({ on, off }) => {
|
||||
const nextOn = new Set(on);
|
||||
const nextOff = new Set(off);
|
||||
if (shown ?? on.has(id)) {
|
||||
nextOn.delete(id);
|
||||
nextOff.add(id);
|
||||
} else {
|
||||
nextOn.add(id);
|
||||
nextOff.delete(id);
|
||||
}
|
||||
return { on: nextOn, off: nextOff };
|
||||
});
|
||||
}, []);
|
||||
return { mapBaseLayer, setMapBaseLayer, mapOverlays, toggleMapOverlay };
|
||||
return { mapBaseLayer, setMapBaseLayer, mapOverlays: overlays.on, mapOverlaysOff: overlays.off, toggleMapOverlay };
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ describe("useTourPlanner, a run at the runner's own pace (RUN-01)", () => {
|
||||
run_pace_seconds_per_km: 330,
|
||||
max_hiking_difficulty: null,
|
||||
bike_profile: null,
|
||||
winter_activity: null,
|
||||
ski_discipline: null,
|
||||
duration_seconds: minutes * 60,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { routeWalkingTour, enrichTourElevations, createTour, detailTour, updateTour } = vi.hoisted(() => ({
|
||||
routeWalkingTour: vi.fn(),
|
||||
enrichTourElevations: vi.fn(),
|
||||
createTour: vi.fn(),
|
||||
detailTour: vi.fn(),
|
||||
updateTour: vi.fn(),
|
||||
}));
|
||||
|
||||
// The ski legs are laid by the planner's own drawing; only the routers and the heights are stood in for.
|
||||
vi.mock('./tourRouting', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('./tourRouting')>()),
|
||||
routeWalkingTour,
|
||||
enrichTourElevations,
|
||||
}));
|
||||
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } }));
|
||||
vi.mock('../useTourPermissions', () => ({ useTourPermissions: () => ({ canEdit: true, canAssign: true }) }));
|
||||
|
||||
import { useTourPlanner } from './useTourPlanner';
|
||||
|
||||
// FE-TOUR-SKI-PLAN-001 to FE-TOUR-SKI-PLAN-006
|
||||
|
||||
const savedSki = {
|
||||
place_id: 95,
|
||||
name: 'Over the col',
|
||||
tour_type: 'ski' as const,
|
||||
ski_discipline: 'cross_country' as const,
|
||||
ski_technique: 'skating' as const,
|
||||
distance: 1.9,
|
||||
elevation_gain: 0,
|
||||
elevation_loss: 80,
|
||||
duration: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: null,
|
||||
planned: false,
|
||||
caution: false,
|
||||
revision: 2,
|
||||
};
|
||||
const savedPoints = [
|
||||
{ lat: 47, lng: 11, role: 'start' as const, sequence: 0 },
|
||||
{ lat: 47.01, lng: 11.02, role: 'end' as const, sequence: 1 },
|
||||
];
|
||||
|
||||
/** The heights the elevation source answers: one for every point of the line, falling gently. */
|
||||
async function heightsAlong(line: [number, number][]): Promise<[number, number, number][]> {
|
||||
return line.map(([lat, lng], index): [number, number, number] => [lat, lng, 1600 - index]);
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(450);
|
||||
});
|
||||
}
|
||||
|
||||
async function plannedSki(tripId: number) {
|
||||
const hook = renderHook(() => useTourPlanner({ tripId }));
|
||||
act(() => {
|
||||
hook.result.current.setName('Over the col');
|
||||
hook.result.current.setTourType('ski');
|
||||
hook.result.current.addWaypoint(47, 11);
|
||||
hook.result.current.addWaypoint(47.01, 11.02);
|
||||
});
|
||||
await settle();
|
||||
return hook;
|
||||
}
|
||||
|
||||
describe('useTourPlanner, a ski and winter tour (SKI-02)', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
localStorage.clear();
|
||||
routeWalkingTour.mockReset();
|
||||
enrichTourElevations.mockReset().mockImplementation(heightsAlong);
|
||||
createTour.mockReset();
|
||||
detailTour.mockReset();
|
||||
updateTour.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('FE-TOUR-SKI-PLAN-001: draws every leg straight, asks no router, reads the heights, and works out no time', async () => {
|
||||
const { result } = await plannedSki(81);
|
||||
expect(result.current.isSki).toBe(true);
|
||||
expect(routeWalkingTour).not.toHaveBeenCalled();
|
||||
expect(enrichTourElevations).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.status).toBe('ready');
|
||||
expect(result.current.routeParts?.every((part) => part.free)).toBe(true);
|
||||
// Sampled along its length, so the heights follow the ground between the two points.
|
||||
expect(result.current.route?.length ?? 0).toBeGreaterThan(2);
|
||||
expect(result.current.routeAnalysis?.distanceKm ?? 0).toBeGreaterThan(1.5);
|
||||
expect(result.current.durationSeconds).toBeNull();
|
||||
expect(result.current.walkingDurationMinutes).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TOUR-SKI-PLAN-002: nothing is chosen at first, and Save waits for a discipline and a cross-country technique', async () => {
|
||||
const { result } = await plannedSki(82);
|
||||
expect(result.current.ski.choice).toEqual({ discipline: null, technique: null });
|
||||
expect(result.current.skiMissing).toBe('discipline');
|
||||
expect(result.current.canSave).toBe(false);
|
||||
act(() => result.current.ski.change({ discipline: 'cross_country', technique: null }));
|
||||
expect(result.current.skiMissing).toBe('technique');
|
||||
expect(result.current.canSave).toBe(false);
|
||||
act(() => result.current.ski.change({ discipline: 'cross_country', technique: 'skating' }));
|
||||
expect(result.current.skiMissing).toBeNull();
|
||||
expect(result.current.canSave).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-TOUR-SKI-PLAN-003: a discipline changed lays no leg again and reads no height again', async () => {
|
||||
const { result } = await plannedSki(83);
|
||||
act(() => result.current.ski.change({ discipline: 'alpine', technique: null }));
|
||||
await settle();
|
||||
act(() => result.current.ski.change({ discipline: 'snowshoe', technique: null }));
|
||||
await settle();
|
||||
expect(enrichTourElevations).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.status).toBe('ready');
|
||||
expect(result.current.routeCurrent).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-TOUR-SKI-PLAN-004: saves the type, the discipline and no time, nothing of a hike', async () => {
|
||||
const saved = { ...savedSki, ski_discipline: 'winter_hike', ski_technique: null };
|
||||
createTour.mockResolvedValue({ tour: saved, waypoints: savedPoints });
|
||||
const { result } = await plannedSki(84);
|
||||
act(() => result.current.ski.change({ discipline: 'winter_hike', technique: 'classic' }));
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
expect(createTour).toHaveBeenCalledWith(
|
||||
84,
|
||||
expect.objectContaining({
|
||||
tour_type: 'ski',
|
||||
ski_discipline: 'winter_hike',
|
||||
ski_technique: null,
|
||||
max_hiking_difficulty: null,
|
||||
hiking_preferences: null,
|
||||
duration_seconds: null,
|
||||
})
|
||||
);
|
||||
expect(result.current.status).toBe('saved');
|
||||
});
|
||||
|
||||
it('FE-TOUR-SKI-PLAN-005: opens a saved ski tour with its discipline and technique, and a hike from it saves neither', async () => {
|
||||
detailTour.mockResolvedValue({ tour: savedSki, waypoints: savedPoints });
|
||||
updateTour.mockResolvedValue({ tour: { ...savedSki, tour_type: 'hike', revision: 3 }, waypoints: savedPoints });
|
||||
const { result } = renderHook(() => useTourPlanner({ tripId: 85 }));
|
||||
await act(async () => {
|
||||
await result.current.openTour({ place_id: 95 });
|
||||
});
|
||||
await settle();
|
||||
expect(result.current.tourType).toBe('ski');
|
||||
expect(result.current.ski.choice).toEqual({ discipline: 'cross_country', technique: 'skating' });
|
||||
expect(routeWalkingTour).not.toHaveBeenCalled();
|
||||
|
||||
routeWalkingTour.mockResolvedValue({
|
||||
coordinates: [
|
||||
[47, 11],
|
||||
[47.01, 11.02],
|
||||
],
|
||||
distanceMeters: 1900,
|
||||
durationSeconds: 1800,
|
||||
});
|
||||
act(() => result.current.setTourType('hike'));
|
||||
await settle();
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
expect(updateTour).toHaveBeenCalledWith(
|
||||
85,
|
||||
95,
|
||||
expect.objectContaining({ tour_type: 'hike', ski_discipline: null, ski_technique: null })
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-TOUR-SKI-PLAN-006: a draft keeps its ski choice across a reload, and a new tour starts with none', async () => {
|
||||
const first = await plannedSki(86);
|
||||
act(() => first.result.current.ski.change({ discipline: 'touring', technique: null }));
|
||||
first.unmount();
|
||||
|
||||
const again = renderHook(() => useTourPlanner({ tripId: 86 }));
|
||||
expect(again.result.current.tourType).toBe('ski');
|
||||
expect(again.result.current.ski.choice).toEqual({ discipline: 'touring', technique: null });
|
||||
act(() => again.result.current.returnToNeutral());
|
||||
expect(again.result.current.ski.choice).toEqual({ discipline: null, technique: null });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { hikingPreferencesOf, isClosedLoop, paddleSpeedKmhSchema, tourWebsiteSchema, type BikeProfile, type HikingPreferences, type PlannableTourType, type RunProfile, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourMaxHikingDifficulty, type WinterActivity } from '@trek/shared'
|
||||
import { hikingPreferencesOf, isClosedLoop, paddleSpeedKmhSchema, tourWebsiteSchema, type BikeProfile, type HikingPreferences, type PlannableTourType, type RunProfile, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourMaxHikingDifficulty } from '@trek/shared'
|
||||
import { paddleRouteRepo } from '../../../repo/paddleRouteRepo'
|
||||
import { tourRepo } from '../../../repo/tourRepo'
|
||||
import { randomId } from '../../../utils/randomId'
|
||||
@@ -24,6 +24,8 @@ import { adoptChangedPoints } from './paddleRouting'
|
||||
import { NO_PADDLE_FIELDS, paddleFieldsOf, usePaddleFields, type PaddleFieldValues } from './usePaddleFields'
|
||||
import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions'
|
||||
import { useRunPace } from '../run/useRunPace'
|
||||
import { NO_SKI_CHOICE, type SkiChoice } from '../ski/skiChoice'
|
||||
import { useSkiFields } from '../ski/useSkiFields'
|
||||
import { lostStageEnds } from '../stages/tourStages'
|
||||
import { useTourStages } from '../stages/useTourStages'
|
||||
|
||||
@@ -51,6 +53,8 @@ export interface DerivedTourDraft {
|
||||
runProfile?: RunProfile
|
||||
/** RUN-01: a run planned over keeps the pace it was planned at, so the runner need not type it again. */
|
||||
runPaceSecondsPerKm?: number | null
|
||||
/** SKI-02: a ski import planned over keeps its discipline and technique. */
|
||||
ski?: SkiChoice
|
||||
/** CW-07: where the points came from, said in the planner until the draft is saved. */
|
||||
source?: DraftSource
|
||||
}
|
||||
@@ -134,9 +138,6 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
bikeProfile: restored?.bikeProfile, runProfile: restored?.runProfile })
|
||||
const { ref: choicesRef, load: loadChoices } = routingChoices
|
||||
const { difficulty: maxHikingDifficulty, preferences: hikingPreferences, bikeProfile, runProfile } = routingChoices.choices
|
||||
const [winterActivity, setWinterActivityState] = useState<WinterActivity | null>(restored?.winterActivity ?? null)
|
||||
// HIK-01: the label the switch brings back, the tour's own while it is off, winter hiking for every other tour.
|
||||
const [winterActivityChoice, setWinterActivityChoice] = useState<WinterActivity>(restored?.winterActivity ?? 'winter_hike')
|
||||
const [tourType, setTourTypeState] = useState<PlannableTourType>(restored?.tourType ?? 'hike')
|
||||
const [selectedWaypointId, setSelectedWaypointId] = useState<string | null>(restored?.waypoints[0]?.id ?? null)
|
||||
const history = usePlannerHistory<HistoryEntry>()
|
||||
@@ -234,6 +235,9 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
const paddle = usePaddleFields(restored?.paddle ?? NO_PADDLE_FIELDS, markEdited)
|
||||
const runPace = useRunPace(restored?.runPaceSecondsPerKm ?? null, markEdited)
|
||||
const loadRunPace = runPace.load
|
||||
// SKI-02: a ski tour's discipline and technique, nothing chosen until the traveller picks.
|
||||
const ski = useSkiFields(restored?.ski ?? NO_SKI_CHOICE, markEdited)
|
||||
const loadSki = ski.load
|
||||
const loadPaddle = paddle.load
|
||||
const paddleBoat = paddle.boatType
|
||||
// The router's own default where the tour names none, so a plan and its cache key agree.
|
||||
@@ -246,7 +250,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
}, [])
|
||||
const forgetFocus = useCallback(() => setRouteProfileFocus(null), [])
|
||||
const routing = useTourRoute({
|
||||
tripId, waypoints, tourType, maxHikingDifficulty, hikingPreferences, bikeProfile, runProfile, paddleBoat, paddleLakeStyle, retryToken,
|
||||
tripId, waypoints, tourType, maxHikingDifficulty, hikingPreferences, bikeProfile, runProfile, paddleBoat, paddleLakeStyle, skiChoice: ski.choice, retryToken,
|
||||
error, setStatus, setError, onCleared: forgetFocus, patchWaypoints,
|
||||
})
|
||||
const { route, routeParts, paddleRoute, enrichedGeometry, routeCurrent, distanceMeters, durationSeconds,
|
||||
@@ -478,19 +482,12 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
if (value !== breakAdditionalMinutes && markEdited()) setBreakAdditionalMinutesState(value)
|
||||
}, [breakAdditionalMinutes, markEdited])
|
||||
|
||||
/** HIK-01: the hike's winter label, a field like the description: it plans nothing again and is no step of the history. */
|
||||
const setWinterActivity = useCallback((value: WinterActivity | null) => {
|
||||
if (value === winterActivity || !markEdited()) return
|
||||
setWinterActivityState(value)
|
||||
if (value) setWinterActivityChoice(value)
|
||||
}, [markEdited, winterActivity])
|
||||
|
||||
|
||||
/**
|
||||
* PAD-01, BIK-01, RUN-01, HOR-01: the type, as one step of the history with
|
||||
* the route, since a paddle tour draws every leg by hand, neither a bike tour
|
||||
* nor a run takes a via ferrata and a ride takes every leg from a horse route
|
||||
* or by hand. Nothing is put on a day by the choice itself.
|
||||
* PAD-01, BIK-01, RUN-01, HOR-01, SKI-02: the type, as one step of the history
|
||||
* with the route, since a paddle tour draws every leg by hand, neither a bike
|
||||
* tour, a run nor a ski tour takes a via ferrata and a ride takes every leg
|
||||
* from a horse route or by hand. Nothing is put on a day by the choice itself.
|
||||
*/
|
||||
const setTourType = useCallback((value: PlannableTourType) => {
|
||||
if (!editPermissionRef.current || saveInFlightRef.current || value === tourTypeRef.current) return
|
||||
@@ -501,7 +498,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
tourTypeRef.current = value
|
||||
setTourTypeState(value)
|
||||
setStageLoss(0)
|
||||
const noFerrata = value === 'bike' || value === 'run'
|
||||
const noFerrata = value === 'bike' || value === 'run' || value === 'ski'
|
||||
const next = normalizePlannerWaypoints(noFerrata ? ridablePoints(waypointsRef.current) : waypointsRef.current, value)
|
||||
waypointsRef.current = next
|
||||
setWaypoints(next)
|
||||
@@ -537,17 +534,17 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
})
|
||||
const stageCount = Math.max(1, stages.stages.length)
|
||||
const facts = useMemo(() => tourPlannerFacts({
|
||||
tourType, maxHikingDifficulty, hikingPreferences, bikeProfile, winterActivity, runProfile, runPaceSecondsPerKm, website,
|
||||
tourType, maxHikingDifficulty, hikingPreferences, bikeProfile, ski: ski.choice, runProfile, runPaceSecondsPerKm, website,
|
||||
plannedDurationMinutes, breakAdditionalMinutes,
|
||||
paddle: { boatType, waterContext, speedKmh, portageSpeedKmh, lakeStyle },
|
||||
paddleSpeedInvalid, paddleEstimate, routeAnalysis, durationSeconds, stays, stageCount,
|
||||
}), [bikeProfile, boatType, breakAdditionalMinutes, durationSeconds, hikingPreferences, lakeStyle, maxHikingDifficulty, paddleEstimate, paddleSpeedInvalid,
|
||||
plannedDurationMinutes, portageSpeedKmh, routeAnalysis, runPaceSecondsPerKm, runProfile, speedKmh, stageCount, stays, tourType, waterContext,
|
||||
website, winterActivity])
|
||||
website, ski.choice])
|
||||
// CW-02: a city walk's times as the overview says them, from the same derivation as its planned total.
|
||||
const { websiteInvalid, plannedDurationInvalid, breakAdditionalInvalid, paddleIncomplete, walkingDurationMinutes,
|
||||
plannedTotalMinutes, plannedTotalInvalid, savedDurationSeconds, savedBreakAdditionalMinutes, typeFields, stationStays,
|
||||
runPaceMissing, runPaceInvalid } = facts
|
||||
runPaceMissing, runPaceInvalid, skiMissing } = facts
|
||||
|
||||
const returnToNeutral = useCallback(() => {
|
||||
if (saveInFlightRef.current) return
|
||||
@@ -568,8 +565,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
waypointsRef.current = []
|
||||
loadChoices(DEFAULT_ROUTING_CHOICES)
|
||||
loadRunPace(null)
|
||||
setWinterActivityState(null)
|
||||
setWinterActivityChoice('winter_hike')
|
||||
loadSki(NO_SKI_CHOICE)
|
||||
tourTypeRef.current = 'hike'
|
||||
setSelectedWaypointId(null)
|
||||
clearHistory()
|
||||
@@ -592,7 +588,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setElevationProfileExpanded(true)
|
||||
setNewDraftGeneration(generation => generation + 1)
|
||||
setMapFocusKey(key => key + 1)
|
||||
}, [adoptRevision, clearHistory, clearRoute, invalidateDetailRequest, interruptRouting, loadChoices, loadPaddle, loadRunPace, forgetDraft])
|
||||
}, [adoptRevision, clearHistory, clearRoute, invalidateDetailRequest, interruptRouting, loadChoices, loadPaddle, loadRunPace, loadSki, forgetDraft])
|
||||
|
||||
const forgetDeletedTour = useCallback((placeId: number) => {
|
||||
deletedTourPlaceIdsRef.current.add(placeId)
|
||||
@@ -643,6 +639,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
loadChoices({ difficulty: draft.difficulty, ...(draft.bikeProfile ? { bikeProfile: draft.bikeProfile } : {}),
|
||||
...(draft.runProfile ? { runProfile: draft.runProfile } : {}) })
|
||||
loadRunPace(draft.runPaceSecondsPerKm ?? null)
|
||||
if (draft.ski) loadSki(draft.ski)
|
||||
setNameState(draft.name)
|
||||
setDescriptionState(draft.description ?? '')
|
||||
setWebsiteState(draft.website ?? '')
|
||||
@@ -660,7 +657,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setHasUnsavedChanges(true)
|
||||
return true
|
||||
},
|
||||
[adoptRevision, loadChoices, loadPaddle, loadRunPace, returnToNeutral])
|
||||
[adoptRevision, loadChoices, loadPaddle, loadRunPace, loadSki, returnToNeutral])
|
||||
|
||||
const cancelNewTour = useCallback(() => setNewTourConfirmationOpen(false), [])
|
||||
const toggleElevationProfile = useCallback(() => {
|
||||
@@ -735,8 +732,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
loadChoices({ difficulty: opened.difficulty, preferences: opened.preferences, bikeProfile: opened.bikeProfile,
|
||||
runProfile: opened.runProfile })
|
||||
loadRunPace(opened.runPaceSecondsPerKm)
|
||||
setWinterActivityState(opened.winterActivity)
|
||||
setWinterActivityChoice(opened.winterActivityChoice)
|
||||
loadSki(opened.ski)
|
||||
tourTypeRef.current = opened.tourType
|
||||
setSelectedWaypointId(nextWaypoints[0]?.id ?? null)
|
||||
clearHistory()
|
||||
@@ -766,7 +762,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setOpeningTourId(null)
|
||||
}
|
||||
}
|
||||
}, [adoptRevision, clearHistory, clearRoute, interruptRouting, loadChoices, loadPaddle, loadRunPace, forgetDraft, tripId])
|
||||
}, [adoptRevision, clearHistory, clearRoute, interruptRouting, loadChoices, loadPaddle, loadRunPace, loadSki, forgetDraft, tripId])
|
||||
|
||||
const retry = useCallback(() => {
|
||||
if (waypointsRef.current.length < 2) return
|
||||
@@ -804,8 +800,8 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasUnsavedChanges) return
|
||||
const draft: DraftPayload = { version: 10, name, description, website, plannedDurationMinutes, breakAdditionalMinutes, waypoints, maxHikingDifficulty, editingPlaceId,
|
||||
derivedFromPlaceId, draftSource, tourType, baseRevision: revision.base, hikingPreferences, bikeProfile, winterActivity,
|
||||
const draft: DraftPayload = { version: 11, name, description, website, plannedDurationMinutes, breakAdditionalMinutes, waypoints, maxHikingDifficulty, editingPlaceId,
|
||||
derivedFromPlaceId, draftSource, tourType, baseRevision: revision.base, hikingPreferences, bikeProfile, ski: ski.choice,
|
||||
runProfile, runPaceSecondsPerKm,
|
||||
paddle: { boatType: paddle.boatType, waterContext: paddle.waterContext, speedKmh: paddle.speedKmh, portageSpeedKmh: paddle.portageSpeedKmh, lakeStyle: paddle.lakeStyle },
|
||||
}
|
||||
@@ -813,7 +809,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
}, [breakAdditionalMinutes,
|
||||
derivedFromPlaceId, draftSource, description, editingPlaceId, hasUnsavedChanges, maxHikingDifficulty, name, plannedDurationMinutes, keepDraft, waypoints, website,
|
||||
tourType, paddle.boatType, paddle.waterContext, paddle.speedKmh, paddle.portageSpeedKmh, paddle.lakeStyle, revision.base, hikingPreferences,
|
||||
bikeProfile, winterActivity, runProfile, runPaceSecondsPerKm])
|
||||
bikeProfile, ski.choice, runProfile, runPaceSecondsPerKm])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasUnsavedChanges) return
|
||||
@@ -825,7 +821,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
const canSave = canEdit && !isSaving && hasUnsavedChanges && name.trim().length > 0 && status === 'ready'
|
||||
&& routeCurrent && hasCompleteElevation(enrichedGeometry)
|
||||
&& description.length <= 2000 && !websiteInvalid && !plannedDurationInvalid && !breakAdditionalInvalid && !plannedTotalInvalid
|
||||
&& !paddleIncomplete && !(isPaddle && paddleSpeedInvalid) && !runPaceMissing && !runPaceInvalid
|
||||
&& !paddleIncomplete && !(isPaddle && paddleSpeedInvalid) && !runPaceMissing && !runPaceInvalid && !skiMissing
|
||||
|
||||
/**
|
||||
* Saves the draft: over the tour it edits, naming the revision it was loaded
|
||||
@@ -914,7 +910,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
return {
|
||||
tripId, canEdit, canAssign,
|
||||
name, setName, maxHikingDifficulty, setMaxHikingDifficulty, hikingPreferences, setHikingPreferences,
|
||||
bikeProfile, setBikeProfile, isBike: tourType === 'bike', isHorse: tourType === 'horse', winterActivity, setWinterActivity, winterActivityChoice,
|
||||
bikeProfile, setBikeProfile, isBike: tourType === 'bike', isHorse: tourType === 'horse', isSki: tourType === 'ski', ski, skiMissing,
|
||||
runProfile, setRunProfile, isRun: tourType === 'run', runPace, runPaceMissing, runPaceInvalid,
|
||||
tourType, setTourType, isPaddle, paddle, paddleSpeedInvalid, paddleIncomplete,
|
||||
description, setDescription, website, setWebsite, websiteInvalid,
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { routeWalkingTour, routeCityWalk, enrichTourElevations, createTour, detailTour, updateTour } = vi.hoisted(
|
||||
() => ({
|
||||
routeWalkingTour: vi.fn(),
|
||||
routeCityWalk: vi.fn(),
|
||||
enrichTourElevations: vi.fn(),
|
||||
createTour: vi.fn(),
|
||||
detailTour: vi.fn(),
|
||||
updateTour: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock('./tourRouting', () => ({ routeWalkingTour, routeCityWalk, enrichTourElevations }));
|
||||
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } }));
|
||||
vi.mock('../useTourPermissions', () => ({ useTourPermissions: () => ({ canEdit: true, canAssign: true }) }));
|
||||
|
||||
import { useTourPlanner } from './useTourPlanner';
|
||||
|
||||
// FE-TOUR-WINTER-001 to FE-TOUR-WINTER-006
|
||||
|
||||
const routed = {
|
||||
coordinates: [
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
] as [number, number][],
|
||||
distanceMeters: 2200,
|
||||
durationSeconds: 1800,
|
||||
};
|
||||
const enriched = [
|
||||
[48, 11, 1200],
|
||||
[48.01, 11.02, 1350],
|
||||
] as [number, number, number][];
|
||||
const savedHike = {
|
||||
place_id: 88,
|
||||
name: 'Snowy ridge',
|
||||
tour_type: 'hike' as const,
|
||||
winter_activity: 'snowshoe' as const,
|
||||
distance: 2.2,
|
||||
elevation_gain: 150,
|
||||
elevation_loss: 0,
|
||||
duration: 60,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: true,
|
||||
caution: false,
|
||||
revision: 5,
|
||||
};
|
||||
const savedPoints = [
|
||||
{ lat: 48, lng: 11, role: 'start' as const, sequence: 0 },
|
||||
{ lat: 48.01, lng: 11.02, role: 'end' as const, sequence: 1 },
|
||||
];
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(450);
|
||||
});
|
||||
}
|
||||
|
||||
describe('useTourPlanner, the winter label of a hike (HIK-01)', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
localStorage.clear();
|
||||
routeWalkingTour.mockReset().mockResolvedValue(routed);
|
||||
routeCityWalk.mockReset().mockResolvedValue(routed);
|
||||
enrichTourElevations.mockReset().mockResolvedValue(enriched);
|
||||
createTour.mockReset();
|
||||
detailTour.mockReset();
|
||||
updateTour.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('FE-TOUR-WINTER-001: a hike starts without one, and saves without one as before', async () => {
|
||||
createTour.mockResolvedValue({ tour: { ...savedHike, winter_activity: null }, waypoints: [] });
|
||||
const { result } = renderHook(() => useTourPlanner({ tripId: 61 }));
|
||||
expect(result.current.winterActivity).toBeNull();
|
||||
act(() => {
|
||||
result.current.setName('Ridge');
|
||||
result.current.addWaypoint(48, 11);
|
||||
result.current.addWaypoint(48.01, 11.02);
|
||||
});
|
||||
await settle();
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
expect(createTour).toHaveBeenCalledWith(61, expect.objectContaining({ tour_type: 'hike', winter_activity: null }));
|
||||
});
|
||||
|
||||
it('FE-TOUR-WINTER-002: the label plans nothing again, is no step of the history and is kept in the draft', async () => {
|
||||
const { result } = renderHook(() => useTourPlanner({ tripId: 62 }));
|
||||
act(() => {
|
||||
result.current.setName('Ridge');
|
||||
result.current.addWaypoint(48, 11);
|
||||
result.current.addWaypoint(48.01, 11.02);
|
||||
});
|
||||
await settle();
|
||||
const { route, walkingDurationMinutes } = result.current;
|
||||
routeWalkingTour.mockClear();
|
||||
act(() => result.current.setWinterActivity('winter_hike'));
|
||||
await settle();
|
||||
expect(result.current.winterActivity).toBe('winter_hike');
|
||||
expect(routeWalkingTour).not.toHaveBeenCalled();
|
||||
expect(result.current.route).toEqual(route);
|
||||
expect(result.current.walkingDurationMinutes).toBe(walkingDurationMinutes);
|
||||
expect(result.current.status).toBe('ready');
|
||||
expect(result.current.canSave).toBe(true);
|
||||
expect(JSON.parse(localStorage.getItem('tour-draft-62') || 'null')).toMatchObject({
|
||||
winterActivity: 'winter_hike',
|
||||
});
|
||||
// An undo takes back the last point, not the label.
|
||||
act(() => result.current.undo());
|
||||
expect(result.current.winterActivity).toBe('winter_hike');
|
||||
});
|
||||
|
||||
it('FE-TOUR-WINTER-003: a draft comes back with its label, and a new tour starts without one', () => {
|
||||
const first = renderHook(() => useTourPlanner({ tripId: 63 }));
|
||||
act(() => first.result.current.setWinterActivity('snowshoe'));
|
||||
first.unmount();
|
||||
const again = renderHook(() => useTourPlanner({ tripId: 63 }));
|
||||
expect(again.result.current.winterActivity).toBe('snowshoe');
|
||||
act(() => again.result.current.startNewTour(true));
|
||||
expect(again.result.current.winterActivity).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TOUR-WINTER-004: opens a saved hike with its label, and taking it off saves the same route', async () => {
|
||||
detailTour.mockResolvedValue({ tour: savedHike, waypoints: savedPoints });
|
||||
updateTour.mockResolvedValue({
|
||||
tour: { ...savedHike, winter_activity: null, revision: 6 },
|
||||
waypoints: savedPoints,
|
||||
});
|
||||
const { result } = renderHook(() => useTourPlanner({ tripId: 64 }));
|
||||
await act(async () => {
|
||||
await result.current.openTour({ place_id: 88 });
|
||||
});
|
||||
expect(result.current.winterActivity).toBe('snowshoe');
|
||||
expect(result.current.hasUnsavedChanges).toBe(false);
|
||||
act(() => result.current.setWinterActivity(null));
|
||||
expect(result.current.hasUnsavedChanges).toBe(true);
|
||||
await settle();
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
expect(updateTour).toHaveBeenCalledWith(
|
||||
64,
|
||||
88,
|
||||
expect.objectContaining({
|
||||
tour_type: 'hike',
|
||||
winter_activity: null,
|
||||
max_hiking_difficulty: 2,
|
||||
waypoints: [
|
||||
expect.objectContaining({ lat: 48, lng: 11, role: 'start' }),
|
||||
expect.objectContaining({ lat: 48.01, lng: 11.02, role: 'end' }),
|
||||
],
|
||||
expected_revision: 5,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-TOUR-WINTER-005: the same label again is no edit, and another type saves none', async () => {
|
||||
createTour.mockResolvedValue({ tour: { ...savedHike, tour_type: 'walk', winter_activity: null }, waypoints: [] });
|
||||
const { result } = renderHook(() => useTourPlanner({ tripId: 65 }));
|
||||
act(() => result.current.setWinterActivity(null));
|
||||
expect(result.current.hasUnsavedChanges).toBe(false);
|
||||
act(() => {
|
||||
result.current.setName('Old town');
|
||||
result.current.setWinterActivity('snowshoe');
|
||||
result.current.addWaypoint(48, 11);
|
||||
result.current.addWaypoint(48.01, 11.02);
|
||||
result.current.setTourType('walk');
|
||||
});
|
||||
await settle();
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
expect(createTour).toHaveBeenCalledWith(65, expect.objectContaining({ tour_type: 'walk', winter_activity: null }));
|
||||
});
|
||||
|
||||
it('FE-TOUR-WINTER-006: keeps the label the switch brings back with the tour, winter hiking for the next one', async () => {
|
||||
detailTour.mockResolvedValue({ tour: savedHike, waypoints: savedPoints });
|
||||
const { result } = renderHook(() => useTourPlanner({ tripId: 66 }));
|
||||
expect(result.current.winterActivityChoice).toBe('winter_hike');
|
||||
act(() => result.current.setWinterActivity('snowshoe'));
|
||||
act(() => result.current.setWinterActivity(null));
|
||||
expect(result.current.winterActivityChoice).toBe('snowshoe');
|
||||
act(() => result.current.startNewTour(true));
|
||||
expect(result.current.winterActivity).toBeNull();
|
||||
expect(result.current.winterActivityChoice).toBe('winter_hike');
|
||||
await act(async () => {
|
||||
await result.current.openTour({ place_id: 88 });
|
||||
});
|
||||
expect(result.current.winterActivityChoice).toBe('snowshoe');
|
||||
act(() => result.current.discard());
|
||||
expect(result.current.winterActivityChoice).toBe('winter_hike');
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,9 @@ import {
|
||||
type SetStateAction,
|
||||
} from 'react';
|
||||
import { randomId } from '../../../utils/randomId';
|
||||
import type { SkiChoice } from '../ski/skiChoice';
|
||||
import { planPaddleStep, type PaddleTourRoute } from './paddleRouting';
|
||||
import { routeSkiTour, skiRouteKey } from './skiRouting';
|
||||
import {
|
||||
normalizePlannerWaypoints,
|
||||
routeInputsKey,
|
||||
@@ -52,6 +54,8 @@ export interface TourRouteInputs {
|
||||
runProfile: RunProfile;
|
||||
paddleBoat: PaddleBoatType | null;
|
||||
paddleLakeStyle: PaddleLakeStyle;
|
||||
/** SKI-02: a ski tour's discipline and technique, what its legs are laid for. */
|
||||
skiChoice: SkiChoice;
|
||||
/** Raised to plan the same points again. */
|
||||
retryToken: number;
|
||||
/** The planner's error, so a point found unreachable is only named while that is the error. */
|
||||
@@ -131,19 +135,21 @@ async function routePaddle({ inputs, plannedKey, runKey, signal, stale, setPlann
|
||||
|
||||
/**
|
||||
* A route on land for the type of the draft: a bike tour and a run for their
|
||||
* profile, a city walk, a hike within its limit, and a ride (HOR-01) put
|
||||
* together from its sections, never routed.
|
||||
* profile, a city walk, a hike within its limit, a ride (HOR-01) put together
|
||||
* from its sections, never routed, and a ski tour (SKI-02) laid by its own
|
||||
* routing, which draws every leg straight until the piste router comes.
|
||||
*/
|
||||
async function routeLand(inputs: RouteRun['inputs'], signal: AbortSignal) {
|
||||
const { waypoints, tourType, bikeProfile, runProfile, maxHikingDifficulty, hikingPreferences } = inputs;
|
||||
if (tourType === 'horse') return rideTour(waypoints);
|
||||
if (tourType === 'ski') return await routeSkiTour(waypoints, inputs.skiChoice, signal);
|
||||
if (tourType === 'bike') return await routeBikeTour(waypoints, bikeProfile, signal);
|
||||
if (tourType === 'run') return await routeRun(waypoints, runProfile, signal);
|
||||
if (tourType === 'walk') return await routeCityWalk(waypoints, signal);
|
||||
return await routeWalkingTour(waypoints, signal, maxHikingDifficulty, hikingPreferences);
|
||||
}
|
||||
|
||||
// A hike, a run, a city walk, a bike tour or a ride on land, then the heights along it.
|
||||
// A hike, a run, a city walk, a bike tour, a ride or a ski tour on land, then the heights along it.
|
||||
async function routeOnLand({ inputs, plannedKey, runKey, signal, stale, setPlanned, setUnreachable }: RouteRun) {
|
||||
const { setStatus, setError } = inputs;
|
||||
setStatus('routing');
|
||||
@@ -201,7 +207,8 @@ function scheduleRoute(run: RouteRun): () => void {
|
||||
* or its choices change: a hike on foot within its limit, a bike tour and a
|
||||
* run for their profile (BIK-01, RUN-01), a paddle tour on the water by the
|
||||
* server's router, a ride put together from its horse route sections and
|
||||
* manual legs (HOR-01). Every tour on land then gets its heights; a later
|
||||
* manual legs (HOR-01), a ski tour with its legs drawn (SKI-02). Every tour on
|
||||
* land then gets its heights; a later
|
||||
* change makes an answer still on its way stale, and a failure keeps the last
|
||||
* route that was planned.
|
||||
*/
|
||||
@@ -216,6 +223,7 @@ export function useTourRoute(inputs: TourRouteInputs) {
|
||||
runProfile,
|
||||
paddleBoat,
|
||||
paddleLakeStyle,
|
||||
skiChoice,
|
||||
retryToken,
|
||||
error,
|
||||
setStatus,
|
||||
@@ -241,6 +249,8 @@ export function useTourRoute(inputs: TourRouteInputs) {
|
||||
|
||||
const waypointKey = useMemo(() => tourWaypointKey(waypoints), [waypoints]);
|
||||
// What a route was planned for besides its points: a route of another type or profile is not this one's.
|
||||
// A ski tour's choice counts as far as its routing lays the legs by it (nothing while they are drawn straight).
|
||||
const skiKey = skiRouteKey(skiChoice);
|
||||
const choices = [
|
||||
tourType,
|
||||
bikeProfile,
|
||||
@@ -249,6 +259,7 @@ export function useTourRoute(inputs: TourRouteInputs) {
|
||||
hikingPreferences,
|
||||
paddleBoat,
|
||||
paddleLakeStyle,
|
||||
skiKey,
|
||||
];
|
||||
const plannedKey = `${waypointKey}#${JSON.stringify(choices)}`;
|
||||
// CW-02, CW-03: a station's own fields and the keys a save answered are no part of what a route is planned from,
|
||||
@@ -291,6 +302,7 @@ export function useTourRoute(inputs: TourRouteInputs) {
|
||||
runProfile,
|
||||
paddleBoat,
|
||||
paddleLakeStyle,
|
||||
skiChoice,
|
||||
setStatus,
|
||||
setError,
|
||||
patchWaypoints,
|
||||
@@ -325,6 +337,7 @@ export function useTourRoute(inputs: TourRouteInputs) {
|
||||
runProfile,
|
||||
setError,
|
||||
setStatus,
|
||||
skiChoice,
|
||||
tourType,
|
||||
tripId,
|
||||
]);
|
||||
|
||||
@@ -7,18 +7,22 @@ import { skiChoiceMissing, type SkiChoice } from './skiChoice';
|
||||
import { SKI_DISCIPLINE_ICON } from './skiDiscipline';
|
||||
|
||||
/**
|
||||
* SKI-02: what a ski track is, chosen on purpose: the discipline, none until
|
||||
* one is picked, and for cross-country the technique, Unknown among its
|
||||
* answers. The technique stays in sight but dimmed while another discipline
|
||||
* is chosen. The info dot says what TREK does with the track. The GPX import
|
||||
* and the classification dialog share it.
|
||||
* SKI-02: what a ski tour is, chosen on purpose: the discipline, none until
|
||||
* one is picked, six of them in two columns, and for cross-country the
|
||||
* technique, Unknown among its answers. The technique stays in sight but
|
||||
* dimmed while another discipline is chosen. The info dot says what TREK does
|
||||
* with the tour. The GPX import, the classification dialog and the planner
|
||||
* share it; `planned` says the choice is asked for a tour to save rather than
|
||||
* a track to import.
|
||||
*/
|
||||
export function SkiClassificationFields({
|
||||
choice,
|
||||
onChange,
|
||||
planned = false,
|
||||
}: {
|
||||
choice: SkiChoice;
|
||||
onChange: (choice: SkiChoice) => void;
|
||||
planned?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const crossCountry = choice.discipline === 'cross_country';
|
||||
@@ -34,13 +38,14 @@ export function SkiClassificationFields({
|
||||
value={(choice.discipline ?? '') as SkiDiscipline}
|
||||
onChange={(discipline) => onChange({ ...choice, discipline })}
|
||||
label={t('tours.ski.discipline')}
|
||||
fill
|
||||
columns={2}
|
||||
options={SKI_DISCIPLINES.map((discipline) => {
|
||||
const Icon = SKI_DISCIPLINE_ICON[discipline];
|
||||
return {
|
||||
value: discipline,
|
||||
label: t(`tours.ski.discipline.${discipline}`),
|
||||
icon: <Icon size={13} strokeWidth={2} aria-hidden="true" />,
|
||||
// Two columns in a narrow rail: a long name ends in an ellipsis rather than spilling out.
|
||||
label: <span className="min-w-0 truncate">{t(`tours.ski.discipline.${discipline}`)}</span>,
|
||||
icon: <Icon size={13} strokeWidth={2} aria-hidden="true" className="flex-none" />,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
@@ -64,7 +69,13 @@ export function SkiClassificationFields({
|
||||
</fieldset>
|
||||
{missing && (
|
||||
<TourNotice role="status" icon={<Info size={13} strokeWidth={2} />}>
|
||||
{t(missing === 'discipline' ? 'tours.ski.chooseDiscipline' : 'tours.ski.chooseTechnique')}
|
||||
{t(
|
||||
missing === 'technique'
|
||||
? 'tours.ski.chooseTechnique'
|
||||
: planned
|
||||
? 'tours.ski.chooseDisciplineToSave'
|
||||
: 'tours.ski.chooseDiscipline'
|
||||
)}
|
||||
</TourNotice>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { SkiDiscipline, SkiTechnique, TourListItem } from '@trek/shared';
|
||||
import { CableCar, createLucideIcon, Spline, TrendingUp, type LucideIcon } from 'lucide-react';
|
||||
import { CableCar, createLucideIcon, Footprints, Snowflake, Spline, TrendingUp, type LucideIcon } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* SKI-02: a snowboard seen from above, laid across the square with its two
|
||||
* bindings. Lucide has no board, and the snowflake is the winter hike's mark
|
||||
* (HIK-01): both sit in the same corner badge, so the board needs a glyph of
|
||||
* its own to be told apart there.
|
||||
* bindings. Lucide has no board, and the snowflake is the winter hike's mark:
|
||||
* both sit in the same corner badge, so the board needs a glyph of its own to
|
||||
* be told apart there.
|
||||
*/
|
||||
const BOARD = 'rotate(-45 12 12)';
|
||||
const Snowboard = createLucideIcon('snowboard', [
|
||||
@@ -15,26 +15,29 @@ const Snowboard = createLucideIcon('snowboard', [
|
||||
]);
|
||||
|
||||
/**
|
||||
* SKI-02: the icon each discipline goes by: a lift for alpine skiing, a board
|
||||
* for snowboarding, a winding trail for cross-country and the climb on skins
|
||||
* for ski touring. None of them is the winter hike's snowflake, which takes
|
||||
* the same corner of a tile.
|
||||
* SKI-02: the icon each discipline goes by, each its own: a lift for alpine
|
||||
* skiing, a board for snowboarding, a winding trail for cross-country, the
|
||||
* climb on skins for ski touring, footprints in the snow for snowshoeing and a
|
||||
* snowflake for winter hiking. They all take the same corner of a tile, so no
|
||||
* two share a glyph, and none is the tile's own mountain.
|
||||
*/
|
||||
export const SKI_DISCIPLINE_ICON: Record<SkiDiscipline, LucideIcon> = {
|
||||
alpine: CableCar,
|
||||
snowboard: Snowboard,
|
||||
cross_country: Spline,
|
||||
touring: TrendingUp,
|
||||
snowshoe: Footprints,
|
||||
winter_hike: Snowflake,
|
||||
};
|
||||
|
||||
export type SkiTour = Partial<Pick<TourListItem, 'tour_type' | 'ski_discipline' | 'ski_technique'>>;
|
||||
|
||||
/** The discipline of a ski track, or null for any other tour and a ski track listed before it had one. */
|
||||
/** The discipline of a ski tour, or null for any other tour and a ski track listed before it had one. */
|
||||
export function skiDisciplineOf(tour: SkiTour): SkiDiscipline | null {
|
||||
return tour.tour_type === 'ski' ? (tour.ski_discipline ?? null) : null;
|
||||
}
|
||||
|
||||
/** A cross-country track's technique; null for every other discipline and type. */
|
||||
/** A cross-country tour's technique; null for every other discipline and type. */
|
||||
export function skiTechniqueOf(tour: SkiTour): SkiTechnique | null {
|
||||
return skiDisciplineOf(tour) === 'cross_country' ? (tour.ski_technique ?? null) : null;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { TourDayRowAvatar } from '../TourDayRowFacts';
|
||||
import { tourDayRowIcon } from '../tourDayRowIcon';
|
||||
import { TourGradeTile } from '../tourParts';
|
||||
import { WINTER_ACTIVITY_ICON } from '../winter/winterActivityIcon';
|
||||
import { SKI_DISCIPLINE_ICON, skiClassificationText, skiDisciplineOf, skiTechniqueOf } from './skiDiscipline';
|
||||
import { SkiCornerMark, SkiHeadPill } from './skiMarks';
|
||||
|
||||
// FE-TOUR-SKI-MARK-001 to FE-TOUR-SKI-MARK-006
|
||||
// FE-TOUR-SKI-MARK-001 to FE-TOUR-SKI-MARK-007
|
||||
|
||||
const t = (key: string) => key;
|
||||
const skiTrack = {
|
||||
@@ -73,8 +73,22 @@ describe('ski marks (SKI-02)', () => {
|
||||
expect(screen.getByTestId('tour-ski-mark')).toHaveStyle({ width: '22px' });
|
||||
});
|
||||
|
||||
it('FE-TOUR-SKI-MARK-006: no discipline shares its corner glyph with another, nor with a winter label', () => {
|
||||
const icons = [...Object.values(SKI_DISCIPLINE_ICON), ...Object.values(WINTER_ACTIVITY_ICON)];
|
||||
it('FE-TOUR-SKI-MARK-006: each of the six disciplines has a corner glyph of its own, none the ski tile itself', () => {
|
||||
const icons = Object.values(SKI_DISCIPLINE_ICON);
|
||||
expect(icons).toHaveLength(6);
|
||||
expect(new Set(icons).size).toBe(icons.length);
|
||||
expect(icons).not.toContain(tourDayRowIcon('ski'));
|
||||
});
|
||||
|
||||
it('FE-TOUR-SKI-MARK-007: snowshoeing and winter hiking are ski tours with their own marks, a hike carries none', () => {
|
||||
const { rerender } = render(<SkiCornerMark tour={{ tour_type: 'ski', ski_discipline: 'snowshoe' }} />);
|
||||
const snowshoe = screen.getByRole('img', { name: 'tours.ski.discipline.snowshoe' });
|
||||
expect(snowshoe.querySelector('svg.lucide-footprints')).not.toBeNull();
|
||||
rerender(<SkiCornerMark tour={{ tour_type: 'ski', ski_discipline: 'winter_hike' }} />);
|
||||
const winterHike = screen.getByRole('img', { name: 'tours.ski.discipline.winter_hike' });
|
||||
expect(winterHike.querySelector('svg.lucide-snowflake')).not.toBeNull();
|
||||
const hike = { tour_type: 'hike' as const, max_hiking_difficulty: 2, wanderer_ref: null, has_waypoints: true };
|
||||
render(<TourGradeTile tour={hike} />);
|
||||
expect(screen.queryAllByTestId('tour-ski-mark')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
} from './skiDiscipline';
|
||||
|
||||
/**
|
||||
* SKI-02: a ski track's discipline on the corner of its round tile, a frosted
|
||||
* badge carrying the discipline's icon, the way a hike carries its winter
|
||||
* label there. Its tooltip names the discipline and a cross-country technique.
|
||||
* SKI-02: a ski tour's discipline on the corner of its round tile, a frosted
|
||||
* badge carrying the discipline's own icon. Its tooltip names the discipline
|
||||
* and a cross-country technique.
|
||||
*/
|
||||
export function SkiCornerMark({ tour, size = 15 }: { tour: SkiTour; size?: number }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -24,7 +24,7 @@ export function SkiCornerMark({ tour, size = 15 }: { tour: SkiTour; size?: numbe
|
||||
}
|
||||
|
||||
/**
|
||||
* SKI-02: a ski track's discipline as a pill of a tour's head, beside its
|
||||
* SKI-02: a ski tour's discipline as a pill of a tour's head, beside its
|
||||
* type, a cross-country technique as a badge inside it. The tooltip says what
|
||||
* TREK does with the track and what it does not say.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { NO_SKI_CHOICE } from './skiChoice';
|
||||
import { useSkiFields } from './useSkiFields';
|
||||
|
||||
// FE-TOUR-SKI-FIELDS-001 to FE-TOUR-SKI-FIELDS-002
|
||||
|
||||
describe('useSkiFields (SKI-02)', () => {
|
||||
it('FE-TOUR-SKI-FIELDS-001: a change is an edit only when it says something else and the planner allows it', () => {
|
||||
const onEdit = vi.fn(() => true);
|
||||
const { result } = renderHook(() => useSkiFields(NO_SKI_CHOICE, onEdit));
|
||||
act(() => result.current.change({ discipline: null, technique: null }));
|
||||
expect(onEdit).not.toHaveBeenCalled();
|
||||
act(() => result.current.change({ discipline: 'snowshoe', technique: null }));
|
||||
expect(onEdit).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.choice).toEqual({ discipline: 'snowshoe', technique: null });
|
||||
|
||||
onEdit.mockReturnValue(false);
|
||||
act(() => result.current.change({ discipline: 'alpine', technique: null }));
|
||||
expect(result.current.choice).toEqual({ discipline: 'snowshoe', technique: null });
|
||||
});
|
||||
|
||||
it('FE-TOUR-SKI-FIELDS-002: a choice loaded from a tour or a draft is no edit', () => {
|
||||
const onEdit = vi.fn(() => true);
|
||||
const { result } = renderHook(() => useSkiFields(NO_SKI_CHOICE, onEdit));
|
||||
act(() => result.current.load({ discipline: 'cross_country', technique: 'classic' }));
|
||||
expect(result.current.choice).toEqual({ discipline: 'cross_country', technique: 'classic' });
|
||||
expect(onEdit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { SkiChoice } from './skiChoice';
|
||||
|
||||
/**
|
||||
* SKI-02: a planned ski tour's discipline and cross-country technique, fields
|
||||
* of the draft like a paddle tour's boat: nothing chosen until the traveller
|
||||
* picks, and no step of the history. A technique stays remembered while
|
||||
* another discipline is tried. `onEdit` tells the planner the draft changed,
|
||||
* and says whether it may.
|
||||
*/
|
||||
export function useSkiFields(initial: SkiChoice, onEdit: () => boolean) {
|
||||
const [choice, setChoice] = useState<SkiChoice>(initial);
|
||||
const change = useCallback(
|
||||
(next: SkiChoice) => {
|
||||
if (next.discipline === choice.discipline && next.technique === choice.technique) return;
|
||||
if (onEdit()) setChoice(next);
|
||||
},
|
||||
[choice, onEdit]
|
||||
);
|
||||
return {
|
||||
choice,
|
||||
change,
|
||||
/** Loaded from a tour, a draft or an import, or cleared; not an edit. */
|
||||
load: setChoice,
|
||||
};
|
||||
}
|
||||
@@ -143,6 +143,13 @@ describe('tourStages', () => {
|
||||
expect(ride.every((stage) => stage.distanceKm !== null && !stage.overDay)).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-TOUR-STAGE-MODEL-014: a multi-day ski tour has its stages measured but untimed, never the router time (SKI-02)', () => {
|
||||
const ski = tourStagesOf(input(withEnds('b'), { tourType: 'ski' }));
|
||||
expect(ski).toHaveLength(2);
|
||||
expect(ski.map((stage) => stage.minutes)).toEqual([null, null]);
|
||||
expect(ski.every((stage) => stage.distanceKm !== null && stage.gain !== null && !stage.overDay)).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-TOUR-STAGE-MODEL-008: a paddle stage has no heights and is timed at the paddler speed, its lock passed once', () => {
|
||||
const lock = { wait_minutes: 10, transit_minutes: 5 };
|
||||
const points = withEnds('c').map((p) => (p.id === 'c' ? { ...p, lock } : p));
|
||||
|
||||
@@ -219,12 +219,12 @@ function paddleStageMinutes(
|
||||
* pace over its distance, a hike by the signposts' rule over its distance,
|
||||
* climb and descent, and otherwise the router's time for the whole tour
|
||||
* shared out by distance, which is how the router times a ride or a walk on
|
||||
* the flat. A ride on horseback (HOR-01) has no time, as the whole ride has
|
||||
* none, and a run without a pace has none either.
|
||||
* the flat. A ride on horseback (HOR-01) and a ski tour (SKI-02) have no
|
||||
* time, as the whole tour has none, and a run without a pace has none either.
|
||||
*/
|
||||
function stageMinutes(input: TourStageInputs, span: StageSpan, stretch: Stretch, totalKm: number): number | null {
|
||||
if (input.paddle) return paddleStageMinutes(input.points, span, input.paddle);
|
||||
if (input.tourType === 'horse') return null;
|
||||
if (input.tourType === 'horse' || input.tourType === 'ski') return null;
|
||||
if (input.tourType === 'run') {
|
||||
const pace = input.runPaceSecondsPerKm;
|
||||
return pace === null || runPaceInvalid(pace) ? null : runMinutesAtPace(stretch.distanceKm, pace);
|
||||
|
||||
@@ -47,9 +47,10 @@ export function withoutHeights<
|
||||
/**
|
||||
* What the time on the move is called: walking for a hike (in the wording the
|
||||
* place asks for) and for a city walk (CW-02), riding for a bike tour and for
|
||||
* a ride on horseback (HOR-01), paddling for a paddle tour, and for a run
|
||||
* (RUN-01) the running time it is planned with, said as planned wherever it
|
||||
* shows, so it never reads as a time someone recorded.
|
||||
* a ride on horseback (HOR-01), paddling for a paddle tour, ski time for a
|
||||
* ski tour (SKI-02), and for a run (RUN-01) the running time it is planned
|
||||
* with, said as planned wherever it shows, so it never reads as a time
|
||||
* someone recorded.
|
||||
*/
|
||||
export function movingTimeLabel(
|
||||
tour: { tour_type?: string } | undefined,
|
||||
@@ -60,5 +61,6 @@ export function movingTimeLabel(
|
||||
if (tour?.tour_type === 'walk') return t('tours.walk.timeEstimatedShort');
|
||||
if (tour?.tour_type === 'run') return t('tours.run.timeEstimated');
|
||||
if (tour?.tour_type === 'horse') return t('tours.horse.time');
|
||||
if (tour?.tour_type === 'ski') return t('tours.ski.time');
|
||||
return tour?.tour_type === 'bike' ? t('tours.bike.timeEstimatedShort') : hikeLabel;
|
||||
}
|
||||
|
||||
@@ -13,13 +13,14 @@ import type { TourBaseLayer } from '../Map/MapLayerSwitcher';
|
||||
* layer (RC-10), OpenSeaMap's seamarks (PAD-10) and OpenSnowMap's pistes (SKI-01).
|
||||
*
|
||||
* - Hike: the standard map first, as before; topo, OpenHikingMap (MAP-01, while the admin
|
||||
* allows it) and satellite beside it; via ferratas, and the pistes for winter hiking (SKI-01).
|
||||
* allows it) and satellite beside it; via ferratas. A hike is summer only: winter hiking and
|
||||
* snowshoeing are ski tours, so the pistes are not offered here.
|
||||
* - Bike (BIK-01): the standard map, topo for the climbs, CyclOSM (BIK-06, while the admin
|
||||
* allows it) and satellite. No ferratas, a bike tour climbs none.
|
||||
* - Run (RUN-01): the standard map, topo for the climbs and satellite. No ferratas, a run
|
||||
* takes none.
|
||||
* - Ski (SKI-02): the standard map, topo for the slopes and satellite. No ferratas. The
|
||||
* pistes as an overlay (SKI-01).
|
||||
* pistes as an overlay (SKI-01), the only type that offers them, shown until switched off.
|
||||
* - Paddle: the standard map, whose water reads clearly, and satellite. No topo and
|
||||
* no ferratas, those are hiking maps. Seamarks as an overlay.
|
||||
* - Horse (HOR-01): the standard map, topo for the lie of the land and satellite for
|
||||
@@ -43,7 +44,7 @@ export interface TourMapLayerSet {
|
||||
const HIKE: TourMapLayerSet = {
|
||||
bases: ['default', 'topo', 'satellite'],
|
||||
defaultBase: 'default',
|
||||
overlays: ['ferratas', 'pistes'],
|
||||
overlays: ['ferratas'],
|
||||
};
|
||||
const HIKE_WITH_HIKING_MAP: TourMapLayerSet = { ...HIKE, bases: ['default', 'topo', 'hiking', 'satellite'] };
|
||||
const BIKE: TourMapLayerSet = { bases: ['default', 'topo', 'satellite'], defaultBase: 'default', overlays: [] };
|
||||
|
||||
@@ -16,21 +16,19 @@ import { tourMetricFacts } from './tourFacts'
|
||||
import type { TourDayAssignment } from './useTourDayAssignment'
|
||||
import { useRailFold } from './railFold'
|
||||
import { tourDayRowIcon } from './tourDayRowIcon'
|
||||
import { WinterCornerMark } from './winter/winterMarks'
|
||||
import { SkiCornerMark } from './ski/skiMarks'
|
||||
|
||||
/**
|
||||
* A tour's tile in a list: its T-scale grade in place of the mountain, named in
|
||||
* full by its tooltip (a GPX import carries no grade and keeps the mountain),
|
||||
* where it came from, when that was not TREK, as a small badge over its foot,
|
||||
* and a hike's winter label (HIK-01) or a ski track's discipline (SKI-02) at
|
||||
* its top corner.
|
||||
* and a ski tour's discipline (SKI-02) at its top corner.
|
||||
*/
|
||||
export function TourGradeTile({ tour }: { tour: Pick<TourListItem, 'max_hiking_difficulty' | 'wanderer_ref' | 'has_waypoints'> & Partial<Pick<TourListItem, 'tour_type' | 'winter_activity' | 'ski_discipline' | 'ski_technique'>> }) {
|
||||
export function TourGradeTile({ tour }: { tour: Pick<TourListItem, 'max_hiking_difficulty' | 'wanderer_ref' | 'has_waypoints'> & Partial<Pick<TourListItem, 'tour_type' | 'ski_discipline' | 'ski_technique'>> }) {
|
||||
const { t } = useTranslation()
|
||||
const level = tour.max_hiking_difficulty
|
||||
const source = tourSource(tour)
|
||||
// A tour without a grade (a paddle or a bike tour, a run, a ride, a ski track, an import) names its type instead; a hike keeps the mountain.
|
||||
// A tour without a grade (a paddle or a bike tour, a run, a ride, a ski tour, an import) names its type instead; a hike keeps the mountain.
|
||||
const type = tour.tour_type && ['kayak', 'bike', 'run', 'horse', 'ski'].includes(tour.tour_type) ? tour.tour_type : 'hike'
|
||||
const label = level != null ? t(`tours.planner.difficulty.t${level}`) : type !== 'hike' ? t(`tourTypes.${type}`) : null
|
||||
return (
|
||||
@@ -40,7 +38,6 @@ export function TourGradeTile({ tour }: { tour: Pick<TourListItem, 'max_hiking_d
|
||||
{level != null ? `T${level}` : createElement(tourDayRowIcon(type), { size: 15, strokeWidth: 1.9, className: 'text-content-secondary' })}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<WinterCornerMark activity={tour.winter_activity} />
|
||||
<SkiCornerMark tour={tour} />
|
||||
{source !== 'trek' && (
|
||||
<span className="absolute inset-x-0 -bottom-1 flex justify-center">
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { WinterActivity } from '@trek/shared';
|
||||
import { Footprints, Snowflake, type LucideIcon } from 'lucide-react';
|
||||
|
||||
/** HIK-01: the icon each winter label goes by, a snowflake for winter hiking and footprints in the snow for snowshoes. */
|
||||
export const WINTER_ACTIVITY_ICON: Record<WinterActivity, LucideIcon> = {
|
||||
winter_hike: Snowflake,
|
||||
snowshoe: Footprints,
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { TourDayRowAvatar } from '../TourDayRowFacts';
|
||||
import { TourGradeTile } from '../tourParts';
|
||||
import { WinterActivityPill, WinterCornerMark } from './winterMarks';
|
||||
|
||||
// FE-TOUR-WINTER-MARK-001 to FE-TOUR-WINTER-MARK-005
|
||||
|
||||
const hike = { tour_type: 'hike' as const, max_hiking_difficulty: 2, wanderer_ref: null, has_waypoints: true };
|
||||
|
||||
describe('winter marks (HIK-01)', () => {
|
||||
it('FE-TOUR-WINTER-MARK-001: the corner mark names the label, and a hike without one shows none', () => {
|
||||
const { container, rerender } = render(<WinterCornerMark activity={null} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
rerender(<WinterCornerMark activity="snowshoe" size={20} />);
|
||||
const mark = screen.getByRole('img', { name: 'tours.winter.activity.snowshoe' });
|
||||
expect(mark).toHaveAttribute('data-testid', 'tour-winter-mark');
|
||||
expect(mark).toHaveStyle({ width: '20px', height: '20px' });
|
||||
});
|
||||
|
||||
it('FE-TOUR-WINTER-MARK-002: the pill names the label in the head look it is given', () => {
|
||||
render(<WinterActivityPill activity="winter_hike" className="head-pill" />);
|
||||
const pill = screen.getByTestId('tour-detail-winter');
|
||||
expect(pill).toHaveTextContent('tours.winter.activity.winter_hike');
|
||||
expect(pill).toHaveClass('head-pill');
|
||||
});
|
||||
|
||||
it('FE-TOUR-WINTER-MARK-003: a tour list tile carries the mark beside its grade', () => {
|
||||
const { rerender } = render(<TourGradeTile tour={hike} />);
|
||||
expect(screen.queryByTestId('tour-winter-mark')).toBeNull();
|
||||
rerender(<TourGradeTile tour={{ ...hike, winter_activity: 'winter_hike' }} />);
|
||||
expect(screen.getByRole('img', { name: 'tours.winter.activity.winter_hike' })).toBeInTheDocument();
|
||||
expect(screen.getByText('T2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TOUR-WINTER-MARK-004: the day plan tile stays as it was without a label, and carries the mark with one', () => {
|
||||
const { container, rerender } = render(<TourDayRowAvatar tour={hike} size={32} />);
|
||||
expect(container.firstElementChild).toHaveAttribute('data-testid', 'plan-tour-icon');
|
||||
rerender(<TourDayRowAvatar tour={{ ...hike, winter_activity: 'snowshoe' }} size={50} />);
|
||||
expect(screen.getByTestId('plan-tour-icon')).toHaveTextContent('T2');
|
||||
expect(screen.getByTestId('tour-winter-mark')).toHaveStyle({ width: '22px' });
|
||||
});
|
||||
|
||||
it('FE-TOUR-WINTER-MARK-005: the corner mark goes by the same icon as the head pill of its label', () => {
|
||||
for (const activity of ['winter_hike', 'snowshoe'] as const) {
|
||||
const icon = activity === 'snowshoe' ? 'lucide-footprints' : 'lucide-snowflake';
|
||||
const mark = render(<WinterCornerMark activity={activity} />);
|
||||
expect(mark.container.querySelector(`svg.${icon}`)).not.toBeNull();
|
||||
mark.unmount();
|
||||
const pill = render(<WinterActivityPill activity={activity} className="head-pill" />);
|
||||
expect(pill.container.querySelector(`svg.${icon}`)).not.toBeNull();
|
||||
pill.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { WinterActivity } from '@trek/shared';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { TourCornerMark } from '../TourCornerMark';
|
||||
import { WINTER_ACTIVITY_ICON } from './winterActivityIcon';
|
||||
|
||||
/**
|
||||
* HIK-01: a hike's winter label on its round tile, a frosted badge at the
|
||||
* tile's top corner with the label's own icon, the one its head pill shows,
|
||||
* the way the tile carries where a tour came from at its foot. Its tooltip
|
||||
* names the label; nothing is drawn for a hike without one.
|
||||
*/
|
||||
export function WinterCornerMark({
|
||||
activity,
|
||||
size = 15,
|
||||
}: {
|
||||
activity: WinterActivity | null | undefined;
|
||||
/** The badge's diameter in pixels, about half the tile's. */
|
||||
size?: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!activity) return null;
|
||||
return (
|
||||
<TourCornerMark
|
||||
icon={WINTER_ACTIVITY_ICON[activity]}
|
||||
label={t(`tours.winter.activity.${activity}`)}
|
||||
size={size}
|
||||
testId="tour-winter-mark"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* HIK-01: the winter label as a pill of a tour's head, its icon in the info
|
||||
* tone. The tooltip says it is the travellers' label only, never a check of
|
||||
* the way in winter.
|
||||
*/
|
||||
export function WinterActivityPill({ activity, className }: { activity: WinterActivity; className: string }) {
|
||||
const { t } = useTranslation();
|
||||
const Icon = WINTER_ACTIVITY_ICON[activity];
|
||||
return (
|
||||
<Tooltip label={t('tours.winter.labelOnly')}>
|
||||
<span data-testid="tour-detail-winter" className={className} style={fs(10.5)}>
|
||||
<Icon size={11} strokeWidth={2} className="flex-none text-info" aria-hidden="true" />
|
||||
{t(`tours.winter.activity.${activity}`)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -57,8 +57,11 @@ export interface SegmentedOption<T extends string> {
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
/** The track laid out as a grid of this many columns, for more choices than one row holds. */
|
||||
const SEGMENTED_COLUMNS = { 2: 'grid w-full grid-cols-2', 3: 'grid w-full grid-cols-3' } as const
|
||||
|
||||
/** A row of choices on a grey track, the chosen one raised like the status filter (or filled like the view switcher). */
|
||||
export function Segmented<T extends string>({ value, options, onChange, label, accent = false, fill = false }: {
|
||||
export function Segmented<T extends string>({ value, options, onChange, label, accent = false, fill = false, columns }: {
|
||||
value: T
|
||||
options: readonly SegmentedOption<T>[]
|
||||
onChange: (value: T) => void
|
||||
@@ -68,15 +71,18 @@ export function Segmented<T extends string>({ value, options, onChange, label, a
|
||||
accent?: boolean
|
||||
/** Stretch across the row, each option taking an equal share. */
|
||||
fill?: boolean
|
||||
/** Wrap the options into a grid of this many equal columns on the same track. */
|
||||
columns?: keyof typeof SEGMENTED_COLUMNS
|
||||
}) {
|
||||
const track = columns ? SEGMENTED_COLUMNS[columns] : fill ? 'flex w-full' : 'inline-flex'
|
||||
return (
|
||||
<div role="group" aria-label={label} className={`${fill ? 'flex w-full' : 'inline-flex'} flex-none gap-0.5 rounded-[10px] bg-surface-tertiary p-[3px]`}>
|
||||
<div role="group" aria-label={label} className={`${track} flex-none gap-0.5 rounded-[10px] bg-surface-tertiary p-[3px]`}>
|
||||
{options.map(o => {
|
||||
const on = o.value === value
|
||||
const look = on ? (accent ? 'bg-accent text-accent-text' : 'bg-surface-card text-content shadow-sm') : 'text-content-muted hover:text-content'
|
||||
return (
|
||||
<button key={o.value} type="button" onClick={() => onChange(o.value)} aria-pressed={on}
|
||||
className={`${fill ? 'flex-1' : ''} inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-[8px] px-3 py-1.5 font-medium ${look}`}
|
||||
className={`${fill ? 'flex-1' : ''} ${columns ? 'min-w-0' : ''} inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-[8px] px-3 py-1.5 font-medium ${look}`}
|
||||
style={fs(12.5, 'body')}>
|
||||
{o.icon}{o.label}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user