mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
feat(tours): a hike can be marked as a winter hike or a snowshoe hike
HIK-01. The details of the hiking planner gain an optional Winter activity switch, off by default, so the normal hiking flow asks for nothing more. Switched on, the hike is a winter hike or a snowshoe hike; the choice stays dimmed and out of reach while the switch is off. The label is saved with the tour, comes back when it is opened, survives a draft reload, a copy and a take-over, and switching it off takes only the label away: the route, its grade and the days stay as they were. The label is a label only. Routing, the walking time and the 3D terrain view work as for any hike, and the info dot and the head pill's tooltip say that nothing was checked for snow, avalanches or whether a path can be walked in winter. The saved label shows as a frosted snowflake on the corner of the tour's tile in the tour list and the day plan, and as a pill beside the type in the tour's detail. The server keeps it in tours.winter_activity (CHECK list, NULL for every tour saved before and for every other type), refuses it on a bike tour, a city walk and a paddle tour, and keeps the stored label when an older client saves a hike without the field. A snowshoe hike exports to GPX as snowshoeing. list_tours names the field, with the same words on what it does not say.
This commit is contained in:
@@ -13,6 +13,7 @@ import { movingTimeLabel } from './tourFacts';
|
||||
import { formatPlannedTourDuration, plannedTotalText, tourPlannedTimes } from './tourPresentation';
|
||||
import { useWalkProgress } from './walk/walkProgress';
|
||||
import { walkStaysText } from './walk/walkTimeFields';
|
||||
import { WinterCornerMark } from './winter/winterMarks';
|
||||
|
||||
export function TourDayRowIcon({ type, size = 27 }: { type: string; size?: number }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -82,14 +83,32 @@ function Fact({
|
||||
/**
|
||||
* A tour's tile in the day plan and in its detail: the T-scale grade in a round
|
||||
* tile, the same tile the tour lists use, so a tour reads alike wherever it
|
||||
* shows. A GPX import has no grade and shows its type's icon instead.
|
||||
* shows. A GPX import has no grade and shows its type's icon instead. A hike's
|
||||
* winter label (HIK-01) sits on the tile's corner, as in the tour lists.
|
||||
*/
|
||||
export function TourDayRowAvatar({
|
||||
tour,
|
||||
size = 32,
|
||||
}: {
|
||||
tour: Pick<TourListItem, 'tour_type' | 'max_hiking_difficulty'>;
|
||||
tour: Pick<TourListItem, 'tour_type' | 'max_hiking_difficulty'> & Partial<Pick<TourListItem, 'winter_activity'>>;
|
||||
size?: number;
|
||||
}) {
|
||||
const avatar = <TourGradeAvatar tour={tour} size={size} />;
|
||||
if (!tour.winter_activity) return avatar;
|
||||
return (
|
||||
<span className="relative flex flex-none">
|
||||
{avatar}
|
||||
<WinterCornerMark activity={tour.winter_activity} size={Math.round(size * 0.44)} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TourGradeAvatar({
|
||||
tour,
|
||||
size,
|
||||
}: {
|
||||
tour: Pick<TourListItem, 'tour_type' | 'max_hiking_difficulty'>;
|
||||
size: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const level = tour.max_hiking_difficulty;
|
||||
|
||||
@@ -304,6 +304,18 @@ describe('TourDetailDialog', () => {
|
||||
expect(screen.queryByText(/Based on/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('HIK-01: names the winter label of a hike in its head and on its tile, and nothing for a hike without one', () => {
|
||||
const { unmount } = render(<TourDetailDialog {...defaultProps} />)
|
||||
expect(screen.queryByTestId('tour-detail-winter')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('tour-winter-mark')).not.toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, winter_activity: 'snowshoe' }} />)
|
||||
expect(screen.getByTestId('tour-detail-winter')).toHaveTextContent('Snowshoe hike')
|
||||
expect(screen.getByRole('img', { name: 'Snowshoe hike' })).toBeInTheDocument()
|
||||
expect(screen.getAllByText('T2').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('shows the grade on the tile and no second difficulty scale', () => {
|
||||
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, difficulty: 'easy', max_hiking_difficulty: 1 }} />)
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ import { useTourPermissions, type TourPermissionProps } from './useTourPermissio
|
||||
import { WalkRoutePendingHint } from './walk/WalkRoutePending';
|
||||
import { WalkStationsCard } from './walk/WalkStations';
|
||||
import { walkTimeFields } from './walk/walkTimeFields';
|
||||
import { WinterActivityPill } from './winter/winterMarks';
|
||||
|
||||
interface TourDetailDialogProps extends TourPermissionProps {
|
||||
tour: TourListItem;
|
||||
@@ -208,7 +209,7 @@ function tourTimeFields(
|
||||
}
|
||||
|
||||
/**
|
||||
* The head's facts as pills: the type, where it came from, the time (its parts
|
||||
* The head's facts as pills: the type and a hike's winter label, where it came from, the time (its parts
|
||||
* in the tooltip), distance, ascent, descent, and a caution.
|
||||
*/
|
||||
function TourHeadPills({
|
||||
@@ -250,6 +251,8 @@ function TourHeadPills({
|
||||
})}
|
||||
{t(`tourTypes.${tour.tour_type}`)}
|
||||
</span>
|
||||
{/* HIK-01: a hike's winter label, the travellers' own, beside its type. */}
|
||||
{tour.winter_activity && <WinterActivityPill activity={tour.winter_activity} className={HEAD_PILL} />}
|
||||
{/* A paddle tour names its boat and its water, each a badge of its own. */}
|
||||
{tour.boat_type && (
|
||||
<span className={HEAD_PILL} style={fs(10.5)} data-testid="tour-detail-boat">
|
||||
|
||||
@@ -7,6 +7,7 @@ import { INPUT, LABEL } from '../../shared/dialogParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { FoldButton, InfoDot, TourNotice, TourSection } from '../tourParts';
|
||||
import { HikingPreferencesField } from './HikingPreferencesField';
|
||||
import { WinterActivityField } from './WinterActivityField';
|
||||
import { TOUR_DURATION_INPUT_MAX_LENGTH, formatTourDuration, parseTourDuration } from './tourDurationInput';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
|
||||
@@ -324,8 +325,8 @@ function TourDifficultyField({
|
||||
}
|
||||
|
||||
/**
|
||||
* Description, website, the difficulty cap and the time, folded away with the
|
||||
* chevron. The section opens by itself while one of its fields holds a value
|
||||
* Description, website, a hike's difficulty cap, route choice and winter label
|
||||
* (HIK-01), and the time, folded away with the chevron. The section opens by itself while one of its fields holds a value
|
||||
* the planner refuses, so the reason Save is held back is never out of sight.
|
||||
*/
|
||||
export function TourDetailsSection({
|
||||
@@ -364,6 +365,7 @@ export function TourDetailsSection({
|
||||
<>
|
||||
<TourDifficultyField planner={planner} locked={locked} onDifficultyChange={onDifficultyChange} />
|
||||
<HikingPreferencesField planner={planner} locked={locked} />
|
||||
<WinterActivityField planner={planner} locked={locked} />
|
||||
</>
|
||||
)}
|
||||
<TourTimeFields planner={planner} locked={locked} />
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { WinterActivity } from '@trek/shared';
|
||||
import { useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { WinterActivityField } from './WinterActivityField';
|
||||
|
||||
// FE-TOUR-WINTER-FIELD-001 to FE-TOUR-WINTER-FIELD-004
|
||||
|
||||
const toggle = () => screen.getByRole('button', { name: 'tours.winter.title' });
|
||||
const option = (activity: WinterActivity) => screen.getByRole('button', { name: `tours.winter.activity.${activity}` });
|
||||
|
||||
/** The field over a planner that keeps what it is given, as the real one does. */
|
||||
function Stateful({ initial = null }: { initial?: WinterActivity | null }) {
|
||||
const [winterActivity, setWinterActivity] = useState<WinterActivity | null>(initial);
|
||||
return (
|
||||
<>
|
||||
<WinterActivityField planner={{ winterActivity, setWinterActivity }} locked={false} />
|
||||
<output data-testid="saved">{winterActivity ?? 'none'}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe('WinterActivityField (HIK-01)', () => {
|
||||
it('FE-TOUR-WINTER-FIELD-001: is off by default, its choice dimmed and out of reach until it is switched on', () => {
|
||||
const setWinterActivity = vi.fn();
|
||||
render(<WinterActivityField planner={{ winterActivity: null, setWinterActivity }} locked={false} />);
|
||||
expect(toggle()).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.getByTestId('tour-winter-kind')).toHaveClass('opacity-50');
|
||||
expect(option('winter_hike')).toBeDisabled();
|
||||
expect(option('snowshoe')).toBeDisabled();
|
||||
fireEvent.click(option('snowshoe'));
|
||||
expect(setWinterActivity).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('button', { name: 'tours.winter.infoLabel' })).toHaveAccessibleDescription(
|
||||
'tours.winter.info'
|
||||
);
|
||||
fireEvent.click(toggle());
|
||||
expect(setWinterActivity).toHaveBeenCalledWith('winter_hike');
|
||||
});
|
||||
|
||||
it('FE-TOUR-WINTER-FIELD-002: switched on, it names the label chosen and takes the other one', () => {
|
||||
const setWinterActivity = vi.fn();
|
||||
render(<WinterActivityField planner={{ winterActivity: 'snowshoe', setWinterActivity }} locked={false} />);
|
||||
expect(toggle()).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(option('snowshoe')).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(option('winter_hike')).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.getByRole('group', { name: 'tours.winter.kind' })).toBeInTheDocument();
|
||||
fireEvent.click(option('winter_hike'));
|
||||
expect(setWinterActivity).toHaveBeenLastCalledWith('winter_hike');
|
||||
fireEvent.click(toggle());
|
||||
expect(setWinterActivity).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it('FE-TOUR-WINTER-FIELD-003: switched off and on again, it comes back with the label it had', () => {
|
||||
render(<Stateful initial="snowshoe" />);
|
||||
fireEvent.click(toggle());
|
||||
expect(screen.getByTestId('saved')).toHaveTextContent('none');
|
||||
expect(option('snowshoe')).toHaveAttribute('aria-pressed', 'true');
|
||||
fireEvent.click(toggle());
|
||||
expect(screen.getByTestId('saved')).toHaveTextContent('snowshoe');
|
||||
});
|
||||
|
||||
it('FE-TOUR-WINTER-FIELD-004: changes nothing while the tour is locked', () => {
|
||||
const setWinterActivity = vi.fn();
|
||||
render(<WinterActivityField planner={{ winterActivity: 'winter_hike', setWinterActivity }} locked />);
|
||||
fireEvent.click(toggle());
|
||||
fireEvent.click(option('snowshoe'));
|
||||
expect(setWinterActivity).not.toHaveBeenCalled();
|
||||
expect(option('snowshoe')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { WINTER_ACTIVITIES, type WinterActivity } from '@trek/shared';
|
||||
import { Snowflake } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { BOX } from '../../Planner/bookings/bookingParts';
|
||||
import ToggleSwitch from '../../Settings/ToggleSwitch';
|
||||
import { Segmented } from '../../shared/dialogParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { InfoDot } from '../tourParts';
|
||||
import { WINTER_ACTIVITY_ICON } from '../winter/winterMarks';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
|
||||
/**
|
||||
* HIK-01: a hike marked as one done in winter, off by default among the
|
||||
* details, so a hike asks for nothing more. Switched on, it is winter hiking
|
||||
* until snowshoeing is picked; switched off, the label goes and the route and
|
||||
* the days stay. The choice stays in sight but dimmed while the switch is off,
|
||||
* and comes back as it was when it is switched on again. The info dot says it
|
||||
* is a label only: it plans and times nothing differently.
|
||||
*/
|
||||
export function WinterActivityField({
|
||||
planner,
|
||||
locked,
|
||||
}: {
|
||||
planner: Pick<TourPlannerController, 'winterActivity' | 'setWinterActivity'>;
|
||||
locked: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const current = planner.winterActivity ?? null;
|
||||
const [lastChoice, setLastChoice] = useState<WinterActivity>(current ?? 'winter_hike');
|
||||
const on = current !== null;
|
||||
const toggle = () => {
|
||||
if (locked) return;
|
||||
if (current === null) {
|
||||
planner.setWinterActivity(lastChoice);
|
||||
return;
|
||||
}
|
||||
setLastChoice(current);
|
||||
planner.setWinterActivity(null);
|
||||
};
|
||||
const choose = (next: WinterActivity) => {
|
||||
if (locked || !on) return;
|
||||
setLastChoice(next);
|
||||
planner.setWinterActivity(next);
|
||||
};
|
||||
return (
|
||||
<div data-testid="tour-winter-activity" className={`${BOX} min-w-0`}>
|
||||
<div className="flex items-center gap-2.5 px-3 py-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`grid h-7 w-7 flex-none place-items-center rounded-full transition-colors ${on ? 'bg-info-soft text-info' : 'bg-surface-tertiary text-content-faint'}`}
|
||||
>
|
||||
<Snowflake size={14} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1">
|
||||
<span className="min-w-0 truncate font-semibold text-content" style={fs(12.5, 'body')}>
|
||||
{t('tours.winter.title')}
|
||||
</span>
|
||||
<InfoDot label={t('tours.winter.infoLabel')} description={t('tours.winter.info')} />
|
||||
</span>
|
||||
<span className={locked ? 'pointer-events-none opacity-50' : undefined}>
|
||||
<ToggleSwitch on={on} onToggle={toggle} label={t('tours.winter.title')} />
|
||||
</span>
|
||||
</div>
|
||||
<fieldset
|
||||
data-testid="tour-winter-kind"
|
||||
disabled={locked || !on}
|
||||
className={`m-0 min-w-0 border-0 border-t border-solid border-edge-faint px-3 py-2 transition-opacity ${on ? '' : 'opacity-50'}`}
|
||||
>
|
||||
<Segmented<WinterActivity>
|
||||
value={current ?? lastChoice}
|
||||
onChange={choose}
|
||||
label={t('tours.winter.kind')}
|
||||
fill
|
||||
options={WINTER_ACTIVITIES.map((activity) => {
|
||||
const Icon = WINTER_ACTIVITY_ICON[activity];
|
||||
return {
|
||||
value: activity,
|
||||
label: t(`tours.winter.activity.${activity}`),
|
||||
icon: <Icon size={13} strokeWidth={2} aria-hidden="true" />,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,10 +4,12 @@ import {
|
||||
MAX_PLANNED_TOUR_DURATION_MINUTES,
|
||||
paddleLockSchema,
|
||||
plannableTourTypeSchema,
|
||||
winterActivitySchema,
|
||||
type BikeProfile,
|
||||
type HikingPreferences,
|
||||
type PlannableTourType,
|
||||
type TourMaxHikingDifficulty,
|
||||
type WinterActivity,
|
||||
} from '@trek/shared';
|
||||
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||
import type { PaddleFieldValues } from './usePaddleFields';
|
||||
@@ -43,6 +45,8 @@ export interface DraftPayload {
|
||||
hikingPreferences?: HikingPreferences | null;
|
||||
/** BIK-01: the profile a bike tour is routed for. */
|
||||
bikeProfile?: BikeProfile;
|
||||
/** HIK-01: the hike's winter label; absent in a draft older than it. */
|
||||
winterActivity?: WinterActivity | null;
|
||||
}
|
||||
|
||||
const DRAFT_VERSIONS: ReadonlyArray<DraftPayload['version']> = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
@@ -83,12 +87,13 @@ function validDraftWaypoint(point: TourPlannerWaypoint): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** The type of a draft and what its route is planned by, each one absent in a draft older than it. */
|
||||
/** The type of a draft, what its route is planned by and its winter label, each one absent in a draft older than it. */
|
||||
function validRouteChoices(draft: Partial<DraftPayload>): boolean {
|
||||
return (
|
||||
(draft.tourType === undefined || plannableTourTypeSchema.safeParse(draft.tourType).success) &&
|
||||
(draft.bikeProfile === undefined || bikeProfileSchema.safeParse(draft.bikeProfile).success) &&
|
||||
(draft.hikingPreferences == null || hikingPreferencesSchema.safeParse(draft.hikingPreferences).success) &&
|
||||
(draft.winterActivity == null || winterActivitySchema.safeParse(draft.winterActivity).success) &&
|
||||
optionalDifficulty(draft.maxHikingDifficulty)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type PlannableTourType,
|
||||
type TourListItem,
|
||||
type TourMaxHikingDifficulty,
|
||||
type WinterActivity,
|
||||
} from '@trek/shared';
|
||||
import type { RouteGeometryAnalysis } from '../../../utils/routeGeometry';
|
||||
import { hikingTimeMinutes } from '../hikingTime';
|
||||
@@ -28,6 +29,7 @@ export function openedTourFields(tour: TourListItem) {
|
||||
// A type nobody can plan (an import of another kind) opens as a hike, as it always has.
|
||||
tourType: plannableTourTypeSchema.catch('hike').parse(tour.tour_type) as PlannableTourType,
|
||||
bikeProfile: (tour.bike_profile ?? 'touring') as BikeProfile,
|
||||
winterActivity: tour.winter_activity ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,6 +41,8 @@ export interface TourPlannerFactsInput {
|
||||
hikingPreferences: HikingPreferences | null;
|
||||
/** BIK-01: the profile a bike tour is routed for. */
|
||||
bikeProfile: BikeProfile;
|
||||
/** HIK-01: a hike's winter label, none by default; it changes nothing the planner works out. */
|
||||
winterActivity: WinterActivity | null;
|
||||
website: string;
|
||||
plannedDurationMinutes: number | null;
|
||||
breakAdditionalMinutes: number | null;
|
||||
@@ -92,7 +96,10 @@ function walkTimesFor(input: TourPlannerFactsInput, walkingMinutes: number | nul
|
||||
return walkTimesOf({ stayMinutes: 0, unknownStays: 0, stations: 0, ...input.stays, walkingMinutes });
|
||||
}
|
||||
|
||||
/** What the type adds to a save: a hike its routing limit, a bike tour its profile, a paddle tour its boat, water and speed. */
|
||||
/**
|
||||
* What the type adds to a save: a hike its routing limit and its winter label,
|
||||
* a bike tour its profile, a paddle tour its boat, water and speed.
|
||||
*/
|
||||
function typeFieldsOf(input: TourPlannerFactsInput) {
|
||||
const isBike = input.tourType === 'bike';
|
||||
if (input.tourType === 'kayak') {
|
||||
@@ -107,6 +114,7 @@ function typeFieldsOf(input: TourPlannerFactsInput) {
|
||||
portage_speed_kmh: portageSpeedKmh,
|
||||
paddle_lake_style: lakeStyle,
|
||||
hiking_preferences: null,
|
||||
winter_activity: null,
|
||||
};
|
||||
}
|
||||
const isHike = input.tourType === 'hike';
|
||||
@@ -120,6 +128,7 @@ function typeFieldsOf(input: TourPlannerFactsInput) {
|
||||
portage_speed_kmh: null,
|
||||
paddle_lake_style: null,
|
||||
hiking_preferences: isHike ? input.hikingPreferences : null,
|
||||
winter_activity: isHike ? input.winterActivity : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ const hike = (overrides: Partial<TourPlannerFactsInput> = {}): TourPlannerFactsI
|
||||
maxHikingDifficulty: 3,
|
||||
hikingPreferences: null,
|
||||
bikeProfile: 'touring',
|
||||
winterActivity: null,
|
||||
website: '',
|
||||
plannedDurationMinutes: null,
|
||||
breakAdditionalMinutes: null,
|
||||
@@ -56,6 +57,26 @@ describe('tourPlannerFacts', () => {
|
||||
expect(facts.typeFields).toMatchObject({ tour_type: 'hike', max_hiking_difficulty: 3, boat_type: null });
|
||||
});
|
||||
|
||||
it('HIK-01: saves a hike with its winter label and the same times as without, and no other type with one', () => {
|
||||
const analysis = { distanceKm: 8, gain: 600, loss: 600 } as TourPlannerFactsInput['routeAnalysis'];
|
||||
const summer = tourPlannerFacts(hike({ routeAnalysis: analysis, breakAdditionalMinutes: 30 }));
|
||||
const winter = tourPlannerFacts(
|
||||
hike({ routeAnalysis: analysis, breakAdditionalMinutes: 30, winterActivity: 'snowshoe' })
|
||||
);
|
||||
expect(winter.typeFields).toMatchObject({
|
||||
tour_type: 'hike',
|
||||
winter_activity: 'snowshoe',
|
||||
max_hiking_difficulty: 3,
|
||||
});
|
||||
expect(summer.typeFields).toMatchObject({ winter_activity: null });
|
||||
// A label only: the time is the signposts' rule, as for any hike.
|
||||
expect(winter.walkingDurationMinutes).toBe(summer.walkingDurationMinutes);
|
||||
expect(winter.plannedTotalMinutes).toBe(summer.plannedTotalMinutes);
|
||||
for (const tourType of ['bike', 'walk', 'kayak'] as const) {
|
||||
expect(tourPlannerFacts(hike({ tourType, winterActivity: 'winter_hike' })).typeFields.winter_activity).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('holds the fields to their bounds, and a total that runs past a day without an override', () => {
|
||||
const facts = tourPlannerFacts(
|
||||
hike({ website: 'http://plain.example', plannedDurationMinutes: 1.5, breakAdditionalMinutes: -1 })
|
||||
@@ -205,4 +226,15 @@ describe('useTourDraftStorage', () => {
|
||||
act(() => first.result.current.forget());
|
||||
expect(localStorage.getItem('tour-draft-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('HIK-01: gives back a draft with its winter label, and drops one whose label it does not know', () => {
|
||||
localStorage.clear();
|
||||
const draft = { version: 10, name: 'Snowy ridge', waypoints: [{ id: 'a', lat: 47, lng: 11, role: 'start' }] };
|
||||
localStorage.setItem('tour-draft-5', JSON.stringify({ ...draft, winterActivity: 'snowshoe' }));
|
||||
expect(renderHook(() => useTourDraftStorage(5)).result.current.restored).toMatchObject({
|
||||
winterActivity: 'snowshoe',
|
||||
});
|
||||
localStorage.setItem('tour-draft-6', JSON.stringify({ ...draft, winterActivity: 'ski_tour' }));
|
||||
expect(renderHook(() => useTourDraftStorage(6)).result.current.restored).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { hikingPreferencesOf, isClosedLoop, paddleSpeedKmhSchema, tourWebsiteSchema, type BikeProfile, type HikingPreferences, type PlannableTourType, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourMaxHikingDifficulty } from '@trek/shared'
|
||||
import { hikingPreferencesOf, isClosedLoop, paddleSpeedKmhSchema, tourWebsiteSchema, type BikeProfile, type HikingPreferences, type PlannableTourType, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourMaxHikingDifficulty, type WinterActivity } from '@trek/shared'
|
||||
import { paddleRouteRepo } from '../../../repo/paddleRouteRepo'
|
||||
import { tourRepo } from '../../../repo/tourRepo'
|
||||
import { randomId } from '../../../utils/randomId'
|
||||
@@ -123,6 +123,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
const [maxHikingDifficulty, setMaxHikingDifficultyState] = useState<TourMaxHikingDifficulty>(restored?.maxHikingDifficulty ?? 2)
|
||||
const [hikingPreferences, setHikingPreferencesState] = useState<HikingPreferences | null>(restored?.hikingPreferences ?? null)
|
||||
const [bikeProfile, setBikeProfileState] = useState<BikeProfile>(restored?.bikeProfile ?? 'touring')
|
||||
const [winterActivity, setWinterActivityState] = useState<WinterActivity | null>(restored?.winterActivity ?? null)
|
||||
const [tourType, setTourTypeState] = useState<PlannableTourType>(restored?.tourType ?? 'hike')
|
||||
const [selectedWaypointId, setSelectedWaypointId] = useState<string | null>(restored?.waypoints[0]?.id ?? null)
|
||||
const history = usePlannerHistory<HistoryEntry>()
|
||||
@@ -466,6 +467,11 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
if (value !== breakAdditionalMinutes && markEdited()) setBreakAdditionalMinutesState(value)
|
||||
}, [breakAdditionalMinutes, markEdited])
|
||||
|
||||
/** HIK-01: the hike's winter label, a field like the description: it plans nothing again and is no step of the history. */
|
||||
const setWinterActivity = useCallback((value: WinterActivity | null) => {
|
||||
if (value !== winterActivity && markEdited()) setWinterActivityState(value)
|
||||
}, [markEdited, winterActivity])
|
||||
|
||||
|
||||
/**
|
||||
* PAD-01, BIK-01: hike, bike or paddle tour, as one step of the history with
|
||||
@@ -503,11 +509,11 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
// CW-02: a city walk's stays, what of them is known.
|
||||
const stays = useMemo(() => (tourType === 'walk' ? walkTimes(waypoints, null) : null), [tourType, waypoints])
|
||||
const facts = useMemo(() => tourPlannerFacts({
|
||||
tourType, maxHikingDifficulty, hikingPreferences, bikeProfile, website, plannedDurationMinutes, breakAdditionalMinutes,
|
||||
tourType, maxHikingDifficulty, hikingPreferences, bikeProfile, winterActivity, website, plannedDurationMinutes, breakAdditionalMinutes,
|
||||
paddle: { boatType, waterContext, speedKmh, portageSpeedKmh, lakeStyle },
|
||||
paddleSpeedInvalid, paddleEstimate, routeAnalysis, durationSeconds, stays,
|
||||
}), [bikeProfile, boatType, breakAdditionalMinutes, durationSeconds, hikingPreferences, lakeStyle, maxHikingDifficulty, paddleEstimate, paddleSpeedInvalid,
|
||||
plannedDurationMinutes, portageSpeedKmh, routeAnalysis, speedKmh, stays, tourType, waterContext, website])
|
||||
plannedDurationMinutes, portageSpeedKmh, routeAnalysis, speedKmh, stays, tourType, waterContext, website, winterActivity])
|
||||
// CW-02: a city walk's times as the overview says them, from the same derivation as its planned total.
|
||||
const { websiteInvalid, plannedDurationInvalid, breakAdditionalInvalid, paddleIncomplete, walkingDurationMinutes,
|
||||
plannedTotalMinutes, plannedTotalInvalid, savedDurationSeconds, savedBreakAdditionalMinutes, typeFields, stationStays } = facts
|
||||
@@ -535,6 +541,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setHikingPreferencesState(null)
|
||||
bikeProfileRef.current = 'touring'
|
||||
setBikeProfileState('touring')
|
||||
setWinterActivityState(null)
|
||||
tourTypeRef.current = 'hike'
|
||||
setSelectedWaypointId(null)
|
||||
clearHistory()
|
||||
@@ -705,6 +712,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setHikingPreferencesState(opened.preferences)
|
||||
bikeProfileRef.current = opened.bikeProfile
|
||||
setBikeProfileState(opened.bikeProfile)
|
||||
setWinterActivityState(opened.winterActivity)
|
||||
tourTypeRef.current = opened.tourType
|
||||
setSelectedWaypointId(nextWaypoints[0]?.id ?? null)
|
||||
clearHistory()
|
||||
@@ -764,14 +772,14 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
useEffect(() => {
|
||||
if (!hasUnsavedChanges) return
|
||||
const draft: DraftPayload = { version: 10, name, description, website, plannedDurationMinutes, breakAdditionalMinutes, waypoints, maxHikingDifficulty, editingPlaceId,
|
||||
derivedFromPlaceId, draftSource, tourType, baseRevision: revision.base, hikingPreferences, bikeProfile,
|
||||
derivedFromPlaceId, draftSource, tourType, baseRevision: revision.base, hikingPreferences, bikeProfile, winterActivity,
|
||||
paddle: { boatType: paddle.boatType, waterContext: paddle.waterContext, speedKmh: paddle.speedKmh, portageSpeedKmh: paddle.portageSpeedKmh, lakeStyle: paddle.lakeStyle },
|
||||
}
|
||||
keepDraft(draft)
|
||||
}, [breakAdditionalMinutes,
|
||||
derivedFromPlaceId, draftSource, description, editingPlaceId, hasUnsavedChanges, maxHikingDifficulty, name, plannedDurationMinutes, keepDraft, waypoints, website,
|
||||
tourType, paddle.boatType, paddle.waterContext, paddle.speedKmh, paddle.portageSpeedKmh, paddle.lakeStyle, revision.base, hikingPreferences,
|
||||
bikeProfile])
|
||||
bikeProfile, winterActivity])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasUnsavedChanges) return
|
||||
@@ -871,7 +879,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
return {
|
||||
tripId, canEdit, canAssign,
|
||||
name, setName, maxHikingDifficulty, setMaxHikingDifficulty, hikingPreferences, setHikingPreferences,
|
||||
bikeProfile, setBikeProfile, isBike: tourType === 'bike',
|
||||
bikeProfile, setBikeProfile, isBike: tourType === 'bike', winterActivity, setWinterActivity,
|
||||
tourType, setTourType, isPaddle, paddle, paddleSpeedInvalid, paddleIncomplete,
|
||||
description, setDescription, website, setWebsite, websiteInvalid,
|
||||
plannedDurationMinutes, setPlannedDurationMinutes, plannedDurationInvalid,
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { routeWalkingTour, routeCityWalk, enrichTourElevations, createTour, detailTour, updateTour } = vi.hoisted(
|
||||
() => ({
|
||||
routeWalkingTour: vi.fn(),
|
||||
routeCityWalk: vi.fn(),
|
||||
enrichTourElevations: vi.fn(),
|
||||
createTour: vi.fn(),
|
||||
detailTour: vi.fn(),
|
||||
updateTour: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock('./tourRouting', () => ({ routeWalkingTour, routeCityWalk, enrichTourElevations }));
|
||||
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } }));
|
||||
vi.mock('../useTourPermissions', () => ({ useTourPermissions: () => ({ canEdit: true, canAssign: true }) }));
|
||||
|
||||
import { useTourPlanner } from './useTourPlanner';
|
||||
|
||||
// FE-TOUR-WINTER-001 to FE-TOUR-WINTER-005
|
||||
|
||||
const routed = {
|
||||
coordinates: [
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
] as [number, number][],
|
||||
distanceMeters: 2200,
|
||||
durationSeconds: 1800,
|
||||
};
|
||||
const enriched = [
|
||||
[48, 11, 1200],
|
||||
[48.01, 11.02, 1350],
|
||||
] as [number, number, number][];
|
||||
const savedHike = {
|
||||
place_id: 88,
|
||||
name: 'Snowy ridge',
|
||||
tour_type: 'hike' as const,
|
||||
winter_activity: 'snowshoe' as const,
|
||||
distance: 2.2,
|
||||
elevation_gain: 150,
|
||||
elevation_loss: 0,
|
||||
duration: 60,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: true,
|
||||
caution: false,
|
||||
revision: 5,
|
||||
};
|
||||
const savedPoints = [
|
||||
{ lat: 48, lng: 11, role: 'start' as const, sequence: 0 },
|
||||
{ lat: 48.01, lng: 11.02, role: 'end' as const, sequence: 1 },
|
||||
];
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(450);
|
||||
});
|
||||
}
|
||||
|
||||
describe('useTourPlanner, the winter label of a hike (HIK-01)', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
localStorage.clear();
|
||||
routeWalkingTour.mockReset().mockResolvedValue(routed);
|
||||
routeCityWalk.mockReset().mockResolvedValue(routed);
|
||||
enrichTourElevations.mockReset().mockResolvedValue(enriched);
|
||||
createTour.mockReset();
|
||||
detailTour.mockReset();
|
||||
updateTour.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('FE-TOUR-WINTER-001: a hike starts without one, and saves without one as before', async () => {
|
||||
createTour.mockResolvedValue({ tour: { ...savedHike, winter_activity: null }, waypoints: [] });
|
||||
const { result } = renderHook(() => useTourPlanner({ tripId: 61 }));
|
||||
expect(result.current.winterActivity).toBeNull();
|
||||
act(() => {
|
||||
result.current.setName('Ridge');
|
||||
result.current.addWaypoint(48, 11);
|
||||
result.current.addWaypoint(48.01, 11.02);
|
||||
});
|
||||
await settle();
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
expect(createTour).toHaveBeenCalledWith(
|
||||
61,
|
||||
expect.objectContaining({ tour_type: 'hike', winter_activity: null })
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-TOUR-WINTER-002: the label plans nothing again, is no step of the history and is kept in the draft', async () => {
|
||||
const { result } = renderHook(() => useTourPlanner({ tripId: 62 }));
|
||||
act(() => {
|
||||
result.current.setName('Ridge');
|
||||
result.current.addWaypoint(48, 11);
|
||||
result.current.addWaypoint(48.01, 11.02);
|
||||
});
|
||||
await settle();
|
||||
const { route, walkingDurationMinutes } = result.current;
|
||||
routeWalkingTour.mockClear();
|
||||
act(() => result.current.setWinterActivity('winter_hike'));
|
||||
await settle();
|
||||
expect(result.current.winterActivity).toBe('winter_hike');
|
||||
expect(routeWalkingTour).not.toHaveBeenCalled();
|
||||
expect(result.current.route).toEqual(route);
|
||||
expect(result.current.walkingDurationMinutes).toBe(walkingDurationMinutes);
|
||||
expect(result.current.status).toBe('ready');
|
||||
expect(result.current.canSave).toBe(true);
|
||||
expect(JSON.parse(localStorage.getItem('tour-draft-62') || 'null')).toMatchObject({
|
||||
winterActivity: 'winter_hike',
|
||||
});
|
||||
// An undo takes back the last point, not the label.
|
||||
act(() => result.current.undo());
|
||||
expect(result.current.winterActivity).toBe('winter_hike');
|
||||
});
|
||||
|
||||
it('FE-TOUR-WINTER-003: a draft comes back with its label, and a new tour starts without one', () => {
|
||||
const first = renderHook(() => useTourPlanner({ tripId: 63 }));
|
||||
act(() => first.result.current.setWinterActivity('snowshoe'));
|
||||
first.unmount();
|
||||
const again = renderHook(() => useTourPlanner({ tripId: 63 }));
|
||||
expect(again.result.current.winterActivity).toBe('snowshoe');
|
||||
act(() => again.result.current.startNewTour(true));
|
||||
expect(again.result.current.winterActivity).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TOUR-WINTER-004: opens a saved hike with its label, and taking it off saves the same route', async () => {
|
||||
detailTour.mockResolvedValue({ tour: savedHike, waypoints: savedPoints });
|
||||
updateTour.mockResolvedValue({
|
||||
tour: { ...savedHike, winter_activity: null, revision: 6 },
|
||||
waypoints: savedPoints,
|
||||
});
|
||||
const { result } = renderHook(() => useTourPlanner({ tripId: 64 }));
|
||||
await act(async () => {
|
||||
await result.current.openTour({ place_id: 88 });
|
||||
});
|
||||
expect(result.current.winterActivity).toBe('snowshoe');
|
||||
expect(result.current.hasUnsavedChanges).toBe(false);
|
||||
act(() => result.current.setWinterActivity(null));
|
||||
expect(result.current.hasUnsavedChanges).toBe(true);
|
||||
await settle();
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
expect(updateTour).toHaveBeenCalledWith(
|
||||
64,
|
||||
88,
|
||||
expect.objectContaining({
|
||||
tour_type: 'hike',
|
||||
winter_activity: null,
|
||||
max_hiking_difficulty: 2,
|
||||
waypoints: [
|
||||
expect.objectContaining({ lat: 48, lng: 11, role: 'start' }),
|
||||
expect.objectContaining({ lat: 48.01, lng: 11.02, role: 'end' }),
|
||||
],
|
||||
expected_revision: 5,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-TOUR-WINTER-005: the same label again is no edit, and another type saves none', async () => {
|
||||
createTour.mockResolvedValue({ tour: { ...savedHike, tour_type: 'walk', winter_activity: null }, waypoints: [] });
|
||||
const { result } = renderHook(() => useTourPlanner({ tripId: 65 }));
|
||||
act(() => result.current.setWinterActivity(null));
|
||||
expect(result.current.hasUnsavedChanges).toBe(false);
|
||||
act(() => {
|
||||
result.current.setName('Old town');
|
||||
result.current.setWinterActivity('snowshoe');
|
||||
result.current.addWaypoint(48, 11);
|
||||
result.current.addWaypoint(48.01, 11.02);
|
||||
result.current.setTourType('walk');
|
||||
});
|
||||
await settle();
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
expect(createTour).toHaveBeenCalledWith(
|
||||
65,
|
||||
expect.objectContaining({ tour_type: 'walk', winter_activity: null })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -16,13 +16,15 @@ import { tourMetricFacts } from './tourFacts'
|
||||
import type { TourDayAssignment } from './useTourDayAssignment'
|
||||
import { useRailFold } from './railFold'
|
||||
import { tourDayRowIcon } from './tourDayRowIcon'
|
||||
import { WinterCornerMark } from './winter/winterMarks'
|
||||
|
||||
/**
|
||||
* A tour's tile in a list: its T-scale grade in place of the mountain, named in
|
||||
* full by its tooltip (a GPX import carries no grade and keeps the mountain),
|
||||
* and where it came from, when that was not TREK, as a small badge over its foot.
|
||||
* where it came from, when that was not TREK, as a small badge over its foot,
|
||||
* and a hike's winter label (HIK-01) as a snowflake at its top corner.
|
||||
*/
|
||||
export function TourGradeTile({ tour }: { tour: Pick<TourListItem, 'max_hiking_difficulty' | 'wanderer_ref' | 'has_waypoints'> & Partial<Pick<TourListItem, 'tour_type'>> }) {
|
||||
export function TourGradeTile({ tour }: { tour: Pick<TourListItem, 'max_hiking_difficulty' | 'wanderer_ref' | 'has_waypoints'> & Partial<Pick<TourListItem, 'tour_type' | 'winter_activity'>> }) {
|
||||
const { t } = useTranslation()
|
||||
const level = tour.max_hiking_difficulty
|
||||
const source = tourSource(tour)
|
||||
@@ -36,6 +38,7 @@ export function TourGradeTile({ tour }: { tour: Pick<TourListItem, 'max_hiking_d
|
||||
{level != null ? `T${level}` : createElement(tourDayRowIcon(type), { size: 15, strokeWidth: 1.9, className: 'text-content-secondary' })}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<WinterCornerMark activity={tour.winter_activity} />
|
||||
{source !== 'trek' && (
|
||||
<span className="absolute inset-x-0 -bottom-1 flex justify-center">
|
||||
<Tooltip label={hikeSourceBadgeLabel(tour, t)}>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { TourDayRowAvatar } from '../TourDayRowFacts';
|
||||
import { TourGradeTile } from '../tourParts';
|
||||
import { WinterActivityPill, WinterCornerMark } from './winterMarks';
|
||||
|
||||
// FE-TOUR-WINTER-MARK-001 to FE-TOUR-WINTER-MARK-004
|
||||
|
||||
const hike = { tour_type: 'hike' as const, max_hiking_difficulty: 2, wanderer_ref: null, has_waypoints: true };
|
||||
|
||||
describe('winter marks (HIK-01)', () => {
|
||||
it('FE-TOUR-WINTER-MARK-001: the corner mark names the label, and a hike without one shows none', () => {
|
||||
const { container, rerender } = render(<WinterCornerMark activity={null} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
rerender(<WinterCornerMark activity="snowshoe" size={20} />);
|
||||
const mark = screen.getByRole('img', { name: 'tours.winter.activity.snowshoe' });
|
||||
expect(mark).toHaveAttribute('data-testid', 'tour-winter-mark');
|
||||
expect(mark).toHaveStyle({ width: '20px', height: '20px' });
|
||||
});
|
||||
|
||||
it('FE-TOUR-WINTER-MARK-002: the pill names the label in the head look it is given', () => {
|
||||
render(<WinterActivityPill activity="winter_hike" className="head-pill" />);
|
||||
const pill = screen.getByTestId('tour-detail-winter');
|
||||
expect(pill).toHaveTextContent('tours.winter.activity.winter_hike');
|
||||
expect(pill).toHaveClass('head-pill');
|
||||
});
|
||||
|
||||
it('FE-TOUR-WINTER-MARK-003: a tour list tile carries the mark beside its grade', () => {
|
||||
const { rerender } = render(<TourGradeTile tour={hike} />);
|
||||
expect(screen.queryByTestId('tour-winter-mark')).toBeNull();
|
||||
rerender(<TourGradeTile tour={{ ...hike, winter_activity: 'winter_hike' }} />);
|
||||
expect(screen.getByRole('img', { name: 'tours.winter.activity.winter_hike' })).toBeInTheDocument();
|
||||
expect(screen.getByText('T2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TOUR-WINTER-MARK-004: the day plan tile stays as it was without a label, and carries the mark with one', () => {
|
||||
const { container, rerender } = render(<TourDayRowAvatar tour={hike} size={32} />);
|
||||
expect(container.firstElementChild).toHaveAttribute('data-testid', 'plan-tour-icon');
|
||||
rerender(<TourDayRowAvatar tour={{ ...hike, winter_activity: 'snowshoe' }} size={50} />);
|
||||
expect(screen.getByTestId('plan-tour-icon')).toHaveTextContent('T2');
|
||||
expect(screen.getByTestId('tour-winter-mark')).toHaveStyle({ width: '22px' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { WinterActivity } from '@trek/shared';
|
||||
import { Footprints, Snowflake, type LucideIcon } from 'lucide-react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
|
||||
/** HIK-01: the icon each winter label goes by, a snowflake for winter hiking and footprints in the snow for snowshoes. */
|
||||
export const WINTER_ACTIVITY_ICON: Record<WinterActivity, LucideIcon> = {
|
||||
winter_hike: Snowflake,
|
||||
snowshoe: Footprints,
|
||||
};
|
||||
|
||||
/**
|
||||
* HIK-01: a hike's winter label on its round tile, a frosted snowflake badge
|
||||
* at the tile's top corner, the way the tile carries where a tour came from at
|
||||
* its foot. Its tooltip names the label; nothing is drawn for a hike without one.
|
||||
*/
|
||||
export function WinterCornerMark({
|
||||
activity,
|
||||
size = 15,
|
||||
}: {
|
||||
activity: WinterActivity | null | undefined;
|
||||
/** The badge's diameter in pixels, about half the tile's. */
|
||||
size?: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!activity) return null;
|
||||
const label = t(`tours.winter.activity.${activity}`);
|
||||
return (
|
||||
<span className="absolute -end-1 -top-1 flex">
|
||||
<Tooltip label={label}>
|
||||
<span
|
||||
data-testid="tour-winter-mark"
|
||||
role="img"
|
||||
aria-label={label}
|
||||
className="grid place-items-center rounded-full bg-surface-card text-info shadow-sm ring-1 ring-inset ring-edge-faint"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<Snowflake size={Math.round(size * 0.64)} strokeWidth={2.4} aria-hidden="true" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* HIK-01: the winter label as a pill of a tour's head, its icon in the info
|
||||
* tone. The tooltip says it is the travellers' label only, never a check of
|
||||
* the way in winter.
|
||||
*/
|
||||
export function WinterActivityPill({ activity, className }: { activity: WinterActivity; className: string }) {
|
||||
const { t } = useTranslation();
|
||||
const Icon = WINTER_ACTIVITY_ICON[activity];
|
||||
return (
|
||||
<Tooltip label={t('tours.winter.labelOnly')}>
|
||||
<span data-testid="tour-detail-winter" className={className} style={fs(10.5)}>
|
||||
<Icon size={11} strokeWidth={2} className="flex-none text-info" aria-hidden="true" />
|
||||
{t(`tours.winter.activity.${activity}`)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-REPO-TOUR-001 to FE-REPO-TOUR-014
|
||||
// FE-REPO-TOUR-001 to FE-REPO-TOUR-014, FE-REPO-TOUR-042
|
||||
// Tours through the offline core: read-through to Dexie, offline create and
|
||||
// edit written there and queued, and the online paths keeping the copy fresh.
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
@@ -196,6 +196,19 @@ describe('tourRepo.update', () => {
|
||||
expect(await offlineDb.places.get(40)).toMatchObject({ description: 'Keep description', website: 'https://example.org/old' })
|
||||
})
|
||||
|
||||
it('FE-REPO-TOUR-042: offline, a hike takes its winter label at once, keeps it when a save leaves it out, and another type drops it (HIK-01)', async () => {
|
||||
setOnline(false)
|
||||
await offlineDb.tours.put({ ...tour({ winter_activity: 'snowshoe' }), trip_id: 3 })
|
||||
|
||||
expect((await tourRepo.update(3, 40, body)).tour.winter_activity).toBe('snowshoe')
|
||||
expect((await tourRepo.update(3, 40, { ...body, winter_activity: 'winter_hike' })).tour.winter_activity).toBe('winter_hike')
|
||||
expect((await tourRepo.update(3, 40, { ...body, winter_activity: null })).tour.winter_activity).toBeNull()
|
||||
await tourRepo.update(3, 40, { ...body, winter_activity: 'snowshoe' })
|
||||
const bike = { ...body, tour_type: 'bike' as const, bike_profile: 'touring' as const, max_hiking_difficulty: null }
|
||||
expect((await tourRepo.update(3, 40, bike)).tour.winter_activity).toBeNull()
|
||||
expect((await offlineDb.tours.get(40))?.winter_activity).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-REPO-TOUR-012: online edits and GPX imports refresh the cache', async () => {
|
||||
server.use(
|
||||
http.put('/api/trips/3/tours/40', () => HttpResponse.json({ tour: tour({ name: 'Edited' }), waypoints: body.waypoints })),
|
||||
|
||||
@@ -58,6 +58,15 @@ function endpointsOf(routeGeometry: unknown): TourWaypoint[] {
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* HIK-01: a hike's winter label as the server keeps it. Another type has none,
|
||||
* and a hike's save that leaves the field out keeps the label it has.
|
||||
*/
|
||||
function winterField(body: TourCreateRequest): Pick<TourListItem, 'winter_activity'> {
|
||||
if (body.tour_type !== 'hike') return { winter_activity: null }
|
||||
return body.winter_activity === undefined ? {} : { winter_activity: body.winter_activity }
|
||||
}
|
||||
|
||||
/**
|
||||
* What the server will derive from a create or edit, worked out here for the
|
||||
* copy shown until the queued write replays: the same metrics, the duration
|
||||
@@ -82,6 +91,7 @@ function routeFields(body: TourCreateRequest) {
|
||||
match_confidence: 1,
|
||||
caution: false,
|
||||
max_hiking_difficulty: body.max_hiking_difficulty,
|
||||
...winterField(body),
|
||||
has_waypoints: true,
|
||||
...walkStationFacts(body.tour_type, body.waypoints),
|
||||
...(body.planned_duration_minutes !== undefined ? { planned_duration_minutes: body.planned_duration_minutes } : {}),
|
||||
|
||||
Reference in New Issue
Block a user