From a368b979d5979f230ba5c41c641a8e495f792a1d Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 2 May 2025 17:05:16 +0530 Subject: [PATCH] Implement task completion prompt and enhance progress handling - Added logic to prompt users to mark tasks as done when progress reaches 100%, integrating with the socket events for real-time updates. - Updated backend functions to check task statuses and determine if a prompt is necessary based on the task's current state. - Enhanced frontend components to display a modal for confirming task completion, improving user experience and clarity in task management. - Refactored socket event handling to include new events for retrieving "done" statuses, ensuring accurate task status updates across the application. --- docs/task-progress-guide-for-users.md | 42 +++++++++ ...prove-parent-task-progress-calculation.sql | 17 ++++ .../commands/on-get-done-statuses.ts | 49 +++++++++++ .../commands/on-time-estimation-change.ts | 22 ++++- .../commands/on-update-task-progress.ts | 47 +++++++++- worklenz-backend/src/socket.io/events.ts | 3 + worklenz-backend/src/socket.io/index.ts | 3 +- .../task-drawer-progress.tsx | 87 ++++++++++++------- worklenz-frontend/src/shared/socket-events.ts | 3 + 9 files changed, 239 insertions(+), 34 deletions(-) create mode 100644 worklenz-backend/src/socket.io/commands/on-get-done-statuses.ts diff --git a/docs/task-progress-guide-for-users.md b/docs/task-progress-guide-for-users.md index 350329fa..4ff27ae7 100644 --- a/docs/task-progress-guide-for-users.md +++ b/docs/task-progress-guide-for-users.md @@ -76,6 +76,25 @@ The parent task will be approximately 39% complete, with Subtask C having the gr - Only explicitly set weights for tasks that should have different importance - Weights are only relevant for subtasks, not for independent tasks +### Detailed Weighted Progress Calculation Example + +To understand how weighted progress works with different weight values, consider this example: + +For a parent task with two subtasks: +- Subtask A: 80% complete, Weight 50% +- Subtask B: 40% complete, Weight 100% + +The calculation works as follows: + +1. Each subtask's contribution is: (weight × progress) ÷ (sum of all weights) +2. For Subtask A: (50 × 80%) ÷ (50 + 100) = 26.7% +3. For Subtask B: (100 × 40%) ÷ (50 + 100) = 26.7% +4. Total parent progress: 26.7% + 26.7% = 53.3% + +The parent task would be approximately 53% complete. + +This shows how the subtask with twice the weight (Subtask B) has twice the influence on the overall progress calculation, even though it has a lower completion percentage. + ## Time-based Progress Method ### How It Works @@ -108,6 +127,29 @@ The parent task will be approximately 29% complete, with the lengthy Subtask C p - Setting a time estimate to 0 removes that task from progress calculations - Time estimates serve dual purposes: scheduling/resource planning and progress weighting +### Detailed Time-based Progress Calculation Example + +To understand how time-based progress works with different time estimates, consider this example: + +For a parent task with three subtasks: +- Subtask A: 40% complete, Estimated Time 2.5 hours +- Subtask B: 80% complete, Estimated Time 1 hour +- Subtask C: 10% complete, Estimated Time 4 hours + +The calculation works as follows: + +1. Convert hours to minutes: A = 150 min, B = 60 min, C = 240 min +2. Total estimated time: 150 + 60 + 240 = 450 minutes +3. Each subtask's contribution is: (time estimate × progress) ÷ (total time) +4. For Subtask A: (150 × 40%) ÷ 450 = 13.3% +5. For Subtask B: (60 × 80%) ÷ 450 = 10.7% +6. For Subtask C: (240 × 10%) ÷ 450 = 5.3% +7. Total parent progress: 13.3% + 10.7% + 5.3% = 29.3% + +The parent task would be approximately 29% complete. + +This demonstrates how tasks with longer time estimates (like Subtask C) have more influence on the overall progress calculation. Even though Subtask B is 80% complete, its shorter time estimate means it contributes less to the overall progress than the partially-completed but longer Subtask A. + ## Default Progress Method If none of the special progress methods are enabled, WorkLenz uses a simple completion-based approach: diff --git a/worklenz-backend/database/migrations/20250426000000-improve-parent-task-progress-calculation.sql b/worklenz-backend/database/migrations/20250426000000-improve-parent-task-progress-calculation.sql index 7ef0015c..e1d5d1f2 100644 --- a/worklenz-backend/database/migrations/20250426000000-improve-parent-task-progress-calculation.sql +++ b/worklenz-backend/database/migrations/20250426000000-improve-parent-task-progress-calculation.sql @@ -221,6 +221,8 @@ CREATE OR REPLACE FUNCTION update_parent_task_progress() RETURNS TRIGGER AS $$ DECLARE _parent_task_id UUID; + _project_id UUID; + _ratio FLOAT; BEGIN -- Check if this is a subtask IF NEW.parent_task_id IS NOT NULL THEN @@ -232,6 +234,21 @@ BEGIN WHERE id = _parent_task_id; END IF; + -- If this task has progress value of 100 and doesn't have subtasks, we might want to prompt the user + -- to mark it as done. We'll annotate this in a way that the socket handler can detect. + IF NEW.progress_value = 100 OR NEW.weight = 100 OR NEW.total_minutes > 0 THEN + -- Check if task has status in "done" category + SELECT project_id FROM tasks WHERE id = NEW.id INTO _project_id; + + -- Get the progress ratio for this task + SELECT get_task_complete_ratio(NEW.id)->>'ratio' INTO _ratio; + + IF _ratio::FLOAT >= 100 THEN + -- Log that this task is at 100% progress + RAISE NOTICE 'Task % progress is at 100%%, may need status update', NEW.id; + END IF; + END IF; + RETURN NEW; END; $$ LANGUAGE plpgsql; diff --git a/worklenz-backend/src/socket.io/commands/on-get-done-statuses.ts b/worklenz-backend/src/socket.io/commands/on-get-done-statuses.ts new file mode 100644 index 00000000..aa22d4cf --- /dev/null +++ b/worklenz-backend/src/socket.io/commands/on-get-done-statuses.ts @@ -0,0 +1,49 @@ +import { Socket } from "socket.io"; +import db from "../../config/db"; +import { log_error } from "../util"; + +// Define a type for the callback function +type DoneStatusesCallback = (statuses: Array<{ + id: string; + name: string; + sort_order: number; + color_code: string; +}>) => void; + +/** + * Socket handler to get task statuses in the "done" category for a project + * Used when prompting users to mark a task as done when progress reaches 100% + */ +export async function on_get_done_statuses( + io: any, + socket: Socket, + projectId: string, + callback: DoneStatusesCallback +) { + try { + if (!projectId) { + return callback([]); + } + + // Query to get all statuses in the "done" category for the project + const result = await db.query(` + SELECT ts.id, ts.name, ts.sort_order, ts.color_code + FROM task_statuses ts + INNER JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id + WHERE ts.project_id = $1 + AND stsc.is_done = TRUE + ORDER BY ts.sort_order ASC + `, [projectId]); + + const doneStatuses = result.rows; + + console.log(`Found ${doneStatuses.length} "done" statuses for project ${projectId}`); + + // Use callback to return the result + callback(doneStatuses); + + } catch (error) { + log_error(`Error getting "done" statuses for project ${projectId}: ${error}`); + callback([]); + } +} \ No newline at end of file diff --git a/worklenz-backend/src/socket.io/commands/on-time-estimation-change.ts b/worklenz-backend/src/socket.io/commands/on-time-estimation-change.ts index 1860260e..32517845 100644 --- a/worklenz-backend/src/socket.io/commands/on-time-estimation-change.ts +++ b/worklenz-backend/src/socket.io/commands/on-time-estimation-change.ts @@ -26,12 +26,32 @@ async function updateTaskAncestors(io: any, socket: Socket, projectId: string, t const ratio = progressRatio?.rows[0]?.ratio?.ratio || 0; console.log(`Updated task ${taskId} progress after time estimation change: ${ratio}`); + // Check if this task needs a "done" status prompt + let shouldPromptForDone = false; + + if (ratio >= 100) { + // Get the task's current status + const taskStatusResult = await db.query(` + SELECT ts.id, stsc.is_done + FROM tasks t + JOIN task_statuses ts ON t.status_id = ts.id + JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id + WHERE t.id = $1 + `, [taskId]); + + // If the task isn't already in a "done" category, we should prompt the user + if (taskStatusResult.rows.length > 0 && !taskStatusResult.rows[0].is_done) { + shouldPromptForDone = true; + } + } + // Emit the updated progress socket.emit( SocketEvents.TASK_PROGRESS_UPDATED.toString(), { task_id: taskId, - progress_value: ratio + progress_value: ratio, + should_prompt_for_done: shouldPromptForDone } ); diff --git a/worklenz-backend/src/socket.io/commands/on-update-task-progress.ts b/worklenz-backend/src/socket.io/commands/on-update-task-progress.ts index cac1cb43..c04d37d4 100644 --- a/worklenz-backend/src/socket.io/commands/on-update-task-progress.ts +++ b/worklenz-backend/src/socket.io/commands/on-update-task-progress.ts @@ -30,12 +30,32 @@ async function updateTaskAncestors(io: any, socket: Socket, projectId: string, t const ratio = progressRatio?.rows[0]?.ratio?.ratio || 0; console.log(`Updated task ${taskId} progress: ${ratio}`); + // Check if this task needs a "done" status prompt + let shouldPromptForDone = false; + + if (ratio >= 100) { + // Get the task's current status + const taskStatusResult = await db.query(` + SELECT ts.id, stsc.is_done + FROM tasks t + JOIN task_statuses ts ON t.status_id = ts.id + JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id + WHERE t.id = $1 + `, [taskId]); + + // If the task isn't already in a "done" category, we should prompt the user + if (taskStatusResult.rows.length > 0 && !taskStatusResult.rows[0].is_done) { + shouldPromptForDone = true; + } + } + // Emit the updated progress socket.emit( SocketEvents.TASK_PROGRESS_UPDATED.toString(), { task_id: taskId, - progress_value: ratio + progress_value: ratio, + should_prompt_for_done: shouldPromptForDone } ); @@ -81,12 +101,13 @@ export async function on_update_task_progress(io: any, socket: Socket, data: str // Get the current progress value to log the change const currentProgressResult = await db.query( - "SELECT progress_value, project_id FROM tasks WHERE id = $1", + "SELECT progress_value, project_id, status_id FROM tasks WHERE id = $1", [task_id] ); const currentProgress = currentProgressResult.rows[0]?.progress_value; const projectId = currentProgressResult.rows[0]?.project_id; + const statusId = currentProgressResult.rows[0]?.status_id; // Update the task progress in the database await db.query( @@ -103,13 +124,33 @@ export async function on_update_task_progress(io: any, socket: Socket, data: str new_value: progress_value.toString(), socket }); + if (projectId) { + // Check if progress is 100% and the task isn't already in a "done" status category + let shouldPromptForDone = false; + + if (progress_value >= 100) { + // Check if the task's current status is in a "done" category + const statusCategoryResult = await db.query(` + SELECT stsc.is_done + FROM task_statuses ts + JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id + WHERE ts.id = $1 + `, [statusId]); + + // If the task isn't already in a "done" category, we should prompt the user + if (statusCategoryResult.rows.length > 0 && !statusCategoryResult.rows[0].is_done) { + shouldPromptForDone = true; + } + } + // Emit the update to all clients in the project room socket.emit( SocketEvents.TASK_PROGRESS_UPDATED.toString(), { task_id, - progress_value + progress_value, + should_prompt_for_done: shouldPromptForDone } ); diff --git a/worklenz-backend/src/socket.io/events.ts b/worklenz-backend/src/socket.io/events.ts index a8e19a83..c0a58008 100644 --- a/worklenz-backend/src/socket.io/events.ts +++ b/worklenz-backend/src/socket.io/events.ts @@ -67,4 +67,7 @@ export enum SocketEvents { // Task subtasks count events GET_TASK_SUBTASKS_COUNT, TASK_SUBTASKS_COUNT, + + // Task completion events + GET_DONE_STATUSES, } diff --git a/worklenz-backend/src/socket.io/index.ts b/worklenz-backend/src/socket.io/index.ts index 3c5e50b5..04927214 100644 --- a/worklenz-backend/src/socket.io/index.ts +++ b/worklenz-backend/src/socket.io/index.ts @@ -55,6 +55,7 @@ import { on_custom_column_pinned_change } from "./commands/on_custom_column_pinn import { on_update_task_progress } from "./commands/on-update-task-progress"; import { on_update_task_weight } from "./commands/on-update-task-weight"; import { on_get_task_subtasks_count } from "./commands/on-get-task-subtasks-count"; +import { on_get_done_statuses } from "./commands/on-get-done-statuses"; export function register(io: any, socket: Socket) { log(socket.id, "client registered"); @@ -72,7 +73,6 @@ export function register(io: any, socket: Socket) { socket.on(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), data => on_time_estimation_change(io, socket, data)); socket.on(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), data => on_task_description_change(io, socket, data)); socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), data => on_get_task_progress(io, socket, data)); - socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), data => on_get_task_progress(io, socket, data)); socket.on(SocketEvents.TASK_TIMER_START.toString(), data => on_task_timer_start(io, socket, data)); socket.on(SocketEvents.TASK_TIMER_STOP.toString(), data => on_task_timer_stop(io, socket, data)); socket.on(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), data => on_task_sort_order_change(io, socket, data)); @@ -112,6 +112,7 @@ export function register(io: any, socket: Socket) { socket.on(SocketEvents.UPDATE_TASK_PROGRESS.toString(), data => on_update_task_progress(io, socket, data)); socket.on(SocketEvents.UPDATE_TASK_WEIGHT.toString(), data => on_update_task_weight(io, socket, data)); socket.on(SocketEvents.GET_TASK_SUBTASKS_COUNT.toString(), (taskId) => on_get_task_subtasks_count(io, socket, taskId)); + socket.on(SocketEvents.GET_DONE_STATUSES.toString(), (projectId, callback) => on_get_done_statuses(io, socket, projectId, callback)); // socket.io built-in event socket.on("disconnect", (reason) => on_disconnect(io, socket, reason)); diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx index ebb1e694..75faea5b 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx @@ -1,11 +1,11 @@ -import { Form, InputNumber, Tooltip } from 'antd'; +import { Form, InputNumber, Tooltip, Modal } from 'antd'; import { useTranslation } from 'react-i18next'; import { QuestionCircleOutlined } from '@ant-design/icons'; import { useAppSelector } from '@/hooks/useAppSelector'; import { ITaskViewModel } from '@/types/tasks/task.types'; import Flex from 'antd/lib/flex'; import { SocketEvents } from '@/shared/socket-events'; -import { useEffect, useState } from 'react'; +import { useState, useEffect } from 'react'; import { useSocket } from '@/socket/socketContext'; interface TaskDrawerProgressProps { @@ -17,42 +17,20 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => { const { t } = useTranslation('task-drawer/task-drawer'); const { project } = useAppSelector(state => state.projectReducer); const { socket, connected } = useSocket(); - const [confirmedHasSubtasks, setConfirmedHasSubtasks] = useState(null); + const [isCompletionModalVisible, setIsCompletionModalVisible] = useState(false); const isSubTask = !!task?.parent_task_id; - const hasSubTasks = task?.sub_tasks_count > 0 || confirmedHasSubtasks === true; + // Safe handling of sub_tasks_count which might be undefined in some cases + const hasSubTasks = (task?.sub_tasks_count || 0) > 0; - // Additional debug logging - console.log(`TaskDrawerProgress for task ${task.id} (${task.name}): hasSubTasks=${hasSubTasks}, count=${task.sub_tasks_count}, confirmedHasSubtasks=${confirmedHasSubtasks}`); + // Log task details for debugging + console.log(`TaskDrawerProgress: task=${task?.id}, sub_tasks_count=${task?.sub_tasks_count}, hasSubTasks=${hasSubTasks}`); // HIGHEST PRIORITY CHECK: Never show progress inputs for parent tasks with subtasks - // This check happens before any other logic to ensure consistency if (hasSubTasks) { - console.error(`REJECTED: Progress input for parent task ${task.id} with ${task.sub_tasks_count} subtasks. confirmedHasSubtasks=${confirmedHasSubtasks}`); return null; } - // Double-check by directly querying for subtasks from the server - useEffect(() => { - if (connected && task.id) { - socket?.emit(SocketEvents.GET_TASK_SUBTASKS_COUNT.toString(), task.id); - } - - // Listen for the subtask count response - const handleSubtasksCount = (data: any) => { - if (data.task_id === task.id) { - console.log(`Received subtask count for task ${task.id}: ${data.subtask_count}, has_subtasks=${data.has_subtasks}`); - setConfirmedHasSubtasks(data.has_subtasks); - } - }; - - socket?.on(SocketEvents.TASK_SUBTASKS_COUNT.toString(), handleSubtasksCount); - - return () => { - socket?.off(SocketEvents.TASK_SUBTASKS_COUNT.toString(), handleSubtasksCount); - }; - }, [socket, connected, task.id]); - // Never show manual progress input for parent tasks (tasks with subtasks) // Only show progress input for tasks without subtasks const showManualProgressInput = !hasSubTasks; @@ -70,6 +48,11 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => { if (data.weight !== undefined) { form.setFieldsValue({ weight: data.weight }); } + + // Check if we should prompt the user to mark the task as done + if (data.should_prompt_for_done) { + setIsCompletionModalVisible(true); + } } }; @@ -92,6 +75,11 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => { const handleProgressChange = (value: number | null) => { if (connected && task.id && value !== null && !hasSubTasks) { + // Check if progress is set to 100% to show completion confirmation + if (value === 100) { + setIsCompletionModalVisible(true); + } + // Ensure parent_task_id is not undefined const parent_task_id = task.parent_task_id || null; @@ -136,6 +124,36 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => { } }; + const handleMarkTaskAsComplete = () => { + // Close the modal + setIsCompletionModalVisible(false); + + // Find a "Done" status for this project + if (connected && task.id) { + // Emit socket event to get "done" category statuses + socket?.emit(SocketEvents.GET_DONE_STATUSES.toString(), task.project_id, (doneStatuses: any[]) => { + if (doneStatuses && doneStatuses.length > 0) { + // Use the first "done" status + const doneStatusId = doneStatuses[0].id; + + // Emit socket event to update the task status + socket?.emit( + SocketEvents.TASK_STATUS_CHANGE.toString(), + JSON.stringify({ + task_id: task.id, + status_id: doneStatusId, + project_id: task.project_id + }) + ); + + console.log(`Task ${task.id} marked as done with status ${doneStatusId}`); + } else { + console.error(`No "done" statuses found for project ${task.project_id}`); + } + }); + } + }; + const percentFormatter = (value: number | undefined) => (value ? `${value}%` : '0%'); const percentParser = (value: string | undefined) => { const parsed = parseInt(value?.replace('%', '') || '0', 10); @@ -217,6 +235,17 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => { /> )} + + setIsCompletionModalVisible(false)} + okText="Yes, mark as done" + cancelText="No, keep current status" + > +

You've set the progress to 100%. Would you like to update the task status to "Done"?

+
); }; diff --git a/worklenz-frontend/src/shared/socket-events.ts b/worklenz-frontend/src/shared/socket-events.ts index 33bcc0e8..2c952c18 100644 --- a/worklenz-frontend/src/shared/socket-events.ts +++ b/worklenz-frontend/src/shared/socket-events.ts @@ -67,4 +67,7 @@ export enum SocketEvents { // Task subtasks count events GET_TASK_SUBTASKS_COUNT, TASK_SUBTASKS_COUNT, + + // Task completion events + GET_DONE_STATUSES, }