From 66b019c60b739fe832456cb0c2c738230114370a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 17:59:53 +0000 Subject: [PATCH 1/2] 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 2/2] 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 */} +
+ +