merge: update Tours branch with current Places filters

This commit is contained in:
elmocito
2026-10-05 01:50:47 +02:00
63 changed files with 1008 additions and 183 deletions
+20 -2
View File
@@ -6,7 +6,7 @@ import { MAP_CONTROL_SHADOW } from './mapControlShadow'
* One round on/off control in the frosted shell every map control wears, so a new
* one lines up with the compass, the layer switcher and the overview toggle.
*/
export function MapTogglePill({ active, onToggle, label, icon, testId, tooltipPlacement = 'left' }: {
export function MapTogglePill({ active, onToggle, label, icon, testId, tooltipPlacement = 'left', badge = 0, opensDialog = false }: {
active: boolean
onToggle: () => void
label: string
@@ -14,9 +14,18 @@ export function MapTogglePill({ active, onToggle, label, icon, testId, tooltipPl
testId?: string
/** Away from the edge the control hugs: left on the right edge, right on the left one. */
tooltipPlacement?: 'left' | 'right'
/** A count pinned to the top right corner while above zero (how many filters are on, say). */
badge?: number
/**
* The control opens a dialog instead of switching something on and off (the phone
* map's places filter): it then announces the dialog, not a pressed state it
* does not have. `active` still lights it.
*/
opensDialog?: boolean
}) {
return (
<div style={{
position: 'relative',
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto',
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
@@ -30,7 +39,8 @@ export function MapTogglePill({ active, onToggle, label, icon, testId, tooltipPl
type="button"
onClick={onToggle}
aria-label={label}
aria-pressed={active}
aria-pressed={opensDialog ? undefined : active}
aria-haspopup={opensDialog ? 'dialog' : undefined}
data-testid={testId}
className={active ? 'text-accent' : 'text-content-muted'}
style={{
@@ -45,6 +55,14 @@ export function MapTogglePill({ active, onToggle, label, icon, testId, tooltipPl
{icon}
</button>
</Tooltip>
{badge > 0 && (
<span
aria-hidden="true"
className="pointer-events-none absolute -right-[3px] -top-[3px] box-border flex h-4 min-w-[16px] items-center justify-center rounded-full bg-accent px-1 font-geist text-[0.5625rem] font-bold tabular-nums text-accent-text"
>
{badge}
</span>
)}
</div>
)
}
@@ -9,6 +9,7 @@ import { tintOf } from './planParts'
import { CategoryTile } from './PlacesBulkCategoryModal'
import type { PlacesFilter, SidebarState } from './usePlacesSidebar'
import { PLACES_SORTS, type PlacesSort } from './placesSort'
import { RATING_FLOORS, UNCATEGORIZED } from '../../utils/placesFilter'
/**
* Below this the two labels stop fitting side by side and both buttons fall back
@@ -48,8 +49,6 @@ function BandButton({ label, onClick, on = false, lit = false, ariaPressed, aria
)
}
const RATING_FLOORS = ['all', 5, 4, 3, 2, 1] as const
export function PlacesDropOverlay({ t }: SidebarState) {
return (
<div
@@ -260,7 +259,7 @@ function CategoryFilter(S: SidebarState) {
let label = t('places.allCategories')
if (categoryFilters.size === 1) {
const only = categories.find(c => categoryFilters.has(String(c.id)))
if (categoryFilters.has('uncategorized')) label = t('places.noCategory')
if (categoryFilters.has(UNCATEGORIZED)) label = t('places.noCategory')
else if (only) label = only.name
} else if (categoryFilters.size > 1) {
label = `${categoryFilters.size} ${t('places.categoriesSelected')}`
@@ -279,8 +278,8 @@ function CategoryFilter(S: SidebarState) {
))}
{places.some(p => p.category_id == null) && (
<CategoryChoice
on={categoryFilters.has('uncategorized')}
onClick={() => toggleCategoryFilter('uncategorized')}
on={categoryFilters.has(UNCATEGORIZED)}
onClick={() => toggleCategoryFilter(UNCATEGORIZED)}
tile={<CategoryTile size={22} />}
label={t('places.noCategory')}
/>
@@ -1,4 +1,4 @@
// FE-PLANNER-PSHOOK-001 to FE-PLANNER-PSHOOK-059
// FE-PLANNER-PSHOOK-001 to FE-PLANNER-PSHOOK-060
import { http, HttpResponse } from 'msw';
import userEvent from '@testing-library/user-event';
import { render, screen, fireEvent, act, waitFor } from '../../../tests/helpers/render';
@@ -188,13 +188,46 @@ 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']);
it('Tours addon excludes Tour-backed Places from rows, counts, and localities', () => {
const legacyTrack = buildPlace({ id: 41, name: 'Legacy track', route_geometry: '[[1,2],[3,4]]', rating_avg: 4, address: 'Vienna, Austria' });
const ordinary = buildPlace({ id: 42, name: 'Ordinary place', address: 'Linz, Austria' });
const tour = buildPlace({ id: 43, name: 'Tour place', tour_place_id: 43, route_geometry: '[[5,6],[7,8]]', address: 'Denver, United States' });
render(<Host {...makeProps({
places: [legacyTrack, ordinary, tour],
toursEnabled: true,
excludePlaceIds: new Set([tour.id]),
})} />);
expect(S.hasTracks).toBe(false);
expect(names()).toEqual(expect.arrayContaining(['Legacy track', 'Ordinary place']));
expect(names()).not.toContain('Tour place');
expect(S.filterCounts).toEqual({ all: 2, unplanned: 2, planned: 0, tracks: 1 });
expect(S.localities).toEqual([{ country: 'Austria', count: 2, regions: [] }]);
act(() => { S.setRatingFilter(4); });
expect(names()).toEqual(['Legacy track']);
expect(S.filterCounts.all).toBe(2);
});
it('Tours addon OFF preserves legacy track Places and ignores the exclusion set', () => {
const legacyTrack = buildPlace({ id: 51, name: 'Legacy track', route_geometry: '[[1,2],[3,4]]' });
const dormantTour = buildPlace({ id: 52, name: 'Dormant Tour', tour_place_id: 52, route_geometry: '[[5,6],[7,8]]' });
render(<Host {...makeProps({
places: [legacyTrack, dormantTour],
toursEnabled: false,
excludePlaceIds: new Set([dormantTour.id]),
})} />);
expect(S.hasTracks).toBe(true);
expect(S.filterCounts).toEqual({ all: 2, unplanned: 2, planned: 0, tracks: 2 });
act(() => { S.setFilter('tracks'); });
expect(names()).toEqual(expect.arrayContaining(['Legacy track', 'Dormant Tour']));
});
// 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 });
const other = buildPlace({ name: 'Other', category_id: 5 });
@@ -245,6 +278,17 @@ describe('usePlacesSidebar filtering', () => {
expect(names()).toEqual(['Unrated', 'Rated']);
});
it('FE-PLANNER-PSHOOK-060: the star floor lives in the trip store, so the map and the phone list follow it', () => {
const places = [buildPlace({ name: 'Good', rating_avg: 4 }), buildPlace({ name: 'Meh', rating_avg: 2 })];
render(<Host {...makeProps({ places })} />);
act(() => { S.setRatingFilter(3); });
expect(useTripStore.getState().placesRatingFilter).toBe(3);
// And back: a floor picked elsewhere (the phone's filter sheet) narrows this list too.
act(() => { useTripStore.getState().setPlacesRatingFilter('all'); });
expect(S.ratingFilter).toBe('all');
expect(names()).toEqual(['Good', 'Meh']);
});
it('FE-PLANNER-PSHOOK-014: search and category filter combine', () => {
const places = [
buildPlace({ name: 'Cafe Nord', category_id: 4 }),
@@ -18,6 +18,7 @@ import { placeToSaveTarget } from '../Collections/saveTarget'
import type { Place, Category, Day, AssignmentsMap } from '../../types'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
import { placeMatchesSearch } from '../../utils/placeSearch'
import { matchesCategoryFilter, matchesPlacesFilter } from '../../utils/placesFilter'
import { safeHttpUrl } from '../../utils/safeUrl'
import { plannedPlaceIds, plannedPlaceIdsForDay, type PlannedAccommodation } from '../../utils/plannedPlaces'
import type { MenuEntry } from './planParts'
@@ -193,19 +194,20 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const categoryFilters = useTripStore((s) => s.placesCategoryFilter)
const setCategoryFilters = useTripStore((s) => s.setPlacesCategoryFilter)
const [selectMode, setSelectMode] = useState(false)
// Star sort (#1435): list-only toggle, so it stays local (the map keeps its order).
// Minimum average stars, matching the collections filter (#1435): 'all', or a
// floor of 1..5 that unrated places fall through. It replaced a sort toggle,
// which put the best first but still left everything else on the list — no
// help at all when the point is to see only what the group actually rated.
const [ratingFilter, setRatingFilter] = useState<number | 'all'>('all')
// In the trip store with the other filters, so the map markers follow it too.
const ratingFilter = useTripStore((s) => s.placesRatingFilter)
const setRatingFilter = useTripStore((s) => s.setPlacesRatingFilter)
// The list's order (#2093), remembered on this device.
const [placesSort, setPlacesSortState] = useState<PlacesSort>(readPlacesSort)
const setPlacesSort = useCallback((sort: PlacesSort) => { setPlacesSortState(sort); writePlacesSort(sort) }, [])
// Country, or country and region, from each place's resolved position (#2537). List-only,
// like the rating floor.
const [localityFilter, setLocalityFilter] = useState<LocalityFilter | null>(null)
const localityOf = useMemo(() => new Map(places.map(p => [p.id, placeLocality(p, language)])), [places, language])
const localityOf = useMemo(() => new Map(poolPlaces.map(p => [p.id, placeLocality(p, language)])), [poolPlaces, language])
const localities = useMemo(() => localityGroups(poolPlaces.map(p => localityOf.get(p.id)!)), [poolPlaces, localityOf])
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [pendingDeleteIds, setPendingDeleteIds] = useState<number[] | null>(null)
@@ -272,7 +274,6 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
() => !props.toursEnabled && poolPlaces.some(p => p.route_geometry),
[poolPlaces, props.toursEnabled],
)
useEffect(() => { if (filter === 'tracks' && !hasTracks) setFilter('all') }, [hasTracks, filter])
const plannedIds = useMemo(
() => plannedPlaceIds({ assignments, accommodations, reservations }),
@@ -301,16 +302,8 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const filtered = useMemo(() => {
const list = poolPlaces.filter(p => {
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
if (filter === 'planned' && !plannedFilterIds.has(p.id)) return false
if (filter === 'tracks' && !p.route_geometry) return false
if (categoryFilters.size > 0) {
if (p.category_id == null) {
if (!categoryFilters.has('uncategorized')) return false
} else if (!categoryFilters.has(String(p.category_id))) return false
}
if (!matchesPlacesFilter(p, { filter, categoryFilters, ratingFilter }, { plannedIds, plannedFilterIds })) return false
if (!placeMatchesSearch(p, search)) return false
if (ratingFilter !== 'all' && (p.rating_avg == null || p.rating_avg < ratingFilter)) return false
if (localityFilter && !matchesLocality(localityOf.get(p.id), localityFilter)) return false
return true
})
@@ -325,14 +318,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
* different questions.
*/
const filterCounts = useMemo(() => {
const base = poolPlaces.filter(p => {
if (categoryFilters.size > 0) {
if (p.category_id == null) {
if (!categoryFilters.has('uncategorized')) return false
} else if (!categoryFilters.has(String(p.category_id))) return false
}
return placeMatchesSearch(p, search)
})
const base = poolPlaces.filter(p => matchesCategoryFilter(p, categoryFilters) && placeMatchesSearch(p, search))
return {
all: base.length,
unplanned: base.filter(p => !plannedIds.has(p.id)).length,