mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 07:39:39 +02:00
feat(contacts): add NL quick-input and live duplicate detection
Add quick-input bar to NewContactModal that parses natural language contact info (name, company, email, phone, tags) and pre-fills form fields on Enter. Add live duplicate detection that checks name/email against IndexedDB while typing, showing warnings for fuzzy name matches (Levenshtein) and exact email matches. Both features run offline. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ad82a83f20
commit
451ab0338f
3 changed files with 425 additions and 0 deletions
|
|
@ -5,6 +5,9 @@
|
|||
import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte';
|
||||
import SocialMediaFields from './forms/SocialMediaFields.svelte';
|
||||
import DateFields from './forms/DateFields.svelte';
|
||||
import { parseContactInput, formatParsedContactPreview } from '$lib/utils/contact-parser';
|
||||
import { findDuplicates, type DuplicateMatch } from '$lib/utils/duplicate-detector';
|
||||
import { contactCollection } from '$lib/data/local-store';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
|
|
@ -16,6 +19,7 @@
|
|||
let saving = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let firstNameInput: HTMLInputElement;
|
||||
let quickInputRef: HTMLInputElement;
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
// Photo state
|
||||
|
|
@ -55,6 +59,86 @@
|
|||
let discord = $state('');
|
||||
let bluesky = $state('');
|
||||
|
||||
// ─── Quick Input (NL Parser) ───────────────────────────
|
||||
let quickInput = $state('');
|
||||
let quickPreview = $state('');
|
||||
let quickApplied = $state(false);
|
||||
|
||||
function handleQuickInput(e: Event) {
|
||||
const text = (e.target as HTMLInputElement).value;
|
||||
quickInput = text;
|
||||
quickApplied = false;
|
||||
|
||||
if (!text.trim()) {
|
||||
quickPreview = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseContactInput(text);
|
||||
quickPreview = formatParsedContactPreview(parsed);
|
||||
}
|
||||
|
||||
function applyQuickInput() {
|
||||
if (!quickInput.trim() || quickApplied) return;
|
||||
|
||||
const parsed = parseContactInput(quickInput);
|
||||
|
||||
if (parsed.firstName) firstName = parsed.firstName;
|
||||
if (parsed.lastName) lastName = parsed.lastName;
|
||||
if (parsed.email) email = parsed.email;
|
||||
if (parsed.phone) phone = parsed.phone;
|
||||
if (parsed.company) company = parsed.company;
|
||||
|
||||
quickApplied = true;
|
||||
quickInput = '';
|
||||
quickPreview = '';
|
||||
}
|
||||
|
||||
function handleQuickKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
applyQuickInput();
|
||||
// Move focus to first name field
|
||||
firstNameInput?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Live Duplicate Detection ──────────────────────────
|
||||
let duplicates = $state<DuplicateMatch[]>([]);
|
||||
let dupDebounce: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
$effect(() => {
|
||||
// Watch for changes in name or email fields
|
||||
const fn = firstName;
|
||||
const ln = lastName;
|
||||
const em = email;
|
||||
|
||||
clearTimeout(dupDebounce);
|
||||
if (fn || ln || em) {
|
||||
dupDebounce = setTimeout(() => checkDuplicates(fn, ln, em), 300);
|
||||
} else {
|
||||
duplicates = [];
|
||||
}
|
||||
});
|
||||
|
||||
async function checkDuplicates(fn: string, ln: string, em: string) {
|
||||
try {
|
||||
const allContacts = await contactCollection.getAll();
|
||||
duplicates = findDuplicates(
|
||||
{ firstName: fn, lastName: ln, email: em },
|
||||
allContacts.map((c) => ({
|
||||
id: c.id,
|
||||
firstName: c.firstName,
|
||||
lastName: c.lastName,
|
||||
email: c.email,
|
||||
company: c.company,
|
||||
}))
|
||||
);
|
||||
} catch {
|
||||
duplicates = [];
|
||||
}
|
||||
}
|
||||
|
||||
const initials = $derived(() => {
|
||||
const f = firstName?.[0] || '';
|
||||
const l = lastName?.[0] || '';
|
||||
|
|
@ -240,6 +324,47 @@
|
|||
|
||||
<!-- Modal Body -->
|
||||
<div class="modal-body">
|
||||
<!-- Quick Input Bar -->
|
||||
<div class="quick-input-section">
|
||||
<input
|
||||
type="text"
|
||||
class="quick-input"
|
||||
bind:this={quickInputRef}
|
||||
value={quickInput}
|
||||
oninput={handleQuickInput}
|
||||
onkeydown={handleQuickKeydown}
|
||||
placeholder="Schnelleingabe: Max Müller @Firma max@mail.de +49... #tag"
|
||||
/>
|
||||
{#if quickPreview}
|
||||
<div class="quick-preview">{quickPreview}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Duplicate Warning -->
|
||||
{#if duplicates.length > 0}
|
||||
<div class="duplicate-warning" role="alert">
|
||||
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="duplicate-info">
|
||||
<span class="duplicate-label">Mögliches Duplikat:</span>
|
||||
{#each duplicates.slice(0, 3) as dup}
|
||||
<span class="duplicate-name">
|
||||
{dup.displayName}
|
||||
<span class="duplicate-field"
|
||||
>({dup.matchField === 'email' ? 'E-Mail' : 'Name'})</span
|
||||
>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="error-banner" role="alert">
|
||||
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
@ -657,6 +782,78 @@
|
|||
padding: 0 1.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.quick-input-section {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.quick-input {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1px dashed hsl(var(--color-border));
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(var(--color-muted) / 0.3);
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.8125rem;
|
||||
outline: none;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.quick-input:focus {
|
||||
border-style: solid;
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
background: hsl(var(--color-background));
|
||||
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.1);
|
||||
}
|
||||
|
||||
.quick-input::placeholder {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.quick-preview {
|
||||
margin-top: 0.25rem;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.duplicate-warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(35 100% 95%);
|
||||
color: hsl(25 80% 40%);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
:global(.dark) .duplicate-warning {
|
||||
background: hsl(35 60% 15%);
|
||||
color: hsl(35 90% 75%);
|
||||
}
|
||||
|
||||
.duplicate-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.duplicate-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.duplicate-name {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.duplicate-field {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { findDuplicates } from './duplicate-detector';
|
||||
|
||||
const contacts = [
|
||||
{ id: '1', firstName: 'Max', lastName: 'Mustermann', email: 'max@example.com', company: 'ACME' },
|
||||
{ id: '2', firstName: 'Anna', lastName: 'Schmidt', email: 'anna@google.com', company: 'Google' },
|
||||
{ id: '3', firstName: 'Peter', lastName: 'Müller', email: 'peter@mail.de', company: undefined },
|
||||
{ id: '4', firstName: 'Max', lastName: 'Meier', email: 'meier@test.de', company: 'Test GmbH' },
|
||||
];
|
||||
|
||||
describe('findDuplicates', () => {
|
||||
it('should find exact email match', () => {
|
||||
const result = findDuplicates({ email: 'max@example.com' }, contacts);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('1');
|
||||
expect(result[0].matchField).toBe('email');
|
||||
});
|
||||
|
||||
it('should find email match case-insensitively', () => {
|
||||
const result = findDuplicates({ email: 'MAX@EXAMPLE.COM' }, contacts);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('1');
|
||||
});
|
||||
|
||||
it('should find name match (both first + last)', () => {
|
||||
const result = findDuplicates({ firstName: 'Max', lastName: 'Mustermann' }, contacts);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('1');
|
||||
expect(result[0].matchField).toBe('name');
|
||||
});
|
||||
|
||||
it('should find fuzzy name match (typo)', () => {
|
||||
const result = findDuplicates({ firstName: 'Max', lastName: 'Musterman' }, contacts);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('1');
|
||||
});
|
||||
|
||||
it('should not match on first name only when too short', () => {
|
||||
const result = findDuplicates({ firstName: 'M' }, contacts);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should find partial first name match with existing last name', () => {
|
||||
const result = findDuplicates({ firstName: 'Anna' }, contacts);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('2');
|
||||
});
|
||||
|
||||
it('should return no duplicates for unique contact', () => {
|
||||
const result = findDuplicates(
|
||||
{ firstName: 'Lena', lastName: 'Weber', email: 'lena@new.de' },
|
||||
contacts
|
||||
);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should exclude contact by ID (edit mode)', () => {
|
||||
const result = findDuplicates({ email: 'max@example.com' }, contacts, '1');
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should prioritize email matches over name matches', () => {
|
||||
const result = findDuplicates(
|
||||
{ firstName: 'Max', lastName: 'Mustermann', email: 'anna@google.com' },
|
||||
contacts
|
||||
);
|
||||
// Email match (Anna) should come first, name match (Max) second
|
||||
expect(result.length).toBeGreaterThanOrEqual(2);
|
||||
expect(result[0].matchField).toBe('email');
|
||||
expect(result[0].id).toBe('2');
|
||||
});
|
||||
|
||||
it('should show company in display name', () => {
|
||||
const result = findDuplicates({ email: 'max@example.com' }, contacts);
|
||||
expect(result[0].displayName).toContain('ACME');
|
||||
});
|
||||
|
||||
it('should handle empty input gracefully', () => {
|
||||
const result = findDuplicates({}, contacts);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
146
apps/contacts/apps/web/src/lib/utils/duplicate-detector.ts
Normal file
146
apps/contacts/apps/web/src/lib/utils/duplicate-detector.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
/**
|
||||
* Live Duplicate Detector for Contacts
|
||||
*
|
||||
* Checks typed name/email against existing contacts in IndexedDB.
|
||||
* Uses fuzzy name matching (Levenshtein) and exact email matching.
|
||||
* Runs fully offline — no server calls needed.
|
||||
*/
|
||||
|
||||
export interface DuplicateMatch {
|
||||
id: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
email?: string;
|
||||
company?: string;
|
||||
matchField: 'name' | 'email';
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
interface ContactRecord {
|
||||
id: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
email?: string;
|
||||
company?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Levenshtein distance between two strings (case-insensitive).
|
||||
*/
|
||||
function levenshtein(a: string, b: string): number {
|
||||
const la = a.toLowerCase();
|
||||
const lb = b.toLowerCase();
|
||||
const m = la.length;
|
||||
const n = lb.length;
|
||||
|
||||
if (m === 0) return n;
|
||||
if (n === 0) return m;
|
||||
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
|
||||
|
||||
for (let i = 0; i <= m; i++) dp[i][0] = i;
|
||||
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
||||
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
const cost = la[i - 1] === lb[j - 1] ? 0 : 1;
|
||||
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
|
||||
}
|
||||
}
|
||||
|
||||
return dp[m][n];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two names are similar (max 2 edits for names > 4 chars, 1 for shorter).
|
||||
*/
|
||||
function namesSimilar(a: string, b: string): boolean {
|
||||
if (!a || !b) return false;
|
||||
const al = a.toLowerCase().trim();
|
||||
const bl = b.toLowerCase().trim();
|
||||
if (al === bl) return true;
|
||||
// Starts-with match (for typing in progress)
|
||||
if (al.length >= 3 && (bl.startsWith(al) || al.startsWith(bl))) return true;
|
||||
const maxDist = Math.max(al.length, bl.length) > 4 ? 2 : 1;
|
||||
return levenshtein(al, bl) <= maxDist;
|
||||
}
|
||||
|
||||
function buildDisplayName(contact: ContactRecord): string {
|
||||
const name = [contact.firstName, contact.lastName].filter(Boolean).join(' ');
|
||||
if (name && contact.company) return `${name} (${contact.company})`;
|
||||
return name || contact.email || 'Unbekannt';
|
||||
}
|
||||
|
||||
/**
|
||||
* Find potential duplicates for a contact being created/edited.
|
||||
*
|
||||
* @param input - The contact data being entered (partial)
|
||||
* @param existingContacts - All contacts in IndexedDB
|
||||
* @param excludeId - Exclude this contact (for edit mode)
|
||||
* @returns Array of matching contacts, sorted by relevance
|
||||
*/
|
||||
export function findDuplicates(
|
||||
input: { firstName?: string; lastName?: string; email?: string },
|
||||
existingContacts: ContactRecord[],
|
||||
excludeId?: string
|
||||
): DuplicateMatch[] {
|
||||
const matches: DuplicateMatch[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const contact of existingContacts) {
|
||||
if (contact.id === excludeId) continue;
|
||||
|
||||
// Exact email match (strongest signal)
|
||||
if (input.email && contact.email && input.email.toLowerCase() === contact.email.toLowerCase()) {
|
||||
if (!seen.has(contact.id)) {
|
||||
matches.push({
|
||||
...contact,
|
||||
matchField: 'email',
|
||||
displayName: buildDisplayName(contact),
|
||||
});
|
||||
seen.add(contact.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fuzzy name match (both first + last must match)
|
||||
const hasFirst = input.firstName && input.firstName.length >= 2;
|
||||
const hasLast = input.lastName && input.lastName.length >= 2;
|
||||
|
||||
if (hasFirst && hasLast) {
|
||||
// Both names provided — both must be similar
|
||||
if (
|
||||
namesSimilar(input.firstName!, contact.firstName || '') &&
|
||||
namesSimilar(input.lastName!, contact.lastName || '')
|
||||
) {
|
||||
if (!seen.has(contact.id)) {
|
||||
matches.push({
|
||||
...contact,
|
||||
matchField: 'name',
|
||||
displayName: buildDisplayName(contact),
|
||||
});
|
||||
seen.add(contact.id);
|
||||
}
|
||||
}
|
||||
} else if (hasFirst && !hasLast) {
|
||||
// Only first name — exact match on first + any existing last name
|
||||
if (namesSimilar(input.firstName!, contact.firstName || '') && contact.lastName) {
|
||||
if (!seen.has(contact.id)) {
|
||||
matches.push({
|
||||
...contact,
|
||||
matchField: 'name',
|
||||
displayName: buildDisplayName(contact),
|
||||
});
|
||||
seen.add(contact.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Email matches first, then name matches
|
||||
return matches.sort((a, b) => {
|
||||
if (a.matchField === 'email' && b.matchField !== 'email') return -1;
|
||||
if (a.matchField !== 'email' && b.matchField === 'email') return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue