From c9555737d85c2c520053a6f0a08869e3ea6ba4c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 09:37:16 +0000 Subject: [PATCH] Add enhanced task creation forms and leaf-based time calculation Features: 1. Enhanced Task Creation Forms: - New TaskForm component with all metadata fields - Title, tags (comma-separated), time estimate (hours + minutes), flag color - Used in TreeView (root tasks and subtasks) and KanbanView (all columns) - Replace simple title-only inputs with full metadata forms 2. Time Format Changes: - Display: "1h 30m" instead of "1.5h" - Input: Separate hours and minutes fields - Storage: Still integer minutes in backend - Updated formatTime() utility - Updated TaskMenu time editor with hours/minutes inputs 3. Leaf-Based Time Calculation: - Leaf tasks (no subtasks): Show user-entered estimate - Parent tasks (has subtasks): Show sum of all descendant LEAF tasks - Exception: Parent with no leaf estimates shows own estimate as fallback - New functions: calculateLeafTime(), calculateLeafTimeFlat() - Replaces old aggregation that summed all tasks including parents This allows accurate project planning where parent estimates are calculated from leaf tasks, preventing double-counting when both parent and children have estimates. --- frontend/src/components/KanbanView.jsx | 45 +++----- frontend/src/components/TaskForm.jsx | 142 +++++++++++++++++++++++++ frontend/src/components/TaskMenu.jsx | 42 ++++++-- frontend/src/components/TreeView.jsx | 85 ++++----------- frontend/src/utils/format.js | 85 ++++++++------- 5 files changed, 256 insertions(+), 143 deletions(-) create mode 100644 frontend/src/components/TaskForm.jsx diff --git a/frontend/src/components/KanbanView.jsx b/frontend/src/components/KanbanView.jsx index 9d63c29..6474978 100644 --- a/frontend/src/components/KanbanView.jsx +++ b/frontend/src/components/KanbanView.jsx @@ -8,6 +8,7 @@ import { } 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' }, @@ -150,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) { @@ -192,31 +191,11 @@ function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, on {showAddTask && (
-
- setNewTaskTitle(e.target.value)} - placeholder="Task title..." - className="w-full px-2 py-2 bg-cyber-darkest border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange mb-2" - autoFocus - /> -
- - -
-
+ 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); }