fix(map): move the tracks filter fallback to the always-mounted planner hook

The fallback from "Tracks" to "All" once the last track disappears lived in
the desktop sidebar and the phone places browser, neither of which is mounted
while the phone map is in front: the map could sit empty under a filter no
control offers any more. useTripPlanner now owns it, once, for every screen.
This commit is contained in:
EvKoh
2026-10-03 15:49:48 +02:00
committed by Maurice
parent b5514024fa
commit a9f7adf99b
6 changed files with 39 additions and 14 deletions
@@ -188,12 +188,9 @@ describe('usePlacesSidebar filtering', () => {
expect(names()).toEqual(['GPX Track']);
});
it('FE-PLANNER-PSHOOK-008: a tracks filter without any track falls back to "all"', async () => {
seedStore(useTripStore, { placesFilter: 'tracks' });
render(<Host {...makeProps({ places: [buildPlace({ name: 'Plain Spot' })] })} />);
await waitFor(() => expect(useTripStore.getState().placesFilter).toBe('all'));
expect(names()).toEqual(['Plain Spot']);
});
// FE-PLANNER-PSHOOK-008 (a tracks filter without any track falls back to "all")
// moved to useTripPlanner (FE-TP-HOOK-177): the fallback now lives there, so it
// also holds while no places list is mounted.
it('FE-PLANNER-PSHOOK-009: a category filter keeps only that category', () => {
const tagged = buildPlace({ name: 'Tagged', category_id: 4 });
@@ -259,8 +259,9 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const pickFilter = (next: PlacesFilter) => { setFilter(next); setSelectedIds(new Set()) }
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
// Whether to offer the "Tracks" tab. useTripPlanner moves the filter back to
// "all" once the last track is gone, whichever screen is in front.
const hasTracks = useMemo(() => places.some(p => p.route_geometry), [places])
useEffect(() => { if (filter === 'tracks' && !hasTracks) setFilter('all') }, [hasTracks, filter])
const plannedIds = useMemo(
() => plannedPlaceIds({ assignments, accommodations, reservations }),
@@ -63,10 +63,9 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
if (shell.browseFromEdit) setFilter('unplanned')
}, [shell.browseFromEdit, setFilter])
// Whether to offer the "Tracks" chip; useTripPlanner moves the filter back to
// "all" once the last track is gone.
const hasTracks = useMemo(() => places.some(p => p.route_geometry), [places])
useEffect(() => {
if (filter === 'tracks' && !hasTracks) setFilter('all')
}, [filter, hasTracks, setFilter])
// A hotel is linked through its stay and a venue through its booking; neither is
// ever dragged onto a day, and the pool used to call both unplanned (#2072).
@@ -1,4 +1,4 @@
// FE-TP-HOOK-001 to FE-TP-HOOK-176
// FE-TP-HOOK-001 to FE-TP-HOOK-177
import React from 'react'
import { renderHook, act, waitFor } from '@testing-library/react'
import { TranslationProvider } from '../../i18n/TranslationContext'
@@ -644,6 +644,23 @@ describe('useTripPlanner — map derivations', () => {
expect(result.current.mapPlaces.map(p => p.id)).toEqual([2])
})
it('FE-TP-HOOK-177: losing the last track moves the "Tracks" filter back to all', async () => {
// The phone map has no places list mounted, so this hook is what keeps the
// filter from pointing at a pool no control offers any more.
seedTrip({
places: [geo(1), geo(2, { route_geometry: '[[1,2]]' })],
placesFilter: 'tracks',
})
const { result } = await renderPlanner()
expect(useTripStore.getState().placesFilter).toBe('tracks')
act(() => { useTripStore.setState({ places: [geo(1)] }) })
await waitFor(() => expect(useTripStore.getState().placesFilter).toBe('all'))
expect(result.current.mapPlaces.map(p => p.id)).toEqual([1])
})
it('FE-TP-HOOK-027: the category filter honours the uncategorized bucket', async () => {
seedTrip({
places: [geo(1, { category_id: 3 }), geo(2, { category_id: null })],
@@ -768,6 +768,17 @@ export function useTripPlanner() {
})
}, [places, placesCategoryFilter, placesFilter, placesRatingFilter, assignments, expandedDayIds, selectedDayId, days, tripAccommodations, reservations, compactUnplanned])
// The "Tracks" pool is only offered while a place carries a track. When the last
// one goes (deleted here or by a collaborator), fall back to "all" — here, because
// this hook is mounted whatever is on screen: the lists that used to do it are not
// mounted while the phone map is in front, and the map would sit empty under a
// "Tracks" filter no control offers any more.
const setPlacesFilter = useTripStore((s) => s.setPlacesFilter)
const hasTracks = useMemo(() => places.some(p => p.route_geometry), [places])
useEffect(() => {
if (placesFilter === 'tracks' && !hasTracks) setPlacesFilter('all')
}, [placesFilter, hasTracks, setPlacesFilter])
const { route, routeWalking, routeSegments, routeVias, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile, tripAccommodations)
// Road trip mode already draws the whole trip its own way, so the overview stands
// down there rather than drawing a second set of lines over it.
@@ -124,11 +124,11 @@ describe('MPlacesBrowser', () => {
expect(screen.queryByText('Louvre')).not.toBeInTheDocument()
})
it('FE-MOB-PBROW-007: hides the tracks chip and falls back to all when the last track is gone', () => {
seedStore(useTripStore, { placesFilter: 'tracks' })
// The fall back to "all" once the last track is gone lives in useTripPlanner
// (FE-TP-HOOK-177), which stays mounted while this browser is not.
it('FE-MOB-PBROW-007: hides the tracks chip when no place carries a track', () => {
renderBrowser(makePlanner({ places: [LOUVRE, EIFFEL] } as Partial<TripPlanner>))
expect(screen.queryByRole('button', { name: 'places.filterTracks' })).not.toBeInTheDocument()
expect(useTripStore.getState().placesFilter).toBe('all')
expect(screen.getByText('places.count:2')).toBeInTheDocument()
})