Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- 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.
128 lines
3.9 KiB
TypeScript
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)
|
|
}
|