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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-30 10:52:51 +02:00
parent aee0934caf
commit 06e5d9e22b
7 changed files with 146 additions and 72 deletions

View file

@ -254,7 +254,7 @@
// ─── NL Parser State ───────────────────────────────────
let parsePreview = $state('');
let parsedEvent = $state<ParsedEvent | null>(null);
let durationEstimate = $state<DurationEstimate | null>(null);
let autoEstimatedDuration = $state<number | null>(null);
let conflictResult = $state<ConflictResult | null>(null);
let estimateDebounce: ReturnType<typeof setTimeout> | 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)}
<div class="nl-hints">
{#if parsePreview}
<span class="nl-preview">{parsePreview}</span>
{/if}
{#if durationEstimate}
<button type="button" class="nl-estimate" onclick={applyDurationEstimate}>
~{durationEstimate.minutes < 60
? `${durationEstimate.minutes}min`
: `${Math.floor(durationEstimate.minutes / 60)}h${durationEstimate.minutes % 60 ? ` ${durationEstimate.minutes % 60}min` : ''}`}
</button>
{#if autoEstimatedDuration}
<span class="nl-estimate">
~{autoEstimatedDuration < 60
? `${autoEstimatedDuration}min`
: `${Math.floor(autoEstimatedDuration / 60)}h${autoEstimatedDuration % 60 ? ` ${autoEstimatedDuration % 60}min` : ''}`}
</span>
{/if}
{#if conflictResult && conflictResult.hasConflict}
<span class="nl-conflict">
@ -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 {

View file

@ -36,6 +36,7 @@ export interface CalendarAppSettings extends Record<string, unknown> {
// 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;
},

View file

@ -612,6 +612,23 @@
/>
</div>
<div class="setting-item">
<div class="setting-info">
<span class="setting-label">Smarte Dauer</span>
<span class="setting-description"
>Dauer automatisch aus vergangenen Terminen lernen</span
>
</div>
<label class="toggle-wrapper">
<input
type="checkbox"
checked={settingsStore.smartDurationEnabled}
onchange={() =>
settingsStore.set('smartDurationEnabled', !settingsStore.smartDurationEnabled)}
/>
</label>
</div>
<div class="setting-item">
<div class="setting-info">
<span class="setting-label">{$_('settings.defaultReminder')}</span>

View file

@ -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<number | null>(null); // auto-applied, shown in preview
let estimateDebounce: ReturnType<typeof setTimeout> | 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<typeof parseMultiTaskInput>[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 @@
<svelte:window onclick={closeAllPickers} />
<form onsubmit={handleSubmit} class="quick-add-form">
<!-- Parse preview + duration estimate -->
{#if parsePreview || durationEstimate}
<!-- Parse preview + auto-duration -->
{#if parsePreview || autoEstimatedDuration}
<div class="parse-preview">
{#if parsePreview}
<span class="preview-text">{parsePreview}</span>
{/if}
{#if durationEstimate}
<button type="button" class="estimate-btn" onclick={applyEstimate}>
<span class="estimate-icon">~</span>
{formatDuration(durationEstimate.minutes)}
</button>
{#if autoEstimatedDuration}
<span class="auto-duration">~{formatDuration(autoEstimatedDuration)}</span>
{/if}
</div>
{/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 */

View file

@ -34,6 +34,10 @@ export interface TodoAppSettings extends Record<string, unknown> {
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;
},

View file

@ -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,

View file

@ -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}
</SettingsNumberInput>
<SettingsToggle
label="Smarte Dauer"
description="Dauer automatisch aus erledigten Tasks lernen und vorschlagen"
isOn={todoSettings.smartDurationEnabled}
onToggle={(v) => todoSettings.set('smartDurationEnabled', v)}
>
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
{/snippet}
</SettingsToggle>
{#if todoSettings.smartDurationEnabled}
<SettingsSelect
label="Standard-Dauer"
description="Fallback wenn keine Historie vorhanden"
options={durationOptions}
value={String(todoSettings.defaultTaskDuration)}
onchange={(v) => todoSettings.set('defaultTaskDuration', Number(v))}
>
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{/snippet}
</SettingsSelect>
{/if}
<SettingsToggle
label="Löschen bestätigen"
description="Bestätigung vor dem Löschen von Tasks"