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.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -67,4 +67,7 @@ export enum SocketEvents {
|
||||
// Task subtasks count events
|
||||
GET_TASK_SUBTASKS_COUNT,
|
||||
TASK_SUBTASKS_COUNT,
|
||||
|
||||
// Task completion events
|
||||
GET_DONE_STATUSES,
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<boolean | null>(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) => {
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title="Mark Task as Done?"
|
||||
open={isCompletionModalVisible}
|
||||
onOk={handleMarkTaskAsComplete}
|
||||
onCancel={() => setIsCompletionModalVisible(false)}
|
||||
okText="Yes, mark as done"
|
||||
cancelText="No, keep current status"
|
||||
>
|
||||
<p>You've set the progress to 100%. Would you like to update the task status to "Done"?</p>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -67,4 +67,7 @@ export enum SocketEvents {
|
||||
// Task subtasks count events
|
||||
GET_TASK_SUBTASKS_COUNT,
|
||||
TASK_SUBTASKS_COUNT,
|
||||
|
||||
// Task completion events
|
||||
GET_DONE_STATUSES,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user