From 451ab0338fcce94585d977ac7a521173d91e11ee Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 30 Mar 2026 15:15:11 +0200 Subject: [PATCH] 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) --- .../src/lib/components/NewContactModal.svelte | 197 ++++++++++++++++++ .../src/lib/utils/duplicate-detector.test.ts | 82 ++++++++ .../web/src/lib/utils/duplicate-detector.ts | 146 +++++++++++++ 3 files changed, 425 insertions(+) create mode 100644 apps/contacts/apps/web/src/lib/utils/duplicate-detector.test.ts create mode 100644 apps/contacts/apps/web/src/lib/utils/duplicate-detector.ts diff --git a/apps/contacts/apps/web/src/lib/components/NewContactModal.svelte b/apps/contacts/apps/web/src/lib/components/NewContactModal.svelte index 989c9d312..d50bdb4d4 100644 --- a/apps/contacts/apps/web/src/lib/components/NewContactModal.svelte +++ b/apps/contacts/apps/web/src/lib/components/NewContactModal.svelte @@ -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(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([]); + let dupDebounce: ReturnType | 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 @@