From b395ee810314e1497af3e2be7f6a7f646680aad6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 08:23:07 +0000 Subject: [PATCH 1/5] Add v0.1.3 UI features: metadata display, task menu, and search - Display time estimates, tags, and flag colors in TreeView and KanbanView - Add TaskMenu component with three-dot dropdown for editing metadata - Edit time estimates (stored as minutes) - Edit tags (comma-separated input) - Set flag colors (red, orange, yellow, green, blue, purple, pink) - Add SearchBar component in header - Real-time search with 300ms debounce - Optional project filtering - Click results to navigate to project - Integrate TaskMenu into both TreeView and KanbanView - Format time display: "30m" for <60 min, "1.5h" for >=60 min --- frontend/src/App.jsx | 2 + frontend/src/components/KanbanView.jsx | 70 +++++-- frontend/src/components/SearchBar.jsx | 262 +++++++++++++++++++++++++ frontend/src/components/TaskMenu.jsx | 259 ++++++++++++++++++++++++ frontend/src/components/TreeView.jsx | 78 ++++++-- 5 files changed, 636 insertions(+), 35 deletions(-) create mode 100644 frontend/src/components/SearchBar.jsx create mode 100644 frontend/src/components/TaskMenu.jsx 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..07c7b09 100644 --- a/frontend/src/components/KanbanView.jsx +++ b/frontend/src/components/KanbanView.jsx @@ -1,11 +1,13 @@ import { useState, useEffect } from 'react' -import { Plus, Edit2, Trash2, Check, X } from 'lucide-react' +import { Plus, Check, X, Flag } from 'lucide-react' import { getProjectTasks, createTask, updateTask, deleteTask } from '../utils/api' +import { formatTime } from '../utils/format' +import TaskMenu from './TaskMenu' const STATUSES = [ { key: 'backlog', label: 'Backlog', color: 'border-gray-600' }, @@ -14,6 +16,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 +90,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 */} + {(task.estimated_minutes || (task.tags && task.tags.length > 0)) && ( +
+ {/* Time estimate */} + {task.estimated_minutes && ( +
+ + {formatTime(task.estimated_minutes)} +
+ )} + + {/* Tags */} + {task.tags && task.tags.length > 0 && ( +
+ {task.tags.map((tag, idx) => ( + + {tag} + + ))} +
+ )} +
+ )}
+
- - + setIsEditing(true)} + />
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/TaskMenu.jsx b/frontend/src/components/TaskMenu.jsx new file mode 100644 index 0000000..4224864 --- /dev/null +++ b/frontend/src/components/TaskMenu.jsx @@ -0,0 +1,259 @@ +import { useState, useRef, useEffect } from 'react' +import { MoreVertical, Clock, Tag, Flag, Edit2, Trash2, X, Check } 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' } +] + +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 [editTime, setEditTime] = useState(task.estimated_minutes || '') + 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) + } + } + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + } + }, [isOpen]) + + const handleUpdateTime = async () => { + try { + const minutes = editTime ? parseInt(editTime) : 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}`) + } + } + + return ( +
+ + + {isOpen && ( +
+ {/* Time Edit */} + {showTimeEdit ? ( +
+
+ + Time Estimate (minutes) +
+
+ setEditTime(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" + autoFocus + 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 }) => ( +
+ +
+ ) : ( + + )} + + {/* Edit Title */} + + + {/* Delete */} + +
+ )} +
+ ) +} + +export default TaskMenu diff --git a/frontend/src/components/TreeView.jsx b/frontend/src/components/TreeView.jsx index 0c3a9b9..69ba24f 100644 --- a/frontend/src/components/TreeView.jsx +++ b/frontend/src/components/TreeView.jsx @@ -3,10 +3,9 @@ import { ChevronDown, ChevronRight, Plus, - Edit2, - Trash2, Check, - X + X, + Flag } from 'lucide-react' import { getProjectTaskTree, @@ -14,6 +13,8 @@ import { updateTask, deleteTask } from '../utils/api' +import { formatTime } from '../utils/format' +import TaskMenu from './TaskMenu' const STATUS_COLORS = { backlog: 'text-gray-400', @@ -29,6 +30,16 @@ 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) @@ -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 */} + {(task.estimated_minutes || (task.tags && task.tags.length > 0)) && ( +
+ {/* Time estimate */} + {task.estimated_minutes && ( +
+ + {formatTime(task.estimated_minutes)} +
+ )} + + {/* 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)} + /> )} From 444f2744b3bd00ea0799f9e7ca010674c19457be Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 08:45:21 +0000 Subject: [PATCH 2/5] Fix missing Clock icon import causing crash with time estimates --- frontend/src/components/KanbanView.jsx | 2 +- frontend/src/components/TreeView.jsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/KanbanView.jsx b/frontend/src/components/KanbanView.jsx index 07c7b09..fe8f91f 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 } from 'lucide-react' +import { Plus, Check, X, Flag, Clock } from 'lucide-react' import { getProjectTasks, createTask, diff --git a/frontend/src/components/TreeView.jsx b/frontend/src/components/TreeView.jsx index 69ba24f..d444a01 100644 --- a/frontend/src/components/TreeView.jsx +++ b/frontend/src/components/TreeView.jsx @@ -5,7 +5,8 @@ import { Plus, Check, X, - Flag + Flag, + Clock } from 'lucide-react' import { getProjectTaskTree, From 3f309163b694a1deb18bdc4bb78c116b2f237978 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 09:01:45 +0000 Subject: [PATCH 3/5] 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. --- frontend/src/components/KanbanView.jsx | 8 ++-- frontend/src/components/TaskMenu.jsx | 58 +++++++++++++++++++++++++- frontend/src/components/TreeView.jsx | 8 ++-- frontend/src/utils/format.js | 56 +++++++++++++++++++++++++ 4 files changed, 121 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/KanbanView.jsx b/frontend/src/components/KanbanView.jsx index fe8f91f..9d63c29 100644 --- a/frontend/src/components/KanbanView.jsx +++ b/frontend/src/components/KanbanView.jsx @@ -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)) && (
{/* Time estimate */} - {task.estimated_minutes && ( + {formatTimeWithTotal(task, allTasks) && (
- {formatTime(task.estimated_minutes)} + {formatTimeWithTotal(task, allTasks)}
)} diff --git a/frontend/src/components/TaskMenu.jsx b/frontend/src/components/TaskMenu.jsx index 4224864..ddd08a2 100644 --- a/frontend/src/components/TaskMenu.jsx +++ b/frontend/src/components/TaskMenu.jsx @@ -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 (
+ ))} +
+
+ ) : ( + + )} + {/* Edit Title */} - - - + setShowAddTask(false)} + submitLabel="Add Task" + /> )} 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 ( +
+ {/* Title */} +
+ + setTitle(e.target.value)} + placeholder="Enter task title..." + className="w-full px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange" + autoFocus + /> +
+ + {/* Tags */} +
+ + setTags(e.target.value)} + placeholder="coding, bug-fix, frontend" + className="w-full px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange" + /> +
+ + {/* Time Estimate */} +
+ +
+
+ setHours(e.target.value)} + placeholder="Hours" + className="w-full px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange" + /> +
+
+ setMinutes(e.target.value)} + placeholder="Minutes" + className="w-full px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange" + /> +
+
+
+ + {/* Flag Color */} +
+ +
+ {FLAG_COLORS.map(({ name, label, color }) => ( + + ))} +
+
+ + {/* Buttons */} +
+ + +
+
+ ) +} + +export default TaskForm diff --git a/frontend/src/components/TaskMenu.jsx b/frontend/src/components/TaskMenu.jsx index ddd08a2..5c4ff49 100644 --- a/frontend/src/components/TaskMenu.jsx +++ b/frontend/src/components/TaskMenu.jsx @@ -25,7 +25,13 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit }) { const [showTagsEdit, setShowTagsEdit] = useState(false) const [showFlagEdit, setShowFlagEdit] = useState(false) const [showStatusEdit, setShowStatusEdit] = useState(false) - const [editTime, setEditTime] = useState(task.estimated_minutes || '') + + // 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) @@ -48,7 +54,8 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit }) { const handleUpdateTime = async () => { try { - const minutes = editTime ? parseInt(editTime) : null + 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) @@ -125,29 +132,42 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit }) {
- Time Estimate (minutes) + Time Estimate
-
+
setEditTime(e.target.value)} - placeholder="Minutes" + min="0" + value={editHours} + onChange={(e) => 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()} + /> +
+
diff --git a/frontend/src/components/TreeView.jsx b/frontend/src/components/TreeView.jsx index ac1ed90..f9c9de1 100644 --- a/frontend/src/components/TreeView.jsx +++ b/frontend/src/components/TreeView.jsx @@ -16,6 +16,7 @@ import { } from '../utils/api' import { formatTimeWithTotal } from '../utils/format' import TaskMenu from './TaskMenu' +import TaskForm from './TaskForm' const STATUS_COLORS = { backlog: 'text-gray-400', @@ -47,7 +48,6 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) { 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 @@ -74,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() @@ -213,29 +212,11 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) { {/* Add Subtask Form */} {showAddSubtask && (
-
- setNewSubtaskTitle(e.target.value)} - placeholder="New subtask title..." - className="flex-1 px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange" - autoFocus - /> - - -
+ setShowAddSubtask(false)} + submitLabel="Add Subtask" + />
)} @@ -262,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() @@ -280,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) { @@ -322,29 +301,11 @@ function TreeView({ projectId }) { {showAddRoot && (
-
- setNewTaskTitle(e.target.value)} - placeholder="New task title..." - className="flex-1 px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 focus:outline-none focus:border-cyber-orange" - autoFocus - /> - - -
+ setShowAddRoot(false)} + submitLabel="Add Task" + />
)} diff --git a/frontend/src/utils/format.js b/frontend/src/utils/format.js index 3e073f6..6139222 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 @@ -16,58 +22,63 @@ export function formatTags(tags) { return tags.join(', '); } -// Recursively calculate total time estimate including all subtasks -export function calculateTotalTime(task) { - let total = task.estimated_minutes || 0; +// Calculate sum of all LEAF descendant estimates (hierarchical structure) +export function calculateLeafTime(task) { + // If no subtasks, this is a leaf - return its own estimate + if (!task.subtasks || task.subtasks.length === 0) { + return task.estimated_minutes || 0; + } - if (task.subtasks && task.subtasks.length > 0) { - for (const subtask of task.subtasks) { - total += calculateTotalTime(subtask); - } + // Has subtasks, so sum up all leaf descendants + let total = 0; + for (const subtask of task.subtasks) { + total += calculateLeafTime(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 +// Calculate sum of all LEAF descendant estimates (flat task list) +export function calculateLeafTimeFlat(task, allTasks) { + // Find direct children const children = allTasks.filter(t => t.parent_task_id === task.id); - // Recursively add their times + // If no children, this is a leaf - return its own estimate + if (children.length === 0) { + return task.estimated_minutes || 0; + } + + // Has children, so sum up all leaf descendants + let total = 0; for (const child of children) { - total += calculateTotalTimeFlat(child, allTasks); + total += calculateLeafTimeFlat(child, allTasks); } return total; } -// Format time display showing own estimate and total if different +// Format time display based on leaf calculation logic export function formatTimeWithTotal(task, allTasks = null) { - const ownTime = task.estimated_minutes || 0; + // Check if task has subtasks + const hasSubtasks = allTasks + ? allTasks.some(t => t.parent_task_id === task.id) + : (task.subtasks && task.subtasks.length > 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); + // Leaf task: use own estimate + if (!hasSubtasks) { + return formatTime(task.estimated_minutes); } - // Only subtask estimates, no own estimate - if (ownTime === 0) { - return `(${formatTime(totalTime)} from subtasks)`; + // 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); } - // Both own and subtask estimates - return `${formatTime(ownTime)} (${formatTime(totalTime)} total)`; + // Show leaf total + return formatTime(leafTotal); } From 718e5acbe2daf11bd4495dcb8db8c633ac1f4f0a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 09:46:17 +0000 Subject: [PATCH 5/5] Exclude 'done' tasks from parent time estimates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a leaf task is marked as 'done', it no longer contributes to its parent's time estimate. This shows remaining work rather than total estimated work. Example: - Parent with 2 subtasks (30m each) shows 1h - Mark one subtask as done → parent now shows 30m - Mark both done → parent shows 0m (or own estimate if set) This provides accurate tracking of remaining time across the task hierarchy. --- frontend/src/utils/format.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/frontend/src/utils/format.js b/frontend/src/utils/format.js index 6139222..eb8373e 100644 --- a/frontend/src/utils/format.js +++ b/frontend/src/utils/format.js @@ -23,13 +23,14 @@ export function formatTags(tags) { } // 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 no subtasks, this is a leaf - return its own estimate if not done if (!task.subtasks || task.subtasks.length === 0) { - return task.estimated_minutes || 0; + return (task.status !== 'done' && task.estimated_minutes) ? task.estimated_minutes : 0; } - // Has subtasks, so sum up all leaf descendants + // Has subtasks, so sum up all leaf descendants (excluding done tasks) let total = 0; for (const subtask of task.subtasks) { total += calculateLeafTime(subtask); @@ -39,16 +40,17 @@ export function calculateLeafTime(task) { } // 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 no children, this is a leaf - return its own estimate if not done if (children.length === 0) { - return task.estimated_minutes || 0; + return (task.status !== 'done' && task.estimated_minutes) ? task.estimated_minutes : 0; } - // Has children, so sum up all leaf descendants + // Has children, so sum up all leaf descendants (excluding done tasks) let total = 0; for (const child of children) { total += calculateLeafTimeFlat(child, allTasks);