From b395ee810314e1497af3e2be7f6a7f646680aad6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 08:23:07 +0000 Subject: [PATCH 01/13] Add v0.1.3 UI features: metadata display, task menu, and search - Display time estimates, tags, and flag colors in TreeView and KanbanView - Add TaskMenu component with three-dot dropdown for editing metadata - Edit time estimates (stored as minutes) - Edit tags (comma-separated input) - Set flag colors (red, orange, yellow, green, blue, purple, pink) - Add SearchBar component in header - Real-time search with 300ms debounce - Optional project filtering - Click results to navigate to project - Integrate TaskMenu into both TreeView and KanbanView - Format time display: "30m" for <60 min, "1.5h" for >=60 min --- frontend/src/App.jsx | 2 + frontend/src/components/KanbanView.jsx | 70 +++++-- frontend/src/components/SearchBar.jsx | 262 +++++++++++++++++++++++++ frontend/src/components/TaskMenu.jsx | 259 ++++++++++++++++++++++++ frontend/src/components/TreeView.jsx | 78 ++++++-- 5 files changed, 636 insertions(+), 35 deletions(-) create mode 100644 frontend/src/components/SearchBar.jsx create mode 100644 frontend/src/components/TaskMenu.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 79d44d8..e01dc41 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,7 @@ import { Routes, Route } from 'react-router-dom' import ProjectList from './pages/ProjectList' import ProjectView from './pages/ProjectView' +import SearchBar from './components/SearchBar' function App() { return ( @@ -15,6 +16,7 @@ function App() { v0.1.3 + diff --git a/frontend/src/components/KanbanView.jsx b/frontend/src/components/KanbanView.jsx index 9bc9f5f..07c7b09 100644 --- a/frontend/src/components/KanbanView.jsx +++ b/frontend/src/components/KanbanView.jsx @@ -1,11 +1,13 @@ import { useState, useEffect } from 'react' -import { Plus, Edit2, Trash2, Check, X } from 'lucide-react' +import { Plus, Check, X, Flag } from 'lucide-react' import { getProjectTasks, createTask, updateTask, deleteTask } from '../utils/api' +import { formatTime } from '../utils/format' +import TaskMenu from './TaskMenu' const STATUSES = [ { key: 'backlog', label: 'Backlog', color: 'border-gray-600' }, @@ -14,6 +16,16 @@ const STATUSES = [ { key: 'done', label: 'Done', color: 'border-green-500' } ] +const FLAG_COLORS = { + red: 'bg-red-500', + orange: 'bg-orange-500', + yellow: 'bg-yellow-500', + green: 'bg-green-500', + blue: 'bg-blue-500', + purple: 'bg-purple-500', + pink: 'bg-pink-500' +} + function TaskCard({ task, allTasks, onUpdate, onDragStart }) { const [isEditing, setIsEditing] = useState(false) const [editTitle, setEditTitle] = useState(task.title) @@ -78,26 +90,56 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart }) { <>
-
{task.title}
+
+ {/* Flag indicator */} + {task.flag_color && FLAG_COLORS[task.flag_color] && ( + + )} + {task.title} +
+ + {/* Parent task context */} {parentTask && (
↳ subtask of: {parentTask.title}
)} + + {/* Metadata row */} + {(task.estimated_minutes || (task.tags && task.tags.length > 0)) && ( +
+ {/* Time estimate */} + {task.estimated_minutes && ( +
+ + {formatTime(task.estimated_minutes)} +
+ )} + + {/* Tags */} + {task.tags && task.tags.length > 0 && ( +
+ {task.tags.map((tag, idx) => ( + + {tag} + + ))} +
+ )} +
+ )}
+
- - + setIsEditing(true)} + />
diff --git a/frontend/src/components/SearchBar.jsx b/frontend/src/components/SearchBar.jsx new file mode 100644 index 0000000..eeaea18 --- /dev/null +++ b/frontend/src/components/SearchBar.jsx @@ -0,0 +1,262 @@ +import { useState, useEffect, useRef } from 'react' +import { Search, X, Flag } from 'lucide-react' +import { searchTasks, getProjects } from '../utils/api' +import { formatTime } from '../utils/format' +import { useNavigate } from 'react-router-dom' + +const FLAG_COLORS = { + red: 'text-red-500', + orange: 'text-orange-500', + yellow: 'text-yellow-500', + green: 'text-green-500', + blue: 'text-blue-500', + purple: 'text-purple-500', + pink: 'text-pink-500' +} + +function SearchBar() { + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [projects, setProjects] = useState([]) + const [selectedProjects, setSelectedProjects] = useState([]) + const [isSearching, setIsSearching] = useState(false) + const [showResults, setShowResults] = useState(false) + const [showProjectFilter, setShowProjectFilter] = useState(false) + const searchRef = useRef(null) + const navigate = useNavigate() + + useEffect(() => { + loadProjects() + }, []) + + useEffect(() => { + function handleClickOutside(event) { + if (searchRef.current && !searchRef.current.contains(event.target)) { + setShowResults(false) + setShowProjectFilter(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + const loadProjects = async () => { + try { + const data = await getProjects() + setProjects(data) + } catch (err) { + console.error('Failed to load projects:', err) + } + } + + const handleSearch = async (searchQuery) => { + if (!searchQuery.trim()) { + setResults([]) + setShowResults(false) + return + } + + setIsSearching(true) + try { + const projectIds = selectedProjects.length > 0 ? selectedProjects : null + const data = await searchTasks(searchQuery, projectIds) + setResults(data) + setShowResults(true) + } catch (err) { + console.error('Search failed:', err) + setResults([]) + } finally { + setIsSearching(false) + } + } + + const handleQueryChange = (e) => { + const newQuery = e.target.value + setQuery(newQuery) + } + + // Debounced search effect + useEffect(() => { + if (!query.trim()) { + setResults([]) + setShowResults(false) + return + } + + const timeoutId = setTimeout(() => { + handleSearch(query) + }, 300) + + return () => clearTimeout(timeoutId) + }, [query, selectedProjects]) + + const toggleProjectFilter = (projectId) => { + setSelectedProjects(prev => { + if (prev.includes(projectId)) { + return prev.filter(id => id !== projectId) + } else { + return [...prev, projectId] + } + }) + } + + const handleTaskClick = (task) => { + navigate(`/project/${task.project_id}`) + setShowResults(false) + setQuery('') + } + + const clearSearch = () => { + setQuery('') + setResults([]) + setShowResults(false) + } + + return ( +
+
+ {/* Search Input */} +
+ + query && setShowResults(true)} + placeholder="Search tasks..." + className="w-64 pl-9 pr-8 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange placeholder-gray-500" + /> + {query && ( + + )} +
+ + {/* Project Filter Button */} + {projects.length > 1 && ( + + )} +
+ + {/* Project Filter Dropdown */} + {showProjectFilter && ( +
+
+
Filter by projects:
+ {projects.map(project => ( + + ))} + {selectedProjects.length > 0 && ( + + )} +
+
+ )} + + {/* Search Results */} + {showResults && ( +
+ {isSearching ? ( +
Searching...
+ ) : results.length === 0 ? ( +
No results found
+ ) : ( +
+
+ {results.length} result{results.length !== 1 ? 's' : ''} +
+ {results.map(task => { + const project = projects.find(p => p.id === task.project_id) + return ( + + ) + })} +
+ )} +
+ )} +
+ ) +} + +export default SearchBar diff --git a/frontend/src/components/TaskMenu.jsx b/frontend/src/components/TaskMenu.jsx new file mode 100644 index 0000000..4224864 --- /dev/null +++ b/frontend/src/components/TaskMenu.jsx @@ -0,0 +1,259 @@ +import { useState, useRef, useEffect } from 'react' +import { MoreVertical, Clock, Tag, Flag, Edit2, Trash2, X, Check } from 'lucide-react' +import { updateTask } from '../utils/api' + +const FLAG_COLORS = [ + { name: 'red', color: 'bg-red-500' }, + { name: 'orange', color: 'bg-orange-500' }, + { name: 'yellow', color: 'bg-yellow-500' }, + { name: 'green', color: 'bg-green-500' }, + { name: 'blue', color: 'bg-blue-500' }, + { name: 'purple', color: 'bg-purple-500' }, + { name: 'pink', color: 'bg-pink-500' } +] + +function TaskMenu({ task, onUpdate, onDelete, onEdit }) { + const [isOpen, setIsOpen] = useState(false) + const [showTimeEdit, setShowTimeEdit] = useState(false) + const [showTagsEdit, setShowTagsEdit] = useState(false) + const [showFlagEdit, setShowFlagEdit] = useState(false) + const [editTime, setEditTime] = useState(task.estimated_minutes || '') + const [editTags, setEditTags] = useState(task.tags ? task.tags.join(', ') : '') + const menuRef = useRef(null) + + useEffect(() => { + function handleClickOutside(event) { + if (menuRef.current && !menuRef.current.contains(event.target)) { + setIsOpen(false) + setShowTimeEdit(false) + setShowTagsEdit(false) + setShowFlagEdit(false) + } + } + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + } + }, [isOpen]) + + const handleUpdateTime = async () => { + try { + const minutes = editTime ? parseInt(editTime) : null + await updateTask(task.id, { estimated_minutes: minutes }) + setShowTimeEdit(false) + setIsOpen(false) + onUpdate() + } catch (err) { + alert(`Error: ${err.message}`) + } + } + + const handleUpdateTags = async () => { + try { + const tags = editTags + ? editTags.split(',').map(t => t.trim()).filter(t => t.length > 0) + : null + await updateTask(task.id, { tags }) + setShowTagsEdit(false) + setIsOpen(false) + onUpdate() + } catch (err) { + alert(`Error: ${err.message}`) + } + } + + const handleUpdateFlag = async (color) => { + try { + await updateTask(task.id, { flag_color: color }) + setShowFlagEdit(false) + setIsOpen(false) + onUpdate() + } catch (err) { + alert(`Error: ${err.message}`) + } + } + + const handleClearFlag = async () => { + try { + await updateTask(task.id, { flag_color: null }) + setShowFlagEdit(false) + setIsOpen(false) + onUpdate() + } catch (err) { + alert(`Error: ${err.message}`) + } + } + + return ( +
+ + + {isOpen && ( +
+ {/* Time Edit */} + {showTimeEdit ? ( +
+
+ + Time Estimate (minutes) +
+
+ setEditTime(e.target.value)} + placeholder="Minutes" + className="flex-1 px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange" + autoFocus + onClick={(e) => e.stopPropagation()} + /> + + +
+
+ ) : ( + + )} + + {/* Tags Edit */} + {showTagsEdit ? ( +
+
+ + Tags (comma-separated) +
+
+ setEditTags(e.target.value)} + placeholder="coding, bug-fix" + className="flex-1 px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange" + autoFocus + onClick={(e) => e.stopPropagation()} + /> + + +
+
+ ) : ( + + )} + + {/* Flag Color Edit */} + {showFlagEdit ? ( +
+
+ + Flag Color +
+
+ {FLAG_COLORS.map(({ name, color }) => ( +
+ +
+ ) : ( + + )} + + {/* Edit Title */} + + + {/* Delete */} + +
+ )} +
+ ) +} + +export default TaskMenu diff --git a/frontend/src/components/TreeView.jsx b/frontend/src/components/TreeView.jsx index 0c3a9b9..69ba24f 100644 --- a/frontend/src/components/TreeView.jsx +++ b/frontend/src/components/TreeView.jsx @@ -3,10 +3,9 @@ import { ChevronDown, ChevronRight, Plus, - Edit2, - Trash2, Check, - X + X, + Flag } from 'lucide-react' import { getProjectTaskTree, @@ -14,6 +13,8 @@ import { updateTask, deleteTask } from '../utils/api' +import { formatTime } from '../utils/format' +import TaskMenu from './TaskMenu' const STATUS_COLORS = { backlog: 'text-gray-400', @@ -29,6 +30,16 @@ const STATUS_LABELS = { done: 'Done' } +const FLAG_COLORS = { + red: 'bg-red-500', + orange: 'bg-orange-500', + yellow: 'bg-yellow-500', + green: 'bg-green-500', + blue: 'bg-blue-500', + purple: 'bg-purple-500', + pink: 'bg-pink-500' +} + function TaskNode({ task, projectId, onUpdate, level = 0 }) { const [isExpanded, setIsExpanded] = useState(true) const [isEditing, setIsEditing] = useState(false) @@ -139,10 +150,43 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) { ) : ( <>
- {task.title} - - {STATUS_LABELS[task.status]} - +
+ {/* Flag indicator */} + {task.flag_color && FLAG_COLORS[task.flag_color] && ( + + )} + {task.title} + + {STATUS_LABELS[task.status]} + +
+ + {/* Metadata row */} + {(task.estimated_minutes || (task.tags && task.tags.length > 0)) && ( +
+ {/* Time estimate */} + {task.estimated_minutes && ( +
+ + {formatTime(task.estimated_minutes)} +
+ )} + + {/* Tags */} + {task.tags && task.tags.length > 0 && ( +
+ {task.tags.map((tag, idx) => ( + + {tag} + + ))} +
+ )} +
+ )}
{/* Actions */} @@ -154,20 +198,12 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) { > - - + setIsEditing(true)} + /> )} From 444f2744b3bd00ea0799f9e7ca010674c19457be Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 08:45:21 +0000 Subject: [PATCH 02/13] Fix missing Clock icon import causing crash with time estimates --- frontend/src/components/KanbanView.jsx | 2 +- frontend/src/components/TreeView.jsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/KanbanView.jsx b/frontend/src/components/KanbanView.jsx index 07c7b09..fe8f91f 100644 --- a/frontend/src/components/KanbanView.jsx +++ b/frontend/src/components/KanbanView.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { Plus, Check, X, Flag } from 'lucide-react' +import { Plus, Check, X, Flag, Clock } from 'lucide-react' import { getProjectTasks, createTask, diff --git a/frontend/src/components/TreeView.jsx b/frontend/src/components/TreeView.jsx index 69ba24f..d444a01 100644 --- a/frontend/src/components/TreeView.jsx +++ b/frontend/src/components/TreeView.jsx @@ -5,7 +5,8 @@ import { Plus, Check, X, - Flag + Flag, + Clock } from 'lucide-react' import { getProjectTaskTree, From 3f309163b694a1deb18bdc4bb78c116b2f237978 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 09:01:45 +0000 Subject: [PATCH 03/13] Add status change dropdown and aggregated time estimates Features: - Add "Change Status" option to TaskMenu dropdown - Allows changing task status (backlog/in progress/blocked/done) from tree view - Shows current status with checkmark - No longer need to switch to Kanban view to change status - Implement recursive time aggregation for subtasks - Tasks now show total time including all descendant subtasks - Display format varies based on estimates: - "1.5h" - only task's own estimate - "(2h from subtasks)" - only subtask estimates - "1h (3h total)" - both own and subtask estimates - Works in both TreeView (hierarchical) and KanbanView (flat list) - New utility functions: calculateTotalTime, calculateTotalTimeFlat, formatTimeWithTotal This allows better project planning by showing total time investment for tasks with subtasks. --- frontend/src/components/KanbanView.jsx | 8 ++-- frontend/src/components/TaskMenu.jsx | 58 +++++++++++++++++++++++++- frontend/src/components/TreeView.jsx | 8 ++-- frontend/src/utils/format.js | 56 +++++++++++++++++++++++++ 4 files changed, 121 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/KanbanView.jsx b/frontend/src/components/KanbanView.jsx index fe8f91f..9d63c29 100644 --- a/frontend/src/components/KanbanView.jsx +++ b/frontend/src/components/KanbanView.jsx @@ -6,7 +6,7 @@ import { updateTask, deleteTask } from '../utils/api' -import { formatTime } from '../utils/format' +import { formatTimeWithTotal } from '../utils/format' import TaskMenu from './TaskMenu' const STATUSES = [ @@ -106,13 +106,13 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart }) { )} {/* Metadata row */} - {(task.estimated_minutes || (task.tags && task.tags.length > 0)) && ( + {(formatTimeWithTotal(task, allTasks) || (task.tags && task.tags.length > 0)) && (
{/* Time estimate */} - {task.estimated_minutes && ( + {formatTimeWithTotal(task, allTasks) && (
- {formatTime(task.estimated_minutes)} + {formatTimeWithTotal(task, allTasks)}
)} diff --git a/frontend/src/components/TaskMenu.jsx b/frontend/src/components/TaskMenu.jsx index 4224864..ddd08a2 100644 --- a/frontend/src/components/TaskMenu.jsx +++ b/frontend/src/components/TaskMenu.jsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect } from 'react' -import { MoreVertical, Clock, Tag, Flag, Edit2, Trash2, X, Check } from 'lucide-react' +import { MoreVertical, Clock, Tag, Flag, Edit2, Trash2, X, Check, ListTodo } from 'lucide-react' import { updateTask } from '../utils/api' const FLAG_COLORS = [ @@ -12,11 +12,19 @@ const FLAG_COLORS = [ { name: 'pink', color: 'bg-pink-500' } ] +const STATUSES = [ + { key: 'backlog', label: 'Backlog', color: 'text-gray-400' }, + { key: 'in_progress', label: 'In Progress', color: 'text-blue-400' }, + { key: 'blocked', label: 'Blocked', color: 'text-red-400' }, + { key: 'done', label: 'Done', color: 'text-green-400' } +] + function TaskMenu({ task, onUpdate, onDelete, onEdit }) { const [isOpen, setIsOpen] = useState(false) const [showTimeEdit, setShowTimeEdit] = useState(false) const [showTagsEdit, setShowTagsEdit] = useState(false) const [showFlagEdit, setShowFlagEdit] = useState(false) + const [showStatusEdit, setShowStatusEdit] = useState(false) const [editTime, setEditTime] = useState(task.estimated_minutes || '') const [editTags, setEditTags] = useState(task.tags ? task.tags.join(', ') : '') const menuRef = useRef(null) @@ -28,6 +36,7 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit }) { setShowTimeEdit(false) setShowTagsEdit(false) setShowFlagEdit(false) + setShowStatusEdit(false) } } @@ -85,6 +94,17 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit }) { } } + const handleUpdateStatus = async (newStatus) => { + try { + await updateTask(task.id, { status: newStatus }) + setShowStatusEdit(false) + setIsOpen(false) + onUpdate() + } catch (err) { + alert(`Error: ${err.message}`) + } + } + return (
+ ))} +
+
+ ) : ( + + )} + {/* Edit Title */} - - - + setShowAddTask(false)} + submitLabel="Add Task" + /> )} diff --git a/frontend/src/components/TaskForm.jsx b/frontend/src/components/TaskForm.jsx new file mode 100644 index 0000000..8a85ae4 --- /dev/null +++ b/frontend/src/components/TaskForm.jsx @@ -0,0 +1,142 @@ +import { useState } from 'react' +import { Flag } from 'lucide-react' + +const FLAG_COLORS = [ + { name: null, label: 'None', color: 'bg-gray-700' }, + { name: 'red', label: 'Red', color: 'bg-red-500' }, + { name: 'orange', label: 'Orange', color: 'bg-orange-500' }, + { name: 'yellow', label: 'Yellow', color: 'bg-yellow-500' }, + { name: 'green', label: 'Green', color: 'bg-green-500' }, + { name: 'blue', label: 'Blue', color: 'bg-blue-500' }, + { name: 'purple', label: 'Purple', color: 'bg-purple-500' }, + { name: 'pink', label: 'Pink', color: 'bg-pink-500' } +] + +function TaskForm({ onSubmit, onCancel, submitLabel = "Add" }) { + const [title, setTitle] = useState('') + const [tags, setTags] = useState('') + const [hours, setHours] = useState('') + const [minutes, setMinutes] = useState('') + const [flagColor, setFlagColor] = useState(null) + + const handleSubmit = (e) => { + e.preventDefault() + if (!title.trim()) return + + // Convert hours and minutes to total minutes + const totalMinutes = (parseInt(hours) || 0) * 60 + (parseInt(minutes) || 0) + + // Parse tags + const tagList = tags + ? tags.split(',').map(t => t.trim()).filter(t => t.length > 0) + : null + + const taskData = { + title: title.trim(), + tags: tagList && tagList.length > 0 ? tagList : null, + estimated_minutes: totalMinutes > 0 ? totalMinutes : null, + flag_color: flagColor + } + + onSubmit(taskData) + } + + return ( +
+ {/* Title */} +
+ + setTitle(e.target.value)} + placeholder="Enter task title..." + className="w-full px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange" + autoFocus + /> +
+ + {/* Tags */} +
+ + setTags(e.target.value)} + placeholder="coding, bug-fix, frontend" + className="w-full px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange" + /> +
+ + {/* Time Estimate */} +
+ +
+
+ setHours(e.target.value)} + placeholder="Hours" + className="w-full px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange" + /> +
+
+ setMinutes(e.target.value)} + placeholder="Minutes" + className="w-full px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange" + /> +
+
+
+ + {/* Flag Color */} +
+ +
+ {FLAG_COLORS.map(({ name, label, color }) => ( + + ))} +
+
+ + {/* Buttons */} +
+ + +
+
+ ) +} + +export default TaskForm diff --git a/frontend/src/components/TaskMenu.jsx b/frontend/src/components/TaskMenu.jsx index ddd08a2..5c4ff49 100644 --- a/frontend/src/components/TaskMenu.jsx +++ b/frontend/src/components/TaskMenu.jsx @@ -25,7 +25,13 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit }) { const [showTagsEdit, setShowTagsEdit] = useState(false) const [showFlagEdit, setShowFlagEdit] = useState(false) const [showStatusEdit, setShowStatusEdit] = useState(false) - const [editTime, setEditTime] = useState(task.estimated_minutes || '') + + // Calculate hours and minutes from task.estimated_minutes + const initialHours = task.estimated_minutes ? Math.floor(task.estimated_minutes / 60) : '' + const initialMinutes = task.estimated_minutes ? task.estimated_minutes % 60 : '' + + const [editHours, setEditHours] = useState(initialHours) + const [editMinutes, setEditMinutes] = useState(initialMinutes) const [editTags, setEditTags] = useState(task.tags ? task.tags.join(', ') : '') const menuRef = useRef(null) @@ -48,7 +54,8 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit }) { const handleUpdateTime = async () => { try { - const minutes = editTime ? parseInt(editTime) : null + const totalMinutes = (parseInt(editHours) || 0) * 60 + (parseInt(editMinutes) || 0) + const minutes = totalMinutes > 0 ? totalMinutes : null await updateTask(task.id, { estimated_minutes: minutes }) setShowTimeEdit(false) setIsOpen(false) @@ -125,29 +132,42 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit }) {
- Time Estimate (minutes) + Time Estimate
-
+
setEditTime(e.target.value)} - placeholder="Minutes" + min="0" + value={editHours} + onChange={(e) => setEditHours(e.target.value)} + placeholder="Hours" className="flex-1 px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange" autoFocus onClick={(e) => e.stopPropagation()} /> + setEditMinutes(e.target.value)} + placeholder="Minutes" + className="flex-1 px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange" + onClick={(e) => e.stopPropagation()} + /> +
+
diff --git a/frontend/src/components/TreeView.jsx b/frontend/src/components/TreeView.jsx index ac1ed90..f9c9de1 100644 --- a/frontend/src/components/TreeView.jsx +++ b/frontend/src/components/TreeView.jsx @@ -16,6 +16,7 @@ import { } from '../utils/api' import { formatTimeWithTotal } from '../utils/format' import TaskMenu from './TaskMenu' +import TaskForm from './TaskForm' const STATUS_COLORS = { backlog: 'text-gray-400', @@ -47,7 +48,6 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) { const [editTitle, setEditTitle] = useState(task.title) const [editStatus, setEditStatus] = useState(task.status) const [showAddSubtask, setShowAddSubtask] = useState(false) - const [newSubtaskTitle, setNewSubtaskTitle] = useState('') const hasSubtasks = task.subtasks && task.subtasks.length > 0 @@ -74,18 +74,17 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) { } } - const handleAddSubtask = async (e) => { - e.preventDefault() - if (!newSubtaskTitle.trim()) return - + const handleAddSubtask = async (taskData) => { try { await createTask({ project_id: parseInt(projectId), parent_task_id: task.id, - title: newSubtaskTitle, - status: 'backlog' + title: taskData.title, + status: 'backlog', + tags: taskData.tags, + estimated_minutes: taskData.estimated_minutes, + flag_color: taskData.flag_color }) - setNewSubtaskTitle('') setShowAddSubtask(false) setIsExpanded(true) onUpdate() @@ -213,29 +212,11 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) { {/* Add Subtask Form */} {showAddSubtask && (
-
- setNewSubtaskTitle(e.target.value)} - placeholder="New subtask title..." - className="flex-1 px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange" - autoFocus - /> - - -
+ setShowAddSubtask(false)} + submitLabel="Add Subtask" + />
)} @@ -262,7 +243,6 @@ function TreeView({ projectId }) { const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [showAddRoot, setShowAddRoot] = useState(false) - const [newTaskTitle, setNewTaskTitle] = useState('') useEffect(() => { loadTasks() @@ -280,18 +260,17 @@ function TreeView({ projectId }) { } } - const handleAddRootTask = async (e) => { - e.preventDefault() - if (!newTaskTitle.trim()) return - + const handleAddRootTask = async (taskData) => { try { await createTask({ project_id: parseInt(projectId), parent_task_id: null, - title: newTaskTitle, - status: 'backlog' + title: taskData.title, + status: 'backlog', + tags: taskData.tags, + estimated_minutes: taskData.estimated_minutes, + flag_color: taskData.flag_color }) - setNewTaskTitle('') setShowAddRoot(false) loadTasks() } catch (err) { @@ -322,29 +301,11 @@ function TreeView({ projectId }) { {showAddRoot && (
-
- setNewTaskTitle(e.target.value)} - placeholder="New task title..." - className="flex-1 px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 focus:outline-none focus:border-cyber-orange" - autoFocus - /> - - -
+ setShowAddRoot(false)} + submitLabel="Add Task" + />
)} diff --git a/frontend/src/utils/format.js b/frontend/src/utils/format.js index 3e073f6..6139222 100644 --- a/frontend/src/utils/format.js +++ b/frontend/src/utils/format.js @@ -1,4 +1,4 @@ -// Format minutes into display string +// Format minutes into display string (e.g., "1h 30m" or "45m") export function formatTime(minutes) { if (!minutes || minutes === 0) return null; @@ -6,8 +6,14 @@ export function formatTime(minutes) { return `${minutes}m`; } - const hours = minutes / 60; - return `${hours.toFixed(1)}h`; + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + + if (mins === 0) { + return `${hours}h`; + } + + return `${hours}h ${mins}m`; } // Format tags as comma-separated string @@ -16,58 +22,63 @@ export function formatTags(tags) { return tags.join(', '); } -// Recursively calculate total time estimate including all subtasks -export function calculateTotalTime(task) { - let total = task.estimated_minutes || 0; +// Calculate sum of all LEAF descendant estimates (hierarchical structure) +export function calculateLeafTime(task) { + // If no subtasks, this is a leaf - return its own estimate + if (!task.subtasks || task.subtasks.length === 0) { + return task.estimated_minutes || 0; + } - if (task.subtasks && task.subtasks.length > 0) { - for (const subtask of task.subtasks) { - total += calculateTotalTime(subtask); - } + // Has subtasks, so sum up all leaf descendants + let total = 0; + for (const subtask of task.subtasks) { + total += calculateLeafTime(subtask); } return total; } -// Calculate total time for a task from a flat list of all tasks -export function calculateTotalTimeFlat(task, allTasks) { - let total = task.estimated_minutes || 0; - - // Find all direct children +// Calculate sum of all LEAF descendant estimates (flat task list) +export function calculateLeafTimeFlat(task, allTasks) { + // Find direct children const children = allTasks.filter(t => t.parent_task_id === task.id); - // Recursively add their times + // If no children, this is a leaf - return its own estimate + if (children.length === 0) { + return task.estimated_minutes || 0; + } + + // Has children, so sum up all leaf descendants + let total = 0; for (const child of children) { - total += calculateTotalTimeFlat(child, allTasks); + total += calculateLeafTimeFlat(child, allTasks); } return total; } -// Format time display showing own estimate and total if different +// Format time display based on leaf calculation logic export function formatTimeWithTotal(task, allTasks = null) { - const ownTime = task.estimated_minutes || 0; + // Check if task has subtasks + const hasSubtasks = allTasks + ? allTasks.some(t => t.parent_task_id === task.id) + : (task.subtasks && task.subtasks.length > 0); - // If we have a flat task list, use that to calculate total - const totalTime = allTasks - ? calculateTotalTimeFlat(task, allTasks) - : calculateTotalTime(task); - - const subtaskTime = totalTime - ownTime; - - // No time estimates at all - if (totalTime === 0) return null; - - // Only own estimate, no subtasks with time - if (subtaskTime === 0) { - return formatTime(ownTime); + // Leaf task: use own estimate + if (!hasSubtasks) { + return formatTime(task.estimated_minutes); } - // Only subtask estimates, no own estimate - if (ownTime === 0) { - return `(${formatTime(totalTime)} from subtasks)`; + // Parent task: calculate sum of leaf descendants + const leafTotal = allTasks + ? calculateLeafTimeFlat(task, allTasks) + : calculateLeafTime(task); + + // If no leaf estimates exist, fall back to own estimate + if (leafTotal === 0) { + return formatTime(task.estimated_minutes); } - // Both own and subtask estimates - return `${formatTime(ownTime)} (${formatTime(totalTime)} total)`; + // Show leaf total + return formatTime(leafTotal); } From 718e5acbe2daf11bd4495dcb8db8c633ac1f4f0a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 09:46:17 +0000 Subject: [PATCH 05/13] Exclude 'done' tasks from parent time estimates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a leaf task is marked as 'done', it no longer contributes to its parent's time estimate. This shows remaining work rather than total estimated work. Example: - Parent with 2 subtasks (30m each) shows 1h - Mark one subtask as done → parent now shows 30m - Mark both done → parent shows 0m (or own estimate if set) This provides accurate tracking of remaining time across the task hierarchy. --- frontend/src/utils/format.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/frontend/src/utils/format.js b/frontend/src/utils/format.js index 6139222..eb8373e 100644 --- a/frontend/src/utils/format.js +++ b/frontend/src/utils/format.js @@ -23,13 +23,14 @@ export function formatTags(tags) { } // Calculate sum of all LEAF descendant estimates (hierarchical structure) +// Excludes tasks marked as "done" export function calculateLeafTime(task) { - // If no subtasks, this is a leaf - return its own estimate + // If no subtasks, this is a leaf - return its own estimate if not done if (!task.subtasks || task.subtasks.length === 0) { - return task.estimated_minutes || 0; + return (task.status !== 'done' && task.estimated_minutes) ? task.estimated_minutes : 0; } - // Has subtasks, so sum up all leaf descendants + // Has subtasks, so sum up all leaf descendants (excluding done tasks) let total = 0; for (const subtask of task.subtasks) { total += calculateLeafTime(subtask); @@ -39,16 +40,17 @@ export function calculateLeafTime(task) { } // Calculate sum of all LEAF descendant estimates (flat task list) +// Excludes tasks marked as "done" export function calculateLeafTimeFlat(task, allTasks) { // Find direct children const children = allTasks.filter(t => t.parent_task_id === task.id); - // If no children, this is a leaf - return its own estimate + // If no children, this is a leaf - return its own estimate if not done if (children.length === 0) { - return task.estimated_minutes || 0; + return (task.status !== 'done' && task.estimated_minutes) ? task.estimated_minutes : 0; } - // Has children, so sum up all leaf descendants + // Has children, so sum up all leaf descendants (excluding done tasks) let total = 0; for (const child of children) { total += calculateLeafTimeFlat(child, allTasks); From 8000a464c9b75e2371357bcd875795f4a5cb03dd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 16:13:00 +0000 Subject: [PATCH 06/13] Release v0.1.4: Auto-complete parents and done task strikethrough New Features: 1. Auto-Complete Parent Tasks - When all child tasks are marked as "done", parent automatically becomes "done" - Works recursively up the task hierarchy - Implemented in backend crud.py with check_and_update_parent_status() - Prevents manual status management for completed branches 2. Strikethrough for Done Tasks - Time estimates crossed out when task status is "done" - Visual indicator that work is completed - Applied in both TreeView and KanbanView 3. Updated Version - Bumped to v0.1.4 in App.jsx header 4. Documentation - Added comprehensive CHANGELOG.md - Updated README.md with v0.1.4 features - Documented all versions from v0.1.0 to v0.1.4 - Added usage examples, architecture diagrams, troubleshooting Technical Changes: - backend/app/crud.py: Added check_and_update_parent_status() recursive function - frontend/src/components/TreeView.jsx: Added line-through styling for done tasks - frontend/src/components/KanbanView.jsx: Added line-through styling for done tasks - frontend/src/App.jsx: Version updated to v0.1.4 This release completes the intelligent time tracking and auto-completion features, making TESSERACT a fully-featured hierarchical task management system. --- CHANGELOG.md | 157 +++++++++++++++++++++++++ README.md | Bin 7337 -> 11146 bytes backend/app/crud.py | 38 ++++++ frontend/src/App.jsx | 2 +- frontend/src/components/KanbanView.jsx | 2 +- frontend/src/components/TreeView.jsx | 2 +- 6 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2e0f29a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,157 @@ +# Changelog + +All notable changes to TESSERACT will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.4] - 2025-01-XX + +### Added +- Strikethrough styling for time estimates when tasks are marked as "done" +- Auto-complete parent tasks when all child tasks are marked as "done" + - Works recursively up the task hierarchy + - Parents automatically transition to "done" status when all children complete + +### Changed +- Time estimates on completed tasks now display with strikethrough decoration +- Parent task status automatically updates based on children completion state + +## [0.1.3] - 2025-01-XX + +### Added +- Enhanced task creation forms with metadata fields + - Title field (required) + - Tags field (comma-separated input) + - Time estimate fields (hours and minutes) + - Flag color selector with 8 color options +- TaskForm component for consistent task creation across views +- Status change dropdown in TaskMenu (no longer requires Kanban view) +- Leaf-based time calculation system + - Parent tasks show sum of descendant leaf task estimates + - Prevents double-counting when both parents and children have estimates + - Excludes "done" tasks from time calculations +- Time format changed from decimal hours to hours + minutes (e.g., "1h 30m" instead of "1.5h") +- CHANGELOG.md and README.md documentation + +### Changed +- Task creation now includes all metadata fields upfront +- Time estimates display remaining work (excludes completed tasks) +- Time input uses separate hours/minutes fields instead of single minutes field +- Parent task estimates calculated from leaf descendants only + +### Fixed +- Time calculation now accurately represents remaining work +- Time format more human-readable with hours and minutes + +## [0.1.2] - 2025-01-XX + +### Added +- Metadata fields for tasks: + - `estimated_minutes` (Integer) - Time estimate stored in minutes + - `tags` (JSON Array) - Categorization tags + - `flag_color` (String) - Priority flag with 7 color options +- TaskMenu component with three-dot dropdown + - Edit time estimates + - Edit tags (comma-separated) + - Set flag colors + - Edit task title + - Delete tasks +- SearchBar component in header + - Real-time search with 300ms debounce + - Optional project filtering + - Click results to navigate to project + - Displays metadata in results +- Time and tag display in TreeView and KanbanView +- Flag color indicators on tasks +- Backend search endpoint `/api/search` with project filtering + +### Changed +- TreeView and KanbanView now display task metadata +- Enhanced visual design with metadata badges + +## [0.1.1] - 2025-01-XX + +### Fixed +- Tree view indentation now scales properly with nesting depth + - Changed from fixed `ml-6` to calculated `marginLeft: ${level * 1.5}rem` + - Each nesting level adds 1.5rem (24px) of indentation +- Kanban view subtask handling + - All tasks (including subtasks) now appear as individual draggable cards + - Subtasks show parent context: "↳ subtask of: [parent name]" + - Removed nested subtask list display + +### Changed +- Improved visual hierarchy in tree view +- Better subtask representation in Kanban board + +## [0.1.0] - 2025-01-XX + +### Added +- Initial MVP release +- Core Features: + - Arbitrary-depth nested task hierarchies + - Two view modes: Tree View and Kanban Board + - Self-hosted architecture with Docker deployment + - JSON import for LLM-generated task trees +- Technology Stack: + - Backend: Python FastAPI with SQLAlchemy ORM + - Database: SQLite with self-referencing Task model + - Frontend: React + Tailwind CSS + - Deployment: Docker with nginx reverse proxy +- Project Management: + - Create/read/update/delete projects + - Project-specific task trees +- Task Management: + - Create tasks with title, description, status + - Four status types: Backlog, In Progress, Blocked, Done + - Hierarchical task nesting (task → subtask → sub-subtask → ...) + - Add subtasks to any task + - Delete tasks (cascading to all subtasks) +- Tree View: + - Collapsible hierarchical display + - Expand/collapse subtasks + - Visual nesting indentation + - Inline editing + - Status display +- Kanban Board: + - Four columns: Backlog, In Progress, Blocked, Done + - Drag-and-drop to change status + - All tasks shown as cards (including subtasks) +- JSON Import: + - Bulk import task trees from JSON files + - Supports arbitrary nesting depth + - Example import file included +- UI/UX: + - Dark cyberpunk theme + - Orange (#ff6b35) accent color + - Responsive design + - Real-time updates + +### Technical Details +- Backend API endpoints: + - `/api/projects` - Project CRUD + - `/api/tasks` - Task CRUD + - `/api/projects/{id}/tree` - Hierarchical task tree + - `/api/projects/{id}/tasks` - Flat task list + - `/api/projects/{id}/import` - JSON import +- Database Schema: + - `projects` table with id, name, description + - `tasks` table with self-referencing `parent_task_id` +- Frontend Routing: + - `/` - Project list + - `/project/:id` - Project view with Tree/Kanban toggle +- Docker Setup: + - Multi-stage builds for optimization + - Nginx reverse proxy configuration + - Named volumes for database persistence + - Development and production configurations + +## Project Information + +**TESSERACT** - Task Decomposition Engine +A self-hosted web application for managing deeply nested todo trees with advanced time tracking and project planning capabilities. + +**Repository**: https://github.com/serversdwn/tesseract +**License**: MIT +**Author**: serversdwn diff --git a/README.md b/README.md index 8907cf24d7ca26e3c46a4f7ee15b22e0ee74c6ce..811b11c89259cbd66652db57efb17b6e357e4bce 100644 GIT binary patch literal 11146 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 literal 7337 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 OJ@{m(3PhLsWAb0zp`$AR diff --git a/backend/app/crud.py b/backend/app/crud.py index db0f379..17b7924 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -92,6 +92,32 @@ def get_task_with_subtasks(db: Session, task_id: int) -> Optional[models.Task]: ).filter(models.Task.id == task_id).first() +def check_and_update_parent_status(db: Session, parent_id: int): + """Check if all children of a parent are done, and mark parent as done if so""" + # Get all children of this parent + children = db.query(models.Task).filter( + models.Task.parent_task_id == parent_id + ).all() + + # If no children, nothing to do + if not children: + return + + # Check if all children are done + all_done = all(child.status == models.TaskStatus.DONE for child in children) + + if all_done: + # Mark parent as done + parent = get_task(db, parent_id) + if parent and parent.status != models.TaskStatus.DONE: + parent.status = models.TaskStatus.DONE + db.commit() + + # Recursively check grandparent + if parent.parent_task_id: + check_and_update_parent_status(db, parent.parent_task_id) + + def update_task( db: Session, task_id: int, task: schemas.TaskUpdate ) -> Optional[models.Task]: @@ -100,11 +126,23 @@ def update_task( return None update_data = task.model_dump(exclude_unset=True) + status_changed = False + + # Check if status is being updated + if "status" in update_data: + status_changed = True + old_status = db_task.status + for key, value in update_data.items(): setattr(db_task, key, value) db.commit() db.refresh(db_task) + + # If status changed to 'done' and this task has a parent, check if parent should auto-complete + if status_changed and db_task.status == models.TaskStatus.DONE and db_task.parent_task_id: + check_and_update_parent_status(db, db_task.parent_task_id) + return db_task diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e01dc41..175785c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -13,7 +13,7 @@ function App() {

TESSERACT Task Decomposition Engine - v0.1.3 + v0.1.4

diff --git a/frontend/src/components/KanbanView.jsx b/frontend/src/components/KanbanView.jsx index 6474978..43327bb 100644 --- a/frontend/src/components/KanbanView.jsx +++ b/frontend/src/components/KanbanView.jsx @@ -111,7 +111,7 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart }) {
{/* Time estimate */} {formatTimeWithTotal(task, allTasks) && ( -
+
{formatTimeWithTotal(task, allTasks)}
diff --git a/frontend/src/components/TreeView.jsx b/frontend/src/components/TreeView.jsx index f9c9de1..7a3a2ce 100644 --- a/frontend/src/components/TreeView.jsx +++ b/frontend/src/components/TreeView.jsx @@ -166,7 +166,7 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
{/* Time estimate */} {formatTimeWithTotal(task) && ( -
+
{formatTimeWithTotal(task)}
From 66b019c60b739fe832456cb0c2c738230114370a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 17:59:53 +0000 Subject: [PATCH 07/13] Release v0.1.5: Nested Kanban View MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major Feature: Nested Kanban Board - Parent tasks now appear in each column where they have subtasks - Provides hierarchical context while maintaining status-based organization - Eliminates need to choose between hierarchy and status views Parent Card Features: 1. Multi-Column Presence - Parent card appears in every column containing its descendants - Shows "X of Y subtasks in this column" counter - Automatically updates as children move between columns 2. Expandable/Collapsible - Click chevron to show/hide children in that specific column - Each parent instance independently expandable - Children displayed nested with indentation 3. Visual Distinction - Thicker orange border (border-2 vs border) - Bold text styling - "bg-cyber-darker" background instead of "bg-cyber-darkest" - Non-draggable (only leaf tasks can be moved) 4. Recursive Display - getDescendantsInStatus() finds all descendants (not just direct children) - Handles arbitrary nesting depth - Works with sub-subtasks and beyond Technical Implementation: - Added helper functions: - getDescendantsInStatus(taskId, allTasks, status) - hasDescendantsInStatus(taskId, allTasks, status) - Modified TaskCard component with isParent and columnStatus props - Updated KanbanColumn to show both parent and leaf tasks - Only root-level tasks shown (nested children appear when parent expanded) Display Logic: - Each column shows: 1. Root parent tasks with descendants in that status 2. Root leaf tasks with that status - Leaf tasks: tasks with no children - Parent tasks: tasks with at least one child Example Usage: Project "Build Feature" ├─ Backend (2 subtasks in backlog, 1 in progress) └─ Frontend (1 subtask in done) Result: Project card appears in 3 columns: - Backlog: "2 of 3 subtasks in this column" - In Progress: "1 of 3 subtasks in this column" - Done: "1 of 3 subtasks in this column" Documentation: - Updated README with nested Kanban explanation - Added v0.1.5 section to CHANGELOG - Updated version to v0.1.5 in App.jsx - Moved "Nested Kanban" from roadmap to completed features This completes the hierarchical task management vision for TESSERACT, allowing users to see both project structure and status distribution simultaneously without switching views. --- CHANGELOG.md | 25 +++ README.md | 31 ++- frontend/src/App.jsx | 2 +- frontend/src/components/KanbanView.jsx | 299 ++++++++++++++++--------- 4 files changed, 242 insertions(+), 115 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e0f29a..68585c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ All notable changes to TESSERACT will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.5] - 2025-01-XX + +### Added +- **Nested Kanban View** - Major feature implementation + - Parent tasks now appear in each column where they have subtasks + - Parent cards show "X of Y subtasks in this column" indicator + - Parent cards are expandable/collapsible to show children in that column + - Parent cards have distinct visual styling (thicker orange border, bold text) + - Only leaf tasks (tasks with no children) are draggable + - Parent cards automatically appear in multiple columns as children move +- Helper functions for nested Kanban logic: + - `getDescendantsInStatus()` - Get all descendant tasks in a specific status + - `hasDescendantsInStatus()` - Check if parent has any descendants in a status + +### Changed +- Kanban board now labeled "Kanban Board (Nested View)" +- Parent task cards cannot be dragged (only leaf tasks) +- Column task counts now include parent cards +- Improved visual hierarchy with parent/child distinction + +### Improved +- Better visualization of task distribution across statuses +- Easier to see project structure while maintaining status-based organization +- Parent tasks provide context for subtasks in each column + ## [0.1.4] - 2025-01-XX ### Added diff --git a/README.md b/README.md index 811b11c..f202e95 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Task Decomposition Engine** - A self-hosted web application for managing deeply nested todo trees with advanced time tracking and project planning capabilities. -![Version](https://img.shields.io/badge/version-0.1.4-orange) +![Version](https://img.shields.io/badge/version-0.1.5-orange) ![License](https://img.shields.io/badge/license-MIT-blue) ## Overview @@ -12,7 +12,9 @@ TESSERACT is designed for complex project management where tasks naturally decom ### Key Features - **Arbitrary-Depth Nesting**: Create tasks within tasks within tasks - no limits on hierarchy depth -- **Dual View Modes**: Toggle between Tree View (hierarchical) and Kanban Board (status-based) +- **Dual View Modes**: + - **Tree View**: Hierarchical collapsible tree with full nesting + - **Nested Kanban**: Status-based board where parent tasks appear in multiple columns - **Intelligent Time Tracking**: - Leaf-based time calculation (parent times = sum of descendant leaf tasks) - Automatic exclusion of completed tasks from time estimates @@ -101,13 +103,29 @@ docker-compose down -v **Expand/Collapse**: Click chevron icon to show/hide subtasks -#### Kanban View +#### Kanban View (Nested) -**Add Task**: Click "+" in any column to create a task with that status +The Kanban board displays tasks in a nested hierarchy while maintaining status-based columns: -**Move Tasks**: Drag and drop cards between columns to change status +**Parent Cards**: +- Appear in **every column** where they have subtasks +- Display "X of Y subtasks in this column" counter +- Have distinct styling (thick orange border, bold title) +- Click chevron to expand/collapse and see children in that column +- Cannot be dragged (only leaf tasks are draggable) -**Edit Tasks**: Use the three-dot menu same as Tree View +**Leaf Tasks**: +- Appear only in their status column +- Fully draggable between columns +- Standard card styling + +**Add Task**: Click "+" in any column to create a root-level task + +**Move Tasks**: Drag and drop leaf task cards between columns + +**Edit Tasks**: Use the three-dot menu on any card (parent or leaf) + +**Example**: A project with backend (2 tasks in backlog, 1 in progress) and frontend (1 in done) will show the project card in all three columns with appropriate counts. ### Understanding Time Estimates @@ -354,7 +372,6 @@ Ensure JSON structure matches schema. Common issues: See [CHANGELOG.md](CHANGELOG.md) for version history. ### v0.2.0 (Planned) -- Nested Kanban view (parent cards split across columns) - Task dependencies and blocking relationships - Due dates and calendar view - Progress tracking (% complete) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 175785c..d3d3ed9 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -13,7 +13,7 @@ function App() {

TESSERACT Task Decomposition Engine - v0.1.4 + v0.1.5

diff --git a/frontend/src/components/KanbanView.jsx b/frontend/src/components/KanbanView.jsx index 43327bb..0f85aa2 100644 --- a/frontend/src/components/KanbanView.jsx +++ b/frontend/src/components/KanbanView.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { Plus, Check, X, Flag, Clock } from 'lucide-react' +import { Plus, Check, X, Flag, Clock, ChevronDown, ChevronRight } from 'lucide-react' import { getProjectTasks, createTask, @@ -27,14 +27,31 @@ const FLAG_COLORS = { pink: 'bg-pink-500' } -function TaskCard({ task, allTasks, onUpdate, onDragStart }) { - const [isEditing, setIsEditing] = useState(false) - const [editTitle, setEditTitle] = useState(task.title) +// Helper function to get all descendant tasks of a parent in a specific status +function getDescendantsInStatus(taskId, allTasks, status) { + const children = allTasks.filter(t => t.parent_task_id === taskId) + let descendants = [] - // Find parent task if this is a subtask - const parentTask = task.parent_task_id - ? allTasks.find(t => t.id === task.parent_task_id) - : null + for (const child of children) { + if (child.status === status) { + descendants.push(child) + } + // Recursively get descendants + descendants = descendants.concat(getDescendantsInStatus(child.id, allTasks, status)) + } + + return descendants +} + +// Helper function to check if a task has any descendants in a status +function hasDescendantsInStatus(taskId, allTasks, status) { + return getDescendantsInStatus(taskId, allTasks, status).length > 0 +} + +function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatus }) { + const [isEditing, setIsEditing] = useState(false) + const [isExpanded, setIsExpanded] = useState(false) + const [editTitle, setEditTitle] = useState(task.title) const handleSave = async () => { try { @@ -56,100 +73,143 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart }) { } } + // For parent cards, get children in this column's status + const childrenInColumn = isParent ? getDescendantsInStatus(task.id, allTasks, columnStatus) : [] + const totalChildren = isParent ? allTasks.filter(t => t.parent_task_id === task.id).length : 0 + return ( -
onDragStart(e, task)} - className="bg-cyber-darkest border border-cyber-orange/30 rounded-lg p-3 mb-2 cursor-move hover:border-cyber-orange/60 transition-all group" - > - {isEditing ? ( -
- setEditTitle(e.target.value)} - className="flex-1 px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange" - autoFocus - /> - - -
- ) : ( - <> -
-
-
- {/* Flag indicator */} - {task.flag_color && FLAG_COLORS[task.flag_color] && ( - - )} - {task.title} +
+
!isParent && onDragStart(e, task)} + className={`${ + isParent + ? 'bg-cyber-darker border-2 border-cyber-orange/50' + : 'bg-cyber-darkest border border-cyber-orange/30' + } rounded-lg p-3 ${!isParent ? 'cursor-move' : ''} hover:border-cyber-orange/60 transition-all group`} + > + {isEditing ? ( +
+ setEditTitle(e.target.value)} + className="flex-1 px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange" + autoFocus + /> + + +
+ ) : ( + <> +
+
+
+ {/* Expand/collapse for parent cards */} + {isParent && childrenInColumn.length > 0 && ( + + )} + +
+
+ {/* Flag indicator */} + {task.flag_color && FLAG_COLORS[task.flag_color] && ( + + )} + + {task.title} + +
+ + {/* Parent card info: show subtask count in this column */} + {isParent && ( +
+ {childrenInColumn.length} of {totalChildren} subtask{totalChildren !== 1 ? 's' : ''} in this column +
+ )} + + {/* Metadata row */} + {(formatTimeWithTotal(task, allTasks) || (task.tags && task.tags.length > 0)) && ( +
+ {/* Time estimate */} + {formatTimeWithTotal(task, allTasks) && ( +
+ + {formatTimeWithTotal(task, allTasks)} +
+ )} + + {/* Tags */} + {task.tags && task.tags.length > 0 && ( +
+ {task.tags.map((tag, idx) => ( + + {tag} + + ))} +
+ )} +
+ )} +
+
- {/* Parent task context */} - {parentTask && ( -
- ↳ subtask of: {parentTask.title} -
- )} - - {/* Metadata row */} - {(formatTimeWithTotal(task, allTasks) || (task.tags && task.tags.length > 0)) && ( -
- {/* Time estimate */} - {formatTimeWithTotal(task, allTasks) && ( -
- - {formatTimeWithTotal(task, allTasks)} -
- )} - - {/* Tags */} - {task.tags && task.tags.length > 0 && ( -
- {task.tags.map((tag, idx) => ( - - {tag} - - ))} -
- )} -
- )} +
+ setIsEditing(true)} + /> +
+ + )} +
-
- setIsEditing(true)} - /> -
-
- + {/* Expanded children */} + {isParent && isExpanded && childrenInColumn.length > 0 && ( +
+ {childrenInColumn.map(child => ( + + ))} +
)}
) } -function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, onDragOver }) { +function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOver }) { const [showAddTask, setShowAddTask] = useState(false) const handleAddTask = async (taskData) => { @@ -170,16 +230,37 @@ function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, on } } + // Get tasks to display in this column: + // 1. All leaf tasks (no children) with this status + // 2. All parent tasks that have at least one descendant with this status + const leafTasks = allTasks.filter(t => { + const hasChildren = allTasks.some(child => child.parent_task_id === t.id) + return !hasChildren && t.status === status.key + }) + + const parentTasks = allTasks.filter(t => { + const hasChildren = allTasks.some(child => child.parent_task_id === t.id) + return hasChildren && hasDescendantsInStatus(t.id, allTasks, status.key) + }) + + // Only show root-level parents (not nested parents) + const rootParents = parentTasks.filter(t => !t.parent_task_id) + + // Only show root-level leaf tasks (leaf tasks without parents) + const rootLeafTasks = leafTasks.filter(t => !t.parent_task_id) + + const displayTasks = [...rootParents, ...rootLeafTasks] + return (
onDrop(e, status.key)} onDragOver={onDragOver} >

{status.label} - ({tasks.length}) + ({displayTasks.length})

) @@ -265,14 +351,13 @@ function KanbanView({ projectId }) { return (
-

Kanban Board

+

Kanban Board (Nested View)

{STATUSES.map(status => ( t.status === status.key)} allTasks={allTasks} projectId={projectId} onUpdate={loadTasks} From 6302ce403601467128f222627fe5b2a4c768ced0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 18:12:42 +0000 Subject: [PATCH 08/13] Add three new features to v0.1.5 1. Make parent cards draggable with all subtasks - Parent cards can now be dragged between columns - All descendants are automatically moved with the parent - Added isParent flag to drag/drop dataTransfer 2. Add Expand All / Collapse All buttons to Kanban view - Added global expandedCards state management - New buttons in Kanban header with ChevronsDown/Up icons - Allows quick expansion/collapse of all parent cards 3. Add description field to tasks - Added description textarea to TaskForm component - Added description edit option to TaskMenu component - Description displays in both TreeView and KanbanView - Shows below metadata in italic gray text - Backend already supported description field --- frontend/src/components/KanbanView.jsx | 98 +++++++++++++++++++++++--- frontend/src/components/TaskForm.jsx | 14 ++++ frontend/src/components/TaskMenu.jsx | 63 ++++++++++++++++- frontend/src/components/TreeView.jsx | 9 +++ 4 files changed, 173 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/KanbanView.jsx b/frontend/src/components/KanbanView.jsx index 0f85aa2..e9c1ffe 100644 --- a/frontend/src/components/KanbanView.jsx +++ b/frontend/src/components/KanbanView.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { Plus, Check, X, Flag, Clock, ChevronDown, ChevronRight } from 'lucide-react' +import { Plus, Check, X, Flag, Clock, ChevronDown, ChevronRight, ChevronsDown, ChevronsUp } from 'lucide-react' import { getProjectTasks, createTask, @@ -27,6 +27,18 @@ const FLAG_COLORS = { pink: 'bg-pink-500' } +// Helper function to get all descendant tasks recursively +function getAllDescendants(taskId, allTasks) { + const children = allTasks.filter(t => t.parent_task_id === taskId) + let descendants = [...children] + + for (const child of children) { + descendants = descendants.concat(getAllDescendants(child.id, allTasks)) + } + + return descendants +} + // Helper function to get all descendant tasks of a parent in a specific status function getDescendantsInStatus(taskId, allTasks, status) { const children = allTasks.filter(t => t.parent_task_id === taskId) @@ -48,11 +60,19 @@ function hasDescendantsInStatus(taskId, allTasks, status) { return getDescendantsInStatus(taskId, allTasks, status).length > 0 } -function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatus }) { +function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatus, expandedCards, setExpandedCards }) { const [isEditing, setIsEditing] = useState(false) - const [isExpanded, setIsExpanded] = useState(false) const [editTitle, setEditTitle] = useState(task.title) + // Use global expanded state + const isExpanded = expandedCards[task.id] || false + const toggleExpanded = () => { + setExpandedCards(prev => ({ + ...prev, + [task.id]: !prev[task.id] + })) + } + const handleSave = async () => { try { await updateTask(task.id, { title: editTitle }) @@ -80,13 +100,13 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu return (
!isParent && onDragStart(e, task)} + draggable={!isEditing} + onDragStart={(e) => onDragStart(e, task, isParent)} className={`${ isParent ? 'bg-cyber-darker border-2 border-cyber-orange/50' : 'bg-cyber-darkest border border-cyber-orange/30' - } rounded-lg p-3 ${!isParent ? 'cursor-move' : ''} hover:border-cyber-orange/60 transition-all group`} + } rounded-lg p-3 cursor-move hover:border-cyber-orange/60 transition-all group`} > {isEditing ? (
@@ -121,7 +141,7 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu {/* Expand/collapse for parent cards */} {isParent && childrenInColumn.length > 0 && (
)} + + {/* Description */} + {task.description && ( +
+ {task.description} +
+ )}
@@ -201,6 +228,8 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu onDragStart={onDragStart} isParent={false} columnStatus={columnStatus} + expandedCards={expandedCards} + setExpandedCards={setExpandedCards} /> ))}
@@ -209,7 +238,7 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu ) } -function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOver }) { +function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOver, expandedCards, setExpandedCards }) { const [showAddTask, setShowAddTask] = useState(false) const handleAddTask = async (taskData) => { @@ -218,6 +247,7 @@ function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOve project_id: parseInt(projectId), parent_task_id: null, title: taskData.title, + description: taskData.description, status: status.key, tags: taskData.tags, estimated_minutes: taskData.estimated_minutes, @@ -289,11 +319,14 @@ function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOve task={task} allTasks={allTasks} onUpdate={onUpdate} - onDragStart={(e, task) => { + onDragStart={(e, task, isParent) => { e.dataTransfer.setData('taskId', task.id.toString()) + e.dataTransfer.setData('isParent', isParent.toString()) }} isParent={isParent} columnStatus={status.key} + expandedCards={expandedCards} + setExpandedCards={setExpandedCards} /> ) })} @@ -306,6 +339,7 @@ function KanbanView({ projectId }) { const [allTasks, setAllTasks] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') + const [expandedCards, setExpandedCards] = useState({}) useEffect(() => { loadTasks() @@ -323,6 +357,19 @@ function KanbanView({ projectId }) { } } + const handleExpandAll = () => { + const parentTasks = allTasks.filter(t => allTasks.some(child => child.parent_task_id === t.id)) + const newExpandedState = {} + parentTasks.forEach(task => { + newExpandedState[task.id] = true + }) + setExpandedCards(newExpandedState) + } + + const handleCollapseAll = () => { + setExpandedCards({}) + } + const handleDragOver = (e) => { e.preventDefault() } @@ -330,11 +377,22 @@ function KanbanView({ projectId }) { const handleDrop = async (e, newStatus) => { e.preventDefault() const taskId = parseInt(e.dataTransfer.getData('taskId')) + const isParent = e.dataTransfer.getData('isParent') === 'true' if (!taskId) return try { + // Update the dragged task await updateTask(taskId, { status: newStatus }) + + // If it's a parent task, update all descendants + if (isParent) { + const descendants = getAllDescendants(taskId, allTasks) + for (const descendant of descendants) { + await updateTask(descendant.id, { status: newStatus }) + } + } + loadTasks() } catch (err) { alert(`Error: ${err.message}`) @@ -351,7 +409,25 @@ function KanbanView({ projectId }) { return (
-

Kanban Board (Nested View)

+
+

Kanban Board (Nested View)

+
+ + +
+
{STATUSES.map(status => ( @@ -363,6 +439,8 @@ function KanbanView({ projectId }) { onUpdate={loadTasks} onDrop={handleDrop} onDragOver={handleDragOver} + expandedCards={expandedCards} + setExpandedCards={setExpandedCards} /> ))}
diff --git a/frontend/src/components/TaskForm.jsx b/frontend/src/components/TaskForm.jsx index 8a85ae4..049d893 100644 --- a/frontend/src/components/TaskForm.jsx +++ b/frontend/src/components/TaskForm.jsx @@ -14,6 +14,7 @@ const FLAG_COLORS = [ function TaskForm({ onSubmit, onCancel, submitLabel = "Add" }) { const [title, setTitle] = useState('') + const [description, setDescription] = useState('') const [tags, setTags] = useState('') const [hours, setHours] = useState('') const [minutes, setMinutes] = useState('') @@ -33,6 +34,7 @@ function TaskForm({ onSubmit, onCancel, submitLabel = "Add" }) { const taskData = { title: title.trim(), + description: description.trim() || null, tags: tagList && tagList.length > 0 ? tagList : null, estimated_minutes: totalMinutes > 0 ? totalMinutes : null, flag_color: flagColor @@ -56,6 +58,18 @@ function TaskForm({ onSubmit, onCancel, submitLabel = "Add" }) { />
+ {/* Description */} +
+ +