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.
This commit is contained in:
@@ -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)) && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{/* Time estimate */}
|
||||
{task.estimated_minutes && (
|
||||
{formatTimeWithTotal(task, allTasks) && (
|
||||
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||
<Clock size={11} />
|
||||
<span>{formatTime(task.estimated_minutes)}</span>
|
||||
<span>{formatTimeWithTotal(task, allTasks)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
@@ -225,6 +245,42 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit }) {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Status Change */}
|
||||
{showStatusEdit ? (
|
||||
<div className="p-3 border-b border-cyber-orange/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<ListTodo size={14} className="text-cyber-orange" />
|
||||
<span className="text-sm text-gray-300">Change Status</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{STATUSES.map(({ key, label, color }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => handleUpdateStatus(key)}
|
||||
className={`w-full text-left px-2 py-1.5 rounded text-sm ${
|
||||
task.status === key
|
||||
? 'bg-cyber-orange/20 border border-cyber-orange/40'
|
||||
: 'hover:bg-cyber-darker border border-transparent'
|
||||
} ${color} transition-all`}
|
||||
>
|
||||
{label} {task.status === key && '✓'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowStatusEdit(true)
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
|
||||
>
|
||||
<ListTodo size={14} />
|
||||
<span>Change Status</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Edit Title */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
updateTask,
|
||||
deleteTask
|
||||
} from '../utils/api'
|
||||
import { formatTime } from '../utils/format'
|
||||
import { formatTimeWithTotal } from '../utils/format'
|
||||
import TaskMenu from './TaskMenu'
|
||||
|
||||
const STATUS_COLORS = {
|
||||
@@ -163,13 +163,13 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
|
||||
</div>
|
||||
|
||||
{/* Metadata row */}
|
||||
{(task.estimated_minutes || (task.tags && task.tags.length > 0)) && (
|
||||
{(formatTimeWithTotal(task) || (task.tags && task.tags.length > 0)) && (
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
{/* Time estimate */}
|
||||
{task.estimated_minutes && (
|
||||
{formatTimeWithTotal(task) && (
|
||||
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||
<Clock size={12} />
|
||||
<span>{formatTime(task.estimated_minutes)}</span>
|
||||
<span>{formatTimeWithTotal(task)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -15,3 +15,59 @@ export function formatTags(tags) {
|
||||
if (!tags || tags.length === 0) return null;
|
||||
return tags.join(', ');
|
||||
}
|
||||
|
||||
// Recursively calculate total time estimate including all subtasks
|
||||
export function calculateTotalTime(task) {
|
||||
let total = task.estimated_minutes || 0;
|
||||
|
||||
if (task.subtasks && task.subtasks.length > 0) {
|
||||
for (const subtask of task.subtasks) {
|
||||
total += calculateTotalTime(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
|
||||
const children = allTasks.filter(t => t.parent_task_id === task.id);
|
||||
|
||||
// Recursively add their times
|
||||
for (const child of children) {
|
||||
total += calculateTotalTimeFlat(child, allTasks);
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
// Format time display showing own estimate and total if different
|
||||
export function formatTimeWithTotal(task, allTasks = null) {
|
||||
const ownTime = task.estimated_minutes || 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);
|
||||
}
|
||||
|
||||
// Only subtask estimates, no own estimate
|
||||
if (ownTime === 0) {
|
||||
return `(${formatTime(totalTime)} from subtasks)`;
|
||||
}
|
||||
|
||||
// Both own and subtask estimates
|
||||
return `${formatTime(ownTime)} (${formatTime(totalTime)} total)`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user