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..6474978 100644
--- a/frontend/src/components/KanbanView.jsx
+++ b/frontend/src/components/KanbanView.jsx
@@ -1,11 +1,14 @@
import { useState, useEffect } from 'react'
-import { Plus, Edit2, Trash2, Check, X } from 'lucide-react'
+import { Plus, Check, X, Flag, Clock } from 'lucide-react'
import {
getProjectTasks,
createTask,
updateTask,
deleteTask
} from '../utils/api'
+import { formatTimeWithTotal } from '../utils/format'
+import TaskMenu from './TaskMenu'
+import TaskForm from './TaskForm'
const STATUSES = [
{ key: 'backlog', label: 'Backlog', color: 'border-gray-600' },
@@ -14,6 +17,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 +91,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 */}
+ {(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)}
+ />
>
@@ -108,20 +151,18 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart }) {
function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, onDragOver }) {
const [showAddTask, setShowAddTask] = useState(false)
- const [newTaskTitle, setNewTaskTitle] = useState('')
-
- const handleAddTask = async (e) => {
- e.preventDefault()
- if (!newTaskTitle.trim()) return
+ const handleAddTask = async (taskData) => {
try {
await createTask({
project_id: parseInt(projectId),
parent_task_id: null,
- title: newTaskTitle,
- status: status.key
+ title: taskData.title,
+ status: status.key,
+ tags: taskData.tags,
+ estimated_minutes: taskData.estimated_minutes,
+ flag_color: taskData.flag_color
})
- setNewTaskTitle('')
setShowAddTask(false)
onUpdate()
} catch (err) {
@@ -150,31 +191,11 @@ function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, on
{showAddTask && (
-
+
setShowAddTask(false)}
+ submitLabel="Add Task"
+ />
)}
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/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 (
+
+ )
+}
+
+export default TaskForm
diff --git a/frontend/src/components/TaskMenu.jsx b/frontend/src/components/TaskMenu.jsx
new file mode 100644
index 0000000..5c4ff49
--- /dev/null
+++ b/frontend/src/components/TaskMenu.jsx
@@ -0,0 +1,335 @@
+import { useState, useRef, useEffect } from 'react'
+import { MoreVertical, Clock, Tag, Flag, Edit2, Trash2, X, Check, ListTodo } 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' }
+]
+
+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)
+
+ // 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)
+
+ useEffect(() => {
+ function handleClickOutside(event) {
+ if (menuRef.current && !menuRef.current.contains(event.target)) {
+ setIsOpen(false)
+ setShowTimeEdit(false)
+ setShowTagsEdit(false)
+ setShowFlagEdit(false)
+ setShowStatusEdit(false)
+ }
+ }
+
+ if (isOpen) {
+ document.addEventListener('mousedown', handleClickOutside)
+ return () => document.removeEventListener('mousedown', handleClickOutside)
+ }
+ }, [isOpen])
+
+ const handleUpdateTime = async () => {
+ try {
+ 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)
+ 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}`)
+ }
+ }
+
+ const handleUpdateStatus = async (newStatus) => {
+ try {
+ await updateTask(task.id, { status: newStatus })
+ setShowStatusEdit(false)
+ setIsOpen(false)
+ onUpdate()
+ } catch (err) {
+ alert(`Error: ${err.message}`)
+ }
+ }
+
+ return (
+
+
+
+ {isOpen && (
+
+ {/* Time Edit */}
+ {showTimeEdit ? (
+
+
+
+ Time Estimate
+
+
+ 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()}
+ />
+
+
+
+
+
+
+ ) : (
+
+ )}
+
+ {/* 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 }) => (
+
+
+
+ ) : (
+
+ )}
+
+ {/* Status Change */}
+ {showStatusEdit ? (
+
+
+
+ Change Status
+
+
+ {STATUSES.map(({ key, label, color }) => (
+
+ ))}
+
+
+ ) : (
+
+ )}
+
+ {/* Edit Title */}
+
+
+ {/* Delete */}
+
+
+ )}
+
+ )
+}
+
+export default TaskMenu
diff --git a/frontend/src/components/TreeView.jsx b/frontend/src/components/TreeView.jsx
index 0c3a9b9..f9c9de1 100644
--- a/frontend/src/components/TreeView.jsx
+++ b/frontend/src/components/TreeView.jsx
@@ -3,10 +3,10 @@ import {
ChevronDown,
ChevronRight,
Plus,
- Edit2,
- Trash2,
Check,
- X
+ X,
+ Flag,
+ Clock
} from 'lucide-react'
import {
getProjectTaskTree,
@@ -14,6 +14,9 @@ import {
updateTask,
deleteTask
} from '../utils/api'
+import { formatTimeWithTotal } from '../utils/format'
+import TaskMenu from './TaskMenu'
+import TaskForm from './TaskForm'
const STATUS_COLORS = {
backlog: 'text-gray-400',
@@ -29,13 +32,22 @@ 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)
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
@@ -62,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()
@@ -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 */}
+ {(formatTimeWithTotal(task) || (task.tags && task.tags.length > 0)) && (
+
+ {/* Time estimate */}
+ {formatTimeWithTotal(task) && (
+
+
+ {formatTimeWithTotal(task)}
+
+ )}
+
+ {/* 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)}
+ />
>
)}
@@ -176,29 +212,11 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
{/* Add Subtask Form */}
{showAddSubtask && (
-
+ setShowAddSubtask(false)}
+ submitLabel="Add Subtask"
+ />
)}
@@ -225,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()
@@ -243,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) {
@@ -285,29 +301,11 @@ function TreeView({ projectId }) {
{showAddRoot && (
-
+ setShowAddRoot(false)}
+ submitLabel="Add Task"
+ />
)}
diff --git a/frontend/src/utils/format.js b/frontend/src/utils/format.js
index 65a30dc..eb8373e 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
@@ -15,3 +21,66 @@ export function formatTags(tags) {
if (!tags || tags.length === 0) return null;
return tags.join(', ');
}
+
+// 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 not done
+ if (!task.subtasks || task.subtasks.length === 0) {
+ return (task.status !== 'done' && task.estimated_minutes) ? task.estimated_minutes : 0;
+ }
+
+ // Has subtasks, so sum up all leaf descendants (excluding done tasks)
+ let total = 0;
+ for (const subtask of task.subtasks) {
+ total += calculateLeafTime(subtask);
+ }
+
+ return total;
+}
+
+// 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 not done
+ if (children.length === 0) {
+ return (task.status !== 'done' && task.estimated_minutes) ? task.estimated_minutes : 0;
+ }
+
+ // Has children, so sum up all leaf descendants (excluding done tasks)
+ let total = 0;
+ for (const child of children) {
+ total += calculateLeafTimeFlat(child, allTasks);
+ }
+
+ return total;
+}
+
+// Format time display based on leaf calculation logic
+export function formatTimeWithTotal(task, allTasks = null) {
+ // Check if task has subtasks
+ const hasSubtasks = allTasks
+ ? allTasks.some(t => t.parent_task_id === task.id)
+ : (task.subtasks && task.subtasks.length > 0);
+
+ // Leaf task: use own estimate
+ if (!hasSubtasks) {
+ return formatTime(task.estimated_minutes);
+ }
+
+ // 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);
+ }
+
+ // Show leaf total
+ return formatTime(leafTotal);
+}