From 3fdc31fb654faac9c1918d5e30ea795c2c468431 Mon Sep 17 00:00:00 2001 From: Chris Veleris Date: Tue, 22 Jul 2025 21:41:14 +0300 Subject: [PATCH] Fix an issue with check to done task not moving to completed --- backend/routes/tasks.js | 266 +++++++++++--- frontend/Layout.tsx | 1 + frontend/components/Inbox/InboxItemDetail.tsx | 1 + frontend/components/Inbox/InboxItems.tsx | 1 + frontend/components/Inbox/InboxModal.tsx | 3 +- .../components/Project/ProjectDetails.tsx | 5 +- .../Task/TaskForm/TaskSubtasksSection.tsx | 55 ++- frontend/components/Task/TaskItem.tsx | 49 ++- frontend/components/Task/TaskList.tsx | 21 +- frontend/components/Task/TasksToday.tsx | 332 ++++++++++++------ frontend/components/Task/TodayPlan.tsx | 3 + frontend/components/Tasks.tsx | 119 ++++++- frontend/entities/Task.ts | 9 +- frontend/store/useStore.ts | 20 +- frontend/utils/dateUtils.ts | 2 +- 15 files changed, 700 insertions(+), 187 deletions(-) diff --git a/backend/routes/tasks.js b/backend/routes/tasks.js index ad99ae7..7ea9d47 100644 --- a/backend/routes/tasks.js +++ b/backend/routes/tasks.js @@ -26,11 +26,17 @@ async function serializeTask(task) { due_date: subtask.due_date ? subtask.due_date.toISOString().split('T')[0] : null, + completed_at: subtask.completed_at + ? subtask.completed_at.toISOString() + : null, })) : [], due_date: task.due_date ? task.due_date.toISOString().split('T')[0] : null, + completed_at: task.completed_at + ? task.completed_at.toISOString() + : null, today_move_count: todayMoveCount, }; } @@ -90,22 +96,39 @@ async function checkAndUpdateParentTaskCompletion(parentTaskId, userId) { ); if (allSubtasksDone) { - // Update parent task to done - await Task.update( - { - status: Task.STATUS.DONE, - completed_at: new Date(), + // Check if parent is already done to avoid unnecessary updates + const parentTask = await Task.findOne({ + where: { + id: parentTaskId, + user_id: userId, }, - { - where: { - id: parentTaskId, - user_id: userId, + }); + + if ( + parentTask && + parentTask.status !== Task.STATUS.DONE && + parentTask.status !== 'done' + ) { + // Update parent task to done + await Task.update( + { + status: Task.STATUS.DONE, + completed_at: new Date(), }, - } - ); + { + where: { + id: parentTaskId, + user_id: userId, + }, + } + ); + return true; + } } + return false; } catch (error) { console.error('Error checking parent task completion:', error); + return false; } } @@ -138,16 +161,20 @@ async function undoneParentTaskIfNeeded(parentTaskId, userId) { }, } ); + return true; } + return false; } catch (error) { console.error('Error undoing parent task:', error); + return false; } } // Helper function to complete all subtasks when parent is done async function completeAllSubtasks(parentTaskId, userId) { try { - await Task.update( + // Update all subtasks to be completed - this ensures completed_at is set for all + const result = await Task.update( { status: Task.STATUS.DONE, completed_at: new Date(), @@ -159,15 +186,17 @@ async function completeAllSubtasks(parentTaskId, userId) { }, } ); + return result[0] > 0; // Return true if any subtasks were actually updated } catch (error) { console.error('Error completing all subtasks:', error); + return false; } } // Helper function to undone all subtasks when parent is undone async function undoneAllSubtasks(parentTaskId, userId) { try { - await Task.update( + const result = await Task.update( { status: Task.STATUS.NOT_STARTED, completed_at: null, @@ -176,11 +205,16 @@ async function undoneAllSubtasks(parentTaskId, userId) { where: { parent_task_id: parentTaskId, user_id: userId, + status: { + [Op.in]: [Task.STATUS.DONE, 'done'], + }, }, } ); + return result[0] > 0; // Return true if any subtasks were actually updated } catch (error) { console.error('Error undoing all subtasks:', error); + return false; } } @@ -248,9 +282,11 @@ async function filterTasksByParams(params, userId) { default: if (params.status === 'done') { whereClause.status = { [Op.in]: [Task.STATUS.DONE, 'done'] }; - } else { + } else if (!params.client_side_filtering) { + // Only exclude completed tasks if not doing client-side filtering whereClause.status = { [Op.notIn]: [Task.STATUS.DONE, 'done'] }; } + // If client_side_filtering is true, don't add any status filter (include all) } // Filter by tag @@ -827,6 +863,17 @@ router.get('/task/:id', async (req, res) => { through: { attributes: [] }, }, { model: Project, attributes: ['name'], required: false }, + { + model: Task, + as: 'Subtasks', + include: [ + { + model: Tag, + attributes: ['id', 'name'], + through: { attributes: [] }, + }, + ], + }, ], }); @@ -1266,22 +1313,60 @@ router.patch('/task/:id', async (req, res) => { }); } - // Update edited subtasks - const editedSubtasks = subtasks.filter( - (s) => s.isEdited && s.id && s.name && s.name.trim() + // Update edited subtasks and status changes + const subtasksToUpdate = subtasks.filter( + (s) => + s.id && + ((s.isEdited && s.name && s.name.trim()) || + s._statusChanged) ); - if (editedSubtasks.length > 0) { - const updatePromises = editedSubtasks.map((subtask) => - Task.update( - { name: subtask.name.trim() }, - { - where: { - id: subtask.id, - user_id: req.currentUser.id, - }, + if (subtasksToUpdate.length > 0) { + const updatePromises = subtasksToUpdate.map((subtask) => { + const updateData = {}; + + if ( + subtask.isEdited && + subtask.name && + subtask.name.trim() + ) { + updateData.name = subtask.name.trim(); + } + + if ( + subtask._statusChanged || + subtask.status !== undefined + ) { + updateData.status = subtask.status + ? typeof subtask.status === 'string' + ? Task.getStatusValue(subtask.status) + : subtask.status + : Task.STATUS.NOT_STARTED; + + if ( + updateData.status === Task.STATUS.DONE && + !subtask.completed_at + ) { + updateData.completed_at = new Date(); + } else if (updateData.status !== Task.STATUS.DONE) { + updateData.completed_at = null; } - ) - ); + } + + if (subtask.priority !== undefined) { + updateData.priority = subtask.priority + ? typeof subtask.priority === 'string' + ? Task.getPriorityValue(subtask.priority) + : subtask.priority + : Task.PRIORITY.MEDIUM; + } + + return Task.update(updateData, { + where: { + id: subtask.id, + user_id: req.currentUser.id, + }, + }); + }); await Promise.all(updatePromises); } @@ -1296,9 +1381,24 @@ router.patch('/task/:id', async (req, res) => { name: subtask.name.trim(), parent_task_id: task.id, user_id: req.currentUser.id, - priority: Task.PRIORITY.MEDIUM, - status: Task.STATUS.NOT_STARTED, - today: false, + priority: subtask.priority + ? typeof subtask.priority === 'string' + ? Task.getPriorityValue(subtask.priority) + : subtask.priority + : Task.PRIORITY.MEDIUM, + status: subtask.status + ? typeof subtask.status === 'string' + ? Task.getStatusValue(subtask.status) + : subtask.status + : Task.STATUS.NOT_STARTED, + completed_at: + subtask.status === 'done' || + subtask.status === Task.STATUS.DONE + ? subtask.completed_at + ? new Date(subtask.completed_at) + : new Date() + : null, + today: subtask.today || false, recurrence_type: 'none', completion_based: false, }) @@ -1472,15 +1572,10 @@ router.patch('/task/:id', async (req, res) => { ], }); - const taskJson = taskWithAssociations.toJSON(); + // Use serializeTask to include subtasks data + const serializedTask = await serializeTask(taskWithAssociations); - res.json({ - ...taskJson, - tags: taskJson.Tags || [], // Normalize Tags to tags - due_date: taskWithAssociations.due_date - ? taskWithAssociations.due_date.toISOString().split('T')[0] - : null, - }); + res.json(serializedTask); } catch (error) { console.error('Error updating task:', error); res.status(400).json({ @@ -1497,12 +1592,34 @@ router.patch('/task/:id/toggle_completion', async (req, res) => { try { const task = await Task.findOne({ where: { id: req.params.id, user_id: req.currentUser.id }, + include: [ + { + model: Tag, + attributes: ['id', 'name'], + through: { attributes: [] }, + }, + { model: Project, attributes: ['name'], required: false }, + { + model: Task, + as: 'Subtasks', + include: [ + { + model: Tag, + attributes: ['id', 'name'], + through: { attributes: [] }, + }, + ], + }, + ], }); if (!task) { return res.status(404).json({ error: 'Task not found.' }); } + // Track if parent-child logic was executed + let parentChildLogicExecuted = false; + const newStatus = task.status === Task.STATUS.DONE || task.status === 'done' ? task.note @@ -1520,29 +1637,63 @@ router.patch('/task/:id/toggle_completion', async (req, res) => { await task.update(updateData); - // Handle parent-child task completion logic + // Check if subtasks exist in database directly to debug association issue + const directSubtasksQuery = await Task.findAll({ + where: { + parent_task_id: task.id, + user_id: req.currentUser.id, + }, + attributes: ['id', 'name', 'status', 'parent_task_id'], + }); + + // If direct query finds subtasks but task.Subtasks is empty, there's an association issue + if ( + directSubtasksQuery.length > 0 && + (!task.Subtasks || task.Subtasks.length === 0) + ) { + task.Subtasks = directSubtasksQuery; + } + if (task.parent_task_id) { - if (newStatus === Task.STATUS.DONE) { + if (newStatus === Task.STATUS.DONE || newStatus === 'done') { // When subtask is done, check if parent should be done - await checkAndUpdateParentTaskCompletion( + const parentUpdated = await checkAndUpdateParentTaskCompletion( task.parent_task_id, req.currentUser.id ); + if (parentUpdated) { + parentChildLogicExecuted = true; + } } else { // When subtask is undone, undone parent if it was done - await undoneParentTaskIfNeeded( + const parentUpdated = await undoneParentTaskIfNeeded( task.parent_task_id, req.currentUser.id ); + if (parentUpdated) { + parentChildLogicExecuted = true; + } } - } else { - // This is a parent task + } else if (task.Subtasks && task.Subtasks.length > 0) { + // This is a parent task with subtasks if (newStatus === Task.STATUS.DONE) { // When parent is done, complete all subtasks - await completeAllSubtasks(task.id, req.currentUser.id); + const subtasksUpdated = await completeAllSubtasks( + task.id, + req.currentUser.id + ); + if (subtasksUpdated) { + parentChildLogicExecuted = true; + } } else { // When parent is undone, undone all subtasks - await undoneAllSubtasks(task.id, req.currentUser.id); + const subtasksUpdated = await undoneAllSubtasks( + task.id, + req.currentUser.id + ); + if (subtasksUpdated) { + parentChildLogicExecuted = true; + } } } @@ -1552,12 +1703,11 @@ router.patch('/task/:id/toggle_completion', async (req, res) => { nextTask = await RecurringTaskService.handleTaskCompletion(task); } - const response = { - ...task.toJSON(), - due_date: task.due_date - ? task.due_date.toISOString().split('T')[0] - : null, - }; + // Use serializeTask to include subtasks data + const response = await serializeTask(task); + + // If parent-child logic was executed, we might need to reload data + // For now, let the frontend handle the refresh to avoid complex reloading logic if (nextTask) { response.next_task = { @@ -1568,9 +1718,17 @@ router.patch('/task/:id/toggle_completion', async (req, res) => { }; } + // Add flag to response to indicate if parent-child logic was executed + response.parent_child_logic_executed = parentChildLogicExecuted; + res.json(response); } catch (error) { - res.status(422).json({ error: 'Unable to update task' }); + console.error('Error in toggle completion endpoint:', error); + console.error('Error stack:', error.stack); + res.status(422).json({ + error: 'Unable to update task', + details: error.message, + }); } }); diff --git a/frontend/Layout.tsx b/frontend/Layout.tsx index 0ea118b..4af4a55 100644 --- a/frontend/Layout.tsx +++ b/frontend/Layout.tsx @@ -461,6 +461,7 @@ const Layout: React.FC = ({ task={{ name: '', status: 'not_started', + completed_at: null, }} onSave={handleSaveTask} onDelete={async () => {}} diff --git a/frontend/components/Inbox/InboxItemDetail.tsx b/frontend/components/Inbox/InboxItemDetail.tsx index f44d354..9f54cad 100644 --- a/frontend/components/Inbox/InboxItemDetail.tsx +++ b/frontend/components/Inbox/InboxItemDetail.tsx @@ -247,6 +247,7 @@ const InboxItemDetail: React.FC = ({ priority: 'medium', tags: taskTags, project_id: projectId, + completed_at: null, }; if (item.id !== undefined) { diff --git a/frontend/components/Inbox/InboxItems.tsx b/frontend/components/Inbox/InboxItems.tsx index b9e9d08..67f2af6 100644 --- a/frontend/components/Inbox/InboxItems.tsx +++ b/frontend/components/Inbox/InboxItems.tsx @@ -514,6 +514,7 @@ const InboxItems: React.FC = () => { name: '', status: 'not_started', priority: 'medium', + completed_at: null, } } onSave={handleSaveTask} diff --git a/frontend/components/Inbox/InboxModal.tsx b/frontend/components/Inbox/InboxModal.tsx index c37a0ec..fdd051e 100644 --- a/frontend/components/Inbox/InboxModal.tsx +++ b/frontend/components/Inbox/InboxModal.tsx @@ -989,6 +989,7 @@ const InboxModal: React.FC = ({ priority: 'medium', tags: taskTags, project_id: projectId, + completed_at: null, }; try { @@ -1127,8 +1128,8 @@ const InboxModal: React.FC = ({ const newTask: Task = { name: cleanedText, status: 'not_started', + completed_at: null, }; - try { await onSave(newTask); showSuccessToast(t('task.createSuccess')); diff --git a/frontend/components/Project/ProjectDetails.tsx b/frontend/components/Project/ProjectDetails.tsx index 8047968..ed8d6f3 100644 --- a/frontend/components/Project/ProjectDetails.tsx +++ b/frontend/components/Project/ProjectDetails.tsx @@ -122,9 +122,6 @@ const ProjectDetails: React.FC = () => { localStorage.getItem('project_order_by') || 'created_at:desc'; - console.log( - `Fetching ONLY project ${id} with fetchProjectById` - ); const projectData = await fetchProjectById(id, { sort: sortParam, // Remove completed parameter since backend filtering isn't working @@ -171,6 +168,7 @@ const ProjectDetails: React.FC = () => { name: taskName, status: 'not_started', project_id: project.id, + completed_at: null, }); setTasks([...tasks, newTask]); @@ -318,6 +316,7 @@ const ProjectDetails: React.FC = () => { status: 'not_started', project_id: projectId, priority: 'medium', + completed_at: null, }); // Update the tasks list to include the new task diff --git a/frontend/components/Task/TaskForm/TaskSubtasksSection.tsx b/frontend/components/Task/TaskForm/TaskSubtasksSection.tsx index ebab4c5..89ce093 100644 --- a/frontend/components/Task/TaskForm/TaskSubtasksSection.tsx +++ b/frontend/components/Task/TaskForm/TaskSubtasksSection.tsx @@ -26,7 +26,6 @@ const TaskSubtasksSection: React.FC = ({ const subtasksSectionRef = useRef(null); const addInputRef = useRef(null); - const scrollToBottom = () => { setTimeout(() => { if (subtasksSectionRef.current) { @@ -51,6 +50,7 @@ const TaskSubtasksSection: React.FC = ({ isNew: true, // Also keep for UI purposes _isNew: true, + completed_at: null, } as Task; onSubtasksChange([...subtasks, newSubtask]); @@ -113,11 +113,21 @@ const TaskSubtasksSection: React.FC = ({ const handleToggleNewSubtaskCompletion = (index: number) => { const updatedSubtasks = subtasks.map((subtask, i) => { if (i === index) { - const isDone = subtask.status === 'done' || subtask.status === 2; + const isDone = + subtask.status === 'done' || subtask.status === 2; + const newStatus = isDone + ? ('not_started' as const) + : ('done' as const); + const hasId = + subtask.id && + !((subtask as any)._isNew || (subtask as any).isNew); + return { ...subtask, - status: isDone ? ('not_started' as const) : ('done' as const), - completed_at: isDone ? undefined : new Date().toISOString(), + status: newStatus, + completed_at: isDone ? null : new Date().toISOString(), + // Mark for backend update if it has an ID (existing subtask) + _statusChanged: hasId, }; } return subtask; @@ -150,8 +160,16 @@ const TaskSubtasksSection: React.FC = ({ subtask.status || 'not_started' } onToggleCompletion={async () => { - if (subtask.id && onSubtaskUpdate) { - // Existing subtask - use API + if ( + subtask.id && + onSubtaskUpdate && + !( + (subtask as any) + ._isNew || + (subtask as any).isNew + ) + ) { + // Existing subtask - use API for immediate toggle, then update callback try { const updatedSubtask = await toggleTaskCompletion( @@ -167,8 +185,10 @@ const TaskSubtasksSection: React.FC = ({ ); } } else { - // New subtask - handle locally - handleToggleNewSubtaskCompletion(index); + // New subtask or no callback - handle locally + handleToggleNewSubtaskCompletion( + index + ); } }} /> @@ -213,8 +233,17 @@ const TaskSubtasksSection: React.FC = ({ 'not_started' } onToggleCompletion={async () => { - if (subtask.id && onSubtaskUpdate) { - // Existing subtask - use API + if ( + subtask.id && + onSubtaskUpdate && + !( + (subtask as any) + ._isNew || + (subtask as any) + .isNew + ) + ) { + // Existing subtask - use API for immediate toggle, then update callback try { const updatedSubtask = await toggleTaskCompletion( @@ -230,8 +259,10 @@ const TaskSubtasksSection: React.FC = ({ ); } } else { - // New subtask - handle locally - handleToggleNewSubtaskCompletion(index); + // New subtask or no callback - handle locally + handleToggleNewSubtaskCompletion( + index + ); } }} /> diff --git a/frontend/components/Task/TaskItem.tsx b/frontend/components/Task/TaskItem.tsx index f467ff4..8fa9a7a 100644 --- a/frontend/components/Task/TaskItem.tsx +++ b/frontend/components/Task/TaskItem.tsx @@ -69,6 +69,18 @@ const SubtasksDisplay: React.FC = ({ subtask.id ); + // Check if parent-child logic was executed + if ( + updatedSubtask.parent_child_logic_executed + ) { + // For subtasks, we need a full page refresh because the parent task + // might be displayed in multiple places (task list, today view, etc.) + setTimeout(() => { + window.location.reload(); + }, 200); + return; + } + // Update the subtask in local state immediately onSubtaskUpdate( updatedSubtask @@ -125,6 +137,7 @@ import { useTranslation } from 'react-i18next'; interface TaskItemProps { task: Task; onTaskUpdate: (task: Task) => Promise; + onTaskCompletionToggle?: (task: Task) => void; onTaskDelete: (taskId: number) => void; projects: Project[]; hideProjectName?: boolean; @@ -134,6 +147,7 @@ interface TaskItemProps { const TaskItem: React.FC = ({ task, onTaskUpdate, + onTaskCompletionToggle, onTaskDelete, projects, hideProjectName = false, @@ -286,8 +300,39 @@ const TaskItem: React.FC = ({ const handleToggleCompletion = async () => { if (task.id) { try { - const updatedTask = await toggleTaskCompletion(task.id); - await onTaskUpdate(updatedTask); + const response = await toggleTaskCompletion(task.id); + + // Handle the updated task + if (onTaskCompletionToggle) { + onTaskCompletionToggle(response); + } else { + await onTaskUpdate(response); + } + + // Only refresh if parent-child logic was executed (affecting other tasks) + if (response.parent_child_logic_executed) { + // Instead of refreshing, let's refetch and update the task data + setTimeout(async () => { + try { + // Refetch the current task with updated subtasks + const updatedTaskResponse = await fetch( + `/api/task/${task.id}` + ); + if (updatedTaskResponse.ok) { + const updatedTaskData = + await updatedTaskResponse.json(); + await onTaskUpdate(updatedTaskData); + } + } catch (error) { + console.error( + 'Error refetching task after parent-child logic:', + error + ); + // Fallback to refresh if API call fails + window.location.reload(); + } + }, 200); + } } catch (error) { console.error('Error toggling task completion:', error); } diff --git a/frontend/components/Task/TaskList.tsx b/frontend/components/Task/TaskList.tsx index 5da5610..5f1302f 100644 --- a/frontend/components/Task/TaskList.tsx +++ b/frontend/components/Task/TaskList.tsx @@ -6,25 +6,41 @@ import { Task } from '../../entities/Task'; interface TaskListProps { tasks: Task[]; onTaskUpdate: (task: Task) => Promise; + onTaskCompletionToggle?: (task: Task) => void; onTaskCreate?: (task: Task) => void; onTaskDelete: (taskId: number) => void; projects: Project[]; hideProjectName?: boolean; onToggleToday?: (taskId: number) => Promise; + showCompletedTasks?: boolean; // New prop } const TaskList: React.FC = ({ tasks, onTaskUpdate, + onTaskCompletionToggle, onTaskDelete, projects, hideProjectName = false, onToggleToday, + showCompletedTasks = false, // Default to false }) => { + // Conditionally filter tasks based on showCompletedTasks prop + const filteredTasks = showCompletedTasks + ? tasks + : tasks.filter((task) => { + const isCompleted = + task.status === 'done' || + task.status === 'archived' || + task.status === 2 || + task.status === 3; + return !isCompleted; + }); + return (
- {tasks.length > 0 ? ( - tasks.map((task) => ( + {filteredTasks.length > 0 ? ( + filteredTasks.map((task) => (
= ({ { const TasksToday: React.FC = () => { const { t } = useTranslation(); + // Get tasks from store at the top level to avoid conditional hook usage + const storeTasks = useStore((state) => state.tasksStore.tasks); + // Temporarily use local state to debug infinite loop - const [tasks, setTasks] = useState([]); + const [isLoading, setIsLoading] = useState(false); const [isError, setIsError] = useState(false); const [hasInitialized, setHasInitialized] = useState(false); @@ -202,9 +205,13 @@ const TasksToday: React.FC = () => { const { tasks: fetchedTasks, metrics: fetchedMetrics } = await fetchTasks('?type=today'); if (isMounted.current) { - setTasks(fetchedTasks); - setMetrics(fetchedMetrics); - setIsError(false); + // setTasks(fetchedTasks); // Removed local state + if (isMounted.current) { + // setTasks(fetchedTasks); // Removed local state + setMetrics(fetchedMetrics); + useStore.getState().tasksStore.setTasks(fetchedTasks); + setIsError(false); + } } } catch (error) { console.error('Failed to fetch tasks:', error); @@ -400,78 +407,171 @@ const TasksToday: React.FC = () => { async (updatedTask: Task): Promise => { if (!updatedTask.id || !isMounted.current) return; - // Helper function to update a task in an array - const updateTaskInArray = (tasks: Task[]) => - tasks.map((task) => - task.id === updatedTask.id - ? { - ...task, - ...updatedTask, - updated_at: new Date().toISOString(), - } - : task + // Optimistically update UI + setMetrics((prevMetrics) => { + const newMetrics = { ...prevMetrics }; + + // Helper to remove task from a list + const removeTask = (list: Task[]) => + list.filter((task) => task.id !== updatedTask.id); + + // Helper to add or update task in a list + const updateOrAddTask = (list: Task[], taskToProcess: Task) => { + const existingIndex = list.findIndex( + (task) => task.id === taskToProcess.id + ); + if (existingIndex > -1) { + // Task exists, update it by creating a new object and a new array + // Preserve subtasks data to prevent loss + return list.map((task, index) => + index === existingIndex + ? { + ...task, + ...taskToProcess, + // Explicitly preserve subtasks data + subtasks: + taskToProcess.subtasks || + taskToProcess.Subtasks || + task.subtasks || + task.Subtasks || + [], + Subtasks: + taskToProcess.subtasks || + taskToProcess.Subtasks || + task.subtasks || + task.Subtasks || + [], + } + : task + ); + } else { + // Task does not exist, add it by creating a new array + return [...list, taskToProcess]; + } + }; + + // Remove task from all potential "active" lists first + newMetrics.today_plan_tasks = removeTask( + newMetrics.today_plan_tasks || [] ); - - // Check if this task exists in any of our task lists - const taskExistsInLocal = tasks.some( - (task) => task.id === updatedTask.id - ); - const taskExistsInMetrics = - metrics.today_plan_tasks?.some( - (task) => task.id === updatedTask.id - ) || - metrics.suggested_tasks.some( - (task) => task.id === updatedTask.id - ) || - metrics.tasks_due_today.some( - (task) => task.id === updatedTask.id - ) || - metrics.tasks_in_progress.some( - (task) => task.id === updatedTask.id - ) || - metrics.tasks_completed_today.some( - (task) => task.id === updatedTask.id + newMetrics.suggested_tasks = removeTask( + newMetrics.suggested_tasks || [] ); + newMetrics.tasks_due_today = removeTask( + newMetrics.tasks_due_today || [] + ); + newMetrics.tasks_in_progress = removeTask( + newMetrics.tasks_in_progress || [] + ); + newMetrics.tasks_completed_today = removeTask( + newMetrics.tasks_completed_today || [] + ); // Always remove from completed first - // Update local task state - if (taskExistsInLocal) { - setTasks((prevTasks) => updateTaskInArray(prevTasks)); - } + // Now, add the task to the appropriate list(s) based on its new status + if (updatedTask.status === 'done' || updatedTask.status === 2) { + // If completed, add to tasks_completed_today if it was completed today + if (updatedTask.completed_at) { + const completedDate = new Date( + updatedTask.completed_at + ); + const today = new Date(); + if ( + format(completedDate, 'yyyy-MM-dd') === + format(today, 'yyyy-MM-dd') + ) { + newMetrics.tasks_completed_today = updateOrAddTask( + newMetrics.tasks_completed_today, + updatedTask + ); + } + } + } else { + // If not completed, add to relevant active lists + if ( + updatedTask.today && + updatedTask.status !== 'archived' + ) { + newMetrics.today_plan_tasks = updateOrAddTask( + newMetrics.today_plan_tasks, + updatedTask + ); + } + if (updatedTask.status === 'in_progress') { + newMetrics.tasks_in_progress = updateOrAddTask( + newMetrics.tasks_in_progress, + updatedTask + ); + } + // Check if due today (and not already in today_plan_tasks or in_progress) + const isDueToday = + updatedTask.due_date && + format(new Date(updatedTask.due_date), 'yyyy-MM-dd') === + format(new Date(), 'yyyy-MM-dd'); + if ( + isDueToday && + updatedTask.status !== 'archived' && + !newMetrics.today_plan_tasks.some( + (t) => t.id === updatedTask.id + ) && + !newMetrics.tasks_in_progress.some( + (t) => t.id === updatedTask.id + ) + ) { + newMetrics.tasks_due_today = updateOrAddTask( + newMetrics.tasks_due_today, + updatedTask + ); + } + // Check for suggested tasks (and not already in other active lists) + const isSuggested = + !updatedTask.today && + !updatedTask.project_id && + !updatedTask.due_date; + if ( + isSuggested && + updatedTask.status !== 'archived' && + updatedTask.status !== 'done' && + updatedTask.status !== 2 && + !newMetrics.today_plan_tasks.some( + (t) => t.id === updatedTask.id + ) && + !newMetrics.tasks_due_today.some( + (t) => t.id === updatedTask.id + ) && + !newMetrics.tasks_in_progress.some( + (t) => t.id === updatedTask.id + ) + ) { + newMetrics.suggested_tasks = updateOrAddTask( + newMetrics.suggested_tasks, + updatedTask + ); + } + } - if (taskExistsInMetrics) { - setMetrics((prevMetrics) => ({ - ...prevMetrics, - today_plan_tasks: updateTaskInArray( - prevMetrics.today_plan_tasks || [] - ), - suggested_tasks: updateTaskInArray( - prevMetrics.suggested_tasks || [] - ), - tasks_due_today: updateTaskInArray( - prevMetrics.tasks_due_today || [] - ), - tasks_in_progress: updateTaskInArray( - prevMetrics.tasks_in_progress || [] - ), - tasks_completed_today: updateTaskInArray( - prevMetrics.tasks_completed_today || [] - ), - })); - } + // Recalculate total_open_tasks based on the updated active lists + newMetrics.total_open_tasks = + newMetrics.today_plan_tasks.length + + newMetrics.suggested_tasks.length + + newMetrics.tasks_due_today.length + + newMetrics.tasks_in_progress.length; + + return newMetrics; + }); + + // Update the store with the updated task + useStore.getState().tasksStore.updateTaskInStore(updatedTask); try { - // Make API call if the task exists anywhere - if (taskExistsInLocal || taskExistsInMetrics) { - await updateTask(updatedTask.id, updatedTask); - } + // Make API call to persist the change + await updateTask(updatedTask.id, updatedTask); } catch (error) { console.error('Error updating task:', error); - if (isMounted.current) { - // Error handling is now managed by the store - } + // Revert UI on error if necessary, or re-fetch to sync + // For now, just log the error } }, - [tasks, metrics] + [] // Dependencies are now handled by direct state manipulation ); const handleTaskDelete = useCallback( @@ -485,7 +585,7 @@ const TasksToday: React.FC = () => { const { tasks: updatedTasks, metrics: updatedMetrics } = await fetchTasks('?type=today'); if (isMounted.current) { - setTasks(updatedTasks); + useStore.getState().tasksStore.setTasks(updatedTasks); setMetrics(updatedMetrics); } } catch (error) { @@ -506,7 +606,7 @@ const TasksToday: React.FC = () => { const { tasks: updatedTasks, metrics: updatedMetrics } = await fetchTasks('?type=today'); if (isMounted.current) { - setTasks(updatedTasks); + useStore.getState().tasksStore.setTasks(updatedTasks); setMetrics(updatedMetrics); } } catch (error) { @@ -516,6 +616,21 @@ const TasksToday: React.FC = () => { [] ); + const handleTaskCompletionToggle = useCallback( + async (updatedTask: Task): Promise => { + if (!isMounted.current) return; + + try { + // The updatedTask is already the result of the API call from TaskItem + // Use the centralized task update handler to update UI optimistically + await handleTaskUpdate(updatedTask); + } catch (error) { + console.error('Error toggling task completion:', error); + } + }, + [handleTaskUpdate] + ); + // Calculate today's progress for the progress bar const getTodayProgress = () => { const todayTasks = metrics.today_plan_tasks || []; @@ -551,7 +666,7 @@ const TasksToday: React.FC = () => { } // Show error state - if (isError && tasks.length === 0) { + if (isError && storeTasks.length === 0) { return (

@@ -814,7 +929,7 @@ const TasksToday: React.FC = () => { productivityAssistantEnabled && profileSettings.productivity_assistant_enabled === true ? ( ) : null} @@ -854,6 +969,7 @@ const TasksToday: React.FC = () => { onTaskUpdate={handleTaskUpdate} onTaskDelete={handleTaskDelete} onToggleToday={handleToggleToday} + onTaskCompletionToggle={handleTaskCompletionToggle} /> {/* Suggested Tasks - Separate setting */} @@ -890,6 +1006,9 @@ const TasksToday: React.FC = () => { { onTaskDelete={handleTaskDelete} projects={localProjects} onToggleToday={handleToggleToday} + onTaskCompletionToggle={ + handleTaskCompletionToggle + } />

)} @@ -919,37 +1041,49 @@ const TasksToday: React.FC = () => { {/* Completed Tasks - Conditionally Rendered */} {isSettingsLoaded && todaySettings.showCompleted && - metrics.tasks_completed_today.length > 0 && ( -
-
-

- {t('tasks.completedToday')} -

-
- - {metrics.tasks_completed_today.length} - - {isCompletedCollapsed ? ( - - ) : ( - - )} + (() => { + const completedToday = metrics.tasks_completed_today; // Use the already filtered list from backend + return ( +
+
+

+ {t('tasks.completedToday')} +

+
+ + {completedToday.length} + + {isCompletedCollapsed ? ( + + ) : ( + + )} +
+ {!isCompletedCollapsed && + (completedToday.length > 0 ? ( + + ) : ( +

+ {t( + 'tasks.noCompletedTasksToday', + 'No completed tasks today.' + )} +

+ ))}
- {!isCompletedCollapsed && ( - - )} -
- )} + ); + })()} {metrics.tasks_due_today.length === 0 && metrics.tasks_in_progress.length === 0 && diff --git a/frontend/components/Task/TodayPlan.tsx b/frontend/components/Task/TodayPlan.tsx index 66cc6ad..7a61910 100644 --- a/frontend/components/Task/TodayPlan.tsx +++ b/frontend/components/Task/TodayPlan.tsx @@ -11,6 +11,7 @@ interface TodayPlanProps { onTaskUpdate: (task: Task) => Promise; onTaskDelete: (taskId: number) => Promise; onToggleToday?: (taskId: number) => Promise; + onTaskCompletionToggle?: (task: Task) => void; // New prop } const TodayPlan: React.FC = ({ @@ -19,6 +20,7 @@ const TodayPlan: React.FC = ({ onTaskUpdate, onTaskDelete, onToggleToday, + onTaskCompletionToggle, // Destructure new prop }) => { const { t } = useTranslation(); @@ -83,6 +85,7 @@ const TodayPlan: React.FC = ({ onTaskDelete={onTaskDelete} projects={projects} onToggleToday={onToggleToday} + onTaskCompletionToggle={onTaskCompletionToggle} /> ); diff --git a/frontend/components/Tasks.tsx b/frontend/components/Tasks.tsx index 624df30..9e2ca0b 100644 --- a/frontend/components/Tasks.tsx +++ b/frontend/components/Tasks.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useRef } from 'react'; +import React, { useEffect, useState, useRef, useMemo } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import TaskList from './Task/TaskList'; @@ -15,6 +15,7 @@ import { TagIcon, XMarkIcon, MagnifyingGlassIcon, + CheckCircleIcon, } from '@heroicons/react/24/solid'; const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); @@ -45,10 +46,49 @@ const Tasks: React.FC = () => { const [taskSearchQuery, setTaskSearchQuery] = useState(''); const [isInfoExpanded, setIsInfoExpanded] = useState(false); // Collapsed by default const [isSearchExpanded, setIsSearchExpanded] = useState(false); // Collapsed by default + const [showCompleted, setShowCompleted] = useState(false); // Show completed tasks toggle const dropdownRef = useRef(null); const location = useLocation(); const navigate = useNavigate(); + + // Filter tasks based on completion status and search query + const displayTasks = useMemo(() => { + let filteredTasks; + + // First filter by completion status + if (showCompleted) { + // Show only completed tasks (done=2 or archived=3) + filteredTasks = tasks.filter( + (task) => + task.status === 'done' || + task.status === 'archived' || + task.status === 2 || + task.status === 3 + ); + } else { + // Show only non-completed tasks - exclude done(2) and archived(3) + filteredTasks = tasks.filter( + (task) => + task.status !== 'done' && + task.status !== 'archived' && + task.status !== 2 && + task.status !== 3 + ); + } + + // Then filter by search query if provided + if (taskSearchQuery.trim()) { + const query = taskSearchQuery.toLowerCase(); + filteredTasks = filteredTasks.filter( + (task) => + task.name.toLowerCase().includes(query) || + task.note?.toLowerCase().includes(query) + ); + } + + return filteredTasks; + }, [tasks, showCompleted, taskSearchQuery]); const query = new URLSearchParams(location.search); const { title: stateTitle } = location.state || {}; @@ -98,9 +138,15 @@ const Tasks: React.FC = () => { setError(null); try { const tagId = query.get('tag'); + // Fetch all tasks (both completed and non-completed) for client-side filtering + const allTasksUrl = new URLSearchParams(location.search); + // Add special parameter to get ALL tasks (completed and non-completed) + allTasksUrl.set('client_side_filtering', 'true'); + const searchParams = allTasksUrl.toString(); + const [tasksResponse, projectsResponse] = await Promise.all([ fetch( - `/api/tasks${location.search}${tagId ? `&tag=${tagId}` : ''}` + `/api/tasks?${searchParams}${tagId ? `&tag=${tagId}` : ''}` ), fetch('/api/projects'), ]); @@ -196,7 +242,25 @@ const Tasks: React.FC = () => { if (response.ok) { setTasks((prevTasks) => prevTasks.map((task) => - task.id === updatedTask.id ? updatedTask : task + task.id === updatedTask.id + ? { + ...task, + ...updatedTask, + // Explicitly preserve subtasks data + subtasks: + updatedTask.subtasks || + updatedTask.Subtasks || + task.subtasks || + task.Subtasks || + [], + Subtasks: + updatedTask.subtasks || + updatedTask.Subtasks || + task.subtasks || + task.Subtasks || + [], + } + : task ) ); } else { @@ -210,6 +274,15 @@ const Tasks: React.FC = () => { } }; + // Handler specifically for task completion toggles (no API call needed, just state update) + const handleTaskCompletionToggle = (updatedTask: Task) => { + setTasks((prevTasks) => + prevTasks.map((task) => + task.id === updatedTask.id ? updatedTask : task + ) + ); + }; + const handleTaskDelete = async (taskId: number) => { try { const response = await fetch(`/api/task/${taskId}`, { @@ -290,10 +363,6 @@ const Tasks: React.FC = () => { return status !== 'done'; }; - const filteredTasks = tasks.filter((task) => - task.name.toLowerCase().includes(taskSearchQuery.toLowerCase()) - ); - return (
@@ -316,7 +385,7 @@ const Tasks: React.FC = () => {
)}
- {/* Info expand/collapse button, search button, and sort dropdown */} + {/* Info expand/collapse button, search button, show completed toggle, and sort dropdown */}
+ { /> )} - {filteredTasks.length > 0 ? ( + {displayTasks.length > 0 ? ( ) : (
diff --git a/frontend/entities/Task.ts b/frontend/entities/Task.ts index 79c2c73..84aa51a 100644 --- a/frontend/entities/Task.ts +++ b/frontend/entities/Task.ts @@ -24,13 +24,18 @@ export interface Task { recurrence_week_of_month?: number; completion_based?: boolean; recurring_parent_id?: number; - completed_at?: string; + completed_at: string | null; parent_task_id?: number; subtasks?: Task[]; Subtasks?: Task[]; // Handle API response case sensitivity (temporary) } -export type StatusType = 'not_started' | 'in_progress' | 'done' | 'archived'; +export type StatusType = + | 'not_started' + | 'in_progress' + | 'done' + | 'archived' + | 'waiting'; export type PriorityType = 'low' | 'medium' | 'high'; export type RecurrenceType = | 'none' diff --git a/frontend/store/useStore.ts b/frontend/store/useStore.ts index f848bc3..d9f6383 100644 --- a/frontend/store/useStore.ts +++ b/frontend/store/useStore.ts @@ -509,7 +509,25 @@ export const useStore = create((set) => ({ tasksStore: { ...state.tasksStore, tasks: state.tasksStore.tasks.map((task) => - task.id === updatedTask.id ? updatedTask : task + task.id === updatedTask.id + ? { + ...task, + ...updatedTask, + // Explicitly preserve subtasks data + subtasks: + updatedTask.subtasks || + updatedTask.Subtasks || + task.subtasks || + task.Subtasks || + [], + Subtasks: + updatedTask.subtasks || + updatedTask.Subtasks || + task.subtasks || + task.Subtasks || + [], + } + : task ), }, })), diff --git a/frontend/utils/dateUtils.ts b/frontend/utils/dateUtils.ts index 17829f2..39ce27c 100644 --- a/frontend/utils/dateUtils.ts +++ b/frontend/utils/dateUtils.ts @@ -146,7 +146,7 @@ export const isTaskOverdue = (task: { created_at?: string; today_move_count?: number; status: string | number; - completed_at?: string; + completed_at: string | null; }): boolean => { // If task is not in today plan, it's not overdue if (!task.today) {