fix(tours): start the winter label from winter hiking on every other tour

The winter field remembered the label the switch brings back in its own
state, seeded once on mount. The details section stays mounted while the
planner saves, closes or opens another tour, so a snowshoe hike left its
choice behind and the next unrelated hike switched on as snowshoeing.

The planner now keeps that choice beside the label itself: a label set is
remembered, starting a new tour or discarding resets it to winter hiking,
and opening a tour takes the tour's own label.
This commit is contained in:
Maurice
2026-10-20 14:52:15 +02:00
parent 008aaeb408
commit fcdb7e48cd
5 changed files with 89 additions and 23 deletions
@@ -4,26 +4,51 @@ 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
// 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, as the real one does. */
/**
* 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, setWinterActivity] = useState<WinterActivity | null>(initial);
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 }} locked={false} />
<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={{ winterActivity: null, setWinterActivity }} locked={false} />);
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();
@@ -39,7 +64,7 @@ describe('WinterActivityField (HIK-01)', () => {
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} />);
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');
@@ -61,10 +86,32 @@ describe('WinterActivityField (HIK-01)', () => {
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 />);
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,6 +1,5 @@
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';
@@ -15,32 +14,27 @@ import type { TourPlannerController } from './useTourPlanner';
* 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.
* 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'>;
planner: Pick<TourPlannerController, 'winterActivity' | 'setWinterActivity' | 'winterActivityChoice'>;
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);
planner.setWinterActivity(on ? null : planner.winterActivityChoice);
};
const choose = (next: WinterActivity) => {
if (locked || !on) return;
setLastChoice(next);
planner.setWinterActivity(next);
};
return (
@@ -68,7 +62,7 @@ export function WinterActivityField({
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}
value={current ?? planner.winterActivityChoice}
onChange={choose}
label={t('tours.winter.kind')}
fill
@@ -128,6 +128,8 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
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)
// 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 [runProfile, setRunProfileState] = useState<RunProfile>(restored?.runProfile ?? 'running')
const [tourType, setTourTypeState] = useState<PlannableTourType>(restored?.tourType ?? 'hike')
const [selectedWaypointId, setSelectedWaypointId] = useState<string | null>(restored?.waypoints[0]?.id ?? null)
@@ -483,7 +485,9 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
/** 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)
if (value === winterActivity || !markEdited()) return
setWinterActivityState(value)
if (value) setWinterActivityChoice(value)
}, [markEdited, winterActivity])
@@ -564,6 +568,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setRunProfileState('running')
loadRunPace(null)
setWinterActivityState(null)
setWinterActivityChoice('winter_hike')
tourTypeRef.current = 'hike'
setSelectedWaypointId(null)
clearHistory()
@@ -743,6 +748,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setRunProfileState(opened.runProfile)
loadRunPace(opened.runPaceSecondsPerKm)
setWinterActivityState(opened.winterActivity)
setWinterActivityChoice(opened.winterActivity ?? 'winter_hike')
tourTypeRef.current = opened.tourType
setSelectedWaypointId(nextWaypoints[0]?.id ?? null)
clearHistory()
@@ -910,7 +916,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', winterActivity, setWinterActivity,
bikeProfile, setBikeProfile, isBike: tourType === 'bike', winterActivity, setWinterActivity, winterActivityChoice,
runProfile, setRunProfile, isRun: tourType === 'run', runPace, runPaceMissing, runPaceInvalid,
tourType, setTourType, isPaddle, paddle, paddleSpeedInvalid, paddleIncomplete,
description, setDescription, website, setWebsite, websiteInvalid,
@@ -18,7 +18,7 @@ vi.mock('../useTourPermissions', () => ({ useTourPermissions: () => ({ canEdit:
import { useTourPlanner } from './useTourPlanner';
// FE-TOUR-WINTER-001 to FE-TOUR-WINTER-005
// FE-TOUR-WINTER-001 to FE-TOUR-WINTER-006
const routed = {
coordinates: [
@@ -184,4 +184,22 @@ describe('useTourPlanner, the winter label of a hike (HIK-01)', () => {
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');
});
});
+1
View File
@@ -16,6 +16,7 @@ export function hikePlannerDefaults() {
// HIK-01: a hike without a winter label, as every hike starts.
winterActivity: null,
setWinterActivity: vi.fn(),
winterActivityChoice: 'winter_hike' as const,
// RUN-01: no run, so no pace asked for.
isRun: false,
runProfile: 'running' as const,