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();