From a44c966f4d87a804998bf982d1d2cc9d83b25632 Mon Sep 17 00:00:00 2001
From: EvKoh <1309463+EvKoh@users.noreply.github.com>
Date: Sat, 3 Oct 2026 14:14:23 +0200
Subject: [PATCH] feat(places): share the rating floor between the lists and
the map
The minimum-rating filter lived in the desktop sidebar's local state, so it
only ever narrowed the desktop list: the map markers and the phone's places
list ignored it. It now sits in the trip store beside the pool and category
filters, and one module (utils/placesFilter) holds the pool, category and
rating matchers that the desktop list, the phone list and mapPlaces all use,
instead of three hand-copied versions of the same checks.
---
.../Planner/PlacesSidebarHeader.tsx | 3 +-
.../Planner/usePlacesSidebar.test.tsx | 13 +++-
.../components/Planner/usePlacesSidebar.ts | 25 ++-----
.../trip/places/placesBrowserModel.test.ts | 12 ++++
.../screens/trip/places/placesBrowserModel.ts | 26 ++++---
.../pages/tripPlanner/useTripPlanner.test.tsx | 16 ++++-
.../src/pages/tripPlanner/useTripPlanner.ts | 16 ++---
client/src/store/tripStore.ts | 11 +++
client/src/utils/placesFilter.test.ts | 70 +++++++++++++++++++
client/src/utils/placesFilter.ts | 69 ++++++++++++++++++
client/tests/unit/tripStore.test.ts | 26 +++++++
11 files changed, 240 insertions(+), 47 deletions(-)
create mode 100644 client/src/utils/placesFilter.test.ts
create mode 100644 client/src/utils/placesFilter.ts
diff --git a/client/src/components/Planner/PlacesSidebarHeader.tsx b/client/src/components/Planner/PlacesSidebarHeader.tsx
index 69592e20e..9702fcabe 100644
--- a/client/src/components/Planner/PlacesSidebarHeader.tsx
+++ b/client/src/components/Planner/PlacesSidebarHeader.tsx
@@ -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 } 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 (
{
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(
);
+ 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 }),
diff --git a/client/src/components/Planner/usePlacesSidebar.ts b/client/src/components/Planner/usePlacesSidebar.ts
index 8a9669849..cc5843c07 100644
--- a/client/src/components/Planner/usePlacesSidebar.ts
+++ b/client/src/components/Planner/usePlacesSidebar.ts
@@ -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'
@@ -182,12 +183,13 @@ 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
('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(readPlacesSort)
const setPlacesSort = useCallback((sort: PlacesSort) => { setPlacesSortState(sort); writePlacesSort(sort) }, [])
@@ -287,16 +289,8 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const filtered = useMemo(() => {
const list = places.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
})
@@ -311,14 +305,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
* different questions.
*/
const filterCounts = useMemo(() => {
- const base = places.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 = places.filter(p => matchesCategoryFilter(p, categoryFilters) && placeMatchesSearch(p, search))
return {
all: base.length,
unplanned: base.filter(p => !plannedIds.has(p.id)).length,
diff --git a/client/src/mobile/screens/trip/places/placesBrowserModel.test.ts b/client/src/mobile/screens/trip/places/placesBrowserModel.test.ts
index d26d9a9b6..52a6dc005 100644
--- a/client/src/mobile/screens/trip/places/placesBrowserModel.test.ts
+++ b/client/src/mobile/screens/trip/places/placesBrowserModel.test.ts
@@ -144,6 +144,18 @@ describe('placesBrowserModel — tracks pool', () => {
expect(out.map(p => p.id)).toEqual([1])
})
+ it('FE-MOB-PMODEL-015: the rating floor from the store narrows the pool like it narrows the map', () => {
+ const rated = [
+ mkPlace(1, { name: 'Top', rating_avg: 4.6 }),
+ mkPlace(2, { name: 'Fine', rating_avg: 3.2 }),
+ mkPlace(3, { name: 'Unrated' }),
+ ]
+ const out = filterPool(rated, { filter: 'all', categoryFilters: new Set(), ratingFilter: 3, search: '', plannedIds })
+ expect(out.map(p => p.id)).toEqual([1, 2])
+ const all = filterPool(rated, { filter: 'all', categoryFilters: new Set(), search: '', plannedIds })
+ expect(all.map(p => p.id)).toEqual([1, 2, 3])
+ })
+
it('FE-MOB-PMODEL-013: poolCounts counts tracks on the category+search base set', () => {
const c = poolCounts(places, new Set(['4']), '', plannedIds)
expect(c.all).toBe(2)
diff --git a/client/src/mobile/screens/trip/places/placesBrowserModel.ts b/client/src/mobile/screens/trip/places/placesBrowserModel.ts
index dffb19097..3fc6aceaf 100644
--- a/client/src/mobile/screens/trip/places/placesBrowserModel.ts
+++ b/client/src/mobile/screens/trip/places/placesBrowserModel.ts
@@ -1,5 +1,8 @@
import type { AssignmentsMap, Day, Place } from '../../../../types'
import { placeMatchesSearch } from '../../../../utils/placeSearch'
+import {
+ matchesCategoryFilter, matchesPlacesFilter, type PlacesRatingFloor,
+} from '../../../../utils/placesFilter'
import {
plannedPlaceIds as sharedPlannedPlaceIds,
type PlannedAccommodation, type PlannedReservation,
@@ -8,10 +11,14 @@ import {
/**
* Pure filter model of the mobile places browser. Mirrors the desktop
* sidebar's semantics exactly (usePlacesSidebar + the map's mapPlaces memo):
- * the pool filter and category set come from the trip store, so the list here
+ * the pool filter, the category set and the rating floor come from the trip
+ * store and go through the same matcher (utils/placesFilter), so the list here
* and the map markers always agree (#1541).
*/
+/** Kept under this name for the browser's callers; the predicate itself lives in utils/placesFilter. */
+export { matchesCategoryFilter }
+
export function plannedPlaceIds(
assignments: AssignmentsMap,
accommodations: PlannedAccommodation[] = [],
@@ -38,12 +45,6 @@ export function firstPlannedDayNumbers(assignments: AssignmentsMap, days: Day[])
return result
}
-export function matchesCategoryFilter(place: Place, categoryFilters: Set): boolean {
- if (categoryFilters.size === 0) return true
- if (place.category_id == null) return categoryFilters.has('uncategorized')
- return categoryFilters.has(String(place.category_id))
-}
-
export function matchesSearch(place: Place, search: string): boolean {
return placeMatchesSearch(place, search)
}
@@ -51,17 +52,14 @@ export function matchesSearch(place: Place, search: string): boolean {
interface PoolFilterArgs {
filter: string
categoryFilters: Set
+ /** Minimum average stars; omitted means every rating. */
+ ratingFilter?: PlacesRatingFloor
search: string
plannedIds: Set
}
-export function filterPool(places: Place[], { filter, categoryFilters, search, plannedIds }: PoolFilterArgs): Place[] {
- return places.filter(p => {
- if (filter === 'unplanned' && plannedIds.has(p.id)) return false
- if (filter === 'planned' && !plannedIds.has(p.id)) return false
- if (filter === 'tracks' && !p.route_geometry) return false
- return matchesCategoryFilter(p, categoryFilters) && matchesSearch(p, search)
- })
+export function filterPool(places: Place[], { filter, categoryFilters, ratingFilter = 'all', search, plannedIds }: PoolFilterArgs): Place[] {
+ return places.filter(p => matchesPlacesFilter(p, { filter, categoryFilters, ratingFilter }, { plannedIds }) && matchesSearch(p, search))
}
/** Chip counts run on the category+search base set, like the desktop tabs. */
diff --git a/client/src/pages/tripPlanner/useTripPlanner.test.tsx b/client/src/pages/tripPlanner/useTripPlanner.test.tsx
index cfa4f8a26..c4ea889cc 100644
--- a/client/src/pages/tripPlanner/useTripPlanner.test.tsx
+++ b/client/src/pages/tripPlanner/useTripPlanner.test.tsx
@@ -1,4 +1,4 @@
-// FE-TP-HOOK-001 to FE-TP-HOOK-175
+// FE-TP-HOOK-001 to FE-TP-HOOK-176
import React from 'react'
import { renderHook, act, waitFor } from '@testing-library/react'
import { TranslationProvider } from '../../i18n/TranslationContext'
@@ -658,6 +658,20 @@ describe('useTripPlanner — map derivations', () => {
expect(result.current.mapPlaces.map(p => p.id)).toEqual([1])
})
+ it('FE-TP-HOOK-176: the rating floor the lists set also thins the markers', async () => {
+ seedTrip({
+ places: [geo(1, { rating_avg: 4.5 }), geo(2, { rating_avg: 3 }), geo(3, { rating_avg: null })],
+ placesRatingFilter: 4,
+ })
+
+ const { result } = await renderPlanner()
+
+ expect(result.current.mapPlaces.map(p => p.id)).toEqual([1])
+
+ act(() => { useTripStore.getState().setPlacesRatingFilter('all') })
+ expect(result.current.mapPlaces.map(p => p.id)).toEqual([1, 2, 3])
+ })
+
it('FE-TP-HOOK-028: the unplanned filter drops places that sit on a day', async () => {
const planned = geo(1)
seedTrip({
diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts
index 3f2bcf401..bc65e3ac5 100644
--- a/client/src/pages/tripPlanner/useTripPlanner.ts
+++ b/client/src/pages/tripPlanner/useTripPlanner.ts
@@ -81,6 +81,7 @@ import {
} from '../../utils/connectionsVisibility'
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage'
import { plannedPlaceIds, plannedPlaceIdsForDay } from '../../utils/plannedPlaces'
+import { matchesPlacesFilter } from '../../utils/placesFilter'
import { pendingStayPlaceIds } from '../../utils/pendingStays'
import { useDayDelete } from './useDayDelete'
import { useDayAdd } from './useDayAdd'
@@ -711,6 +712,7 @@ export function useTripPlanner() {
// switches can't desync the marker set from the filter UI (#1541).
const placesFilter = useTripStore((s) => s.placesFilter)
const placesCategoryFilter = useTripStore((s) => s.placesCategoryFilter)
+ const placesRatingFilter = useTripStore((s) => s.placesRatingFilter)
const [expandedDayIds, setExpandedDayIds] = useState | null>(null)
@@ -749,28 +751,22 @@ export function useTripPlanner() {
const compactIds = compactUnplanned ? plannedPlaceIds({ assignments, accommodations: tripAccommodations, reservations }) : null
const pendingIds = pendingStayPlaceIds(tripAccommodations, reservations)
+ const filterState = { filter: placesFilter, categoryFilters: placesCategoryFilter, ratingFilter: placesRatingFilter }
return places.filter(p => {
if (!p.lat || !p.lng) return false
- if (placesFilter === 'tracks' && !p.route_geometry) return false
- if (placesCategoryFilter.size > 0) {
- if (p.category_id == null) {
- if (!placesCategoryFilter.has('uncategorized')) return false
- } else if (!placesCategoryFilter.has(String(p.category_id))) return false
- }
// Collapsed-day declutter hides a day's stops on every filter EXCEPT 'planned':
// there the user asked to see the whole plan on the map, so a collapsed day
// must not drop its planned places.
if (placesFilter !== 'planned' && hiddenPlaceIds.has(p.id)) return false
- if (placesFilter === 'unplanned' && plannedIds && plannedIds.has(p.id)) return false
- if (placesFilter === 'planned' && plannedIds && !plannedIds.has(p.id)) return false
- return true
+ // Pool, categories and rating floor: the very matcher the lists use (#1541).
+ return matchesPlacesFilter(p, filterState, { plannedIds })
}).map(p => {
// How the map tells a place apart (#2024, #2281); untouched places keep their identity.
const compact = !!compactIds && !compactIds.has(p.id)
const pending = pendingIds.has(p.id)
return compact || pending ? { ...p, _compact: compact, _pending: pending } : p
})
- }, [places, placesCategoryFilter, placesFilter, assignments, expandedDayIds, selectedDayId, days, tripAccommodations, reservations, compactUnplanned])
+ }, [places, placesCategoryFilter, placesFilter, placesRatingFilter, assignments, expandedDayIds, selectedDayId, days, tripAccommodations, reservations, compactUnplanned])
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
diff --git a/client/src/store/tripStore.ts b/client/src/store/tripStore.ts
index c007b1fa8..22350a800 100644
--- a/client/src/store/tripStore.ts
+++ b/client/src/store/tripStore.ts
@@ -36,6 +36,7 @@ import type { TodoSlice } from './slices/todoSlice'
import type { BudgetSlice } from './slices/budgetSlice'
import type { ReservationsSlice } from './slices/reservationsSlice'
import type { FilesSlice } from './slices/filesSlice'
+import type { PlacesRatingFloor } from '../utils/placesFilter'
export interface TripStoreState
extends PlacesSlice,
@@ -65,12 +66,18 @@ export interface TripStoreState
// unmounts and remounts (#1541).
placesFilter: string
placesCategoryFilter: Set
+ // Minimum average stars. Shared for the same reason: the map markers and the
+ // phone's list follow the floor the desktop list sets, and back (#1435).
+ placesRatingFilter: PlacesRatingFloor
isLoading: boolean
error: string | null
setSelectedDay: (dayId: number | null) => void
setPlacesFilter: (filter: string) => void
setPlacesCategoryFilter: (categoryIds: Set) => void
+ setPlacesRatingFilter: (floor: PlacesRatingFloor) => void
+ /** Lifts the pool, category and rating filters at once. */
+ resetPlacesFilters: () => void
handleRemoteEvent: (event: WebSocketEvent) => void
resetTrip: () => void
loadTrip: (tripId: number | string) => Promise
@@ -97,12 +104,15 @@ export const useTripStore = create((set, get) => ({
selectedDayId: null,
placesFilter: 'all',
placesCategoryFilter: new Set(),
+ placesRatingFilter: 'all',
isLoading: false,
error: null,
setSelectedDay: (dayId: number | null) => set({ selectedDayId: dayId }),
setPlacesFilter: (filter: string) => set({ placesFilter: filter }),
setPlacesCategoryFilter: (categoryIds: Set) => set({ placesCategoryFilter: categoryIds }),
+ setPlacesRatingFilter: (floor: PlacesRatingFloor) => set({ placesRatingFilter: floor }),
+ resetPlacesFilters: () => set({ placesFilter: 'all', placesCategoryFilter: new Set(), placesRatingFilter: 'all' }),
handleRemoteEvent: (event: WebSocketEvent) => handleRemoteEvent(set, get, event),
@@ -123,6 +133,7 @@ export const useTripStore = create((set, get) => ({
selectedDayId: null,
placesFilter: 'all',
placesCategoryFilter: new Set(),
+ placesRatingFilter: 'all',
error: null,
}),
diff --git a/client/src/utils/placesFilter.test.ts b/client/src/utils/placesFilter.test.ts
new file mode 100644
index 000000000..741f65e78
--- /dev/null
+++ b/client/src/utils/placesFilter.test.ts
@@ -0,0 +1,70 @@
+import { describe, it, expect } from 'vitest'
+import { buildPlace } from '../../tests/helpers/factories'
+import {
+ RATING_FLOORS, countActivePlacesFilters, matchesCategoryFilter, matchesPlacesFilter,
+ matchesPoolFilter, matchesRatingFloor,
+} from './placesFilter'
+
+const NONE = new Set()
+
+describe('placesFilter', () => {
+ it('FE-UTIL-PLFILTER-001: offers the same floors as the collections bar', () => {
+ expect(RATING_FLOORS).toEqual(['all', 5, 4, 3, 2, 1])
+ })
+
+ it('FE-UTIL-PLFILTER-002: an empty category set keeps everything; "uncategorized" stands for no category', () => {
+ const tagged = buildPlace({ category_id: 4 })
+ const bare = buildPlace({ category_id: null })
+ expect(matchesCategoryFilter(tagged, NONE)).toBe(true)
+ expect(matchesCategoryFilter(tagged, new Set(['4']))).toBe(true)
+ expect(matchesCategoryFilter(tagged, new Set(['5']))).toBe(false)
+ expect(matchesCategoryFilter(bare, new Set(['4']))).toBe(false)
+ expect(matchesCategoryFilter(bare, new Set(['uncategorized']))).toBe(true)
+ })
+
+ it('FE-UTIL-PLFILTER-003: the rating floor keeps places at or above it and drops the unrated', () => {
+ expect(matchesRatingFloor(buildPlace({ rating_avg: null }), 'all')).toBe(true)
+ expect(matchesRatingFloor(buildPlace({ rating_avg: null }), 1)).toBe(false)
+ expect(matchesRatingFloor(buildPlace({ rating_avg: 4 }), 4)).toBe(true)
+ expect(matchesRatingFloor(buildPlace({ rating_avg: 3.9 }), 4)).toBe(false)
+ })
+
+ it('FE-UTIL-PLFILTER-004: the pool filter reads the planned sets, and a null set filters nothing', () => {
+ const planned = buildPlace({ id: 1 })
+ const loose = buildPlace({ id: 2, route_geometry: '[[1,2],[3,4]]' })
+ const ids = new Set([1])
+ expect(matchesPoolFilter(planned, 'all', { plannedIds: ids })).toBe(true)
+ expect(matchesPoolFilter(planned, 'unplanned', { plannedIds: ids })).toBe(false)
+ expect(matchesPoolFilter(loose, 'unplanned', { plannedIds: ids })).toBe(true)
+ expect(matchesPoolFilter(planned, 'planned', { plannedIds: ids })).toBe(true)
+ expect(matchesPoolFilter(loose, 'planned', { plannedIds: ids })).toBe(false)
+ expect(matchesPoolFilter(planned, 'tracks', { plannedIds: ids })).toBe(false)
+ expect(matchesPoolFilter(loose, 'tracks', { plannedIds: ids })).toBe(true)
+ expect(matchesPoolFilter(planned, 'unplanned', { plannedIds: null })).toBe(true)
+ expect(matchesPoolFilter(loose, 'planned', { plannedIds: null })).toBe(true)
+ })
+
+ it('FE-UTIL-PLFILTER-005: "planned" can be narrowed to the open day while "unplanned" stays trip-wide', () => {
+ const elsewhere = buildPlace({ id: 1 })
+ const sets = { plannedIds: new Set([1, 2]), plannedFilterIds: new Set([2]) }
+ expect(matchesPoolFilter(elsewhere, 'planned', sets)).toBe(false)
+ expect(matchesPoolFilter(elsewhere, 'unplanned', sets)).toBe(false)
+ })
+
+ it('FE-UTIL-PLFILTER-006: the combined matcher needs pool, category and rating to agree', () => {
+ const place = buildPlace({ id: 1, category_id: 4, rating_avg: 4.5 })
+ const sets = { plannedIds: new Set() }
+ const state = { filter: 'unplanned', categoryFilters: new Set(['4']), ratingFilter: 4 as const }
+ expect(matchesPlacesFilter(place, state, sets)).toBe(true)
+ expect(matchesPlacesFilter(place, { ...state, ratingFilter: 5 }, sets)).toBe(false)
+ expect(matchesPlacesFilter(place, { ...state, categoryFilters: new Set(['9']) }, sets)).toBe(false)
+ expect(matchesPlacesFilter(place, state, { plannedIds: new Set([1]) })).toBe(false)
+ })
+
+ it('FE-UTIL-PLFILTER-007: counts each filter that narrows, the category set once', () => {
+ expect(countActivePlacesFilters({ filter: 'all', categoryFilters: NONE, ratingFilter: 'all' })).toBe(0)
+ expect(countActivePlacesFilters({ filter: 'planned', categoryFilters: NONE, ratingFilter: 'all' })).toBe(1)
+ expect(countActivePlacesFilters({ filter: 'all', categoryFilters: new Set(['1', '2']), ratingFilter: 'all' })).toBe(1)
+ expect(countActivePlacesFilters({ filter: 'tracks', categoryFilters: new Set(['1']), ratingFilter: 3 })).toBe(3)
+ })
+})
diff --git a/client/src/utils/placesFilter.ts b/client/src/utils/placesFilter.ts
new file mode 100644
index 000000000..99196ea14
--- /dev/null
+++ b/client/src/utils/placesFilter.ts
@@ -0,0 +1,69 @@
+import type { Place } from '../types'
+
+/**
+ * The trip's places filter, in one place: the pool (all / unplanned / planned /
+ * tracks), the category set and the minimum rating. The desktop list, the phone
+ * list and the map markers of both shells all filter through these helpers, on
+ * the values held in the trip store, so the list and the map can never disagree
+ * on which places are in view (#1541).
+ */
+
+/** A minimum of average stars: 'all', or a floor that unrated places fall through. */
+export type PlacesRatingFloor = number | 'all'
+
+/** The floors offered by every rating picker, the same as the collections bar (#1435). */
+export const RATING_FLOORS = ['all', 5, 4, 3, 2, 1] as const
+
+/** The pseudo category id that stands for "no category". */
+export const UNCATEGORIZED = 'uncategorized'
+
+export function matchesCategoryFilter(place: Place, categoryFilters: ReadonlySet): boolean {
+ if (categoryFilters.size === 0) return true
+ if (place.category_id == null) return categoryFilters.has(UNCATEGORIZED)
+ return categoryFilters.has(String(place.category_id))
+}
+
+export function matchesRatingFloor(place: Place, floor: PlacesRatingFloor): boolean {
+ if (floor === 'all') return true
+ return place.rating_avg != null && place.rating_avg >= floor
+}
+
+export interface PoolFilterSets {
+ /** Every place planned anywhere in the trip: what 'unplanned' leaves out. */
+ plannedIds: ReadonlySet | null
+ /**
+ * What 'planned' keeps. Defaults to `plannedIds`; the planner narrows it to the
+ * open day (#2024) while 'unplanned' stays trip-wide.
+ */
+ plannedFilterIds?: ReadonlySet | null
+}
+
+/** A null set means "not needed for this filter" and filters nothing out. */
+export function matchesPoolFilter(place: Place, filter: string, { plannedIds, plannedFilterIds = plannedIds }: PoolFilterSets): boolean {
+ if (filter === 'unplanned') return !plannedIds?.has(place.id)
+ if (filter === 'planned') return !plannedFilterIds || plannedFilterIds.has(place.id)
+ if (filter === 'tracks') return !!place.route_geometry
+ return true
+}
+
+export interface PlacesFilterState {
+ filter: string
+ categoryFilters: ReadonlySet
+ ratingFilter: PlacesRatingFloor
+}
+
+/** Pool, categories and rating together — the whole filter the store holds. */
+export function matchesPlacesFilter(place: Place, state: PlacesFilterState, sets: PoolFilterSets): boolean {
+ return matchesPoolFilter(place, state.filter, sets)
+ && matchesCategoryFilter(place, state.categoryFilters)
+ && matchesRatingFloor(place, state.ratingFilter)
+}
+
+/**
+ * How many of the three filters are narrowing the places (0 to 3): the pool, the
+ * categories (however many are ticked) and the rating floor. Drives the badge on
+ * the phone's filter buttons.
+ */
+export function countActivePlacesFilters({ filter, categoryFilters, ratingFilter }: PlacesFilterState): number {
+ return (filter !== 'all' ? 1 : 0) + (categoryFilters.size > 0 ? 1 : 0) + (ratingFilter !== 'all' ? 1 : 0)
+}
diff --git a/client/tests/unit/tripStore.test.ts b/client/tests/unit/tripStore.test.ts
index 00bf35b9a..5945c4766 100644
--- a/client/tests/unit/tripStore.test.ts
+++ b/client/tests/unit/tripStore.test.ts
@@ -354,6 +354,32 @@ describe('tripStore', () => {
});
});
+ describe('places filters', () => {
+ it('FE-TRIP-PF-001: the rating floor starts at "all" and is shared through the store', () => {
+ expect(useTripStore.getState().placesRatingFilter).toBe('all');
+ useTripStore.getState().setPlacesRatingFilter(4);
+ expect(useTripStore.getState().placesRatingFilter).toBe(4);
+ });
+
+ it('FE-TRIP-PF-002: resetPlacesFilters lifts the pool, the categories and the rating at once', () => {
+ const s = useTripStore.getState();
+ s.setPlacesFilter('unplanned');
+ s.setPlacesCategoryFilter(new Set(['3', 'uncategorized']));
+ s.setPlacesRatingFilter(5);
+ useTripStore.getState().resetPlacesFilters();
+ const after = useTripStore.getState();
+ expect(after.placesFilter).toBe('all');
+ expect(after.placesCategoryFilter.size).toBe(0);
+ expect(after.placesRatingFilter).toBe('all');
+ });
+
+ it('FE-TRIP-PF-003: resetTrip drops the rating floor of the previous trip', () => {
+ useTripStore.getState().setPlacesRatingFilter(3);
+ useTripStore.getState().resetTrip();
+ expect(useTripStore.getState().placesRatingFilter).toBe('all');
+ });
+ });
+
describe('addTag', () => {
it('FE-TRIP-010: addTag creates tag and appends to tags', async () => {
const existingTag = buildTag();