mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
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.
This commit is contained in:
@@ -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 (
|
||||
<div
|
||||
|
||||
@@ -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';
|
||||
@@ -245,6 +245,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'
|
||||
@@ -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<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) }, [])
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string>): 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<string>
|
||||
/** Minimum average stars; omitted means every rating. */
|
||||
ratingFilter?: PlacesRatingFloor
|
||||
search: string
|
||||
plannedIds: Set<number>
|
||||
}
|
||||
|
||||
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. */
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<Set<number> | 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
|
||||
|
||||
@@ -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<string>
|
||||
// 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<string>) => 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<void>
|
||||
@@ -97,12 +104,15 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
|
||||
selectedDayId: null,
|
||||
placesFilter: 'all',
|
||||
placesCategoryFilter: new Set<string>(),
|
||||
placesRatingFilter: 'all',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
setSelectedDay: (dayId: number | null) => set({ selectedDayId: dayId }),
|
||||
setPlacesFilter: (filter: string) => set({ placesFilter: filter }),
|
||||
setPlacesCategoryFilter: (categoryIds: Set<string>) => set({ placesCategoryFilter: categoryIds }),
|
||||
setPlacesRatingFilter: (floor: PlacesRatingFloor) => set({ placesRatingFilter: floor }),
|
||||
resetPlacesFilters: () => set({ placesFilter: 'all', placesCategoryFilter: new Set<string>(), placesRatingFilter: 'all' }),
|
||||
|
||||
handleRemoteEvent: (event: WebSocketEvent) => handleRemoteEvent(set, get, event),
|
||||
|
||||
@@ -123,6 +133,7 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
|
||||
selectedDayId: null,
|
||||
placesFilter: 'all',
|
||||
placesCategoryFilter: new Set<string>(),
|
||||
placesRatingFilter: 'all',
|
||||
error: null,
|
||||
}),
|
||||
|
||||
|
||||
@@ -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<string>()
|
||||
|
||||
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<number>() }
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<string>): 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<number> | null
|
||||
/**
|
||||
* What 'planned' keeps. Defaults to `plannedIds`; the planner narrows it to the
|
||||
* open day (#2024) while 'unplanned' stays trip-wide.
|
||||
*/
|
||||
plannedFilterIds?: ReadonlySet<number> | 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<string>
|
||||
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)
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user