From f06c98709a643c1b2d2d91b060be7c52141e1da5 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sun, 29 Mar 2026 19:59:02 +0200 Subject: [PATCH] feat(todo): add duration extraction, multi-task splitting, and time estimation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the natural language parser with duration recognition (30min, 2h, 1.5 Stunden), multi-task splitting on keywords (danach, dann, ;) with context inheritance (date/time/project), and a history-based time estimator that suggests durations from similar completed tasks. QuickAdd now shows a live parse preview and duration suggestion. All features run offline against IndexedDB — no AI/API calls needed. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/todo/CLAUDE.md | 20 ++ .../src/lib/components/QuickAddTask.svelte | 175 +++++++++++++- .../web/src/lib/utils/task-parser.test.ts | 141 ++++++++++- .../apps/web/src/lib/utils/task-parser.ts | 173 +++++++++++++ .../web/src/lib/utils/time-estimator.test.ts | 135 +++++++++++ .../apps/web/src/lib/utils/time-estimator.ts | 228 ++++++++++++++++++ 6 files changed, 861 insertions(+), 11 deletions(-) create mode 100644 apps/todo/apps/web/src/lib/utils/time-estimator.test.ts create mode 100644 apps/todo/apps/web/src/lib/utils/time-estimator.ts diff --git a/apps/todo/CLAUDE.md b/apps/todo/CLAUDE.md index d5865ae2f..f166f55b3 100644 --- a/apps/todo/CLAUDE.md +++ b/apps/todo/CLAUDE.md @@ -234,6 +234,26 @@ Recognized patterns: - **Project**: @Projektname - **Labels**: #label1 #label2 - **Recurrence**: jeden Tag, wöchentlich, monatlich +- **Duration**: 30min, 2h, 1.5 Stunden (maps to `estimatedDuration`) + +### Multi-Task Input + +Split multiple tasks with keywords (`danach`, `dann`, `und dann`, `anschließend`, `außerdem`) or semicolons: + +``` +"Morgen um 10 Zahnarzt 1h, danach Einkaufen" +→ Task 1: Zahnarzt (morgen 10:00, 1h) +→ Task 2: Einkaufen (morgen 11:00, auto-offset) + +"Meeting 14 Uhr 1h @Arbeit; Report schreiben; Mails" +→ 3 tasks, all inherit date + project from first task +``` + +Context inheritance: subsequent tasks inherit date, time, and project from the first task. If the first task has a duration, the next task's time is offset accordingly. + +### Time Estimation + +QuickAdd suggests a duration based on completed task history (weighted by project, labels, title similarity, priority). The suggestion appears after 500ms typing pause and can be accepted with one click. Runs fully offline against IndexedDB — no AI/API calls. ## Code Style Guidelines diff --git a/apps/todo/apps/web/src/lib/components/QuickAddTask.svelte b/apps/todo/apps/web/src/lib/components/QuickAddTask.svelte index ff463cea3..5e28857f1 100644 --- a/apps/todo/apps/web/src/lib/components/QuickAddTask.svelte +++ b/apps/todo/apps/web/src/lib/components/QuickAddTask.svelte @@ -5,6 +5,15 @@ import { viewStore } from '$lib/stores/view.svelte'; import type { Project } from '@todo/shared'; import { getActiveProjects, getProjectById } from '$lib/data/task-queries'; + import { + parseMultiTaskInput, + resolveTaskIds, + formatParsedTaskPreview, + formatDuration, + } from '$lib/utils/task-parser'; + import { estimateDuration, type CompletedTaskData } from '$lib/utils/time-estimator'; + import { taskCollection } from '$lib/data/local-store'; + import { labelCollection } from '$lib/data/local-store'; const projectsCtx: { readonly value: Project[] } = getContext('projects'); import type { TaskPriority } from '@todo/shared'; @@ -16,7 +25,7 @@ let isLoading = $state(false); let inputRef: HTMLInputElement; - // Task options + // Task options (used as fallback when parser doesn't extract these) let selectedDate = $state(new Date()); let selectedPriority = $state('medium'); let selectedProjectId = $state(undefined); @@ -26,6 +35,12 @@ let showPriorityPicker = $state(false); let showProjectPicker = $state(false); + // Parser preview + let parsePreview = $state(''); + let parsedTaskCount = $state(0); + let durationEstimate = $state<{ minutes: number; confidence: string } | null>(null); + let estimateDebounce: ReturnType | undefined; + // Quick date options const dateOptions = [ { label: 'Heute', date: new Date() }, @@ -46,6 +61,66 @@ return format(selectedDate, 'dd. MMM', { locale: de }); }); + // Update parse preview on input change + $effect(() => { + const text = inputValue.trim(); + if (!text) { + parsePreview = ''; + parsedTaskCount = 0; + durationEstimate = null; + return; + } + + const tasks = parseMultiTaskInput(text); + parsedTaskCount = tasks.length; + + // Build preview from first task + if (tasks.length > 0) { + const previews = tasks.map((t) => formatParsedTaskPreview(t)).filter(Boolean); + if (tasks.length > 1) { + previews.unshift(`${tasks.length} Aufgaben`); + } + parsePreview = previews.join(' · '); + } + + // Debounced duration estimation + clearTimeout(estimateDebounce); + if (tasks.length === 1 && !tasks[0].estimatedDuration) { + estimateDebounce = setTimeout(() => runEstimate(tasks[0]), 500); + } else { + durationEstimate = null; + } + }); + + async function runEstimate(parsed: ReturnType[0]) { + try { + const allTasks = await taskCollection.getAll(); + const completed: CompletedTaskData[] = allTasks + .filter((t) => t.isCompleted && t.completedAt) + .map((t) => ({ + title: t.title, + projectId: t.projectId, + priority: t.priority, + estimatedDuration: t.estimatedDuration, + completedAt: t.completedAt, + createdAt: t.createdAt, + })); + + const estimate = estimateDuration( + { + title: parsed.title, + projectId: selectedProjectId, + priority: selectedPriority, + }, + completed + ); + + durationEstimate = estimate; + } catch { + durationEstimate = null; + } + } + onMount(() => { inputRef?.focus(); @@ -58,21 +133,42 @@ async function handleSubmit(event: Event) { event.preventDefault(); - const title = inputValue.trim(); - if (!title || isLoading) return; + const text = inputValue.trim(); + if (!text || isLoading) return; isLoading = true; try { - await tasksStore.createTask({ - title, - projectId: selectedProjectId, - dueDate: selectedDate.toISOString(), - priority: selectedPriority, - }); + const projects = projectsCtx.value.map((p) => ({ id: p.id, name: p.name })); + const allLabels = await labelCollection.getAll(); + const labels = allLabels.map((l) => ({ id: l.id, name: l.name })); + + const parsedTasks = parseMultiTaskInput(text); + + for (const parsed of parsedTasks) { + const resolved = resolveTaskIds(parsed, projects, labels); + + await tasksStore.createTask({ + title: resolved.title, + projectId: resolved.projectId ?? selectedProjectId, + dueDate: resolved.dueDate ?? selectedDate.toISOString(), + priority: resolved.priority ?? selectedPriority, + labelIds: resolved.labelIds.length > 0 ? resolved.labelIds : undefined, + recurrenceRule: resolved.recurrenceRule, + subtasks: resolved.subtasks?.map((s, i) => ({ + id: crypto.randomUUID(), + title: s, + isCompleted: false, + order: i, + })), + }); + } // Reset form inputValue = ''; + parsePreview = ''; + parsedTaskCount = 0; + durationEstimate = null; selectedDate = new Date(); selectedPriority = 'medium'; if (viewStore.currentView !== 'project') { @@ -82,13 +178,19 @@ console.error('Failed to create task:', error); } finally { isLoading = false; - // Focus after isLoading is reset (input is no longer disabled) requestAnimationFrame(() => { inputRef?.focus(); }); } } + function applyEstimate() { + if (durationEstimate) { + inputValue = `${inputValue.trim()} ${durationEstimate.minutes}min`; + durationEstimate = null; + } + } + function handleKeydown(event: KeyboardEvent) { if (event.key === 'Escape') { inputValue = ''; @@ -142,6 +244,21 @@
+ + {#if parsePreview || durationEstimate} +
+ {#if parsePreview} + {parsePreview} + {/if} + {#if durationEstimate} + + {/if} +
+ {/if} +
@@ -330,6 +447,44 @@ margin-bottom: 1.5rem; } + .parse-preview { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 1rem; + margin-bottom: 0.25rem; + font-size: 0.75rem; + color: var(--color-muted-foreground, #6b7280); + flex-wrap: wrap; + } + + .preview-text { + opacity: 0.8; + } + + .estimate-btn { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.125rem 0.5rem; + border: 1px dashed rgba(139, 92, 246, 0.4); + background: rgba(139, 92, 246, 0.08); + color: #8b5cf6; + border-radius: 9999px; + font-size: 0.7rem; + cursor: pointer; + transition: all 0.15s; + } + + .estimate-btn:hover { + background: rgba(139, 92, 246, 0.15); + border-color: rgba(139, 92, 246, 0.6); + } + + .estimate-icon { + font-weight: 600; + } + /* Mobile: Fixed at bottom */ @media (max-width: 768px) { .quick-add-form { diff --git a/apps/todo/apps/web/src/lib/utils/task-parser.test.ts b/apps/todo/apps/web/src/lib/utils/task-parser.test.ts index 9c4064793..4c6caa357 100644 --- a/apps/todo/apps/web/src/lib/utils/task-parser.test.ts +++ b/apps/todo/apps/web/src/lib/utils/task-parser.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from 'vitest'; -import { parseTaskInput, resolveTaskIds, formatParsedTaskPreview } from './task-parser'; +import { + parseTaskInput, + resolveTaskIds, + formatParsedTaskPreview, + parseMultiTaskInput, + formatDuration, +} from './task-parser'; describe('parseTaskInput', () => { it('should parse a simple title', () => { @@ -199,4 +205,137 @@ describe('formatParsedTaskPreview', () => { const preview = formatParsedTaskPreview(parsed); expect(preview).toContain(' · '); }); + + it('should format duration in preview', () => { + const parsed = parseTaskInput('Meeting 30min'); + const preview = formatParsedTaskPreview(parsed); + expect(preview).toContain('30min'); + }); +}); + +describe('duration extraction', () => { + it('should parse "30min"', () => { + const result = parseTaskInput('Meeting 30min'); + expect(result.estimatedDuration).toBe(30); + expect(result.title).toBe('Meeting'); + }); + + it('should parse "2h"', () => { + const result = parseTaskInput('Workshop 2h'); + expect(result.estimatedDuration).toBe(120); + expect(result.title).toBe('Workshop'); + }); + + it('should parse "1.5 Stunden"', () => { + const result = parseTaskInput('Recherche 1.5 Stunden'); + expect(result.estimatedDuration).toBe(90); + expect(result.title).not.toContain('Stunden'); + }); + + it('should parse "45 Minuten"', () => { + const result = parseTaskInput('Joggen 45 Minuten'); + expect(result.estimatedDuration).toBe(45); + }); + + it('should parse "1,5h" with comma decimal', () => { + const result = parseTaskInput('Coding 1,5h'); + expect(result.estimatedDuration).toBe(90); + }); + + it('should not extract duration when not present', () => { + const result = parseTaskInput('Einkaufen gehen'); + expect(result.estimatedDuration).toBeUndefined(); + }); + + it('should work with other fields', () => { + const result = parseTaskInput('Meeting 2h morgen !! @Arbeit'); + expect(result.estimatedDuration).toBe(120); + expect(result.priority).toBe('high'); + expect(result.projectName).toBe('Arbeit'); + }); +}); + +describe('parseMultiTaskInput', () => { + it('should return single task for simple input', () => { + const tasks = parseMultiTaskInput('Einkaufen gehen'); + expect(tasks).toHaveLength(1); + expect(tasks[0].title).toBe('Einkaufen gehen'); + }); + + it('should split on "danach"', () => { + const tasks = parseMultiTaskInput('Zahnarzt danach Einkaufen'); + expect(tasks).toHaveLength(2); + expect(tasks[0].title).toBe('Zahnarzt'); + expect(tasks[1].title).toBe('Einkaufen'); + }); + + it('should split on "dann"', () => { + const tasks = parseMultiTaskInput('Kochen dann Abwaschen'); + expect(tasks).toHaveLength(2); + expect(tasks[0].title).toBe('Kochen'); + expect(tasks[1].title).toBe('Abwaschen'); + }); + + it('should split on semicolon', () => { + const tasks = parseMultiTaskInput('Mails; Report; Meeting'); + expect(tasks).toHaveLength(3); + expect(tasks[0].title).toBe('Mails'); + expect(tasks[1].title).toBe('Report'); + expect(tasks[2].title).toBe('Meeting'); + }); + + it('should inherit date context from first task', () => { + const tasks = parseMultiTaskInput('Morgen Zahnarzt danach Einkaufen'); + expect(tasks).toHaveLength(2); + expect(tasks[0].dueDate).toBeDefined(); + expect(tasks[1].dueDate).toBeDefined(); + // Both should be tomorrow + expect(tasks[0].dueDate!.toDateString()).toBe(tasks[1].dueDate!.toDateString()); + }); + + it('should inherit project context from first task', () => { + const tasks = parseMultiTaskInput('Meeting @Arbeit danach Report schreiben'); + expect(tasks).toHaveLength(2); + expect(tasks[0].projectName).toBe('Arbeit'); + expect(tasks[1].projectName).toBe('Arbeit'); + }); + + it('should offset time when first task has duration', () => { + const tasks = parseMultiTaskInput('Meeting 14 Uhr 1h danach Notizen'); + expect(tasks).toHaveLength(2); + expect(tasks[0].dueDate).toBeDefined(); + expect(tasks[1].dueDate).toBeDefined(); + // Second task should start at 15:00 + expect(tasks[1].dueDate!.getHours()).toBe(15); + expect(tasks[1].dueDate!.getMinutes()).toBe(0); + }); + + it('should not split on "dann" inside a word', () => { + const tasks = parseMultiTaskInput('Dokumentation lesen'); + expect(tasks).toHaveLength(1); + }); + + it('should handle ", danach" pattern', () => { + const tasks = parseMultiTaskInput('Zahnarzt, danach Apotheke'); + expect(tasks).toHaveLength(2); + expect(tasks[0].title).toBe('Zahnarzt'); + expect(tasks[1].title).toBe('Apotheke'); + }); +}); + +describe('formatDuration', () => { + it('should format minutes only', () => { + expect(formatDuration(30)).toBe('30min'); + expect(formatDuration(5)).toBe('5min'); + }); + + it('should format full hours', () => { + expect(formatDuration(60)).toBe('1h'); + expect(formatDuration(120)).toBe('2h'); + }); + + it('should format hours and minutes', () => { + expect(formatDuration(90)).toBe('1h 30min'); + expect(formatDuration(75)).toBe('1h 15min'); + }); }); diff --git a/apps/todo/apps/web/src/lib/utils/task-parser.ts b/apps/todo/apps/web/src/lib/utils/task-parser.ts index 40e5f2f45..ab71e9bae 100644 --- a/apps/todo/apps/web/src/lib/utils/task-parser.ts +++ b/apps/todo/apps/web/src/lib/utils/task-parser.ts @@ -20,11 +20,13 @@ import type { TaskPriority } from '@todo/shared'; export interface ParsedTask { title: string; dueDate?: Date; + dueTime?: string; // HH:mm format priority?: TaskPriority; projectName?: string; labelNames: string[]; recurrenceRule?: string; subtasks?: string[]; + estimatedDuration?: number; // in minutes } interface Project { @@ -40,11 +42,13 @@ interface Label { export interface ParsedTaskWithIds { title: string; dueDate?: string; + dueTime?: string; priority?: TaskPriority; projectId?: string; labelIds: string[]; recurrenceRule?: string; subtasks?: string[]; + estimatedDuration?: number; } // Priority keyword translations per locale @@ -59,6 +63,147 @@ const PRIORITY_KEYWORDS: Record< it: { urgent: 'urgente', high: 'importante', medium: 'normale', low: 'dopo' }, }; +// ─── Duration Extraction ─────────────────────────────────── + +const DURATION_PATTERNS: Record = { + de: [ + /(\d+(?:[.,]\d+)?)\s*(?:std|stunden?)\b/i, + /(\d+(?:[.,]\d+)?)\s*h\b/i, + /(\d+)\s*min(?:uten?)?\b/i, + /(\d+(?:[.,]\d+)?)\s*(?:tage?)\b/i, + ], + en: [ + /(\d+(?:[.,]\d+)?)\s*(?:hours?|hrs?)\b/i, + /(\d+(?:[.,]\d+)?)\s*h\b/i, + /(\d+)\s*min(?:utes?)?\b/i, + /(\d+(?:[.,]\d+)?)\s*(?:days?)\b/i, + ], + fr: [ + /(\d+(?:[.,]\d+)?)\s*(?:heures?|hrs?)\b/i, + /(\d+(?:[.,]\d+)?)\s*h\b/i, + /(\d+)\s*min(?:utes?)?\b/i, + /(\d+(?:[.,]\d+)?)\s*(?:jours?)\b/i, + ], + es: [ + /(\d+(?:[.,]\d+)?)\s*(?:horas?|hrs?)\b/i, + /(\d+(?:[.,]\d+)?)\s*h\b/i, + /(\d+)\s*min(?:utos?)?\b/i, + /(\d+(?:[.,]\d+)?)\s*(?:días?)\b/i, + ], + it: [ + /(\d+(?:[.,]\d+)?)\s*(?:ore?)\b/i, + /(\d+(?:[.,]\d+)?)\s*h\b/i, + /(\d+)\s*min(?:uti?)?\b/i, + /(\d+(?:[.,]\d+)?)\s*(?:giorni?)\b/i, + ], +}; + +// Multiplier: [hours, hours, minutes, days] +const DURATION_MULTIPLIERS = [60, 60, 1, 480]; + +/** + * Extract duration from text (e.g. "30min", "2h", "1.5 Stunden") + * Returns duration in minutes. + */ +function extractDuration( + text: string, + locale: ParserLocale = 'de' +): { duration?: number; remaining: string } { + const patterns = DURATION_PATTERNS[locale]; + for (let i = 0; i < patterns.length; i++) { + const match = text.match(patterns[i]); + if (match) { + const value = parseFloat(match[1].replace(',', '.')); + const minutes = Math.round(value * DURATION_MULTIPLIERS[i]); + if (minutes > 0) { + return { + duration: minutes, + remaining: text + .replace(match[0], '') + .replace(/\s{2,}/g, ' ') + .trim(), + }; + } + } + } + return { remaining: text }; +} + +// ─── Multi-Task Splitting ────────────────────────────────── + +const TASK_SPLITTERS = + /\s*(?:,\s*(?:danach|dann|und dann|anschließend|außerdem|afterwards|then|and then|also)\s+|;\s*|\s+(?:danach|dann|und dann|anschließend|afterwards|then|and then)\s+)/i; + +/** + * Parse input that may contain multiple tasks separated by keywords. + * Subsequent tasks inherit date/time context from the first task. + * + * Examples: + * - "Morgen um 10 Zahnarzt, danach Einkaufen" → 2 tasks, both morgen + * - "Meeting 14 Uhr 1h; Report schreiben; Mails beantworten" → 3 tasks + */ +export function parseMultiTaskInput(input: string, locale: ParserLocale = 'de'): ParsedTask[] { + const parts = input.split(TASK_SPLITTERS).filter((s) => s.trim().length > 0); + + if (parts.length <= 1) { + return [parseTaskInput(input, locale)]; + } + + const results: ParsedTask[] = []; + let contextDate: Date | undefined; + let contextTime: string | undefined; + let contextProject: string | undefined; + let lastEndMinutes: number | undefined; // track end time for "danach" offset + + for (let i = 0; i < parts.length; i++) { + const parsed = parseTaskInput(parts[i].trim(), locale); + + if (i === 0) { + // First task sets the context + contextDate = parsed.dueDate; + contextTime = parsed.dueTime; + contextProject = parsed.projectName; + + // Calculate end time if duration is known + if (parsed.dueDate && parsed.estimatedDuration) { + lastEndMinutes = + parsed.dueDate.getHours() * 60 + parsed.dueDate.getMinutes() + parsed.estimatedDuration; + } else if (parsed.dueDate) { + lastEndMinutes = parsed.dueDate.getHours() * 60 + parsed.dueDate.getMinutes(); + } + } else { + // Inherit context if not explicitly set + if (!parsed.dueDate && contextDate) { + // If we have a lastEndMinutes from previous task, use that as start time + if (lastEndMinutes !== undefined && lastEndMinutes > 0) { + const inherited = new Date(contextDate); + inherited.setHours(Math.floor(lastEndMinutes / 60), lastEndMinutes % 60, 0, 0); + parsed.dueDate = inherited; + parsed.dueTime = `${String(Math.floor(lastEndMinutes / 60)).padStart(2, '0')}:${String(lastEndMinutes % 60).padStart(2, '0')}`; + } else { + parsed.dueDate = contextDate; + parsed.dueTime = contextTime; + } + } + if (!parsed.projectName && contextProject) { + parsed.projectName = contextProject; + } + + // Update end time for next task + if (parsed.dueDate && parsed.estimatedDuration) { + lastEndMinutes = + parsed.dueDate.getHours() * 60 + parsed.dueDate.getMinutes() + parsed.estimatedDuration; + } else if (parsed.dueDate) { + lastEndMinutes = undefined; // no duration → can't offset further + } + } + + results.push(parsed); + } + + return results; +} + /** * Extract subtasks from "Title: item1, item2, item3" pattern */ @@ -139,6 +284,11 @@ export function parseTaskInput(input: string, locale: ParserLocale = 'de'): Pars text = priorityResult.remaining; const priority = priorityResult.priority; + // Extract duration (before date parsing to avoid "2h" being confused) + const durationResult = extractDuration(text, locale); + text = durationResult.remaining; + const estimatedDuration = durationResult.duration; + // Extract project (@ProjectName) - task-specific const projectResult = extractAtReference(text); text = projectResult.remaining; @@ -150,17 +300,24 @@ export function parseTaskInput(input: string, locale: ParserLocale = 'de'): Pars // Combine date and time const dueDate = combineDateAndTime(base.date, base.time); + // Preserve time as HH:mm string for context inheritance + const dueTime = base.time + ? `${String(base.time.hours).padStart(2, '0')}:${String(base.time.minutes).padStart(2, '0')}` + : undefined; + // Check for subtask pattern "Title: item1, item2, item3" const subtaskResult = extractSubtasks(base.title); return { title: subtaskResult.title, dueDate, + dueTime, priority, projectName, labelNames: base.tagNames, recurrenceRule, subtasks: subtaskResult.subtasks, + estimatedDuration, }; } @@ -196,11 +353,13 @@ export function resolveTaskIds( return { title: parsed.title, dueDate: parsed.dueDate?.toISOString(), + dueTime: parsed.dueTime, priority: parsed.priority, projectId, labelIds, recurrenceRule: parsed.recurrenceRule, subtasks: parsed.subtasks, + estimatedDuration: parsed.estimatedDuration, }; } @@ -213,6 +372,16 @@ const PRIORITY_LABELS: Record> = { it: { low: '🟢 Dopo', medium: '🟡 Normale', high: '🟠 Importante', urgent: '🔴 Urgente' }, }; +/** + * Format duration in minutes to human-readable string + */ +export function formatDuration(minutes: number): string { + if (minutes < 60) return `${minutes}min`; + const h = Math.floor(minutes / 60); + const m = minutes % 60; + return m > 0 ? `${h}h ${m}min` : `${h}h`; +} + /** * Format parsed task for preview display */ @@ -245,6 +414,10 @@ export function formatParsedTaskPreview(parsed: ParsedTask, locale: ParserLocale parts.push(`🔄 ${parsed.recurrenceRule}`); } + if (parsed.estimatedDuration) { + parts.push(`⏱️ ${formatDuration(parsed.estimatedDuration)}`); + } + if (parsed.subtasks && parsed.subtasks.length > 0) { parts.push(`📋 ${parsed.subtasks.length} Subtasks`); } diff --git a/apps/todo/apps/web/src/lib/utils/time-estimator.test.ts b/apps/todo/apps/web/src/lib/utils/time-estimator.test.ts new file mode 100644 index 000000000..5ba827701 --- /dev/null +++ b/apps/todo/apps/web/src/lib/utils/time-estimator.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from 'vitest'; +import { estimateDuration, type CompletedTaskData } from './time-estimator'; + +function makeTask(overrides: Partial = {}): CompletedTaskData { + return { + title: 'Default task', + projectId: null, + labelIds: [], + priority: 'medium', + estimatedDuration: 30, + completedAt: '2026-03-28T12:00:00Z', + createdAt: '2026-03-28T11:30:00Z', + ...overrides, + }; +} + +describe('estimateDuration', () => { + it('should return null with insufficient data', () => { + const result = estimateDuration( + { title: 'New task', priority: 'medium' }, + [makeTask(), makeTask()] // only 2, need 3 + ); + expect(result).toBeNull(); + }); + + it('should estimate from similar tasks in same project', () => { + const history = Array.from({ length: 5 }, () => + makeTask({ projectId: 'proj-1', estimatedDuration: 60 }) + ); + + const result = estimateDuration( + { title: 'Something', projectId: 'proj-1', priority: 'medium' }, + history + ); + + expect(result).not.toBeNull(); + expect(result!.minutes).toBe(60); + expect(result!.sampleSize).toBe(5); + }); + + it('should weight title overlap higher', () => { + const history = [ + // 3 "Einkaufen" tasks at 45min + makeTask({ title: 'Einkaufen Rewe', estimatedDuration: 45 }), + makeTask({ title: 'Einkaufen Aldi', estimatedDuration: 45 }), + makeTask({ title: 'Einkaufen Edeka', estimatedDuration: 45 }), + // 3 unrelated tasks at 120min, different priority to avoid matching + makeTask({ title: 'Report schreiben', priority: 'high', estimatedDuration: 120 }), + makeTask({ title: 'Meeting vorbereiten', priority: 'high', estimatedDuration: 120 }), + makeTask({ title: 'Docs updaten', priority: 'high', estimatedDuration: 120 }), + ]; + + const result = estimateDuration({ title: 'Einkaufen Lidl', priority: 'medium' }, history); + + expect(result).not.toBeNull(); + // Should be closer to 45 than 120 because title overlap matters more + expect(result!.minutes).toBeLessThan(60); + }); + + it('should use completedAt - createdAt when no estimatedDuration', () => { + const history = Array.from({ length: 5 }, () => + makeTask({ + title: 'Meeting', + estimatedDuration: null, + createdAt: '2026-03-28T10:00:00Z', + completedAt: '2026-03-28T10:30:00Z', // 30 min + }) + ); + + const result = estimateDuration({ title: 'Meeting prep', priority: 'medium' }, history); + + expect(result).not.toBeNull(); + expect(result!.minutes).toBe(30); + }); + + it('should round to nice numbers', () => { + const history = Array.from({ length: 5 }, () => + makeTask({ projectId: 'p1', estimatedDuration: 37 }) + ); + + const result = estimateDuration( + { title: 'Task', projectId: 'p1', priority: 'medium' }, + history + ); + + expect(result).not.toBeNull(); + // 37 minutes should round to 35 or 40 (nearest 5) + expect(result!.minutes % 5).toBe(0); + }); + + it('should return higher confidence with more samples and better scores', () => { + const history = Array.from({ length: 15 }, () => + makeTask({ + title: 'Standup Meeting', + projectId: 'proj-1', + labelIds: ['label-1'], + estimatedDuration: 15, + }) + ); + + const result = estimateDuration( + { title: 'Standup Meeting', projectId: 'proj-1', labelIds: ['label-1'], priority: 'medium' }, + history + ); + + expect(result).not.toBeNull(); + expect(result!.confidence).toBe('high'); + }); + + it('should ignore tasks with unreasonable completion times', () => { + const history = [ + // These have no estimatedDuration, and completion took >8h (unreasonable) + ...Array.from({ length: 3 }, () => + makeTask({ + title: 'Quick task', + estimatedDuration: null, + createdAt: '2026-03-20T10:00:00Z', + completedAt: '2026-03-28T10:00:00Z', // 8 days - unreasonable + }) + ), + // These have proper durations + ...Array.from({ length: 3 }, () => + makeTask({ + title: 'Quick task', + estimatedDuration: 15, + }) + ), + ]; + + const result = estimateDuration({ title: 'Quick task', priority: 'medium' }, history); + + expect(result).not.toBeNull(); + expect(result!.minutes).toBe(15); + }); +}); diff --git a/apps/todo/apps/web/src/lib/utils/time-estimator.ts b/apps/todo/apps/web/src/lib/utils/time-estimator.ts new file mode 100644 index 000000000..8db732072 --- /dev/null +++ b/apps/todo/apps/web/src/lib/utils/time-estimator.ts @@ -0,0 +1,228 @@ +/** + * Time Estimator — Suggests task duration based on historical data + * + * Uses weighted similarity scoring across project, labels, title words, + * and priority to find similar completed tasks and compute a weighted average. + * + * Fully offline — runs against local IndexedDB data. + */ + +export interface CompletedTaskData { + title: string; + projectId?: string | null; + labelIds?: string[]; + priority: string; + estimatedDuration?: number | null; // minutes + completedAt?: string | null; + createdAt?: string | null; +} + +export interface DurationEstimate { + minutes: number; + confidence: 'low' | 'medium' | 'high'; + sampleSize: number; +} + +interface ScoredTask { + duration: number; + score: number; +} + +const STOP_WORDS = new Set([ + // German + 'der', + 'die', + 'das', + 'ein', + 'eine', + 'und', + 'oder', + 'für', + 'mit', + 'von', + 'zu', + 'im', + 'am', + 'an', + 'auf', + 'in', + 'den', + 'dem', + 'des', + 'ist', + 'sind', + 'bei', + 'nach', + 'vor', + 'über', + 'noch', + 'nicht', + 'sich', + 'auch', + 'mal', + // English + 'the', + 'a', + 'an', + 'and', + 'or', + 'for', + 'with', + 'from', + 'to', + 'in', + 'on', + 'at', + 'is', + 'are', + 'not', + 'also', +]); + +/** + * Normalize and tokenize a title into significant words + */ +function tokenize(title: string): string[] { + return title + .toLowerCase() + .replace(/[^a-zäöüßàáâãèéêëìíîïòóôõùúûü0-9\s]/g, '') + .split(/\s+/) + .filter((w) => w.length > 2 && !STOP_WORDS.has(w)); +} + +/** + * Calculate Jaccard-like overlap between two token sets + */ +function titleOverlap(a: string[], b: string[]): number { + if (a.length === 0 || b.length === 0) return 0; + const setB = new Set(b); + const shared = a.filter((w) => setB.has(w)).length; + return shared / Math.max(a.length, b.length); +} + +/** + * Compute similarity score between a new task and a historical task + */ +function similarity( + newTask: { title: string; projectId?: string | null; labelIds?: string[]; priority: string }, + historical: CompletedTaskData, + newTokens: string[] +): number { + let score = 0; + + // Same project is the strongest signal + if (newTask.projectId && historical.projectId && newTask.projectId === historical.projectId) { + score += 3; + } + + // Shared labels + if (newTask.labelIds && historical.labelIds) { + const histSet = new Set(historical.labelIds); + const shared = newTask.labelIds.filter((id) => histSet.has(id)).length; + score += shared * 2; + } + + // Same priority + if (newTask.priority === historical.priority) { + score += 1; + } + + // Title word overlap (strongest for exact matches) + const histTokens = tokenize(historical.title); + const overlap = titleOverlap(newTokens, histTokens); + if (overlap > 0.5) score += 3; + else if (overlap > 0.2) score += 2; + else if (overlap > 0) score += 1; + + return score; +} + +/** + * Get the effective duration of a completed task in minutes. + * Prefers estimatedDuration if set, otherwise falls back to + * time between creation and completion (capped at 8h). + */ +function getEffectiveDuration(task: CompletedTaskData): number | null { + if (task.estimatedDuration && task.estimatedDuration > 0) { + return task.estimatedDuration; + } + + if (task.completedAt && task.createdAt) { + const diff = + (new Date(task.completedAt).getTime() - new Date(task.createdAt).getTime()) / 60000; + // Only use creation-to-completion if it's reasonable (5min - 8h) + if (diff >= 5 && diff <= 480) { + return Math.round(diff); + } + } + + return null; +} + +/** + * Estimate duration for a new task based on completed task history. + * + * @param newTask - The task to estimate + * @param history - Completed tasks with duration data + * @param minSamples - Minimum similar tasks needed (default: 3) + * @returns Estimate or null if insufficient data + */ +export function estimateDuration( + newTask: { title: string; projectId?: string | null; labelIds?: string[]; priority: string }, + history: CompletedTaskData[], + minSamples = 3 +): DurationEstimate | null { + const newTokens = tokenize(newTask.title); + + const scored: ScoredTask[] = []; + for (const task of history) { + const duration = getEffectiveDuration(task); + if (duration === null) continue; + + const score = similarity(newTask, task, newTokens); + if (score > 0) { + scored.push({ duration, score }); + } + } + + if (scored.length < minSamples) return null; + + // Sort by score descending, take top 20 for stability + scored.sort((a, b) => b.score - a.score); + const top = scored.slice(0, 20); + + // Weighted average + let totalWeight = 0; + let totalDuration = 0; + for (const { duration, score } of top) { + totalWeight += score; + totalDuration += duration * score; + } + + const minutes = Math.round(totalDuration / totalWeight); + + // Round to nice numbers + const rounded = roundToNice(minutes); + + // Confidence based on sample size and score spread + const maxScore = top[0].score; + const confidence: DurationEstimate['confidence'] = + top.length >= 10 && maxScore >= 5 + ? 'high' + : top.length >= 5 && maxScore >= 3 + ? 'medium' + : 'low'; + + return { minutes: rounded, confidence, sampleSize: top.length }; +} + +/** + * Round minutes to human-friendly values + */ +function roundToNice(minutes: number): number { + if (minutes <= 10) return Math.round(minutes / 5) * 5 || 5; + if (minutes <= 30) return Math.round(minutes / 5) * 5; + if (minutes <= 60) return Math.round(minutes / 15) * 15; + if (minutes <= 240) return Math.round(minutes / 30) * 30; + return Math.round(minutes / 60) * 60; +}