diff --git a/apps/todo/apps/web/src/app.html b/apps/todo/apps/web/src/app.html index 076d6148e..95592e23e 100644 --- a/apps/todo/apps/web/src/app.html +++ b/apps/todo/apps/web/src/app.html @@ -15,8 +15,8 @@ - - + + diff --git a/apps/todo/apps/web/src/lib/utils/task-parser.ts b/apps/todo/apps/web/src/lib/utils/task-parser.ts new file mode 100644 index 000000000..399e08504 --- /dev/null +++ b/apps/todo/apps/web/src/lib/utils/task-parser.ts @@ -0,0 +1,260 @@ +import { + addDays, + nextMonday, + nextTuesday, + nextWednesday, + nextThursday, + nextFriday, + nextSaturday, + nextSunday, + setHours, + setMinutes, + parse, +} from 'date-fns'; +import { de } from 'date-fns/locale'; +import type { TaskPriority } from '@todo/shared'; + +export interface ParsedTask { + title: string; + dueDate?: Date; + priority?: TaskPriority; + projectName?: string; + labelNames: string[]; +} + +interface Project { + id: string; + name: string; +} + +interface Label { + id: string; + name: string; +} + +export interface ParsedTaskWithIds { + title: string; + dueDate?: string; + priority?: TaskPriority; + projectId?: string; + labelIds: string[]; +} + +// Priority patterns +const PRIORITY_PATTERNS: { pattern: RegExp; priority: TaskPriority }[] = [ + { pattern: /!{3,}|!dringend|!urgent/i, priority: 'urgent' }, + { pattern: /!{2}|!hoch|!high/i, priority: 'high' }, + { pattern: /!mittel|!medium/i, priority: 'medium' }, + { pattern: /!niedrig|!low/i, priority: 'low' }, +]; + +// Date patterns (German) +const DATE_PATTERNS: { pattern: RegExp; getDate: () => Date }[] = [ + { pattern: /\bheute\b/i, getDate: () => new Date() }, + { pattern: /\bmorgen\b/i, getDate: () => addDays(new Date(), 1) }, + { pattern: /\bübermorgen\b/i, getDate: () => addDays(new Date(), 2) }, + { pattern: /\bin\s*(\d+)\s*tage?n?\b/i, getDate: () => new Date() }, // Handled specially + { pattern: /\bnächste[nr]?\s*woche\b/i, getDate: () => addDays(new Date(), 7) }, + { pattern: /\bnächste[nr]?\s*montag\b/i, getDate: () => nextMonday(new Date()) }, + { pattern: /\bnächste[nr]?\s*dienstag\b/i, getDate: () => nextTuesday(new Date()) }, + { pattern: /\bnächste[nr]?\s*mittwoch\b/i, getDate: () => nextWednesday(new Date()) }, + { pattern: /\bnächste[nr]?\s*donnerstag\b/i, getDate: () => nextThursday(new Date()) }, + { pattern: /\bnächste[nr]?\s*freitag\b/i, getDate: () => nextFriday(new Date()) }, + { pattern: /\bnächste[nr]?\s*samstag\b/i, getDate: () => nextSaturday(new Date()) }, + { pattern: /\bnächste[nr]?\s*sonntag\b/i, getDate: () => nextSunday(new Date()) }, + { pattern: /\bmontag\b/i, getDate: () => nextMonday(new Date()) }, + { pattern: /\bdienstag\b/i, getDate: () => nextTuesday(new Date()) }, + { pattern: /\bmittwoch\b/i, getDate: () => nextWednesday(new Date()) }, + { pattern: /\bdonnerstag\b/i, getDate: () => nextThursday(new Date()) }, + { pattern: /\bfreitag\b/i, getDate: () => nextFriday(new Date()) }, + { pattern: /\bsamstag\b/i, getDate: () => nextSaturday(new Date()) }, + { pattern: /\bsonntag\b/i, getDate: () => nextSunday(new Date()) }, +]; + +// Time pattern +const TIME_PATTERN = /\b(?:um\s*)?(\d{1,2})(?::(\d{2}))?\s*(?:uhr)?\b/i; + +// Specific date pattern (DD.MM. or DD.MM.YYYY) +const SPECIFIC_DATE_PATTERN = /\b(\d{1,2})\.(\d{1,2})\.?(\d{2,4})?\b/; + +/** + * Parse natural language task input + */ +export function parseTaskInput(input: string): ParsedTask { + let text = input.trim(); + let dueDate: Date | undefined; + let priority: TaskPriority | undefined; + let projectName: string | undefined; + const labelNames: string[] = []; + + // Extract priority (!hoch, !!, etc.) + for (const { pattern, priority: p } of PRIORITY_PATTERNS) { + if (pattern.test(text)) { + priority = p; + text = text.replace(pattern, '').trim(); + break; + } + } + + // Extract project (@ProjectName) + const projectMatch = text.match(/@(\S+)/); + if (projectMatch) { + projectName = projectMatch[1]; + text = text.replace(/@\S+/, '').trim(); + } + + // Extract labels (#label1 #label2) + const labelRegex = /#(\S+)/g; + let labelMatch; + while ((labelMatch = labelRegex.exec(text)) !== null) { + labelNames.push(labelMatch[1]); + } + text = text.replace(/#\S+/g, '').trim(); + + // Extract specific date (DD.MM. or DD.MM.YYYY) + const specificDateMatch = text.match(SPECIFIC_DATE_PATTERN); + if (specificDateMatch) { + const day = parseInt(specificDateMatch[1], 10); + const month = parseInt(specificDateMatch[2], 10) - 1; + const year = specificDateMatch[3] + ? parseInt(specificDateMatch[3], 10) < 100 + ? 2000 + parseInt(specificDateMatch[3], 10) + : parseInt(specificDateMatch[3], 10) + : new Date().getFullYear(); + + dueDate = new Date(year, month, day); + text = text.replace(SPECIFIC_DATE_PATTERN, '').trim(); + } + + // Extract relative date (heute, morgen, nächsten Montag, etc.) + if (!dueDate) { + // Special handling for "in X Tagen" + const inDaysMatch = text.match(/\bin\s*(\d+)\s*tage?n?\b/i); + if (inDaysMatch) { + const days = parseInt(inDaysMatch[1], 10); + dueDate = addDays(new Date(), days); + text = text.replace(/\bin\s*\d+\s*tage?n?\b/i, '').trim(); + } else { + for (const { pattern, getDate } of DATE_PATTERNS) { + if (pattern.test(text)) { + dueDate = getDate(); + text = text.replace(pattern, '').trim(); + break; + } + } + } + } + + // Extract time (um 14 Uhr, 14:00, etc.) + const timeMatch = text.match(TIME_PATTERN); + if (timeMatch && dueDate) { + const hours = parseInt(timeMatch[1], 10); + const minutes = timeMatch[2] ? parseInt(timeMatch[2], 10) : 0; + dueDate = setHours(setMinutes(dueDate, minutes), hours); + text = text.replace(TIME_PATTERN, '').trim(); + } else if (timeMatch && !dueDate) { + // Time without date = today + dueDate = new Date(); + const hours = parseInt(timeMatch[1], 10); + const minutes = timeMatch[2] ? parseInt(timeMatch[2], 10) : 0; + dueDate = setHours(setMinutes(dueDate, minutes), hours); + text = text.replace(TIME_PATTERN, '').trim(); + } + + // Clean up multiple spaces + const title = text.replace(/\s+/g, ' ').trim(); + + return { + title, + dueDate, + priority, + projectName, + labelNames, + }; +} + +/** + * Resolve project and label names to IDs + */ +export function resolveTaskIds( + parsed: ParsedTask, + projects: Project[], + labels: Label[] +): ParsedTaskWithIds { + let projectId: string | undefined; + const labelIds: string[] = []; + + // Find project by name (case-insensitive) + if (parsed.projectName) { + const project = projects.find( + (p) => p.name.toLowerCase() === parsed.projectName!.toLowerCase() + ); + if (project) { + projectId = project.id; + } + } + + // Find labels by name (case-insensitive) + for (const labelName of parsed.labelNames) { + const label = labels.find((l) => l.name.toLowerCase() === labelName.toLowerCase()); + if (label) { + labelIds.push(label.id); + } + } + + return { + title: parsed.title, + dueDate: parsed.dueDate?.toISOString(), + priority: parsed.priority, + projectId, + labelIds, + }; +} + +/** + * Format parsed task for preview display + */ +export function formatParsedTaskPreview(parsed: ParsedTask): string { + const parts: string[] = []; + + if (parsed.dueDate) { + const now = new Date(); + const tomorrow = addDays(now, 1); + + if (parsed.dueDate.toDateString() === now.toDateString()) { + parts.push('📅 Heute'); + } else if (parsed.dueDate.toDateString() === tomorrow.toDateString()) { + parts.push('📅 Morgen'); + } else { + parts.push( + `📅 ${parsed.dueDate.toLocaleDateString('de-DE', { weekday: 'short', day: 'numeric', month: 'short' })}` + ); + } + + // Add time if not midnight + if (parsed.dueDate.getHours() !== 0 || parsed.dueDate.getMinutes() !== 0) { + parts[parts.length - 1] += + ` ${parsed.dueDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`; + } + } + + if (parsed.priority) { + const priorityLabels: Record = { + low: '🟢 Niedrig', + medium: '🟡 Mittel', + high: '🟠 Hoch', + urgent: '🔴 Dringend', + }; + parts.push(priorityLabels[parsed.priority]); + } + + if (parsed.projectName) { + parts.push(`📁 ${parsed.projectName}`); + } + + if (parsed.labelNames.length > 0) { + parts.push(`🏷️ ${parsed.labelNames.join(', ')}`); + } + + return parts.join(' · '); +} diff --git a/apps/todo/apps/web/src/routes/(app)/+layout.svelte b/apps/todo/apps/web/src/routes/(app)/+layout.svelte index 847cacb4f..5f51106a5 100644 --- a/apps/todo/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/todo/apps/web/src/routes/(app)/+layout.svelte @@ -9,11 +9,13 @@ PillDropdownItem, CommandBarItem, QuickAction, + CreatePreview, } from '@manacore/shared-ui'; import { authStore } from '$lib/stores/auth.svelte'; import { userSettings } from '$lib/stores/user-settings.svelte'; import { projectsStore } from '$lib/stores/projects.svelte'; import { labelsStore } from '$lib/stores/labels.svelte'; + import { tasksStore } from '$lib/stores/tasks.svelte'; import { theme } from '$lib/stores/theme'; import { isSidebarMode as sidebarModeStore, @@ -28,6 +30,7 @@ import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n'; import { getPillAppItems } from '@manacore/shared-branding'; import { getTasks } from '$lib/api/tasks'; + import { parseTaskInput, resolveTaskIds, formatParsedTaskPreview } from '$lib/utils/task-parser'; // App switcher items const appItems = getPillAppItems('todo'); @@ -69,6 +72,35 @@ goto(`/task/${item.id}`); } + // CommandBar create - parse input and show preview + function handleCommandBarParseCreate(query: string): CreatePreview | null { + if (!query.trim()) return null; + + const parsed = parseTaskInput(query); + const preview = formatParsedTaskPreview(parsed); + + return { + title: `"${parsed.title}" als Aufgabe erstellen`, + subtitle: preview || 'Neue Aufgabe', + }; + } + + // CommandBar create - actually create the task + async function handleCommandBarCreate(query: string): Promise { + if (!query.trim()) return; + + const parsed = parseTaskInput(query); + const resolved = resolveTaskIds(parsed, projectsStore.projects, labelsStore.labels); + + await tasksStore.createTask({ + title: resolved.title, + dueDate: resolved.dueDate, + priority: resolved.priority, + projectId: resolved.projectId, + labelIds: resolved.labelIds, + }); + } + let isSidebarMode = $state(false); let isCollapsed = $state(false); @@ -326,9 +358,13 @@ onSearch={handleCommandBarSearch} onSelect={handleCommandBarSelect} quickActions={commandBarQuickActions} - placeholder="Aufgabe suchen..." + placeholder="Aufgabe suchen oder erstellen..." emptyText="Keine Aufgaben gefunden" searchingText="Suche..." + onCreate={handleCommandBarCreate} + onParseCreate={handleCommandBarParseCreate} + createText="Als Aufgabe erstellen" + createShortcut="⌘↵" /> diff --git a/apps/todo/apps/web/static/sw.js b/apps/todo/apps/web/static/sw.js index 7992ed8ec..f2af67d36 100644 --- a/apps/todo/apps/web/static/sw.js +++ b/apps/todo/apps/web/static/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'todo-v1'; +const CACHE_NAME = 'todo-v2'; const OFFLINE_URL = '/offline.html'; // Assets, die immer gecacht werden sollen @@ -8,23 +8,16 @@ const STATIC_CACHE_URLS = ['/', '/offline.html', '/icons/icon.svg', '/manifest.j const CACHE_STRATEGIES = { // Netzwerk zuerst, dann Cache (für HTML/Navigation) networkFirst: [/\/$/, /\.html$/, /^\/kanban/, /^\/settings/, /^\/mana/, /^\/feedback/], - // Cache zuerst, dann Netzwerk (für Assets) + // Cache zuerst, dann Netzwerk (für Assets) - nur für gebaute Assets, nicht /src/ cacheFirst: [ - /\.css$/, - /\.js$/, + /\/_app\//, // SvelteKit gebaute Assets /\.woff2?$/, /\.ttf$/, /\.otf$/, - /\.svg$/, - /\.png$/, - /\.jpg$/, - /\.jpeg$/, - /\.webp$/, /\.ico$/, - /\/_app\//, ], - // Nur Netzwerk (für API-Calls) - networkOnly: [/\/api\//, /localhost:3018/], + // Nur Netzwerk (für API-Calls und Dev-Server) + networkOnly: [/\/api\//, /localhost:3018/, /^\/src\//, /^\/@/, /^\/node_modules\//], }; // Service Worker Installation diff --git a/packages/shared-ui/src/command-bar/CommandBar.svelte b/packages/shared-ui/src/command-bar/CommandBar.svelte index 71822e8a7..bef7acb19 100644 --- a/packages/shared-ui/src/command-bar/CommandBar.svelte +++ b/packages/shared-ui/src/command-bar/CommandBar.svelte @@ -19,6 +19,11 @@ onclick?: () => void; } + export interface CreatePreview { + title: string; + subtitle: string; + } + interface Props { open: boolean; onClose: () => void; @@ -28,6 +33,11 @@ placeholder?: string; emptyText?: string; searchingText?: string; + // New: Task creation support + onCreate?: (query: string) => Promise; + onParseCreate?: (query: string) => CreatePreview | null; + createText?: string; + createShortcut?: string; } let { @@ -39,21 +49,35 @@ placeholder = 'Suchen...', emptyText = 'Keine Ergebnisse gefunden', searchingText = 'Suche...', + onCreate, + onParseCreate, + createText = 'Als Eintrag erstellen', + createShortcut = '⌘↵', }: Props = $props(); let searchQuery = $state(''); let results = $state([]); let loading = $state(false); + let creating = $state(false); let selectedIndex = $state(0); let searchTimeout: ReturnType; let inputElement: HTMLInputElement; + // Computed create preview + let createPreview = $derived( + searchQuery.trim() && onParseCreate ? onParseCreate(searchQuery) : null + ); + + // Check if create option is selected (it's always first when available) + let isCreateSelected = $derived(selectedIndex === 0 && createPreview !== null); + // Reset state when modal opens $effect(() => { if (open) { searchQuery = ''; results = []; selectedIndex = 0; + creating = false; setTimeout(() => inputElement?.focus(), 50); } }); @@ -82,6 +106,20 @@ }, 150); } + async function handleCreate() { + if (!onCreate || !searchQuery.trim() || creating) return; + + creating = true; + try { + await onCreate(searchQuery); + onClose(); + } catch (error) { + console.error('Create error:', error); + } finally { + creating = false; + } + } + function handleKeydown(event: KeyboardEvent) { if (event.key === 'Escape') { event.preventDefault(); @@ -89,10 +127,23 @@ return; } + // Cmd/Ctrl+Enter to create directly + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + if (onCreate && searchQuery.trim()) { + handleCreate(); + } + return; + } + if (event.key === 'ArrowDown') { event.preventDefault(); - const maxIndex = searchQuery.trim() ? results.length - 1 : quickActions.length - 1; - selectedIndex = Math.min(selectedIndex + 1, maxIndex); + // Calculate max index including create option + const hasCreate = createPreview !== null; + const maxIndex = searchQuery.trim() + ? (hasCreate ? 1 : 0) + results.length - 1 + : quickActions.length - 1; + selectedIndex = Math.min(selectedIndex + 1, Math.max(0, maxIndex)); return; } @@ -104,8 +155,17 @@ if (event.key === 'Enter') { event.preventDefault(); - if (searchQuery.trim() && results.length > 0) { - selectItem(results[selectedIndex]); + if (searchQuery.trim()) { + // If create option is selected + if (isCreateSelected && onCreate) { + handleCreate(); + } else if (results.length > 0) { + // Adjust index for results (subtract 1 if create option exists) + const resultIndex = createPreview !== null ? selectedIndex - 1 : selectedIndex; + if (resultIndex >= 0 && resultIndex < results.length) { + selectItem(results[resultIndex]); + } + } } else if (!searchQuery.trim() && quickActions.length > 0) { const action = quickActions[selectedIndex]; if (action.href) { @@ -184,23 +244,63 @@ {#if searchQuery.trim()}
+ + {#if createPreview && onCreate} + + {/if} + {#if loading}
{searchingText}
- {:else if results.length === 0} + {:else if results.length === 0 && !createPreview}
{emptyText}
- {:else} + {:else if results.length > 0} +
+ Suchergebnisse +
{#each results as item, index (item.id)} + {@const adjustedIndex = createPreview ? index + 1 : index}