ultisuite-client/lib/contacts/contact-match-score.ts
R3D347HR4Y 07d57f13a8
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
Add Contact Avatar Features and Improve UI Components
- Introduced new ContactAvatar and ContactAvatarPicker components for enhanced avatar management in contact views.
- Updated ContactDetailView and ContactFormView to utilize the new avatar components, improving user experience when adding or editing contacts.
- Enhanced ContactHoverCard and ContactRow components to display avatars, providing a more visually appealing interface.
- Added loading and error states in ContactsListView for better user feedback during data fetching.
- Implemented a new ContactsLoadState component to handle loading and error scenarios in the contacts list.
- Updated package.json to include @formkit/auto-animate for improved UI animations.
2026-06-06 20:26:51 +02:00

128 lines
3.9 KiB
TypeScript

import type { ApiContact } from "@/lib/api/types"
import type { FullContact } from "./types"
import { fullContactDisplayName } from "./types"
export function normalizeContactSearchText(value: string): string {
return value
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.trim()
}
function queryTokens(query: string): string[] {
return normalizeContactSearchText(query).split(/\s+/).filter(Boolean)
}
/** Score 0-1. Strict substring match only (no fuzzy). Higher = closer match. */
export function fieldMatchScore(haystack: string, needle: string): number {
const h = normalizeContactSearchText(haystack)
const n = normalizeContactSearchText(needle)
if (!n || !h.includes(n)) return 0
if (h === n) return 1
if (h.startsWith(n)) {
return 0.95 + 0.05 * (n.length / h.length)
}
for (const word of h.split(/[\s@._+-]+/)) {
if (!word) continue
if (word.startsWith(n)) {
return 0.88 + 0.07 * (n.length / word.length)
}
}
const idx = h.indexOf(n)
const positionBonus = 1 - (idx / Math.max(h.length, 1)) * 0.35
const lengthBonus = n.length / Math.max(h.length, 1)
return 0.42 + 0.28 * positionBonus + 0.22 * lengthBonus
}
function scoreAgainstFields(fields: string[], query: string): number {
const tokens = queryTokens(query)
if (tokens.length === 0) return 0
let total = 0
for (const token of tokens) {
let best = 0
for (const field of fields) {
if (!field) continue
best = Math.max(best, fieldMatchScore(field, token))
best = Math.max(best, fieldMatchScore(field, query))
}
if (best === 0) return 0
total += best
}
return total / tokens.length
}
function fullContactSearchFields(contact: FullContact): string[] {
const fields = [
fullContactDisplayName(contact),
contact.firstName,
contact.lastName,
contact.middleName,
contact.company,
contact.department,
contact.jobTitle,
contact.website,
contact.notes,
...(contact.nicknames ?? []),
...contact.emails.map((e) => e.value),
...contact.phones.map((p) => p.value),
...(contact.addresses ?? []).flatMap((a) => [
a.street,
a.city,
a.region,
a.postalCode,
a.country,
]),
]
return fields.filter((value): value is string => Boolean(value?.trim()))
}
function apiContactSearchFields(contact: ApiContact): string[] {
return [contact.full_name, contact.email, contact.phone, contact.org].filter(
(value): value is string => Boolean(value?.trim())
)
}
export function scoreFullContact(contact: FullContact, query: string): number {
return scoreAgainstFields(fullContactSearchFields(contact), query)
}
export function scoreApiContact(contact: ApiContact, query: string): number {
return scoreAgainstFields(apiContactSearchFields(contact), query)
}
export function rankFullContacts(contacts: FullContact[], query: string): FullContact[] {
const trimmed = query.trim()
if (!trimmed) return contacts
return contacts
.map((contact) => ({ contact, score: scoreFullContact(contact, trimmed) }))
.filter((entry) => entry.score > 0)
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score
const nameA = fullContactDisplayName(a.contact) || a.contact.emails[0]?.value || ""
const nameB = fullContactDisplayName(b.contact) || b.contact.emails[0]?.value || ""
return nameA.localeCompare(nameB, "fr")
})
.map((entry) => entry.contact)
}
export function rankApiContacts(contacts: ApiContact[], query: string): ApiContact[] {
const trimmed = query.trim()
if (!trimmed) return contacts
return contacts
.map((contact) => ({ contact, score: scoreApiContact(contact, trimmed) }))
.filter((entry) => entry.score > 0)
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score
return a.contact.full_name.localeCompare(b.contact.full_name, "fr")
})
.map((entry) => entry.contact)
}