diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index 13948c0..b7ea35f 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -15,8 +15,9 @@ import { Layers, Share2, Users, + CheckSquare, } from 'lucide-react'; -import { TaskList, Thought, Space } from '../../lib/types'; +import { TaskList, Thought, Space, SubTask } from '../../lib/types'; import { api, triggerHaptic } from '../../lib/api'; const getInitials = (name?: string): string => { @@ -67,6 +68,10 @@ export const TasksView: React.FC = ({ const [activeNotesTaskId, setActiveNotesTaskId] = useState(null); const [notesDraft, setNotesDraft] = useState(''); + // Subtask creation state + const [addingSubtaskTaskId, setAddingSubtaskTaskId] = useState(null); + const [subtaskDraft, setSubtaskDraft] = useState(''); + const loadData = async () => { setLoading(true); try { @@ -125,6 +130,168 @@ export const TasksView: React.FC = ({ setActiveNotesTaskId(null); }; + const handleAddSubtask = async (task: Thought, title: string) => { + const trimmed = title.trim(); + if (!trimmed) return; + triggerHaptic(10); + const newSub: SubTask = { + id: `sub_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, + title: trimmed, + isCompleted: false, + createdAt: new Date().toISOString(), + }; + const updatedSubtasks = [...(task.subtasks || []), newSub]; + setTasks((prev) => + prev.map((t) => (t.id === task.id ? { ...t, subtasks: updatedSubtasks } : t)) + ); + setSubtaskDraft(''); + await api.updateThought(task.id, { subtasks: updatedSubtasks }); + }; + + const handleToggleSubtask = async (task: Thought, subtaskId: string) => { + triggerHaptic(10); + const updatedSubtasks = (task.subtasks || []).map((s) => + s.id === subtaskId ? { ...s, isCompleted: !s.isCompleted } : s + ); + setTasks((prev) => + prev.map((t) => (t.id === task.id ? { ...t, subtasks: updatedSubtasks } : t)) + ); + await api.updateThought(task.id, { subtasks: updatedSubtasks }); + }; + + const handleDeleteSubtask = async (task: Thought, subtaskId: string) => { + triggerHaptic(10); + const updatedSubtasks = (task.subtasks || []).filter((s) => s.id !== subtaskId); + setTasks((prev) => + prev.map((t) => (t.id === task.id ? { ...t, subtasks: updatedSubtasks } : t)) + ); + await api.updateThought(task.id, { subtasks: updatedSubtasks }); + }; + + const renderTaskProgressBar = (task: Thought) => { + if (!task.subtasks || task.subtasks.length === 0) return null; + const total = task.subtasks.length; + const done = task.subtasks.filter((s) => s.isCompleted).length; + const percent = Math.round((done / total) * 100); + + return ( +
+
+ + + + {done}/{total} Unteraufgaben + + + + {percent}% + +
+
+
+
+
+ ); + }; + + const renderSubtaskList = (task: Thought) => { + const hasSubtasks = task.subtasks && task.subtasks.length > 0; + const isAdding = addingSubtaskTaskId === task.id; + + if (!hasSubtasks && !isAdding) return null; + + return ( +
+ {hasSubtasks && ( +
+ {task.subtasks!.map((sub) => ( +
+ + +
+ ))} +
+ )} + + {isAdding && ( +
{ + e.preventDefault(); + handleAddSubtask(task, subtaskDraft); + }} + className="flex items-center gap-1 pt-1" + > + setSubtaskDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + setAddingSubtaskTaskId(null); + setSubtaskDraft(''); + } + }} + placeholder="Neue Unteraufgabe… (Enter)" + autoFocus + className="flex-1 h-6 px-1.5 text-xs bg-canvas border border-accent rounded-btn text-main placeholder-faint focus:outline-none" + /> + + +
+ )} +
+ ); + }; + const handleConvertToDoc = async (task: Thought) => { triggerHaptic(20); const updated = await api.triageAction(task.id, 'keep', { convertTo: 'document' }); @@ -657,6 +824,9 @@ export const TasksView: React.FC = ({
+ {/* Subtasks Progress Bar on Title */} + {renderTaskProgressBar(task)} + {/* Notes Preview / Inline Editor */} {activeNotesTaskId === task.id ? (
@@ -695,6 +865,9 @@ export const TasksView: React.FC = ({
) : null} + {/* Subtasks Checklist & Inline Creator */} + {renderSubtaskList(task)} + {/* Footer & Movement Controls */}
@@ -714,6 +887,18 @@ export const TasksView: React.FC = ({ + Notiz )} +
@@ -764,6 +949,9 @@ export const TasksView: React.FC = ({
+ {/* Subtasks Progress Bar on Title */} + {renderTaskProgressBar(task)} + {/* Notes Preview / Inline Editor */} {activeNotesTaskId === task.id ? (
@@ -801,25 +989,56 @@ export const TasksView: React.FC = ({
) : null} + {/* Subtasks Checklist & Inline Creator */} + {renderSubtaskList(task)} + {/* Movement Controls */}
- +
+ {!task.notes && ( + + )} + +
- +
+ + + +
))} @@ -849,6 +1068,10 @@ export const TasksView: React.FC = ({ {task.content}

+ {/* Subtasks Progress Bar & Checklist */} + {renderTaskProgressBar(task)} + {renderSubtaskList(task)} +
+ {!task.notes && activeNotesTaskId !== task.id && ( +
+ + {/* Progress bar for child todos if any exist */} + {(() => { + const childTodos = getDirectChildTodos(selectedDoc?.blocks || [], originalIndex); + if (childTodos.length === 0) return null; + const doneChild = childTodos.filter((c) => c.checked).length; + const totalChild = childTodos.length; + const pct = Math.round((doneChild / totalChild) * 100); + return ( +
+
+ + {doneChild}/{totalChild} erledigt + + + {pct}% + +
+
+
+
+
+ ); + })()}
)} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 5b4e3c0..0ae2434 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -13,6 +13,7 @@ import { Team, TaskList, Thought, + SubTask, User, UserInvite, } from './types'; @@ -309,6 +310,7 @@ export const api = { taskListId?: string; kanbanStatus?: 'todo' | 'in_progress' | 'done'; notes?: string; + subtasks?: SubTask[]; } ): Promise { return request(`/api/v1/thoughts/${thoughtId}`, { diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 46e15e1..d182e69 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -34,6 +34,13 @@ export interface UserInvite { expiresAt: string; } +export interface SubTask { + id: string; + title: string; + isCompleted: boolean; + createdAt?: string; +} + export interface Thought { id: string; userId: string; @@ -49,6 +56,7 @@ export interface Thought { taskListId?: string; kanbanStatus?: 'todo' | 'in_progress' | 'done'; notes?: string; + subtasks?: SubTask[]; alarmSent?: boolean; createdAt: string; triagedAt?: string; diff --git a/internal/api/server.go b/internal/api/server.go index caecf6f..4691fd3 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -637,20 +637,21 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) { thoughtID := chi.URLParam(r, "id") var req struct { - Content *string `json:"content"` - IsCompleted *bool `json:"isCompleted"` - DueDate *string `json:"dueDate"` - Priority *string `json:"priority"` - TaskListID *string `json:"taskListId"` - KanbanStatus *string `json:"kanbanStatus"` - Notes *string `json:"notes"` + Content *string `json:"content"` + IsCompleted *bool `json:"isCompleted"` + DueDate *string `json:"dueDate"` + Priority *string `json:"priority"` + TaskListID *string `json:"taskListId"` + KanbanStatus *string `json:"kanbanStatus"` + Notes *string `json:"notes"` + Subtasks *[]domain.SubTask `json:"subtasks"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") return } - updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority, req.TaskListID, req.KanbanStatus, req.Notes) + updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority, req.TaskListID, req.KanbanStatus, req.Notes, req.Subtasks) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return diff --git a/internal/domain/models.go b/internal/domain/models.go index bc752d3..ed838e8 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -77,6 +77,13 @@ const ( StatusConverted ThoughtStatus = "converted" ) +type SubTask struct { + ID string `json:"id"` + Title string `json:"title"` + IsCompleted bool `json:"isCompleted"` + CreatedAt string `json:"createdAt,omitempty"` +} + type Thought struct { ID string `json:"id"` UserID string `json:"userId"` @@ -92,6 +99,7 @@ type Thought struct { TaskListID string `json:"taskListId,omitempty"` KanbanStatus string `json:"kanbanStatus,omitempty"` // todo | in_progress | done Notes string `json:"notes,omitempty"` + Subtasks []SubTask `json:"subtasks"` AlarmSent bool `json:"alarmSent,omitempty"` CreatedAt string `json:"createdAt"` TriagedAt string `json:"triagedAt,omitempty"` diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 2e1da3e..9e2074b 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -108,6 +108,7 @@ func (s *Store) migrate() error { kanban_status TEXT NOT NULL DEFAULT 'todo', notes TEXT NOT NULL DEFAULT '', alarm_sent INTEGER NOT NULL DEFAULT 0, + subtasks TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL, triaged_at TEXT DEFAULT '', FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE @@ -263,6 +264,7 @@ func (s *Store) migrate() error { "ALTER TABLE thoughts ADD COLUMN kanban_status TEXT NOT NULL DEFAULT 'todo';", "ALTER TABLE thoughts ADD COLUMN notes TEXT NOT NULL DEFAULT '';", "ALTER TABLE thoughts ADD COLUMN alarm_sent INTEGER NOT NULL DEFAULT 0;", + "ALTER TABLE thoughts ADD COLUMN subtasks TEXT NOT NULL DEFAULT '[]';", "ALTER TABLE documents ADD COLUMN folder_path TEXT NOT NULL DEFAULT '';", } @@ -645,13 +647,14 @@ func (s *Store) CreateThoughtWithDetails(userID, content, priority, source, dueD } _, err := s.db.Exec( - `INSERT INTO thoughts (id, user_id, content, tags, priority, source, status, is_completed, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, 0, ?)`, + `INSERT INTO thoughts (id, user_id, content, tags, priority, source, status, is_completed, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, subtasks) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, 0, ?, '[]')`, t.ID, t.UserID, t.Content, string(tagsJSON), t.Priority, t.Source, string(t.Status), t.DueDate, t.TaskListID, t.KanbanStatus, t.Notes, t.CreatedAt, ) if err != nil { return nil, err } + t.Subtasks = []domain.SubTask{} return t, nil } @@ -659,13 +662,13 @@ func scanThought(rows interface { Scan(dest ...any) error }) (*domain.Thought, error) { t := &domain.Thought{} - var tagsStr, statusStr string + var tagsStr, subtasksStr, statusStr string var isCompleted, alarmSent int err := rows.Scan( &t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source, &statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID, &t.DueDate, &t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent, - &t.CreatedAt, &t.TriagedAt, + &t.CreatedAt, &t.TriagedAt, &subtasksStr, ) if err != nil { return nil, err @@ -682,12 +685,16 @@ func scanThought(rows interface { } t.Tags = []string{} _ = json.Unmarshal([]byte(tagsStr), &t.Tags) + t.Subtasks = []domain.SubTask{} + if subtasksStr != "" && subtasksStr != "null" { + _ = json.Unmarshal([]byte(subtasksStr), &t.Subtasks) + } return t, nil } func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) { row := s.db.QueryRow( - `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at + `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (SELECT id FROM task_lists WHERE space_id IN (SELECT space_id FROM space_members WHERE user_id = ?)))`, thoughtID, userID, userID, ) @@ -703,14 +710,14 @@ func (s *Store) GetThoughts(userID, statusFilter string, limit int) ([]domain.Th if statusFilter != "" && statusFilter != "all" { rows, err = s.db.Query( - `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at + `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks FROM thoughts WHERE user_id = ? AND status = ? ORDER BY CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, created_at DESC LIMIT ?`, userID, statusFilter, limit, ) } else { rows, err = s.db.Query( - `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at + `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks FROM thoughts WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`, userID, limit, @@ -736,7 +743,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er var err error if taskListID != "" { rows, err = s.db.Query( - `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at + `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks FROM thoughts WHERE (user_id = ? OR task_list_id IN (SELECT id FROM task_lists WHERE space_id IN (SELECT space_id FROM space_members WHERE user_id = ?))) AND task_list_id = ? @@ -746,7 +753,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er ) } else { rows, err = s.db.Query( - `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at + `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks FROM thoughts WHERE (user_id = ? OR task_list_id IN (SELECT id FROM task_lists WHERE space_id IN (SELECT space_id FROM space_members WHERE user_id = ?))) AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task')) @@ -839,7 +846,7 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target return s.GetThoughtByID(userID, thoughtID) } -func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string, isCompleted *bool, dueDate *string, priority *string, taskListID *string, kanbanStatus *string, notes *string) (*domain.Thought, error) { +func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string, isCompleted *bool, dueDate *string, priority *string, taskListID *string, kanbanStatus *string, notes *string, subtasks *[]domain.SubTask) (*domain.Thought, error) { thought, err := s.GetThoughtByID(userID, thoughtID) if err != nil { return nil, err @@ -877,16 +884,20 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string, if notes != nil { thought.Notes = *notes } + if subtasks != nil { + thought.Subtasks = *subtasks + } tagsJSON, _ := json.Marshal(thought.Tags) + subtasksJSON, _ := json.Marshal(thought.Subtasks) completedInt := 0 if thought.IsCompleted { completedInt = 1 } _, err = s.db.Exec( - `UPDATE thoughts SET content = ?, tags = ?, is_completed = ?, due_date = ?, priority = ?, task_list_id = ?, kanban_status = ?, notes = ? WHERE id = ? AND user_id = ?`, - thought.Content, string(tagsJSON), completedInt, thought.DueDate, thought.Priority, thought.TaskListID, thought.KanbanStatus, thought.Notes, thoughtID, userID, + `UPDATE thoughts SET content = ?, tags = ?, is_completed = ?, due_date = ?, priority = ?, task_list_id = ?, kanban_status = ?, notes = ?, subtasks = ? WHERE id = ? AND user_id = ?`, + thought.Content, string(tagsJSON), completedInt, thought.DueDate, thought.Priority, thought.TaskListID, thought.KanbanStatus, thought.Notes, string(subtasksJSON), thoughtID, userID, ) if err != nil { return nil, err @@ -1574,7 +1585,7 @@ func (s *Store) SearchAll(userID, query string) ([]domain.Thought, []domain.Docu q := "%" + strings.ToLower(strings.TrimSpace(query)) + "%" tRows, err := s.db.Query( - `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at + `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks FROM thoughts WHERE user_id = ? AND LOWER(content) LIKE ? ORDER BY created_at DESC LIMIT 30`, userID, q, )