fix(search): place searches find a name typed without its accents (#3075)

"Obidos" did not find "Óbidos", nor "Sao Bras" "São Brás". The search of the
places list, a collection, the saved-place picker, the import from a trip
and the location picker now compare without case and Latin accents, with
letters such as ß, ø or ł folded to their plain spelling. Marks outside Latin
script stay, since there they change the word.
This commit is contained in:
Maurice
2026-11-12 23:06:36 +01:00
parent a39f3fe40c
commit 0855bafca7
7 changed files with 71 additions and 19 deletions
@@ -6,6 +6,7 @@ import { loadUnionPlaces } from '../../store/collectionUnion'
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
import type { TranslationFn } from '../../types'
import { textMatchesSearch } from '../../utils/placeSearch'
import { fs } from '../shared/DialogShell'
import { EditorField, INPUT, LABEL } from '../shared/dialogParts'
import { ColumnHead, HintCard, SIDE_COLUMN, WHITE_BUTTON } from '../Planner/placeDialogParts'
@@ -143,12 +144,10 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
const [shown, setShown] = useState(PAGE)
const visible = useMemo(() => {
const q = search.trim().toLowerCase()
const list = places.filter(p => {
if (listFilter !== 'all' && p.collection_id !== listFilter) return false
if (statusFilter !== 'all' && p.status !== statusFilter) return false
if (!q) return true
return p.name.toLowerCase().includes(q) || (p.address ?? '').toLowerCase().includes(q)
return textMatchesSearch([p.name, p.address], search)
})
if (center) list.sort((a, b) => distanceTo(a, center) - distanceTo(b, center))
else list.sort((a, b) => a.name.localeCompare(b.name))
@@ -9,6 +9,7 @@ import { getCategoryIcon } from '../shared/categoryIcons'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { formatDate } from '../../utils/formatters'
import { textMatchesSearch } from '../../utils/placeSearch'
import { getApiErrorMessage } from '../../types'
import type { Category, TranslationFn } from '../../types'
import type { CollectionImportablePlace, Trip } from '@trek/shared'
@@ -128,10 +129,9 @@ export default function ImportFromTripModal({ isOpen, collectionId, collectionNa
const savedCount = items.length - newCount
const visible = useMemo(() => {
const q = query.trim().toLowerCase()
return items
.filter(p => (onlyNew ? !p.already_in_list : true))
.filter(p => !q || p.name.toLowerCase().includes(q) || (p.address ?? '').toLowerCase().includes(q))
.filter(p => textMatchesSearch([p.name, p.address], query))
.sort((a, b) => importOrder(a) - importOrder(b) || a.name.localeCompare(b.name))
}, [items, onlyNew, query])
@@ -1,6 +1,7 @@
import { useEffect, useId, useRef, useState } from 'react'
import { MapPin, Search } from 'lucide-react'
import { mapsApi } from '../../api/client'
import { textMatchesSearch } from '../../utils/placeSearch'
import { useTranslation } from '../../i18n'
import { useLocationBias } from '../../hooks/useLocationBias'
import { usePlaceSuggestions } from '../../hooks/usePlaceSuggestions'
@@ -196,7 +197,7 @@ function SourceMark({ label }: { label: string | null }) {
function pickRows<T>(value: LocationPoint | null, typed: string, places: LocationPoint[] | undefined, results: T[]) {
const showPicks = !value && typed.length < 3 && !!places?.length
const rows: (T | LocationPoint)[] = showPicks
? (places ?? []).filter(p => !typed || p.name.toLowerCase().includes(typed.toLowerCase()))
? (places ?? []).filter(p => textMatchesSearch([p.name], typed))
: results
return { showPicks, rows }
}
@@ -53,6 +53,17 @@ const HOLE = undefined as unknown as CollectionPlace;
describe('collectionsModel', () => {
// ── filterPlaces ────────────────────────────────────────────────────────────
describe('filterPlaces', () => {
it('FE-COLLECTIONS-MODEL-045: the search finds Óbidos and São Brás typed without accents (#3075)', () => {
const places = [
cp({ id: 1, name: 'Óbidos', status: 'idea' }),
cp({ id: 2, name: 'São Brás de Alportel', status: 'idea' }),
cp({ id: 3, name: 'Lagos', status: 'idea', address: 'Rua de Évora' }),
];
expect(filterPlaces(places, 'all', 'obidos').map(p => p.id)).toEqual([1]);
expect(filterPlaces(places, 'all', 'Sao Bras').map(p => p.id)).toEqual([2]);
expect(filterPlaces(places, 'all', 'evora').map(p => p.id)).toEqual([3]);
});
it('FE-COLLECTIONS-MODEL-001: status filter keeps only the matching status', () => {
const places = [
cp({ id: 1, name: 'A', status: 'idea' }),
@@ -1,5 +1,6 @@
import type { CollectionPlace, CollectionLabel } from '@trek/shared'
import type { StatusFilter, CollectionSortMode } from '../../store/collectionStore'
import { textMatchesSearch } from '../../utils/placeSearch'
/**
* Pure data shaping + presentation metadata for the Collections page. No React
@@ -36,7 +37,6 @@ export function filterPlaces(
labelFilter: number[] = [],
ratingFilter: number | 'all' = 'all',
): CollectionPlace[] {
const q = search.trim().toLowerCase()
return places.filter(p => {
if (!p) return false
if (statusFilter !== 'all' && p.status !== statusFilter) return false
@@ -44,12 +44,7 @@ export function filterPlaces(
// Minimum-average-stars filter (#1435): unrated places drop out once a floor is set.
if (ratingFilter !== 'all' && (p.rating_avg == null || p.rating_avg < ratingFilter)) return false
if (labelFilter.length && !labelFilter.some(id => (p.label_ids ?? []).includes(id))) return false
if (!q) return true
return (
p.name.toLowerCase().includes(q) ||
(p.address ?? '').toLowerCase().includes(q) ||
(p.notes ?? '').toLowerCase().includes(q)
)
return textMatchesSearch([p.name, p.address, p.notes], search)
})
}
+23 -1
View File
@@ -1,6 +1,6 @@
// FE-UTIL-PLACESEARCH-001 to FE-UTIL-PLACESEARCH-004
import { describe, it, expect } from 'vitest'
import { placeMatchesSearch } from './placeSearch'
import { foldForSearch, placeMatchesSearch, textMatchesSearch } from './placeSearch'
const place = {
name: 'Café Central',
@@ -32,3 +32,25 @@ describe('placeMatchesSearch', () => {
expect(placeMatchesSearch(place, 'pizza')).toBe(false)
})
})
describe('search without accents (#3075)', () => {
it('FE-UTIL-PLACESEARCH-005: a query without accents finds the accented name, and the other way round', () => {
for (const [query, name] of [['Obidos', 'Óbidos'], ['Evora', 'Évora'], ['Sao Bras', 'São Brás'], ['Loule', 'Loulé']]) {
expect(placeMatchesSearch({ name, address: null, description: null, notes: null }, query)).toBe(true)
expect(placeMatchesSearch({ name: query, address: null, description: null, notes: null }, name)).toBe(true)
}
})
it('FE-UTIL-PLACESEARCH-006: letters with the mark built in fold to their plain spelling', () => {
expect(foldForSearch('Straße')).toBe('strasse')
expect(foldForSearch('Łódź')).toBe('lodz')
expect(foldForSearch('Ærø')).toBe('aero')
expect(textMatchesSearch(['Kraków'], 'krakow')).toBe(true)
})
it('FE-UTIL-PLACESEARCH-007: marks that change a word outside Latin script stay, so が is not か', () => {
expect(foldForSearch('が')).toBe('が')
expect(textMatchesSearch(['かき'], 'がき')).toBe(false)
expect(foldForSearch('한국')).toBe('한국')
})
})
+29 -5
View File
@@ -10,9 +10,33 @@ type Searchable = Pick<Place, 'name' | 'address' | 'description' | 'notes'>
* 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),
)
return textMatchesSearch([place.name, place.address, place.description, place.notes], query)
}
/** The combining accents of Latin script (U+0300 to U+036F), the marks NFD splits off a letter. */
const LATIN_MARKS = new RegExp(`[${String.fromCharCode(0x300)}-${String.fromCharCode(0x36f)}]`, 'g')
/** Letters that carry their mark in the letter itself, so taking the marks off leaves them as they are. */
const FOLDED_LETTERS: Record<string, string> = { ß: 'ss', ø: 'o', æ: 'ae', œ: 'oe', ł: 'l', đ: 'd', ð: 'd', þ: 'th', ı: 'i' }
/**
* Text as a search compares it: lower case and without accents, so "Obidos" finds
* "Óbidos", "Sao Bras" finds "São Brás" and the other way round (#3075). Only
* Latin marks go: a kana's voicing mark or a Hangul syllable stays whole, since
* there it changes the word.
*/
export function foldForSearch(text: string): string {
return text
.toLocaleLowerCase()
.normalize('NFD')
.replace(LATIN_MARKS, '')
.replace(/[ßøæœłđðþı]/g, ch => FOLDED_LETTERS[ch] ?? ch)
.normalize('NFC')
}
/** Whether any of the fields holds the query, accents and case aside. An empty query matches everything. */
export function textMatchesSearch(fields: readonly (string | null | undefined)[], query: string): boolean {
const q = foldForSearch(query.trim())
if (!q) return true
return fields.some(field => !!field && foldForSearch(field).includes(q))
}