From fcdb7e48cd4b02302de28b6b97d0663e9c105450 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 20 Oct 2026 14:52:15 +0200 Subject: [PATCH] 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. --- .../planner/WinterActivityField.test.tsx | 61 ++++++++++++++++--- .../Tours/planner/WinterActivityField.tsx | 20 +++--- .../Tours/planner/useTourPlanner.ts | 10 ++- .../planner/useTourPlanner.winter.test.tsx | 20 +++++- client/tests/helpers/tourPlannerStub.ts | 1 + 5 files changed, 89 insertions(+), 23 deletions(-) diff --git a/client/src/components/Tours/planner/WinterActivityField.test.tsx b/client/src/components/Tours/planner/WinterActivityField.test.tsx index b061182b3..0d147af3c 100644 --- a/client/src/components/Tours/planner/WinterActivityField.test.tsx +++ b/client/src/components/Tours/planner/WinterActivityField.test.tsx @@ -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(initial); + const [winterActivity, setActivity] = useState(initial); + const [winterActivityChoice, setChoice] = useState(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 ( <> - + {winterActivity ?? 'none'} + + ); } +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(); + render(); 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(); + render(); 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(); + render(); 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(); + 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(); + expect(option('snowshoe')).toHaveAttribute('aria-pressed', 'true'); + fireEvent.click(toggle()); + expect(setWinterActivity).toHaveBeenCalledWith('snowshoe'); + }); }); diff --git a/client/src/components/Tours/planner/WinterActivityField.tsx b/client/src/components/Tours/planner/WinterActivityField.tsx index 7b3281f3c..1feff2915 100644 --- a/client/src/components/Tours/planner/WinterActivityField.tsx +++ b/client/src/components/Tours/planner/WinterActivityField.tsx @@ -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; + planner: Pick; locked: boolean; }) { const { t } = useTranslation(); const current = planner.winterActivity ?? null; - const [lastChoice, setLastChoice] = useState(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'}`} > - value={current ?? lastChoice} + value={current ?? planner.winterActivityChoice} onChange={choose} label={t('tours.winter.kind')} fill diff --git a/client/src/components/Tours/planner/useTourPlanner.ts b/client/src/components/Tours/planner/useTourPlanner.ts index f6612207a..36f112a3e 100644 --- a/client/src/components/Tours/planner/useTourPlanner.ts +++ b/client/src/components/Tours/planner/useTourPlanner.ts @@ -128,6 +128,8 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe const [hikingPreferences, setHikingPreferencesState] = useState(restored?.hikingPreferences ?? null) const [bikeProfile, setBikeProfileState] = useState(restored?.bikeProfile ?? 'touring') const [winterActivity, setWinterActivityState] = useState(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(restored?.winterActivity ?? 'winter_hike') const [runProfile, setRunProfileState] = useState(restored?.runProfile ?? 'running') const [tourType, setTourTypeState] = useState(restored?.tourType ?? 'hike') const [selectedWaypointId, setSelectedWaypointId] = useState(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, diff --git a/client/src/components/Tours/planner/useTourPlanner.winter.test.tsx b/client/src/components/Tours/planner/useTourPlanner.winter.test.tsx index 56f3af544..12c9794c0 100644 --- a/client/src/components/Tours/planner/useTourPlanner.winter.test.tsx +++ b/client/src/components/Tours/planner/useTourPlanner.winter.test.tsx @@ -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'); + }); }); diff --git a/client/tests/helpers/tourPlannerStub.ts b/client/tests/helpers/tourPlannerStub.ts index eaeff3e66..b961ba460 100644 --- a/client/tests/helpers/tourPlannerStub.ts +++ b/client/tests/helpers/tourPlannerStub.ts @@ -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,