mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
aee0934caf
commit
06e5d9e22b
7 changed files with 146 additions and 72 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue