feat(todo): add task edit modal and fix task loading

- Add TaskEditModal with full task editing (title, description, dates,
  priority, status, project, labels, subtasks, recurrence, notes,
  storypoints, duration, fun rating)
- Add SubtaskList component with drag-and-drop reordering
- Add onEdit prop to TaskItem and TaskList for modal integration
- Fix task loading on homepage by simplifying fetchAllTasks to load
  all tasks without isCompleted filter
- Fix isCompleted query parameter parsing in backend DTO

🤖 Generated with [Claude Code](https://claude.ai/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-09 14:04:11 +01:00
parent 863dd621f5
commit 3e35e6a2f4
7 changed files with 1718 additions and 25 deletions

View file

@ -28,7 +28,11 @@ export class QueryTasksDto {
status?: TaskStatus;
@IsOptional()
@Transform(({ value }) => value === 'true' || value === true)
@Transform(({ value }) => {
if (value === 'true' || value === true) return true;
if (value === 'false' || value === false) return false;
return undefined;
})
@IsBoolean()
isCompleted?: boolean;

View file

@ -0,0 +1,404 @@
<script lang="ts">
import type { Subtask } from '@todo/shared';
import { dndzone } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
interface Props {
subtasks: Subtask[];
onChange: (subtasks: Subtask[]) => void;
}
let { subtasks, onChange }: Props = $props();
let newSubtaskTitle = $state('');
let editingId = $state<string | null>(null);
let editingTitle = $state('');
// Convert subtasks to items with id for dnd
let items = $derived(
subtasks.map((s, index) => ({
...s,
id: s.id,
order: index,
}))
);
function handleDndConsider(e: CustomEvent<{ items: Subtask[] }>) {
onChange(e.detail.items.map((item, index) => ({ ...item, order: index })));
}
function handleDndFinalize(e: CustomEvent<{ items: Subtask[] }>) {
onChange(e.detail.items.map((item, index) => ({ ...item, order: index })));
}
function toggleComplete(id: string) {
const updated = subtasks.map((s) =>
s.id === id
? {
...s,
isCompleted: !s.isCompleted,
completedAt: !s.isCompleted ? new Date().toISOString() : null,
}
: s
);
onChange(updated);
}
function deleteSubtask(id: string) {
onChange(subtasks.filter((s) => s.id !== id));
}
function startEditing(subtask: Subtask) {
editingId = subtask.id;
editingTitle = subtask.title;
}
function saveEdit() {
if (!editingId || !editingTitle.trim()) {
cancelEdit();
return;
}
const updated = subtasks.map((s) =>
s.id === editingId ? { ...s, title: editingTitle.trim() } : s
);
onChange(updated);
cancelEdit();
}
function cancelEdit() {
editingId = null;
editingTitle = '';
}
function handleEditKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
saveEdit();
} else if (e.key === 'Escape') {
cancelEdit();
}
}
function addSubtask() {
if (!newSubtaskTitle.trim()) return;
const newSubtask: Subtask = {
id: crypto.randomUUID(),
title: newSubtaskTitle.trim(),
isCompleted: false,
order: subtasks.length,
};
onChange([...subtasks, newSubtask]);
newSubtaskTitle = '';
}
function handleAddKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
addSubtask();
}
}
</script>
<div class="subtask-list">
{#if items.length > 0}
<div
class="subtask-items"
use:dndzone={{ items, flipDurationMs: 200, dropTargetStyle: {} }}
onconsider={handleDndConsider}
onfinalize={handleDndFinalize}
>
{#each items as subtask (subtask.id)}
<div class="subtask-item" animate:flip={{ duration: 200 }}>
<!-- Drag handle -->
<div class="drag-handle" aria-label="Ziehen zum Sortieren">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 8h16M4 16h16"
/>
</svg>
</div>
<!-- Checkbox -->
<button
type="button"
class="subtask-checkbox"
class:checked={subtask.isCompleted}
onclick={() => toggleComplete(subtask.id)}
>
{#if subtask.isCompleted}
<svg class="check-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="3"
d="M5 13l4 4L19 7"
/>
</svg>
{/if}
</button>
<!-- Title (editable) -->
{#if editingId === subtask.id}
<input
type="text"
class="subtask-edit-input"
bind:value={editingTitle}
onkeydown={handleEditKeydown}
onblur={saveEdit}
/>
{:else}
<button
type="button"
class="subtask-title"
class:completed={subtask.isCompleted}
ondblclick={() => startEditing(subtask)}
>
{subtask.title}
</button>
{/if}
<!-- Delete button -->
<button
type="button"
class="subtask-delete"
onclick={() => deleteSubtask(subtask.id)}
title="Löschen"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/each}
</div>
{/if}
<!-- Add new subtask -->
<div class="add-subtask">
<div class="add-icon">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</div>
<input
type="text"
class="add-input"
placeholder="Subtask hinzufügen..."
bind:value={newSubtaskTitle}
onkeydown={handleAddKeydown}
/>
{#if newSubtaskTitle.trim()}
<button type="button" class="add-btn" onclick={addSubtask}> Hinzufügen </button>
{/if}
</div>
</div>
<style>
.subtask-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.subtask-items {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.subtask-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 0.75rem;
background: rgba(0, 0, 0, 0.03);
transition: all 0.15s;
}
:global(.dark) .subtask-item {
background: rgba(255, 255, 255, 0.05);
}
.subtask-item:hover {
background: rgba(0, 0, 0, 0.06);
}
:global(.dark) .subtask-item:hover {
background: rgba(255, 255, 255, 0.1);
}
.drag-handle {
cursor: grab;
color: #9ca3af;
padding: 0.25rem;
opacity: 0.5;
transition: opacity 0.15s;
}
.subtask-item:hover .drag-handle {
opacity: 1;
}
.drag-handle:active {
cursor: grabbing;
}
.subtask-checkbox {
width: 1.125rem;
height: 1.125rem;
border-radius: 0.25rem;
border: 2px solid rgba(0, 0, 0, 0.2);
background: transparent;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
padding: 0;
}
:global(.dark) .subtask-checkbox {
border-color: rgba(255, 255, 255, 0.3);
}
.subtask-checkbox:hover {
border-color: #8b5cf6;
background: rgba(139, 92, 246, 0.1);
}
.subtask-checkbox.checked {
background: #8b5cf6;
border-color: #8b5cf6;
}
.check-icon {
width: 0.625rem;
height: 0.625rem;
color: white;
}
.subtask-title {
flex: 1;
text-align: left;
background: none;
border: none;
padding: 0;
font-size: 0.875rem;
color: #374151;
cursor: text;
}
:global(.dark) .subtask-title {
color: #e5e7eb;
}
.subtask-title.completed {
text-decoration: line-through;
color: #9ca3af;
}
.subtask-edit-input {
flex: 1;
background: white;
border: 1px solid #8b5cf6;
border-radius: 0.375rem;
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
color: #374151;
outline: none;
}
:global(.dark) .subtask-edit-input {
background: rgba(255, 255, 255, 0.1);
color: #e5e7eb;
}
.subtask-delete {
opacity: 0;
padding: 0.25rem;
border: none;
background: transparent;
color: #9ca3af;
cursor: pointer;
border-radius: 0.375rem;
transition: all 0.15s;
}
.subtask-item:hover .subtask-delete {
opacity: 1;
}
.subtask-delete:hover {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
/* Add subtask */
.add-subtask {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 0.75rem;
border: 1px dashed rgba(0, 0, 0, 0.15);
transition: all 0.15s;
}
:global(.dark) .add-subtask {
border-color: rgba(255, 255, 255, 0.15);
}
.add-subtask:focus-within {
border-color: #8b5cf6;
background: rgba(139, 92, 246, 0.05);
}
.add-icon {
color: #9ca3af;
}
.add-input {
flex: 1;
background: transparent;
border: none;
outline: none;
font-size: 0.875rem;
color: #374151;
}
.add-input::placeholder {
color: #9ca3af;
}
:global(.dark) .add-input {
color: #e5e7eb;
}
.add-btn {
padding: 0.25rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
color: white;
background: #8b5cf6;
border: none;
border-radius: 0.5rem;
cursor: pointer;
transition: background 0.15s;
}
.add-btn:hover {
background: #7c3aed;
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -9,9 +9,16 @@
showCompleted?: boolean;
onToggleComplete: () => void;
onDelete: () => void;
onEdit?: () => void;
}
let { task, showCompleted = false, onToggleComplete, onDelete }: Props = $props();
let { task, showCompleted = false, onToggleComplete, onDelete, onEdit }: Props = $props();
function handleContentClick() {
if (onEdit) {
onEdit();
}
}
// Priority colors
const priorityColors: Record<string, string> = {
@ -67,8 +74,8 @@
{/if}
</button>
<!-- Content -->
<div class="task-content">
<!-- Content (clickable to edit) -->
<button type="button" class="task-content" onclick={handleContentClick}>
<span class="task-title" class:line-through={task.isCompleted}>
{task.title}
</span>
@ -120,7 +127,7 @@
{/if}
</div>
{/if}
</div>
</button>
<!-- Project indicator -->
{#if projectColor()}
@ -232,6 +239,11 @@
display: flex;
flex-direction: column;
gap: 0.25rem;
background: none;
border: none;
padding: 0;
text-align: left;
cursor: pointer;
}
.task-title {

View file

@ -6,9 +6,10 @@
interface Props {
tasks: Task[];
showCompleted?: boolean;
onEditTask?: (task: Task) => void;
}
let { tasks, showCompleted = false }: Props = $props();
let { tasks, showCompleted = false, onEditTask }: Props = $props();
async function handleToggleComplete(task: Task) {
if (task.isCompleted) {
@ -30,6 +31,7 @@
{showCompleted}
onToggleComplete={() => handleToggleComplete(task)}
onDelete={() => handleDelete(task.id)}
onEdit={onEditTask ? () => onEditTask(task) : undefined}
/>
{/each}
</div>

View file

@ -119,18 +119,10 @@ export const tasksStore = {
loading = true;
error = null;
try {
// Fetch both incomplete and completed tasks
const [incompleteTasks, completedTasks] = await Promise.all([
tasksApi.getTasks({ isCompleted: false }),
tasksApi.getTasks({ isCompleted: true }),
]);
// Deduplicate tasks by ID (in case API returns duplicates)
const allTasks = [...incompleteTasks, ...completedTasks];
const uniqueTasksMap = new Map<string, Task>();
for (const task of allTasks) {
uniqueTasksMap.set(task.id, task);
}
tasks = Array.from(uniqueTasksMap.values());
// Fetch all tasks without filter - let frontend handle filtering
const allTasks = await tasksApi.getTasks({});
console.log('API response - all tasks:', allTasks.length);
tasks = allTasks;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch all tasks';
console.error('Failed to fetch all tasks:', e);
@ -171,9 +163,13 @@ export const tasksStore = {
* Get tasks due today
*/
get todayTasks(): Task[] {
const today = startOfDay(new Date());
return tasks.filter((t) => {
if (!t.dueDate || t.isCompleted) return false;
return isToday(new Date(t.dueDate));
if (t.isCompleted) return false;
// Include tasks without dueDate as "today" tasks (inbox behavior)
if (!t.dueDate) return true;
const taskDate = startOfDay(new Date(t.dueDate));
return taskDate.getTime() === today.getTime();
});
},

View file

@ -10,9 +10,11 @@
import TaskList from '$lib/components/TaskList.svelte';
import QuickAddTask from '$lib/components/QuickAddTask.svelte';
import CollapsibleSection from '$lib/components/CollapsibleSection.svelte';
import TaskEditModal from '$lib/components/TaskEditModal.svelte';
import type { Task } from '@todo/shared';
let isLoading = $state(true);
let editingTask = $state<Task | null>(null);
onMount(async () => {
if (!authStore.isAuthenticated) {
@ -21,7 +23,13 @@
}
viewStore.setToday();
await tasksStore.fetchAllTasks();
try {
await tasksStore.fetchAllTasks();
} catch (error) {
console.error('Failed to load tasks:', error);
}
isLoading = false;
});
@ -71,6 +79,42 @@
upcomingCount === 0 &&
completedTasks.length === 0
);
// Modal handlers
function openEditModal(task: Task) {
editingTask = task;
}
function closeEditModal() {
editingTask = null;
}
async function handleSaveTask(data: Partial<Task>) {
if (!editingTask) return;
try {
// Update task
await tasksStore.updateTask(editingTask.id, data);
// Update labels if provided
if ('labelIds' in data) {
await tasksStore.updateLabels(editingTask.id, (data as any).labelIds);
}
closeEditModal();
} catch (error) {
console.error('Failed to save task:', error);
}
}
async function handleDeleteTask(taskId: string) {
try {
await tasksStore.deleteTask(taskId);
closeEditModal();
} catch (error) {
console.error('Failed to delete task:', error);
}
}
</script>
<svelte:head>
@ -114,7 +158,7 @@
variant="warning"
defaultOpen={true}
>
<TaskList tasks={overdueTasks} />
<TaskList tasks={overdueTasks} onEditTask={openEditModal} />
</CollapsibleSection>
{/if}
@ -131,7 +175,7 @@
<p>Keine Aufgaben für heute</p>
</div>
{:else}
<TaskList tasks={todayTasks} />
<TaskList tasks={todayTasks} onEditTask={openEditModal} />
{/if}
</CollapsibleSection>
@ -154,7 +198,7 @@
<h3 class="text-sm font-medium text-muted-foreground mb-2 pl-2">
{group.label} ({group.tasks.length})
</h3>
<TaskList tasks={group.tasks} />
<TaskList tasks={group.tasks} onEditTask={openEditModal} />
</div>
{/each}
</div>
@ -174,13 +218,24 @@
<p>Noch keine erledigten Aufgaben</p>
</div>
{:else}
<TaskList tasks={completedTasks} showCompleted />
<TaskList tasks={completedTasks} showCompleted onEditTask={openEditModal} />
{/if}
</CollapsibleSection>
</div>
{/if}
</div>
<!-- Task Edit Modal -->
{#if editingTask}
<TaskEditModal
task={editingTask}
open={true}
onClose={closeEditModal}
onSave={handleSaveTask}
onDelete={handleDeleteTask}
/>
{/if}
<style>
.unified-view {
padding-bottom: 100px;