feat(manacore/web): add unified QuickInputBar with context-aware adapters

Implement adapter pattern for the QuickInputBar that automatically
switches behavior based on the current route:
- /todo: NL task parser (priority, dates, labels, subtasks)
- /calendar: NL event parser (time, duration, recurrence, location)
- /contacts: NL contact parser (company, email, phone, tags)
- /times: search alarms and world clocks
- /planta: NL plant parser (species, care actions)
- fallback: cross-app search via SearchRegistry

Architecture: InputBarAdapter interface, lazy-loaded adapters per
module, route-reactive resolution in layout. Contact parser ported
from standalone contacts app.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 16:24:17 +02:00
parent 475ed87a41
commit 3bd717bc93
9 changed files with 576 additions and 0 deletions

View file

@ -0,0 +1,76 @@
/**
* Calendar QuickInputBar Adapter
*/
import type { InputBarAdapter } from '$lib/quick-input/types';
import type { QuickInputItem } from '@manacore/shared-ui';
import { db } from '$lib/data/database';
import { parseEventInput, resolveEventIds, formatParsedEventPreview } from './utils/event-parser';
import { toCalendar, toCalendarEvent } from './queries';
import type { LocalCalendar, LocalEvent } from './types';
import { format, isSameDay } from 'date-fns';
import { de } from 'date-fns/locale';
export function createAdapter(): InputBarAdapter {
return {
placeholder: 'Neuer Termin oder suchen...',
appIcon: 'calendar',
deferSearch: true,
createText: 'Erstellen',
emptyText: 'Keine Termine gefunden',
async onSearch(query) {
const q = query.toLowerCase();
const events = await db.table<LocalEvent>('events').toArray();
return events
.filter((e) => !e.deletedAt && e.title?.toLowerCase().includes(q))
.sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime())
.slice(0, 10)
.map((e) => ({
id: e.id,
title: e.title || '',
subtitle: e.startDate
? format(new Date(e.startDate), 'dd. MMM yyyy, HH:mm', { locale: de })
: '',
}));
},
onSelect(item: QuickInputItem) {
// Could open event detail modal — for now just navigate
window.dispatchEvent(new CustomEvent('calendar:open-event', { detail: { id: item.id } }));
},
onParseCreate(query) {
if (!query.trim()) return null;
const parsed = parseEventInput(query);
const preview = formatParsedEventPreview(parsed);
return {
title: `"${parsed.title}" erstellen`,
subtitle: preview || 'Neuer Termin',
};
},
async onCreate(query) {
if (!query.trim()) return;
const parsed = parseEventInput(query);
const calendars = (await db.table<LocalCalendar>('calendars').toArray())
.filter((c) => !c.deletedAt)
.map(toCalendar);
const tags = await db.table('tags').toArray();
const defaultCal = calendars.find((c) => c.isDefault) || calendars[0];
const resolved = resolveEventIds(parsed, calendars, tags, defaultCal?.id);
const { eventsStore } = await import('./stores/events.svelte');
await eventsStore.createEvent({
calendarId: resolved.calendarId || defaultCal?.id || '',
title: resolved.title,
description: null,
startTime: resolved.startTime || new Date().toISOString(),
endTime: resolved.endTime || new Date(Date.now() + 3600000).toISOString(),
isAllDay: resolved.isAllDay || false,
location: resolved.location || null,
recurrenceRule: resolved.recurrenceRule || null,
});
},
};
}

View file

@ -0,0 +1,69 @@
/**
* Contacts QuickInputBar Adapter
*/
import type { InputBarAdapter } from '$lib/quick-input/types';
import type { QuickInputItem } from '@manacore/shared-ui';
import { db } from '$lib/data/database';
import { parseContactInput, formatParsedContactPreview } from './utils/contact-parser';
import type { LocalContact } from './types';
import { contactModalStore } from './stores/modal.svelte';
export function createAdapter(): InputBarAdapter {
return {
placeholder: 'Neuer Kontakt oder suchen...',
appIcon: 'contacts',
deferSearch: true,
createText: 'Erstellen',
emptyText: 'Keine Kontakte gefunden',
async onSearch(query) {
const q = query.toLowerCase();
const contacts = await db.table<LocalContact>('contacts').toArray();
return contacts
.filter(
(c) =>
!c.deletedAt &&
!c.isArchived &&
(c.firstName?.toLowerCase().includes(q) ||
c.lastName?.toLowerCase().includes(q) ||
c.email?.toLowerCase().includes(q) ||
c.company?.toLowerCase().includes(q))
)
.slice(0, 10)
.map((c) => ({
id: c.id,
title: [c.firstName, c.lastName].filter(Boolean).join(' ') || c.email || '',
subtitle: c.company || c.email || '',
}));
},
onSelect(item: QuickInputItem) {
// Navigate to contact or open detail
window.location.hash = `contact=${item.id}`;
},
onParseCreate(query) {
if (!query.trim()) return null;
const parsed = parseContactInput(query);
const preview = formatParsedContactPreview(parsed);
return {
title: parsed.displayName ? `"${parsed.displayName}" erstellen` : 'Kontakt erstellen',
subtitle: preview || 'Neuer Kontakt',
};
},
async onCreate(query) {
if (!query.trim()) return;
const parsed = parseContactInput(query);
// Open the contact modal with prefilled data
contactModalStore.open({
firstName: parsed.firstName,
lastName: parsed.lastName,
email: parsed.email,
phone: parsed.phone,
company: parsed.company,
});
},
};
}

View file

@ -0,0 +1,144 @@
/**
* Contact Parser Natural language contact input parsing.
*
* Ported from apps/contacts/apps/web/src/lib/utils/contact-parser.ts
*
* Examples:
* - "Max Mustermann @ACME Corp max@example.com #kunde"
* - "Anna Schmidt bei Google +49 123 456789"
*/
import { extractTags, extractAtReference } from '@manacore/shared-utils';
export interface ParsedContact {
displayName: string;
firstName?: string;
lastName?: string;
company?: string;
email?: string;
phone?: string;
tagNames: string[];
}
export interface ParsedContactWithIds {
displayName: string;
firstName?: string;
lastName?: string;
company?: string;
email?: string;
phone?: string;
tagIds: string[];
}
const EMAIL_PATTERN = /\b([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})\b/;
const PHONE_PATTERNS: RegExp[] = [
/\+\d{1,3}[-\s]?\d{2,4}[-\s]?\d{3,}[-\s]?\d*/,
/\b0\d{2,4}[-\s/]?\d{3,}[-\s]?\d*/,
/\b\d{6,}\b/,
];
const COMPANY_PATTERNS: RegExp[] = [
/\bbei\s+([^@#]+?)(?=\s+(?:@|#|\+|[a-zA-Z0-9._%+-]+@)|$)/i,
/\bvon\s+([^@#]+?)(?=\s+(?:@|#|\+|[a-zA-Z0-9._%+-]+@)|$)/i,
];
function extractEmail(text: string): { email?: string; remaining: string } {
const match = text.match(EMAIL_PATTERN);
if (match) {
return { email: match[1], remaining: text.replace(EMAIL_PATTERN, '').trim() };
}
return { email: undefined, remaining: text };
}
function extractPhone(text: string): { phone?: string; remaining: string } {
for (const pattern of PHONE_PATTERNS) {
const match = text.match(pattern);
if (match) {
return { phone: match[0].trim(), remaining: text.replace(pattern, '').trim() };
}
}
return { phone: undefined, remaining: text };
}
function extractCompanyPattern(text: string): { company?: string; remaining: string } {
for (const pattern of COMPANY_PATTERNS) {
const match = text.match(pattern);
if (match) {
return { company: match[1].trim(), remaining: text.replace(pattern, '').trim() };
}
}
return { company: undefined, remaining: text };
}
function parseNames(displayName: string): { firstName?: string; lastName?: string } {
const parts = displayName.trim().split(/\s+/);
if (parts.length === 0) return {};
if (parts.length === 1) return { firstName: parts[0] };
return { firstName: parts[0], lastName: parts.slice(1).join(' ') };
}
export function parseContactInput(input: string): ParsedContact {
let text = input.trim();
const tagsResult = extractTags(text);
text = tagsResult.remaining;
const tagNames = tagsResult.value || [];
const atRefResult = extractAtReference(text);
text = atRefResult.remaining;
let company = atRefResult.value;
if (!company) {
const companyPatternResult = extractCompanyPattern(text);
text = companyPatternResult.remaining;
company = companyPatternResult.company;
}
const emailResult = extractEmail(text);
text = emailResult.remaining;
const phoneResult = extractPhone(text);
text = phoneResult.remaining;
const displayName = text.replace(/\s+/g, ' ').trim();
const { firstName, lastName } = parseNames(displayName);
return {
displayName,
firstName,
lastName,
company,
email: emailResult.email,
phone: phoneResult.phone,
tagNames,
};
}
export function resolveContactIds(
parsed: ParsedContact,
tags: { id: string; name: string }[]
): ParsedContactWithIds {
const tagIds = parsed.tagNames
.map((name) => tags.find((t) => t.name.toLowerCase() === name.toLowerCase())?.id)
.filter((id): id is string => !!id);
return {
displayName: parsed.displayName,
firstName: parsed.firstName,
lastName: parsed.lastName,
company: parsed.company,
email: parsed.email,
phone: parsed.phone,
tagIds,
};
}
export function formatParsedContactPreview(parsed: ParsedContact): string {
const parts: string[] = [];
if (parsed.company) parts.push(parsed.company);
if (parsed.email) parts.push(parsed.email);
if (parsed.phone) parts.push(parsed.phone);
if (parsed.tagNames.length > 0) parts.push(parsed.tagNames.map((t) => `#${t}`).join(' '));
return parts.join(' · ');
}

View file

@ -0,0 +1,59 @@
/**
* Planta QuickInputBar Adapter
*/
import type { InputBarAdapter } from '$lib/quick-input/types';
import type { QuickInputItem } from '@manacore/shared-ui';
import { db } from '$lib/data/database';
import { parsePlantInput, formatParsedPlantPreview } from './utils/plant-parser';
import { plantTable } from './collections';
export function createAdapter(): InputBarAdapter {
return {
placeholder: 'Neue Pflanze oder suchen...',
appIcon: 'plant',
deferSearch: true,
createText: 'Hinzufügen',
emptyText: 'Keine Pflanzen gefunden',
async onSearch(query) {
const q = query.toLowerCase();
const plants = await db.table('plants').toArray();
return (plants as Record<string, unknown>[])
.filter(
(p) =>
!(p.deletedAt as string) &&
((p.name as string)?.toLowerCase().includes(q) ||
(p.species as string)?.toLowerCase().includes(q))
)
.slice(0, 10)
.map((p) => ({
id: p.id as string,
title: (p.name as string) || '',
subtitle: (p.species as string) || (p.location as string) || '',
}));
},
onSelect() {},
onParseCreate(query) {
if (!query.trim()) return null;
const parsed = parsePlantInput(query);
const preview = formatParsedPlantPreview(parsed);
return {
title: `"${parsed.name}" hinzufügen`,
subtitle: preview || 'Neue Pflanze',
};
},
async onCreate(query) {
if (!query.trim()) return;
const parsed = parsePlantInput(query);
await plantTable.add({
id: crypto.randomUUID(),
name: parsed.name,
species: parsed.species,
});
},
};
}

View file

@ -0,0 +1,57 @@
/**
* Times QuickInputBar Adapter
*
* Provides search across time entries (stopwatch sessions)
* and quick-create for new timer/alarm entries.
*/
import type { InputBarAdapter } from '$lib/quick-input/types';
import type { QuickInputItem } from '@manacore/shared-ui';
import { db } from '$lib/data/database';
import { parseEntryInput, formatParsedEntryPreview } from './utils/entry-parser';
export function createAdapter(): InputBarAdapter {
return {
placeholder: 'Suchen in Times...',
appIcon: 'clock',
deferSearch: false,
emptyText: 'Keine Einträge gefunden',
async onSearch(query) {
const q = query.toLowerCase();
// Search across alarms and world clocks
const alarms = await db.table('alarms').toArray();
const worldClocks = await db.table('worldClocks').toArray();
const results: QuickInputItem[] = [];
for (const a of alarms as Record<string, unknown>[]) {
if (!(a.deletedAt as string) && (a.label as string)?.toLowerCase().includes(q)) {
results.push({
id: a.id as string,
title: (a.label as string) || 'Alarm',
subtitle: `${(a.hour as number)?.toString().padStart(2, '0')}:${(a.minute as number)?.toString().padStart(2, '0')}`,
});
}
}
for (const wc of worldClocks as Record<string, unknown>[]) {
if (
!(wc.deletedAt as string) &&
((wc.label as string)?.toLowerCase().includes(q) ||
(wc.timezone as string)?.toLowerCase().includes(q))
) {
results.push({
id: wc.id as string,
title: (wc.label as string) || (wc.timezone as string) || '',
subtitle: 'Weltuhr',
});
}
}
return results.slice(0, 10);
},
onSelect() {},
};
}

View file

@ -0,0 +1,65 @@
/**
* Todo QuickInputBar Adapter
*/
import type { InputBarAdapter } from '$lib/quick-input/types';
import type { QuickInputItem } from '@manacore/shared-ui';
import { goto } from '$app/navigation';
import { db } from '$lib/data/database';
import { parseTaskInput, resolveTaskIds, formatParsedTaskPreview } from './utils/task-parser';
import type { LocalTask } from './types';
export function createAdapter(): InputBarAdapter {
return {
placeholder: 'Neue Aufgabe oder suchen...',
appIcon: 'todo',
deferSearch: true,
createText: 'Erstellen',
emptyText: 'Keine Aufgaben gefunden',
async onSearch(query) {
const q = query.toLowerCase();
const tasks = await db.table<LocalTask>('tasks').toArray();
return tasks
.filter(
(t) =>
!t.deletedAt &&
!t.isCompleted &&
(t.title?.toLowerCase().includes(q) || t.description?.toLowerCase().includes(q))
)
.slice(0, 10)
.map((t) => ({
id: t.id,
title: t.title || '',
subtitle: t.dueDate ? new Date(t.dueDate).toLocaleDateString('de-DE') : 'Keine Frist',
}));
},
onSelect(item: QuickInputItem) {
goto(`/todo?task=${item.id}`);
},
onParseCreate(query) {
if (!query.trim()) return null;
const parsed = parseTaskInput(query);
return {
title: `"${parsed.title}" erstellen`,
subtitle: formatParsedTaskPreview(parsed) || 'Neue Aufgabe',
};
},
async onCreate(query) {
if (!query.trim()) return;
const parsed = parseTaskInput(query);
const allTags = await db.table('tags').toArray();
const resolved = resolveTaskIds(parsed, allTags);
const { tasksStore } = await import('./stores/tasks.svelte');
await tasksStore.createTask({
title: resolved.title,
dueDate: resolved.dueDate,
priority: resolved.priority,
labelIds: resolved.labelIds,
});
},
};
}

View file

@ -0,0 +1,40 @@
/**
* Fallback Adapter Global cross-app search for non-module pages.
*
* Used on /home, /dashboard, /settings, etc. where no specific module
* is active. Delegates to the SearchRegistry to search across all apps.
*/
import { goto } from '$app/navigation';
import type { QuickInputItem } from '@manacore/shared-ui';
import type { SearchRegistry } from '$lib/search/registry';
import type { InputBarAdapter } from './types';
export function createFallbackAdapter(searchRegistry: SearchRegistry): InputBarAdapter {
return {
placeholder: 'Suchen...',
appIcon: 'search',
deferSearch: false,
emptyText: 'Keine Ergebnisse gefunden',
async onSearch(query: string): Promise<QuickInputItem[]> {
const groups = await searchRegistry.search(query, { limit: 5 });
return groups
.flatMap((g) =>
g.results.map((r) => ({
id: `${r.appId}:${r.id}`,
title: r.title,
subtitle: `${g.appName} · ${r.subtitle ?? r.type}`,
icon: g.appIcon,
_href: r.href,
}))
)
.slice(0, 10) as QuickInputItem[];
},
onSelect(item: QuickInputItem) {
const href = (item as QuickInputItem & { _href?: string })._href;
if (href) goto(href);
},
};
}

View file

@ -0,0 +1,29 @@
/**
* Adapter Registry Maps route prefixes to lazy adapter loaders.
*
* Each entry loads the module's adapter only when the user navigates
* to that module, keeping the initial bundle small.
*/
type AdapterModule = { createAdapter: (...args: unknown[]) => unknown };
const registry = new Map<string, () => Promise<AdapterModule>>([
['/todo', () => import('$lib/modules/todo/quick-input-adapter')],
['/calendar', () => import('$lib/modules/calendar/quick-input-adapter')],
['/contacts', () => import('$lib/modules/contacts/quick-input-adapter')],
['/times', () => import('$lib/modules/times/quick-input-adapter')],
['/planta', () => import('$lib/modules/planta/quick-input-adapter')],
]);
/**
* Find the adapter loader for a given pathname.
* Returns null if no module matches (fallback adapter should be used).
*/
export function getAdapterLoader(pathname: string): (() => Promise<AdapterModule>) | null {
for (const [prefix, loader] of registry) {
if (pathname === prefix || pathname.startsWith(prefix + '/')) {
return loader;
}
}
return null;
}

View file

@ -0,0 +1,37 @@
/**
* Unified QuickInputBar Adapter Type Definitions
*
* Each module implements InputBarAdapter to provide context-aware
* search, create-preview, and create behavior for the QuickInputBar.
*/
import type { QuickInputItem, CreatePreview, HighlightPattern } from '@manacore/shared-ui';
export interface InputBarAdapter {
// Required
onSearch: (query: string) => Promise<QuickInputItem[]>;
onSelect: (item: QuickInputItem) => void;
// Create (optional — modules without create don't set these)
onParseCreate?: (query: string) => CreatePreview | null;
onCreate?: (query: string) => Promise<void>;
onSearchChange?: (query: string, results: QuickInputItem[]) => void;
// Display
placeholder: string;
appIcon: string;
emptyText?: string;
createText?: string;
deferSearch?: boolean;
// Calendar-style default selector
defaultOptions?: { id: string; label: string }[];
selectedDefaultId?: string;
defaultOptionLabel?: string;
onDefaultChange?: (id: string) => void;
// Highlight patterns
highlightPatterns?: HighlightPattern[];
}
export type InputBarAdapterFactory = () => InputBarAdapter;