diff --git a/client/src/components/Planner/DayPlanSidebarMobileAddPlaceButton.tsx b/client/src/components/Planner/DayPlanSidebarMobileAddPlaceButton.tsx index 96f942c22..4864d75f2 100644 --- a/client/src/components/Planner/DayPlanSidebarMobileAddPlaceButton.tsx +++ b/client/src/components/Planner/DayPlanSidebarMobileAddPlaceButton.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { Plus, X, MapPin } from 'lucide-react' import { useTranslation } from '../../i18n' import type { Place, AssignmentsMap } from '../../types' +import { placeMatchesSearch } from '../../utils/placeSearch' export function MobileAddPlaceButton({ dayId, places, assignments, onAssign, onAddNew }: { dayId: number @@ -17,9 +18,7 @@ export function MobileAddPlaceButton({ dayId, places, assignments, onAssign, onA // Find places not assigned to this day const assignedToDay = new Set((assignments[String(dayId)] || []).map(a => a.place_id)) const available = places.filter(p => !assignedToDay.has(p.id)) - const filtered = search.trim() - ? available.filter(p => p.name.toLowerCase().includes(search.toLowerCase())) - : available + const filtered = available.filter(p => placeMatchesSearch(p, search)) return (
diff --git a/client/src/components/Planner/usePlacesSidebar.ts b/client/src/components/Planner/usePlacesSidebar.ts index 9cf90d3e7..557aeedc3 100644 --- a/client/src/components/Planner/usePlacesSidebar.ts +++ b/client/src/components/Planner/usePlacesSidebar.ts @@ -16,6 +16,7 @@ import { useSaveToCollectionStore } from '../../store/saveToCollectionStore' import { placeToSaveTarget } from '../Collections/saveTarget' import type { Place, Category, Day, AssignmentsMap } from '../../types' import { getGoogleMapsUrlForPlace } from './placeGoogleMaps' +import { placeMatchesSearch } from '../../utils/placeSearch' import { safeHttpUrl } from '../../utils/safeUrl' import { plannedPlaceIds, plannedPlaceIdsForDay, type PlannedAccommodation } from '../../utils/plannedPlaces' import type { MenuEntry } from './planParts' @@ -290,8 +291,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) { if (!categoryFilters.has('uncategorized')) return false } else if (!categoryFilters.has(String(p.category_id))) return false } - if (search && !p.name.toLowerCase().includes(search.toLowerCase()) && - !(p.address || '').toLowerCase().includes(search.toLowerCase())) 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 @@ -307,16 +307,13 @@ export function usePlacesSidebar(props: PlacesSidebarProps) { * different questions. */ const filterCounts = useMemo(() => { - const needle = search.toLowerCase() 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 } - if (search && !p.name.toLowerCase().includes(needle) && - !(p.address || '').toLowerCase().includes(needle)) return false - return true + return placeMatchesSearch(p, search) }) return { all: base.length, diff --git a/client/src/mobile/screens/trip/places/placesBrowserModel.ts b/client/src/mobile/screens/trip/places/placesBrowserModel.ts index fae376d1e..dffb19097 100644 --- a/client/src/mobile/screens/trip/places/placesBrowserModel.ts +++ b/client/src/mobile/screens/trip/places/placesBrowserModel.ts @@ -1,4 +1,5 @@ import type { AssignmentsMap, Day, Place } from '../../../../types' +import { placeMatchesSearch } from '../../../../utils/placeSearch' import { plannedPlaceIds as sharedPlannedPlaceIds, type PlannedAccommodation, type PlannedReservation, @@ -44,9 +45,7 @@ export function matchesCategoryFilter(place: Place, categoryFilters: Set } export function matchesSearch(place: Place, search: string): boolean { - if (!search) return true - const q = search.toLowerCase() - return place.name.toLowerCase().includes(q) || (place.address || '').toLowerCase().includes(q) + return placeMatchesSearch(place, search) } interface PoolFilterArgs { diff --git a/client/src/utils/placeSearch.test.ts b/client/src/utils/placeSearch.test.ts new file mode 100644 index 000000000..01677361f --- /dev/null +++ b/client/src/utils/placeSearch.test.ts @@ -0,0 +1,34 @@ +// FE-UTIL-PLACESEARCH-001 to FE-UTIL-PLACESEARCH-004 +import { describe, it, expect } from 'vitest' +import { placeMatchesSearch } from './placeSearch' + +const place = { + name: 'Café Central', + address: 'Herrengasse 14, Wien', + description: 'Classic Viennese coffee house', + notes: 'Book a table ahead', +} + +describe('placeMatchesSearch', () => { + it('FE-UTIL-PLACESEARCH-001: an empty or blank query matches every place', () => { + expect(placeMatchesSearch(place, '')).toBe(true) + expect(placeMatchesSearch(place, ' ')).toBe(true) + }) + + it('FE-UTIL-PLACESEARCH-002: finds the name and the address, ignoring case', () => { + expect(placeMatchesSearch(place, 'central')).toBe(true) + expect(placeMatchesSearch(place, 'HERRENGASSE')).toBe(true) + }) + + it('FE-UTIL-PLACESEARCH-003: also finds the description and the notes (#2252)', () => { + expect(placeMatchesSearch(place, 'viennese')).toBe(true) + expect(placeMatchesSearch(place, 'table ahead')).toBe(true) + }) + + it('FE-UTIL-PLACESEARCH-004: missing fields are skipped, and a miss is a miss', () => { + const bare = { name: 'Stop', address: null, description: undefined, notes: null } + expect(placeMatchesSearch(bare, 'stop ')).toBe(true) + expect(placeMatchesSearch(bare, 'nowhere')).toBe(false) + expect(placeMatchesSearch(place, 'pizza')).toBe(false) + }) +}) diff --git a/client/src/utils/placeSearch.ts b/client/src/utils/placeSearch.ts new file mode 100644 index 000000000..caecc18ea --- /dev/null +++ b/client/src/utils/placeSearch.ts @@ -0,0 +1,18 @@ +import type { Place } from '../types' + +type Searchable = Pick + +/** + * Whether a place matches the search box of the places list (#2252). Name and + * address as before, and also the description and the notes, so a place found + * by what was written about it ("vegan", "book ahead") turns up too. One helper + * for the desktop list, its filter counts and the phone's browser, so the three + * can never disagree about what a search finds. + */ +export function placeMatchesSearch(place: Searchable, query: string): boolean { + const q = query.trim().toLowerCase() + if (!q) return true + return [place.name, place.address, place.description, place.notes].some( + field => !!field && field.toLowerCase().includes(q), + ) +}