Release v0.1.5: Nested Kanban View
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.
This commit is contained in:
25
CHANGELOG.md
25
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/),
|
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).
|
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
|
## [0.1.4] - 2025-01-XX
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
31
README.md
31
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.
|
**Task Decomposition Engine** - A self-hosted web application for managing deeply nested todo trees with advanced time tracking and project planning capabilities.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
@@ -12,7 +12,9 @@ TESSERACT is designed for complex project management where tasks naturally decom
|
|||||||
### Key Features
|
### Key Features
|
||||||
|
|
||||||
- **Arbitrary-Depth Nesting**: Create tasks within tasks within tasks - no limits on hierarchy depth
|
- **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**:
|
- **Intelligent Time Tracking**:
|
||||||
- Leaf-based time calculation (parent times = sum of descendant leaf tasks)
|
- Leaf-based time calculation (parent times = sum of descendant leaf tasks)
|
||||||
- Automatic exclusion of completed tasks from time estimates
|
- 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
|
**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
|
### Understanding Time Estimates
|
||||||
|
|
||||||
@@ -354,7 +372,6 @@ Ensure JSON structure matches schema. Common issues:
|
|||||||
See [CHANGELOG.md](CHANGELOG.md) for version history.
|
See [CHANGELOG.md](CHANGELOG.md) for version history.
|
||||||
|
|
||||||
### v0.2.0 (Planned)
|
### v0.2.0 (Planned)
|
||||||
- Nested Kanban view (parent cards split across columns)
|
|
||||||
- Task dependencies and blocking relationships
|
- Task dependencies and blocking relationships
|
||||||
- Due dates and calendar view
|
- Due dates and calendar view
|
||||||
- Progress tracking (% complete)
|
- Progress tracking (% complete)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ function App() {
|
|||||||
<h1 className="text-2xl font-bold text-cyber-orange">
|
<h1 className="text-2xl font-bold text-cyber-orange">
|
||||||
TESSERACT
|
TESSERACT
|
||||||
<span className="ml-3 text-sm text-gray-500">Task Decomposition Engine</span>
|
<span className="ml-3 text-sm text-gray-500">Task Decomposition Engine</span>
|
||||||
<span className="ml-2 text-xs text-gray-600">v0.1.4</span>
|
<span className="ml-2 text-xs text-gray-600">v0.1.5</span>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<SearchBar />
|
<SearchBar />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
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 {
|
import {
|
||||||
getProjectTasks,
|
getProjectTasks,
|
||||||
createTask,
|
createTask,
|
||||||
@@ -27,14 +27,31 @@ const FLAG_COLORS = {
|
|||||||
pink: 'bg-pink-500'
|
pink: 'bg-pink-500'
|
||||||
}
|
}
|
||||||
|
|
||||||
function TaskCard({ task, allTasks, onUpdate, onDragStart }) {
|
// Helper function to get all descendant tasks of a parent in a specific status
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
function getDescendantsInStatus(taskId, allTasks, status) {
|
||||||
const [editTitle, setEditTitle] = useState(task.title)
|
const children = allTasks.filter(t => t.parent_task_id === taskId)
|
||||||
|
let descendants = []
|
||||||
|
|
||||||
// Find parent task if this is a subtask
|
for (const child of children) {
|
||||||
const parentTask = task.parent_task_id
|
if (child.status === status) {
|
||||||
? allTasks.find(t => t.id === task.parent_task_id)
|
descendants.push(child)
|
||||||
: null
|
}
|
||||||
|
// 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 () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -56,11 +73,20 @@ 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 (
|
return (
|
||||||
|
<div className="mb-2">
|
||||||
<div
|
<div
|
||||||
draggable={!isEditing}
|
draggable={!isEditing && !isParent}
|
||||||
onDragStart={(e) => onDragStart(e, task)}
|
onDragStart={(e) => !isParent && 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"
|
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 ? (
|
{isEditing ? (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -90,19 +116,33 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart }) {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Expand/collapse for parent cards */}
|
||||||
|
{isParent && childrenInColumn.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="text-cyber-orange hover:text-cyber-orange-bright"
|
||||||
|
>
|
||||||
|
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
{/* Flag indicator */}
|
{/* Flag indicator */}
|
||||||
{task.flag_color && FLAG_COLORS[task.flag_color] && (
|
{task.flag_color && FLAG_COLORS[task.flag_color] && (
|
||||||
<Flag size={12} className={`${FLAG_COLORS[task.flag_color].replace('bg-', 'text-')}`} fill="currentColor" />
|
<Flag size={12} className={`${FLAG_COLORS[task.flag_color].replace('bg-', 'text-')}`} fill="currentColor" />
|
||||||
)}
|
)}
|
||||||
<span className="text-gray-200">{task.title}</span>
|
<span className={`${isParent ? 'font-semibold text-cyber-orange' : 'text-gray-200'}`}>
|
||||||
|
{task.title}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Parent task context */}
|
{/* Parent card info: show subtask count in this column */}
|
||||||
{parentTask && (
|
{isParent && (
|
||||||
<div className="text-xs text-gray-500 mt-1">
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
↳ subtask of: <span className="text-cyber-orange">{parentTask.title}</span>
|
{childrenInColumn.length} of {totalChildren} subtask{totalChildren !== 1 ? 's' : ''} in this column
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -133,6 +173,8 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<TaskMenu
|
<TaskMenu
|
||||||
@@ -146,10 +188,28 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart }) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded children */}
|
||||||
|
{isParent && isExpanded && childrenInColumn.length > 0 && (
|
||||||
|
<div className="ml-6 mt-2 space-y-2">
|
||||||
|
{childrenInColumn.map(child => (
|
||||||
|
<TaskCard
|
||||||
|
key={child.id}
|
||||||
|
task={child}
|
||||||
|
allTasks={allTasks}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
isParent={false}
|
||||||
|
columnStatus={columnStatus}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, onDragOver }) {
|
function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOver }) {
|
||||||
const [showAddTask, setShowAddTask] = useState(false)
|
const [showAddTask, setShowAddTask] = useState(false)
|
||||||
|
|
||||||
const handleAddTask = async (taskData) => {
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex-1 min-w-[280px] bg-cyber-darker rounded-lg p-4 border-t-4 ${status.color}"
|
className={`flex-1 min-w-[280px] bg-cyber-darker rounded-lg p-4 border-t-4 ${status.color}`}
|
||||||
onDrop={(e) => onDrop(e, status.key)}
|
onDrop={(e) => onDrop(e, status.key)}
|
||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h3 className="font-semibold text-gray-200">
|
<h3 className="font-semibold text-gray-200">
|
||||||
{status.label}
|
{status.label}
|
||||||
<span className="ml-2 text-xs text-gray-500">({tasks.length})</span>
|
<span className="ml-2 text-xs text-gray-500">({displayTasks.length})</span>
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddTask(true)}
|
onClick={() => setShowAddTask(true)}
|
||||||
@@ -200,7 +281,9 @@ function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, on
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{tasks.map(task => (
|
{displayTasks.map(task => {
|
||||||
|
const isParent = allTasks.some(t => t.parent_task_id === task.id)
|
||||||
|
return (
|
||||||
<TaskCard
|
<TaskCard
|
||||||
key={task.id}
|
key={task.id}
|
||||||
task={task}
|
task={task}
|
||||||
@@ -209,8 +292,11 @@ function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, on
|
|||||||
onDragStart={(e, task) => {
|
onDragStart={(e, task) => {
|
||||||
e.dataTransfer.setData('taskId', task.id.toString())
|
e.dataTransfer.setData('taskId', task.id.toString())
|
||||||
}}
|
}}
|
||||||
|
isParent={isParent}
|
||||||
|
columnStatus={status.key}
|
||||||
/>
|
/>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -265,14 +351,13 @@ function KanbanView({ projectId }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-semibold text-gray-300 mb-4">Kanban Board</h3>
|
<h3 className="text-xl font-semibold text-gray-300 mb-4">Kanban Board (Nested View)</h3>
|
||||||
|
|
||||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||||
{STATUSES.map(status => (
|
{STATUSES.map(status => (
|
||||||
<KanbanColumn
|
<KanbanColumn
|
||||||
key={status.key}
|
key={status.key}
|
||||||
status={status}
|
status={status}
|
||||||
tasks={allTasks.filter(t => t.status === status.key)}
|
|
||||||
allTasks={allTasks}
|
allTasks={allTasks}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onUpdate={loadTasks}
|
onUpdate={loadTasks}
|
||||||
|
|||||||
Reference in New Issue
Block a user