From 06e5d9e22b76e265924a54da635eccb13b5d916c Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 30 Mar 2026 10:52:51 +0200 Subject: [PATCH] feat(todo,calendar): auto-apply smart duration, add settings toggle Duration estimation now auto-applies without requiring a button click. When no explicit duration is typed, the system uses history-based estimation (weighted by project/calendar, title, labels) with the configurable default as fallback. Both apps get a "Smarte Dauer" toggle and default duration picker in Settings. The learned duration improves over time as more tasks/events are completed. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/event/QuickEventOverlay.svelte | 60 ++++++++-------- .../web/src/lib/stores/settings.svelte.ts | 5 ++ .../src/routes/(app)/settings/+page.svelte | 17 +++++ .../src/lib/components/QuickAddTask.svelte | 70 ++++++++----------- .../web/src/lib/stores/settings.svelte.ts | 14 ++++ .../apps/web/src/lib/stores/tasks.svelte.ts | 2 + .../src/routes/(app)/settings/+page.svelte | 50 +++++++++++++ 7 files changed, 146 insertions(+), 72 deletions(-) diff --git a/apps/calendar/apps/web/src/lib/components/event/QuickEventOverlay.svelte b/apps/calendar/apps/web/src/lib/components/event/QuickEventOverlay.svelte index 571de01b3..3920111c6 100644 --- a/apps/calendar/apps/web/src/lib/components/event/QuickEventOverlay.svelte +++ b/apps/calendar/apps/web/src/lib/components/event/QuickEventOverlay.svelte @@ -254,7 +254,7 @@ // ─── NL Parser State ─────────────────────────────────── let parsePreview = $state(''); let parsedEvent = $state(null); - let durationEstimate = $state(null); + let autoEstimatedDuration = $state(null); let conflictResult = $state(null); let estimateDebounce: ReturnType | undefined; let parserApplied = $state(false); // track if we already applied parser results @@ -264,7 +264,7 @@ if (isEditMode || !text.trim()) { parsePreview = ''; parsedEvent = null; - durationEstimate = null; + autoEstimatedDuration = null; conflictResult = null; return; } @@ -282,8 +282,8 @@ try { const allEvents = await eventCollection.getAll(); - // Duration estimation (only if no explicit duration in input) - if (!parsed.duration && parsed.title) { + // Auto-estimate duration (only if no explicit duration and smart duration enabled) + if (settingsStore.smartDurationEnabled && !parsed.duration && parsed.title) { const history: HistoricalEventData[] = allEvents.map((e) => ({ title: e.title, calendarId: e.calendarId, @@ -291,12 +291,13 @@ endDate: e.endDate, allDay: e.allDay, })); - durationEstimate = estimateEventDuration( + const estimate = estimateEventDuration( { title: parsed.title, calendarId: calendarId || undefined }, history ); + autoEstimatedDuration = estimate?.minutes ?? settingsStore.defaultEventDuration; } else { - durationEstimate = null; + autoEstimatedDuration = null; } // Conflict detection (only if we have a start+end time) @@ -317,7 +318,7 @@ conflictResult = null; } } catch { - durationEstimate = null; + autoEstimatedDuration = null; conflictResult = null; } } @@ -367,22 +368,25 @@ recurrenceRule = parsed.recurrenceRule; } + // Auto-apply estimated duration to endDate if no explicit end was parsed + if ( + settingsStore.smartDurationEnabled && + !parsed.duration && + parsed.startDate && + autoEstimatedDuration + ) { + const endDate = new Date(parsed.startDate.getTime() + autoEstimatedDuration * 60_000); + endDateStr = format(endDate, 'yyyy-MM-dd'); + endTimeStr = format(endDate, 'HH:mm'); + } + // Update draft event with new times updateDraftTimes(); parserApplied = true; // Clear preview after applying parsePreview = ''; - durationEstimate = null; - } - - function applyDurationEstimate() { - if (!durationEstimate || !parsedEvent?.startDate) return; - const endDate = new Date(parsedEvent.startDate.getTime() + durationEstimate.minutes * 60_000); - endDateStr = format(endDate, 'yyyy-MM-dd'); - endTimeStr = format(endDate, 'HH:mm'); - updateDraftTimes(); - durationEstimate = null; + autoEstimatedDuration = null; } // Editable date/time strings (for form inputs) @@ -901,17 +905,17 @@ aria-label="Terminname" required /> - {#if parsePreview || durationEstimate || (conflictResult && conflictResult.hasConflict)} + {#if parsePreview || autoEstimatedDuration || (conflictResult && conflictResult.hasConflict)}
{#if parsePreview} {parsePreview} {/if} - {#if durationEstimate} - + {#if autoEstimatedDuration} + + ~{autoEstimatedDuration < 60 + ? `${autoEstimatedDuration}min` + : `${Math.floor(autoEstimatedDuration / 60)}h${autoEstimatedDuration % 60 ? ` ${autoEstimatedDuration % 60}min` : ''}`} + {/if} {#if conflictResult && conflictResult.hasConflict} @@ -1475,18 +1479,10 @@ .nl-estimate { display: inline-flex; padding: 0.0625rem 0.4rem; - border: 1px dashed hsl(var(--color-primary) / 0.4); background: hsl(var(--color-primary) / 0.08); color: hsl(var(--color-primary)); border-radius: 9999px; font-size: 0.65rem; - cursor: pointer; - transition: all 0.15s; - } - - .nl-estimate:hover { - background: hsl(var(--color-primary) / 0.15); - border-color: hsl(var(--color-primary) / 0.6); } .nl-conflict { diff --git a/apps/calendar/apps/web/src/lib/stores/settings.svelte.ts b/apps/calendar/apps/web/src/lib/stores/settings.svelte.ts index 77a3d7d56..d59be174a 100644 --- a/apps/calendar/apps/web/src/lib/stores/settings.svelte.ts +++ b/apps/calendar/apps/web/src/lib/stores/settings.svelte.ts @@ -36,6 +36,7 @@ export interface CalendarAppSettings extends Record { // Event defaults defaultEventDuration: number; + smartDurationEnabled: boolean; defaultReminder: number; // Voice input settings @@ -67,6 +68,7 @@ const DEFAULT_SETTINGS: CalendarAppSettings = { showBirthdays: true, showBirthdayAge: true, defaultEventDuration: 60, + smartDurationEnabled: true, defaultReminder: 15, sttLanguage: 'de', }; @@ -203,6 +205,9 @@ export const settingsStore = { get defaultEventDuration() { return baseStore.settings.defaultEventDuration; }, + get smartDurationEnabled() { + return baseStore.settings.smartDurationEnabled; + }, get defaultReminder() { return baseStore.settings.defaultReminder; }, diff --git a/apps/calendar/apps/web/src/routes/(app)/settings/+page.svelte b/apps/calendar/apps/web/src/routes/(app)/settings/+page.svelte index 60efdb364..7b19ba868 100644 --- a/apps/calendar/apps/web/src/routes/(app)/settings/+page.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/settings/+page.svelte @@ -612,6 +612,23 @@ />
+
+
+ Smarte Dauer + Dauer automatisch aus vergangenen Terminen lernen +
+ +
+
{$_('settings.defaultReminder')} diff --git a/apps/todo/apps/web/src/lib/components/QuickAddTask.svelte b/apps/todo/apps/web/src/lib/components/QuickAddTask.svelte index 5e28857f1..9f099c910 100644 --- a/apps/todo/apps/web/src/lib/components/QuickAddTask.svelte +++ b/apps/todo/apps/web/src/lib/components/QuickAddTask.svelte @@ -14,6 +14,7 @@ import { estimateDuration, type CompletedTaskData } from '$lib/utils/time-estimator'; import { taskCollection } from '$lib/data/local-store'; import { labelCollection } from '$lib/data/local-store'; + import { todoSettings } from '$lib/stores/settings.svelte'; const projectsCtx: { readonly value: Project[] } = getContext('projects'); import type { TaskPriority } from '@todo/shared'; @@ -38,7 +39,7 @@ // Parser preview let parsePreview = $state(''); let parsedTaskCount = $state(0); - let durationEstimate = $state<{ minutes: number; confidence: string } | null>(null); + let autoEstimatedDuration = $state(null); // auto-applied, shown in preview let estimateDebounce: ReturnType | undefined; // Quick date options @@ -67,7 +68,7 @@ if (!text) { parsePreview = ''; parsedTaskCount = 0; - durationEstimate = null; + autoEstimatedDuration = null; return; } @@ -83,16 +84,21 @@ parsePreview = previews.join(' · '); } - // Debounced duration estimation + // Auto-estimate duration if enabled and no explicit duration clearTimeout(estimateDebounce); - if (tasks.length === 1 && !tasks[0].estimatedDuration) { - estimateDebounce = setTimeout(() => runEstimate(tasks[0]), 500); + if (todoSettings.smartDurationEnabled && tasks.length === 1 && !tasks[0].estimatedDuration) { + estimateDebounce = setTimeout(() => runEstimate(tasks[0]), 400); } else { - durationEstimate = null; + autoEstimatedDuration = null; } }); async function runEstimate(parsed: ReturnType[0]) { + if (!todoSettings.smartDurationEnabled) { + autoEstimatedDuration = null; + return; + } + try { const allTasks = await taskCollection.getAll(); const completed: CompletedTaskData[] = allTasks @@ -115,9 +121,10 @@ completed ); - durationEstimate = estimate; + // Auto-apply: use estimate if available, otherwise fall back to default + autoEstimatedDuration = estimate?.minutes ?? todoSettings.defaultTaskDuration; } catch { - durationEstimate = null; + autoEstimatedDuration = todoSettings.defaultTaskDuration; } } @@ -148,6 +155,12 @@ for (const parsed of parsedTasks) { const resolved = resolveTaskIds(parsed, projects, labels); + // Duration: explicit from parser > auto-estimated > default (if enabled) + const duration = + resolved.estimatedDuration ?? + (todoSettings.smartDurationEnabled ? autoEstimatedDuration : undefined) ?? + undefined; + await tasksStore.createTask({ title: resolved.title, projectId: resolved.projectId ?? selectedProjectId, @@ -155,6 +168,7 @@ priority: resolved.priority ?? selectedPriority, labelIds: resolved.labelIds.length > 0 ? resolved.labelIds : undefined, recurrenceRule: resolved.recurrenceRule, + estimatedDuration: duration, subtasks: resolved.subtasks?.map((s, i) => ({ id: crypto.randomUUID(), title: s, @@ -168,7 +182,7 @@ inputValue = ''; parsePreview = ''; parsedTaskCount = 0; - durationEstimate = null; + autoEstimatedDuration = null; selectedDate = new Date(); selectedPriority = 'medium'; if (viewStore.currentView !== 'project') { @@ -184,13 +198,6 @@ } } - function applyEstimate() { - if (durationEstimate) { - inputValue = `${inputValue.trim()} ${durationEstimate.minutes}min`; - durationEstimate = null; - } - } - function handleKeydown(event: KeyboardEvent) { if (event.key === 'Escape') { inputValue = ''; @@ -244,17 +251,14 @@
- - {#if parsePreview || durationEstimate} + + {#if parsePreview || autoEstimatedDuration}
{#if parsePreview} {parsePreview} {/if} - {#if durationEstimate} - + {#if autoEstimatedDuration} + ~{formatDuration(autoEstimatedDuration)} {/if}
{/if} @@ -462,27 +466,13 @@ opacity: 0.8; } - .estimate-btn { + .auto-duration { display: inline-flex; - align-items: center; - gap: 0.25rem; - padding: 0.125rem 0.5rem; - border: 1px dashed rgba(139, 92, 246, 0.4); + padding: 0.0625rem 0.4rem; 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; + font-size: 0.65rem; } /* Mobile: Fixed at bottom */ diff --git a/apps/todo/apps/web/src/lib/stores/settings.svelte.ts b/apps/todo/apps/web/src/lib/stores/settings.svelte.ts index a0551029f..cffb853c9 100644 --- a/apps/todo/apps/web/src/lib/stores/settings.svelte.ts +++ b/apps/todo/apps/web/src/lib/stores/settings.svelte.ts @@ -34,6 +34,10 @@ export interface TodoAppSettings extends Record { dailyDigestEnabled: boolean; overdueNotifications: boolean; + // Smart Duration + smartDurationEnabled: boolean; + defaultTaskDuration: number; // minutes, auto-learned or manual + // Productivity focusMode: boolean; pomodoroEnabled: boolean; @@ -72,6 +76,10 @@ const DEFAULT_SETTINGS: TodoAppSettings = { dailyDigestEnabled: false, overdueNotifications: true, + // Smart Duration + smartDurationEnabled: true, + defaultTaskDuration: 30, // 30 min default, auto-learned over time + // Productivity focusMode: false, pomodoroEnabled: false, @@ -148,6 +156,12 @@ export const todoSettings = { get overdueNotifications() { return baseStore.settings.overdueNotifications; }, + get smartDurationEnabled() { + return baseStore.settings.smartDurationEnabled; + }, + get defaultTaskDuration() { + return baseStore.settings.defaultTaskDuration; + }, get focusMode() { return baseStore.settings.focusMode; }, diff --git a/apps/todo/apps/web/src/lib/stores/tasks.svelte.ts b/apps/todo/apps/web/src/lib/stores/tasks.svelte.ts index b44ffeb9c..6365eae4c 100644 --- a/apps/todo/apps/web/src/lib/stores/tasks.svelte.ts +++ b/apps/todo/apps/web/src/lib/stores/tasks.svelte.ts @@ -27,6 +27,7 @@ export const tasksStore = { labelIds?: string[]; subtasks?: Subtask[]; recurrenceRule?: string; + estimatedDuration?: number; }) { error = null; try { @@ -39,6 +40,7 @@ export const tasksStore = { priority: data.priority ?? 'medium', isCompleted: false, dueDate: data.dueDate ?? null, + estimatedDuration: data.estimatedDuration ?? null, order: count, recurrenceRule: data.recurrenceRule ?? null, subtasks: data.subtasks, diff --git a/apps/todo/apps/web/src/routes/(app)/settings/+page.svelte b/apps/todo/apps/web/src/routes/(app)/settings/+page.svelte index ae292a226..a7e4158fe 100644 --- a/apps/todo/apps/web/src/routes/(app)/settings/+page.svelte +++ b/apps/todo/apps/web/src/routes/(app)/settings/+page.svelte @@ -51,6 +51,17 @@ { value: 1440, label: '1 Tag' }, ]; + const durationOptions = [ + { value: '5', label: '5 min' }, + { value: '10', label: '10 min' }, + { value: '15', label: '15 min' }, + { value: '30', label: '30 min' }, + { value: '45', label: '45 min' }, + { value: '60', label: '1 Stunde' }, + { value: '90', label: '1,5 Stunden' }, + { value: '120', label: '2 Stunden' }, + ]; + // Project options for quick add (computed) let projectOptions = $derived([ { value: null, label: 'Inbox' }, @@ -234,6 +245,45 @@ {/snippet} + todoSettings.set('smartDurationEnabled', v)} + > + {#snippet icon()} + + + + {/snippet} + + + {#if todoSettings.smartDurationEnabled} + todoSettings.set('defaultTaskDuration', Number(v))} + > + {#snippet icon()} + + + + {/snippet} + + {/if} +