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..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 } from 'lucide-react' +import { Plus, Check, X, Flag, Clock, ChevronDown, ChevronRight, ChevronsDown, ChevronsUp } from 'lucide-react' import { getProjectTasks, createTask, @@ -27,14 +27,51 @@ const FLAG_COLORS = { pink: 'bg-pink-500' } -function TaskCard({ task, allTasks, onUpdate, onDragStart }) { +// 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) + let descendants = [] + + 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, expandedCards, setExpandedCards }) { const [isEditing, setIsEditing] = useState(false) const [editTitle, setEditTitle] = useState(task.title) - // Find parent task if this is a subtask - const parentTask = task.parent_task_id - ? allTasks.find(t => t.id === task.parent_task_id) - : null + // Use global expanded state + const isExpanded = expandedCards[task.id] || false + const toggleExpanded = () => { + setExpandedCards(prev => ({ + ...prev, + [task.id]: !prev[task.id] + })) + } const handleSave = async () => { try { @@ -56,100 +93,152 @@ 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} +
+
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 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} + + ))} +
+ )} +
+ )} + + {/* Description */} + {task.description && ( +
+ {task.description} +
+ )} +
+
- {/* 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, expandedCards, setExpandedCards }) { const [showAddTask, setShowAddTask] = useState(false) const handleAddTask = async (taskData) => { @@ -158,6 +247,7 @@ function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, on 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, @@ -170,16 +260,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})

) @@ -220,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() @@ -237,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() } @@ -244,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}`) @@ -265,19 +409,38 @@ function KanbanView({ projectId }) { return (
-

Kanban Board

+
+

Kanban Board (Nested View)

+
+ + +
+
{STATUSES.map(status => ( t.status === status.key)} allTasks={allTasks} projectId={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 */} +
+ +