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
+70
View File
@@ -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)
})
})
+71
View File
@@ -0,0 +1,71 @@
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 apply this same predicate to the same
* values, held in the trip store (#1541). The map keeps two deliberate
* differences of its own: with a day open, "planned" shows that day's plan only
* (#2024), and the stops of collapsed days are left off the map (except under
* "planned").
*/
/** 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)
}