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:
Till JS 2026-03-30 15:15:11 +02:00
parent ad82a83f20
commit 451ab0338f
3 changed files with 425 additions and 0 deletions

View file

@ -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;

View file

@ -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);
});
});

View 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;
});
}