From ec96b1bc834d5819708d24b39894c7c19bd19b97 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sun, 5 Apr 2026 16:52:44 +0200 Subject: [PATCH] feat(habits+todo): duration field for habits, calendar scheduling for tasks Habits: - Add defaultDuration field to Habit type and domain model - HabitForm: duration input (minutes) alongside target-per-day - Logged habits with defaultDuration auto-set endDate on their TimeBlock Todo: - Task DetailView: "Kalender planen" button to schedule tasks on calendar - Creates/updates/removes TimeBlock via scheduledBlockId - Date + time inputs with one-click unschedule Co-Authored-By: Claude Opus 4.6 (1M context) --- .../habits/components/HabitForm.svelte | 17 +++ .../web/src/lib/modules/habits/queries.ts | 1 + .../apps/web/src/lib/modules/habits/types.ts | 1 + .../lib/modules/todo/views/DetailView.svelte | 140 ++++++++++++++++-- 4 files changed, 150 insertions(+), 9 deletions(-) diff --git a/apps/manacore/apps/web/src/lib/modules/habits/components/HabitForm.svelte b/apps/manacore/apps/web/src/lib/modules/habits/components/HabitForm.svelte index 5eec6d8f4..1bf07428e 100644 --- a/apps/manacore/apps/web/src/lib/modules/habits/components/HabitForm.svelte +++ b/apps/manacore/apps/web/src/lib/modules/habits/components/HabitForm.svelte @@ -22,6 +22,9 @@ let icon = $state(habit?.icon ?? 'star'); let color = $state(habit?.color ?? '#6366f1'); let targetPerDay = $state(habit?.targetPerDay?.toString() ?? ''); + let defaultDurationMin = $state( + habit?.defaultDuration ? String(Math.round(habit.defaultDuration / 60)) : '' + ); let showIconPicker = $state(false); async function handleSubmit(e: Event) { @@ -29,6 +32,7 @@ if (!title.trim()) return; const target = targetPerDay.trim() ? parseInt(targetPerDay) : null; + const durationSec = defaultDurationMin.trim() ? parseInt(defaultDurationMin) * 60 : null; if (habit) { await habitsStore.updateHabit(habit.id, { @@ -36,6 +40,7 @@ icon, color, targetPerDay: target, + defaultDuration: durationSec, }); } else { await habitsStore.createHabit({ @@ -43,6 +48,7 @@ icon, color, targetPerDay: target, + defaultDuration: durationSec, }); } @@ -116,6 +122,17 @@ bind:value={targetPerDay} /> +
diff --git a/apps/manacore/apps/web/src/lib/modules/habits/queries.ts b/apps/manacore/apps/web/src/lib/modules/habits/queries.ts index f2af3cb2f..7f8ec38f1 100644 --- a/apps/manacore/apps/web/src/lib/modules/habits/queries.ts +++ b/apps/manacore/apps/web/src/lib/modules/habits/queries.ts @@ -19,6 +19,7 @@ export function toHabit(local: LocalHabit): Habit { icon: local.icon ?? EMOJI_TO_ICON_MAP[(local as Record).emoji] ?? 'star', color: local.color, targetPerDay: local.targetPerDay, + defaultDuration: local.defaultDuration ?? null, order: local.order, isArchived: local.isArchived, createdAt: local.createdAt ?? new Date().toISOString(), diff --git a/apps/manacore/apps/web/src/lib/modules/habits/types.ts b/apps/manacore/apps/web/src/lib/modules/habits/types.ts index 34976efcf..166dda26b 100644 --- a/apps/manacore/apps/web/src/lib/modules/habits/types.ts +++ b/apps/manacore/apps/web/src/lib/modules/habits/types.ts @@ -33,6 +33,7 @@ export interface Habit { icon: string; color: string; targetPerDay: number | null; + defaultDuration: number | null; // seconds order: number; isArchived: boolean; createdAt: string; diff --git a/apps/manacore/apps/web/src/lib/modules/todo/views/DetailView.svelte b/apps/manacore/apps/web/src/lib/modules/todo/views/DetailView.svelte index 3971eb81f..1cc6ab094 100644 --- a/apps/manacore/apps/web/src/lib/modules/todo/views/DetailView.svelte +++ b/apps/manacore/apps/web/src/lib/modules/todo/views/DetailView.svelte @@ -6,7 +6,9 @@ import { liveQuery } from 'dexie'; import { db } from '$lib/data/database'; import { tasksStore } from '../stores/tasks.svelte'; - import { Check, Trash, X } from '@manacore/shared-icons'; + import { getBlock } from '$lib/data/time-blocks/service'; + import type { LocalTimeBlock } from '$lib/data/time-blocks/types'; + import { Check, Trash, X, CalendarBlank } from '@manacore/shared-icons'; import type { ViewProps } from '$lib/app-registry'; import type { LocalTask, TaskPriority } from '../types'; import { useAllTags, getTagsByIds } from '$lib/stores/tags.svelte'; @@ -25,6 +27,11 @@ let editDueDate = $state(''); let editPriority = $state('medium'); + // Schedule fields + let scheduleDate = $state(''); + let scheduleTime = $state(''); + let isScheduled = $state(false); + // Track whether user is actively editing to prevent overwrite from liveQuery let focused = $state(false); @@ -53,13 +60,30 @@ }); $effect(() => { - const sub = liveQuery(() => db.table('tasks').get(taskId)).subscribe((val) => { - task = val ?? null; - if (val && !focused) { - editTitle = val.title; - editDescription = val.description ?? ''; - editDueDate = val.dueDate?.split('T')[0] ?? ''; - editPriority = val.priority; + const sub = liveQuery(async () => { + const t = await db.table('tasks').get(taskId); + if (!t) return { task: null, block: null }; + const block = t.scheduledBlockId ? await getBlock(t.scheduledBlockId) : null; + return { task: t, block: block ?? null }; + }).subscribe((val) => { + task = val?.task ?? null; + if (val?.task && !focused) { + editTitle = val.task.title; + editDescription = val.task.description ?? ''; + editDueDate = val.task.dueDate?.split('T')[0] ?? ''; + editPriority = val.task.priority; + // Load schedule from TimeBlock + if (val.block) { + isScheduled = true; + scheduleDate = val.block.startDate.split('T')[0]; + scheduleTime = val.block.startDate.includes('T') + ? val.block.startDate.split('T')[1]?.substring(0, 5) + : ''; + } else { + isScheduled = false; + scheduleDate = ''; + scheduleTime = ''; + } } }); return () => sub.unsubscribe(); @@ -72,9 +96,28 @@ description: editDescription.trim() || undefined, dueDate: editDueDate ? new Date(editDueDate).toISOString() : null, priority: editPriority, + _scheduleStartDate: isScheduled && scheduleDate ? scheduleDate : null, + _scheduleStartTime: isScheduled && scheduleTime ? scheduleTime : null, }); } + async function toggleSchedule() { + if (isScheduled) { + // Unschedule + isScheduled = false; + scheduleDate = ''; + scheduleTime = ''; + } else { + // Schedule for tomorrow 9:00 by default + isScheduled = true; + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + scheduleDate = tomorrow.toISOString().split('T')[0]; + scheduleTime = '09:00'; + } + await saveField(); + } + async function handlePriorityChange() { await tasksStore.updateTask(taskId, { priority: editPriority }); } @@ -172,9 +215,44 @@ {#if task.estimatedDuration}
Dauer - {task.estimatedDuration} Min. + {Math.round(task.estimatedDuration / 60)} Min.
{/if} + + +
+ Kalender + {#if isScheduled} +
+ (focused = true)} + onblur={saveField} + /> + (focused = true)} + onblur={saveField} + /> + +
+ {:else} + + {/if} +
@@ -363,6 +441,50 @@ :global(.dark) .prop-value { color: #e5e7eb; } + .schedule-fields { + display: flex; + align-items: center; + gap: 0.375rem; + } + .schedule-btn { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + border: 1px dashed rgba(0, 0, 0, 0.15); + border-radius: 0.375rem; + background: transparent; + font-size: 0.75rem; + color: #6b7280; + cursor: pointer; + transition: all 0.15s; + } + .schedule-btn:hover { + border-color: #3b82f6; + color: #3b82f6; + background: rgba(59, 130, 246, 0.05); + } + :global(.dark) .schedule-btn { + border-color: rgba(255, 255, 255, 0.15); + color: #9ca3af; + } + :global(.dark) .schedule-btn:hover { + border-color: #3b82f6; + color: #3b82f6; + } + .unschedule-btn { + padding: 0.25rem; + border: none; + background: transparent; + border-radius: 0.25rem; + color: #9ca3af; + cursor: pointer; + } + .unschedule-btn:hover { + color: #ef4444; + background: rgba(239, 68, 68, 0.1); + } + .prop-select, .prop-input { font-size: 0.8125rem;