All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 2m37s
- Implementar UTabs (Contactos, Aplicaciones, Perfil) en app.vue - Crear componentes ContactsList, ContactsFilters, ContactItem - Agregar server routes para obtener contactos via Metabase API - Sistema de aliases por usuario guardados en archivos JSON - Filtros: nombre (fuzzy search), ID, teléfono, empleado - Click en contacto abre WhatsApp - Estilo glassmorphism consistente con la app
120 lines
3.0 KiB
TypeScript
120 lines
3.0 KiB
TypeScript
/**
|
|
* Composable para búsqueda fuzzy de texto
|
|
* Normaliza acentos y es tolerante a typos básicos
|
|
*/
|
|
export const useFuzzySearch = () => {
|
|
/**
|
|
* Normaliza un texto para comparación:
|
|
* - Convierte a minúsculas
|
|
* - Elimina acentos/diacríticos
|
|
* - Elimina espacios extra
|
|
*/
|
|
const normalize = (text: string): string => {
|
|
return text
|
|
.toLowerCase()
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.trim()
|
|
}
|
|
|
|
/**
|
|
* Verifica si el texto contiene la query (fuzzy)
|
|
* Retorna true si hay match
|
|
*/
|
|
const fuzzyMatch = (text: string, query: string): boolean => {
|
|
if (!query) return true
|
|
if (!text) return false
|
|
|
|
const normalizedText = normalize(text)
|
|
const normalizedQuery = normalize(query)
|
|
|
|
// Match exacto (substring)
|
|
if (normalizedText.includes(normalizedQuery)) return true
|
|
|
|
// Match por palabras - cada palabra de la query debe coincidir con alguna palabra del texto
|
|
const textWords = normalizedText.split(/\s+/)
|
|
const queryWords = normalizedQuery.split(/\s+/)
|
|
|
|
return queryWords.every(queryWord =>
|
|
textWords.some(textWord =>
|
|
textWord.startsWith(queryWord) || textWord.includes(queryWord)
|
|
)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Calcula un score de relevancia (0-100)
|
|
* Mayor score = mejor match
|
|
*/
|
|
const fuzzyScore = (text: string, query: string): number => {
|
|
if (!query) return 100
|
|
if (!text) return 0
|
|
|
|
const normalizedText = normalize(text)
|
|
const normalizedQuery = normalize(query)
|
|
|
|
// Match exacto completo
|
|
if (normalizedText === normalizedQuery) return 100
|
|
|
|
// Match al inicio
|
|
if (normalizedText.startsWith(normalizedQuery)) return 90
|
|
|
|
// Match exacto (substring)
|
|
if (normalizedText.includes(normalizedQuery)) {
|
|
const position = normalizedText.indexOf(normalizedQuery)
|
|
return 80 - (position / normalizedText.length) * 20
|
|
}
|
|
|
|
// Match por palabras
|
|
const textWords = normalizedText.split(/\s+/)
|
|
const queryWords = normalizedQuery.split(/\s+/)
|
|
|
|
let matchedWords = 0
|
|
let startMatches = 0
|
|
|
|
queryWords.forEach(queryWord => {
|
|
const match = textWords.find(textWord =>
|
|
textWord.startsWith(queryWord) || textWord.includes(queryWord)
|
|
)
|
|
if (match) {
|
|
matchedWords++
|
|
if (match.startsWith(queryWord)) startMatches++
|
|
}
|
|
})
|
|
|
|
if (matchedWords === 0) return 0
|
|
|
|
const matchRatio = matchedWords / queryWords.length
|
|
const startBonus = startMatches / queryWords.length * 10
|
|
|
|
return Math.round(matchRatio * 60 + startBonus)
|
|
}
|
|
|
|
/**
|
|
* Filtra y ordena una lista por relevancia de búsqueda
|
|
*/
|
|
const filterAndSort = <T>(
|
|
items: T[],
|
|
query: string,
|
|
getText: (item: T) => string
|
|
): T[] => {
|
|
if (!query) return items
|
|
|
|
return items
|
|
.map(item => ({
|
|
item,
|
|
score: fuzzyScore(getText(item), query)
|
|
}))
|
|
.filter(({ score }) => score > 0)
|
|
.sort((a, b) => b.score - a.score)
|
|
.map(({ item }) => item)
|
|
}
|
|
|
|
return {
|
|
normalize,
|
|
fuzzyMatch,
|
|
fuzzyScore,
|
|
filterAndSort
|
|
}
|
|
}
|