diff --git a/docs/task-progress-guide-for-users.md b/docs/task-progress-guide-for-users.md index 081ca16e..350329fa 100644 --- a/docs/task-progress-guide-for-users.md +++ b/docs/task-progress-guide-for-users.md @@ -62,17 +62,17 @@ The parent task will show as 60% complete (average of 30%, 60%, and 90%). ### Example If you have a parent task with three subtasks: -- Subtask A: 50% complete, Weight 200 (critical task) -- Subtask B: 75% complete, Weight 100 (standard task) -- Subtask C: 25% complete, Weight 300 (major task) +- Subtask A: 50% complete, Weight 60% (important task) +- Subtask B: 75% complete, Weight 20% (less important task) +- Subtask C: 25% complete, Weight 100% (critical task) -The parent task will be approximately 42% complete, with Subtask C having the greatest impact due to its higher weight. +The parent task will be approximately 39% complete, with Subtask C having the greatest impact due to its higher weight. ### Important Notes About Weights -- Default weight is 100 if not specified -- You can set weights from 0 to any reasonable number (typically 1-1000) -- Setting a weight to 0 removes that task from progress calculations +- Default weight is 100% if not specified +- Weights range from 0% to 100% +- Setting a weight to 0% removes that task from progress calculations - Only explicitly set weights for tasks that should have different importance - Weights are only relevant for subtasks, not for independent tasks diff --git a/docs/task-progress-methods.md b/docs/task-progress-methods.md index 5a75ac95..b931b7f5 100644 --- a/docs/task-progress-methods.md +++ b/docs/task-progress-methods.md @@ -44,6 +44,7 @@ This mode allows assigning different weights to subtasks to reflect their relati - The UI shows a weight input for subtasks in the task drawer - Manual progress input is still required for tasks without subtasks - Default weight is 100 if not specified +- Weight values range from 0 to 100% **Calculation Logic:** - For tasks without subtasks: Uses the manually entered progress value @@ -224,10 +225,9 @@ In Weighted Progress mode, both the manual progress input and weight assignment - If a leaf task's progress is not manually set, it defaults to 0% (or 100% if completed) 2. **Weight Assignment**: - - Each task can be assigned a weight value (default 100 if not specified) + - Each task can be assigned a weight value between 0-100% (default 100% if not specified) - Higher weight values give tasks more influence in parent task progress calculations - - Weight values are typically whole numbers between 0 and 1000 - - A weight of 0 means the task doesn't contribute to the parent's progress calculation + - A weight of 0% means the task doesn't contribute to the parent's progress calculation 3. **Parent Task Calculation**: The weighted progress formula is: @@ -237,24 +237,24 @@ In Weighted Progress mode, both the manual progress input and weight assignment **Example Calculation**: Consider a parent task with three subtasks: - - Subtask A: Progress 50%, Weight 200 - - Subtask B: Progress 75%, Weight 100 - - Subtask C: Progress 25%, Weight 300 + - Subtask A: Progress 50%, Weight 60% + - Subtask B: Progress 75%, Weight 20% + - Subtask C: Progress 25%, Weight 100% Calculation: ``` - ParentProgress = ((50 * 200) + (75 * 100) + (25 * 300)) / (200 + 100 + 300) - ParentProgress = (10000 + 7500 + 7500) / 600 - ParentProgress = 25000 / 600 - ParentProgress = 41.67% + ParentProgress = ((50 * 60) + (75 * 20) + (25 * 100)) / (60 + 20 + 100) + ParentProgress = (3000 + 1500 + 2500) / 180 + ParentProgress = 7000 / 180 + ParentProgress = 38.89% ``` Notice that Subtask C, despite having the lowest progress, has a significant impact on the parent task progress due to its higher weight. 4. **Zero Weight Handling**: Tasks with zero weight are excluded from the calculation: - - Subtask A: Progress 40%, Weight 50 - - Subtask B: Progress 80%, Weight 0 + - Subtask A: Progress 40%, Weight 50% + - Subtask B: Progress 80%, Weight 0% Calculation: ``` @@ -263,13 +263,13 @@ In Weighted Progress mode, both the manual progress input and weight assignment ParentProgress = 40% ``` - In this case, only Subtask A influences the parent task progress because Subtask B has a weight of 0. + In this case, only Subtask A influences the parent task progress because Subtask B has a weight of 0%. 5. **Default Weight Behavior**: When weights aren't explicitly assigned to some tasks: - - Subtask A: Progress 30%, Weight 60 (explicitly set) - - Subtask B: Progress 70%, Weight not set (defaults to 100) - - Subtask C: Progress 90%, Weight not set (defaults to 100) + - Subtask A: Progress 30%, Weight 60% (explicitly set) + - Subtask B: Progress 70%, Weight not set (defaults to 100%) + - Subtask C: Progress 90%, Weight not set (defaults to 100%) Calculation: ``` diff --git a/task-progress-methods.md b/task-progress-methods.md new file mode 100644 index 00000000..11b18ef5 --- /dev/null +++ b/task-progress-methods.md @@ -0,0 +1,244 @@ +# Task Progress Tracking Methods in WorkLenz + +## Overview +WorkLenz supports three different methods for tracking task progress, each suitable for different project management approaches: + +1. **Manual Progress** - Direct input of progress percentages +2. **Weighted Progress** - Tasks have weights that affect overall progress calculation +3. **Time-based Progress** - Progress calculated based on estimated time vs. time spent + +These modes can be selected when creating or editing a project in the project drawer. + +## 1. Manual Progress Mode + +This mode allows direct input of progress percentages for individual tasks without subtasks. + +**Implementation:** +- Enabled by setting `use_manual_progress` to true in the project settings +- Progress is updated through the `on-update-task-progress.ts` socket event handler +- The UI shows a manual progress input slider in the task drawer for tasks without subtasks +- Updates the database with `progress_value` and sets `manual_progress` flag to true + +**Calculation Logic:** +- For tasks without subtasks: Uses the manually set progress value +- For parent tasks: Calculates the average of all subtask progress values +- Subtask progress comes from either manual values or completion status (0% or 100%) + +**Code Example:** +```typescript +// Manual progress update via socket.io +socket?.emit(SocketEvents.UPDATE_TASK_PROGRESS.toString(), JSON.stringify({ + task_id: task.id, + progress_value: value, + parent_task_id: task.parent_task_id +})); +``` + +### Showing Progress in Subtask Rows + +When manual progress is enabled in a project, progress is shown in the following ways: + +1. **In Task List Views**: + - Subtasks display their individual progress values in the progress column + - Parent tasks display the calculated average progress of all subtasks + +2. **Implementation Details**: + - The progress values are stored in the `progress_value` column in the database + - For subtasks with manual progress set, the value is shown directly + - For subtasks without manual progress, the completion status determines the value (0% or 100%) + - The task view model includes both `progress` and `complete_ratio` properties + +**Relevant Components:** +```typescript +// From task-list-progress-cell.tsx +const TaskListProgressCell = ({ task }: TaskListProgressCellProps) => { + return task.is_sub_task ? null : ( + + + + ); +}; +``` + +**Task Progress Calculation in Backend:** +```typescript +// From tasks-controller-base.ts +// For tasks without subtasks, respect manual progress if set +if (task.manual_progress === true && task.progress_value !== null) { + // For manually set progress, use that value directly + task.progress = parseInt(task.progress_value); + task.complete_ratio = parseInt(task.progress_value); +} +``` + +## 2. Weighted Progress Mode + +This mode allows assigning different weights to subtasks to reflect their relative importance in the overall task or project progress. + +**Implementation:** +- Enabled by setting `use_weighted_progress` to true in the project settings +- Weights are updated through the `on-update-task-weight.ts` socket event handler +- The UI shows a weight input for subtasks in the task drawer +- Default weight is 100 if not specified + +**Calculation Logic:** +- Progress is calculated using a weighted average: `SUM(progress_value * weight) / SUM(weight)` +- This gives more influence to tasks with higher weights +- A parent task's progress is the weighted average of its subtasks' progress + +**Code Example:** +```typescript +// Weight update via socket.io +socket?.emit(SocketEvents.UPDATE_TASK_WEIGHT.toString(), JSON.stringify({ + task_id: task.id, + weight: value, + parent_task_id: task.parent_task_id +})); +``` + +## 3. Time-based Progress Mode + +This mode calculates progress based on estimated time vs. actual time spent. + +**Implementation:** +- Enabled by setting `use_time_progress` to true in the project settings +- Uses task time estimates (hours and minutes) for calculation +- No separate socket handler needed as it's calculated automatically + +**Calculation Logic:** +- Progress is calculated using time as the weight: `SUM(progress_value * estimated_minutes) / SUM(estimated_minutes)` +- For tasks with time tracking, estimated vs. actual time can be factored in +- Parent task progress is weighted by the estimated time of each subtask + +**SQL Example:** +```sql +WITH subtask_progress AS ( + SELECT + CASE + WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN + progress_value + ELSE + CASE + WHEN EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = t.id + AND is_done IS TRUE + ) THEN 100 + ELSE 0 + END + END AS progress_value, + COALESCE(total_hours * 60 + total_minutes, 0) AS estimated_minutes + FROM tasks t + WHERE t.parent_task_id = _task_id + AND t.archived IS FALSE +) +SELECT COALESCE( + SUM(progress_value * estimated_minutes) / NULLIF(SUM(estimated_minutes), 0), + 0 +) +FROM subtask_progress +INTO _ratio; +``` + +## Default Progress Tracking (when no special mode is selected) + +If no specific progress mode is enabled, the system falls back to a traditional completion-based calculation: + +**Implementation:** +- Default mode when all three special modes are disabled +- Based on task completion status only + +**Calculation Logic:** +- For tasks without subtasks: 0% if not done, 100% if done +- For parent tasks: `(completed_tasks / total_tasks) * 100` +- Counts both the parent and all subtasks in the calculation + +**SQL Example:** +```sql +-- Traditional calculation based on completion status +SELECT (CASE + WHEN EXISTS(SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = _task_id + AND is_done IS TRUE) THEN 1 + ELSE 0 END) +INTO _parent_task_done; + +SELECT COUNT(*) +FROM tasks_with_status_view +WHERE parent_task_id = _task_id + AND is_done IS TRUE +INTO _sub_tasks_done; + +_total_completed = _parent_task_done + _sub_tasks_done; +_total_tasks = _sub_tasks_count + 1; -- +1 for the parent task + +IF _total_tasks = 0 THEN + _ratio = 0; +ELSE + _ratio = (_total_completed / _total_tasks) * 100; +END IF; +``` + +## Technical Implementation Details + +The progress calculation logic is implemented in PostgreSQL functions, primarily in the `get_task_complete_ratio` function. Progress updates flow through the system as follows: + +1. **User Action**: User updates task progress or weight in the UI +2. **Socket Event**: Client emits socket event (UPDATE_TASK_PROGRESS or UPDATE_TASK_WEIGHT) +3. **Server Handler**: Server processes the event in the respective handler function +4. **Database Update**: Progress/weight value is updated in the database +5. **Recalculation**: If needed, parent task progress is recalculated +6. **Broadcast**: Changes are broadcast to all clients in the project room +7. **UI Update**: Client UI updates to reflect the new progress values + +This architecture allows for real-time updates and consistent progress calculation across all clients. + +## Associated Files and Components + +### Backend Files + +1. **Socket Event Handlers**: + - `worklenz-backend/src/socket.io/commands/on-update-task-progress.ts` - Handles manual progress updates + - `worklenz-backend/src/socket.io/commands/on-update-task-weight.ts` - Handles task weight updates + +2. **Database Functions**: + - `worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql` - Contains the `get_task_complete_ratio` function that calculates progress based on the selected method + - Functions that support project creation/updates with progress mode settings: + - `create_project` + - `update_project` + +3. **Controllers**: + - `worklenz-backend/src/controllers/project-workload/workload-gannt-base.ts` - Contains the `calculateTaskCompleteRatio` method + - `worklenz-backend/src/controllers/projects-controller.ts` - Handles project-level progress calculations + - `worklenz-backend/src/controllers/tasks-controller-base.ts` - Handles task progress calculation and updates task view models + +### Frontend Files + +1. **Project Configuration**: + - `worklenz-frontend/src/components/projects/project-drawer/project-drawer.tsx` - Contains UI for selecting progress method when creating/editing projects + +2. **Progress Visualization Components**: + - `worklenz-frontend/src/components/project-list/project-list-table/project-list-progress/progress-list-progress.tsx` - Displays project progress + - `worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress.tsx` - Displays task progress + - `worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-progress-cell/task-list-progress-cell.tsx` - Alternative task progress cell + - `worklenz-frontend/src/components/task-list-common/task-row/task-row-progress/task-row-progress.tsx` - Displays progress in task rows + +3. **Progress Input Components**: + - `worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx` - Component for inputting task progress/weight + +## Choosing the Right Progress Method + +Each progress method is suitable for different types of projects: + +- **Manual Progress**: Best for creative work where progress is subjective +- **Weighted Progress**: Ideal for projects where some tasks are more significant than others +- **Time-based Progress**: Perfect for projects where time estimates are reliable and important + +Project managers can choose the appropriate method when creating or editing a project in the project drawer, based on their team's workflow and project requirements. \ No newline at end of file diff --git a/worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql b/worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql index b43b8a75..8898e599 100644 --- a/worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql +++ b/worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql @@ -39,8 +39,14 @@ BEGIN INTO _use_manual_progress, _use_weighted_progress, _use_time_progress; END IF; - -- If manual progress is enabled and has a value, use it directly - IF _is_manual IS TRUE AND _manual_value IS NOT NULL THEN + -- Get all subtasks + SELECT COUNT(*) + FROM tasks + WHERE parent_task_id = _task_id AND archived IS FALSE + INTO _sub_tasks_count; + + -- If manual progress is enabled and has a value AND there are no subtasks, use it directly + IF _is_manual IS TRUE AND _manual_value IS NOT NULL AND _sub_tasks_count = 0 THEN RETURN JSON_BUILD_OBJECT( 'ratio', _manual_value, 'total_completed', 0, @@ -49,12 +55,6 @@ BEGIN ); END IF; - -- Get all subtasks - SELECT COUNT(*) - FROM tasks - WHERE parent_task_id = _task_id AND archived IS FALSE - INTO _sub_tasks_count; - -- If there are no subtasks, just use the parent task's status IF _sub_tasks_count = 0 THEN SELECT (CASE @@ -145,7 +145,7 @@ BEGIN ELSE 0 END END AS progress_value, - COALESCE(total_hours * 60 + total_minutes, 0) AS estimated_minutes + COALESCE(total_minutes, 0) AS estimated_minutes FROM tasks t WHERE t.parent_task_id = _task_id AND t.archived IS FALSE @@ -350,8 +350,8 @@ BEGIN RETURNING id INTO _project_id; -- register the project log - INSERT INTO project_logs (project_id, team_id, team_member_id, description) - VALUES (_project_id, _team_id, _team_member_id, _project_created_log) + INSERT INTO project_logs (project_id, team_id, description) + VALUES (_project_id, _team_id, _project_created_log) RETURNING id INTO _project_created_log_id; -- add the team member in the project as a user @@ -360,8 +360,8 @@ BEGIN (SELECT id FROM project_access_levels WHERE key = 'MEMBER')); -- register the project log - INSERT INTO project_logs (project_id, team_id, team_member_id, description) - VALUES (_project_id, _team_id, _team_member_id, _project_member_added_log); + INSERT INTO project_logs (project_id, team_id, description) + VALUES (_project_id, _team_id, _project_member_added_log); -- insert default project columns PERFORM insert_task_list_columns(_project_id); @@ -657,4 +657,26 @@ ADD COLUMN IF NOT EXISTS use_manual_progress BOOLEAN DEFAULT FALSE, ADD COLUMN IF NOT EXISTS use_weighted_progress BOOLEAN DEFAULT FALSE, ADD COLUMN IF NOT EXISTS use_time_progress BOOLEAN DEFAULT FALSE; +-- Add a trigger to reset manual progress when a task gets a new subtask +CREATE OR REPLACE FUNCTION reset_parent_task_manual_progress() RETURNS TRIGGER AS +$$ +BEGIN + -- When a task gets a new subtask (parent_task_id is set), reset the parent's manual_progress flag + IF NEW.parent_task_id IS NOT NULL THEN + UPDATE tasks + SET manual_progress = false + WHERE id = NEW.parent_task_id + AND manual_progress = true; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Create the trigger on the tasks table +DROP TRIGGER IF EXISTS reset_parent_manual_progress_trigger ON tasks; +CREATE TRIGGER reset_parent_manual_progress_trigger +AFTER INSERT OR UPDATE OF parent_task_id ON tasks +FOR EACH ROW +EXECUTE FUNCTION reset_parent_task_manual_progress(); + COMMIT; \ No newline at end of file diff --git a/worklenz-backend/database/migrations/20250425000000-update-time-based-progress.sql b/worklenz-backend/database/migrations/20250425000000-update-time-based-progress.sql new file mode 100644 index 00000000..b817ddd7 --- /dev/null +++ b/worklenz-backend/database/migrations/20250425000000-update-time-based-progress.sql @@ -0,0 +1,221 @@ +-- Migration: Update time-based progress mode to work for all tasks +-- Date: 2025-04-25 +-- Version: 1.0.0 + +BEGIN; + +-- Update function to use time-based progress for all tasks +CREATE OR REPLACE FUNCTION get_task_complete_ratio(_task_id uuid) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _parent_task_done FLOAT = 0; + _sub_tasks_done FLOAT = 0; + _sub_tasks_count FLOAT = 0; + _total_completed FLOAT = 0; + _total_tasks FLOAT = 0; + _ratio FLOAT = 0; + _is_manual BOOLEAN = FALSE; + _manual_value INTEGER = NULL; + _project_id UUID; + _use_manual_progress BOOLEAN = FALSE; + _use_weighted_progress BOOLEAN = FALSE; + _use_time_progress BOOLEAN = FALSE; +BEGIN + -- Check if manual progress is set for this task + SELECT manual_progress, progress_value, project_id + FROM tasks + WHERE id = _task_id + INTO _is_manual, _manual_value, _project_id; + + -- Check if the project uses manual progress + IF _project_id IS NOT NULL THEN + SELECT COALESCE(use_manual_progress, FALSE), + COALESCE(use_weighted_progress, FALSE), + COALESCE(use_time_progress, FALSE) + FROM projects + WHERE id = _project_id + INTO _use_manual_progress, _use_weighted_progress, _use_time_progress; + END IF; + + -- Get all subtasks + SELECT COUNT(*) + FROM tasks + WHERE parent_task_id = _task_id AND archived IS FALSE + INTO _sub_tasks_count; + + -- Always respect manual progress value if set + IF _is_manual IS TRUE AND _manual_value IS NOT NULL THEN + RETURN JSON_BUILD_OBJECT( + 'ratio', _manual_value, + 'total_completed', 0, + 'total_tasks', 0, + 'is_manual', TRUE + ); + END IF; + + -- If there are no subtasks, just use the parent task's status (unless in time-based mode) + IF _sub_tasks_count = 0 THEN + -- Use time-based estimation for tasks without subtasks if enabled + IF _use_time_progress IS TRUE THEN + -- For time-based tasks without subtasks, we still need some progress calculation + -- If the task is completed, return 100% + -- Otherwise, use the progress value if set manually, or 0 + SELECT + CASE + WHEN EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = _task_id + AND is_done IS TRUE + ) THEN 100 + ELSE COALESCE(_manual_value, 0) + END + INTO _ratio; + ELSE + -- Traditional calculation for non-time-based tasks + SELECT (CASE + WHEN EXISTS(SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = _task_id + AND is_done IS TRUE) THEN 1 + ELSE 0 END) + INTO _parent_task_done; + + _ratio = _parent_task_done * 100; + END IF; + ELSE + -- If project uses manual progress, calculate based on subtask manual progress values + IF _use_manual_progress IS TRUE THEN + WITH subtask_progress AS ( + SELECT + CASE + -- If subtask has manual progress, use that value + WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN + progress_value + -- Otherwise use completion status (0 or 100) + ELSE + CASE + WHEN EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = t.id + AND is_done IS TRUE + ) THEN 100 + ELSE 0 + END + END AS progress_value + FROM tasks t + WHERE t.parent_task_id = _task_id + AND t.archived IS FALSE + ) + SELECT COALESCE(AVG(progress_value), 0) + FROM subtask_progress + INTO _ratio; + -- If project uses weighted progress, calculate based on subtask weights + ELSIF _use_weighted_progress IS TRUE THEN + WITH subtask_progress AS ( + SELECT + CASE + -- If subtask has manual progress, use that value + WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN + progress_value + -- Otherwise use completion status (0 or 100) + ELSE + CASE + WHEN EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = t.id + AND is_done IS TRUE + ) THEN 100 + ELSE 0 + END + END AS progress_value, + COALESCE(weight, 100) AS weight + FROM tasks t + WHERE t.parent_task_id = _task_id + AND t.archived IS FALSE + ) + SELECT COALESCE( + SUM(progress_value * weight) / NULLIF(SUM(weight), 0), + 0 + ) + FROM subtask_progress + INTO _ratio; + -- If project uses time-based progress, calculate based on estimated time + ELSIF _use_time_progress IS TRUE THEN + WITH subtask_progress AS ( + SELECT + CASE + -- If subtask has manual progress, use that value + WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN + progress_value + -- Otherwise use completion status (0 or 100) + ELSE + CASE + WHEN EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = t.id + AND is_done IS TRUE + ) THEN 100 + ELSE 0 + END + END AS progress_value, + COALESCE(total_minutes, 0) AS estimated_minutes + FROM tasks t + WHERE t.parent_task_id = _task_id + AND t.archived IS FALSE + ) + SELECT COALESCE( + SUM(progress_value * estimated_minutes) / NULLIF(SUM(estimated_minutes), 0), + 0 + ) + FROM subtask_progress + INTO _ratio; + ELSE + -- Traditional calculation based on completion status + SELECT (CASE + WHEN EXISTS(SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = _task_id + AND is_done IS TRUE) THEN 1 + ELSE 0 END) + INTO _parent_task_done; + + SELECT COUNT(*) + FROM tasks_with_status_view + WHERE parent_task_id = _task_id + AND is_done IS TRUE + INTO _sub_tasks_done; + + _total_completed = _parent_task_done + _sub_tasks_done; + _total_tasks = _sub_tasks_count + 1; -- +1 for the parent task + + IF _total_tasks = 0 THEN + _ratio = 0; + ELSE + _ratio = (_total_completed / _total_tasks) * 100; + END IF; + END IF; + END IF; + + -- Ensure ratio is between 0 and 100 + IF _ratio < 0 THEN + _ratio = 0; + ELSIF _ratio > 100 THEN + _ratio = 100; + END IF; + + RETURN JSON_BUILD_OBJECT( + 'ratio', _ratio, + 'total_completed', _total_completed, + 'total_tasks', _total_tasks, + 'is_manual', _is_manual + ); +END +$$; + +COMMIT; \ No newline at end of file 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 new file mode 100644 index 00000000..7ef0015c --- /dev/null +++ b/worklenz-backend/database/migrations/20250426000000-improve-parent-task-progress-calculation.sql @@ -0,0 +1,272 @@ +-- Migration: Improve parent task progress calculation using weights and time estimation +-- Date: 2025-04-26 +-- Version: 1.0.0 + +BEGIN; + +-- Update function to better calculate parent task progress based on subtask weights or time estimations +CREATE OR REPLACE FUNCTION get_task_complete_ratio(_task_id uuid) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _parent_task_done FLOAT = 0; + _sub_tasks_done FLOAT = 0; + _sub_tasks_count FLOAT = 0; + _total_completed FLOAT = 0; + _total_tasks FLOAT = 0; + _ratio FLOAT = 0; + _is_manual BOOLEAN = FALSE; + _manual_value INTEGER = NULL; + _project_id UUID; + _use_manual_progress BOOLEAN = FALSE; + _use_weighted_progress BOOLEAN = FALSE; + _use_time_progress BOOLEAN = FALSE; +BEGIN + -- Check if manual progress is set for this task + SELECT manual_progress, progress_value, project_id + FROM tasks + WHERE id = _task_id + INTO _is_manual, _manual_value, _project_id; + + -- Check if the project uses manual progress + IF _project_id IS NOT NULL THEN + SELECT COALESCE(use_manual_progress, FALSE), + COALESCE(use_weighted_progress, FALSE), + COALESCE(use_time_progress, FALSE) + FROM projects + WHERE id = _project_id + INTO _use_manual_progress, _use_weighted_progress, _use_time_progress; + END IF; + + -- Get all subtasks + SELECT COUNT(*) + FROM tasks + WHERE parent_task_id = _task_id AND archived IS FALSE + INTO _sub_tasks_count; + + -- Only respect manual progress for tasks without subtasks + IF _is_manual IS TRUE AND _manual_value IS NOT NULL AND _sub_tasks_count = 0 THEN + RETURN JSON_BUILD_OBJECT( + 'ratio', _manual_value, + 'total_completed', 0, + 'total_tasks', 0, + 'is_manual', TRUE + ); + END IF; + + -- If there are no subtasks, just use the parent task's status + IF _sub_tasks_count = 0 THEN + -- For tasks without subtasks in time-based mode + IF _use_time_progress IS TRUE THEN + SELECT + CASE + WHEN EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = _task_id + AND is_done IS TRUE + ) THEN 100 + ELSE COALESCE(_manual_value, 0) + END + INTO _ratio; + ELSE + -- Traditional calculation for non-time-based tasks + SELECT (CASE + WHEN EXISTS(SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = _task_id + AND is_done IS TRUE) THEN 1 + ELSE 0 END) + INTO _parent_task_done; + + _ratio = _parent_task_done * 100; + END IF; + ELSE + -- For parent tasks with subtasks, always use the appropriate calculation based on project mode + -- If project uses manual progress, calculate based on subtask manual progress values + IF _use_manual_progress IS TRUE THEN + WITH subtask_progress AS ( + SELECT + CASE + -- If subtask has manual progress, use that value + WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN + progress_value + -- Otherwise use completion status (0 or 100) + ELSE + CASE + WHEN EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = t.id + AND is_done IS TRUE + ) THEN 100 + ELSE 0 + END + END AS progress_value + FROM tasks t + WHERE t.parent_task_id = _task_id + AND t.archived IS FALSE + ) + SELECT COALESCE(AVG(progress_value), 0) + FROM subtask_progress + INTO _ratio; + -- If project uses weighted progress, calculate based on subtask weights + ELSIF _use_weighted_progress IS TRUE THEN + WITH subtask_progress AS ( + SELECT + CASE + -- If subtask has manual progress, use that value + WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN + progress_value + -- Otherwise use completion status (0 or 100) + ELSE + CASE + WHEN EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = t.id + AND is_done IS TRUE + ) THEN 100 + ELSE 0 + END + END AS progress_value, + COALESCE(weight, 100) AS weight -- Default weight is 100 if not specified + FROM tasks t + WHERE t.parent_task_id = _task_id + AND t.archived IS FALSE + ) + SELECT COALESCE( + SUM(progress_value * weight) / NULLIF(SUM(weight), 0), + 0 + ) + FROM subtask_progress + INTO _ratio; + -- If project uses time-based progress, calculate based on estimated time (total_minutes) + ELSIF _use_time_progress IS TRUE THEN + WITH subtask_progress AS ( + SELECT + CASE + -- If subtask has manual progress, use that value + WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN + progress_value + -- Otherwise use completion status (0 or 100) + ELSE + CASE + WHEN EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = t.id + AND is_done IS TRUE + ) THEN 100 + ELSE 0 + END + END AS progress_value, + COALESCE(total_minutes, 0) AS estimated_minutes -- Use time estimation for weighting + FROM tasks t + WHERE t.parent_task_id = _task_id + AND t.archived IS FALSE + ) + SELECT COALESCE( + SUM(progress_value * estimated_minutes) / NULLIF(SUM(estimated_minutes), 0), + 0 + ) + FROM subtask_progress + INTO _ratio; + ELSE + -- Traditional calculation based on completion status when no special mode is enabled + SELECT (CASE + WHEN EXISTS(SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = _task_id + AND is_done IS TRUE) THEN 1 + ELSE 0 END) + INTO _parent_task_done; + + SELECT COUNT(*) + FROM tasks_with_status_view + WHERE parent_task_id = _task_id + AND is_done IS TRUE + INTO _sub_tasks_done; + + _total_completed = _parent_task_done + _sub_tasks_done; + _total_tasks = _sub_tasks_count + 1; -- +1 for the parent task + + IF _total_tasks = 0 THEN + _ratio = 0; + ELSE + _ratio = (_total_completed / _total_tasks) * 100; + END IF; + END IF; + END IF; + + -- Ensure ratio is between 0 and 100 + IF _ratio < 0 THEN + _ratio = 0; + ELSIF _ratio > 100 THEN + _ratio = 100; + END IF; + + RETURN JSON_BUILD_OBJECT( + 'ratio', _ratio, + 'total_completed', _total_completed, + 'total_tasks', _total_tasks, + 'is_manual', _is_manual + ); +END +$$; + +-- Make sure we recalculate parent task progress when subtask progress changes +CREATE OR REPLACE FUNCTION update_parent_task_progress() RETURNS TRIGGER AS +$$ +DECLARE + _parent_task_id UUID; +BEGIN + -- Check if this is a subtask + IF NEW.parent_task_id IS NOT NULL THEN + _parent_task_id := NEW.parent_task_id; + + -- Force any parent task with subtasks to NOT use manual progress + UPDATE tasks + SET manual_progress = FALSE + WHERE id = _parent_task_id; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Create trigger for updates to task progress +DROP TRIGGER IF EXISTS update_parent_task_progress_trigger ON tasks; +CREATE TRIGGER update_parent_task_progress_trigger +AFTER UPDATE OF progress_value, weight, total_minutes ON tasks +FOR EACH ROW +EXECUTE FUNCTION update_parent_task_progress(); + +-- Create a function to ensure parent tasks never have manual progress when they have subtasks +CREATE OR REPLACE FUNCTION ensure_parent_task_without_manual_progress() RETURNS TRIGGER AS +$$ +BEGIN + -- If this is a new subtask being created or a task is being converted to a subtask + IF NEW.parent_task_id IS NOT NULL THEN + -- Force the parent task to NOT use manual progress + UPDATE tasks + SET manual_progress = FALSE + WHERE id = NEW.parent_task_id; + + -- Log that we've reset manual progress for a parent task + RAISE NOTICE 'Reset manual progress for parent task % because it has subtasks', NEW.parent_task_id; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Create trigger for when tasks are created or updated with a parent_task_id +DROP TRIGGER IF EXISTS ensure_parent_task_without_manual_progress_trigger ON tasks; +CREATE TRIGGER ensure_parent_task_without_manual_progress_trigger +AFTER INSERT OR UPDATE OF parent_task_id ON tasks +FOR EACH ROW +EXECUTE FUNCTION ensure_parent_task_without_manual_progress(); + +COMMIT; \ No newline at end of file diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index 131be72a..286157cb 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -97,8 +97,11 @@ export default class TasksControllerV2 extends TasksControllerBase { try { const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [taskId]); const [data] = result.rows; - data.info.ratio = +data.info.ratio.toFixed(); - return data.info; + if (data && data.info && data.info.ratio !== undefined) { + data.info.ratio = +((data.info.ratio || 0).toFixed()); + return data.info; + } + return null; } catch (error) { return null; } @@ -198,6 +201,7 @@ export default class TasksControllerV2 extends TasksControllerBase { (SELECT use_manual_progress FROM projects WHERE id = t.project_id) AS project_use_manual_progress, (SELECT use_weighted_progress FROM projects WHERE id = t.project_id) AS project_use_weighted_progress, (SELECT use_time_progress FROM projects WHERE id = t.project_id) AS project_use_time_progress, + (SELECT get_task_complete_ratio(t.id)->>'ratio') AS complete_ratio, (SELECT phase_id FROM task_phase WHERE task_id = t.id) AS phase_id, (SELECT name @@ -371,7 +375,7 @@ export default class TasksControllerV2 extends TasksControllerBase { const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [task.id]); const [data] = result.rows; if (data && data.info) { - task.complete_ratio = +data.info.ratio.toFixed(); + task.complete_ratio = +(data.info.ratio || 0).toFixed(); task.completed_count = data.info.total_completed; task.total_tasks_count = data.info.total_tasks; } @@ -440,7 +444,7 @@ export default class TasksControllerV2 extends TasksControllerBase { const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [task.id]); const [ratioData] = result.rows; if (ratioData && ratioData.info) { - task.complete_ratio = +ratioData.info.ratio.toFixed(); + task.complete_ratio = +(ratioData.info.ratio || 0).toFixed(); task.completed_count = ratioData.info.total_completed; task.total_tasks_count = ratioData.info.total_tasks; console.log(`Updated task ${task.id} (${task.name}) from DB: complete_ratio=${task.complete_ratio}`); @@ -482,6 +486,53 @@ export default class TasksControllerV2 extends TasksControllerBase { return res.status(200).send(new ServerResponse(true, task)); } + @HandleExceptions() + public static async resetParentTaskManualProgress(parentTaskId: string): Promise { + try { + // Check if this task has subtasks + const subTasksResult = await db.query( + "SELECT COUNT(*) as subtask_count FROM tasks WHERE parent_task_id = $1 AND archived IS FALSE", + [parentTaskId] + ); + + const subtaskCount = parseInt(subTasksResult.rows[0]?.subtask_count || "0"); + + // If it has subtasks, reset the manual_progress flag to false + if (subtaskCount > 0) { + await db.query( + "UPDATE tasks SET manual_progress = false WHERE id = $1", + [parentTaskId] + ); + console.log(`Reset manual progress for parent task ${parentTaskId} with ${subtaskCount} subtasks`); + + // Get the project settings to determine which calculation method to use + const projectResult = await db.query( + "SELECT project_id FROM tasks WHERE id = $1", + [parentTaskId] + ); + + const projectId = projectResult.rows[0]?.project_id; + + if (projectId) { + // Recalculate the parent task's progress based on its subtasks + const progressResult = await db.query( + "SELECT get_task_complete_ratio($1) AS ratio", + [parentTaskId] + ); + + const progressRatio = progressResult.rows[0]?.ratio?.ratio || 0; + + // Emit the updated progress value to all clients + // Note: We don't have socket context here, so we can't directly emit + // This will be picked up on the next client refresh + console.log(`Recalculated progress for parent task ${parentTaskId}: ${progressRatio}%`); + } + } + } catch (error) { + log_error(`Error resetting parent task manual progress: ${error}`); + } + } + @HandleExceptions() public static async convertToSubtask(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { @@ -521,6 +572,11 @@ export default class TasksControllerV2 extends TasksControllerBase { ? [req.body.id, req.body.to_group_id] : [req.body.id, req.body.project_id, req.body.parent_task_id, req.body.to_group_id]; await db.query(q, params); + + // Reset the parent task's manual progress when converting a task to a subtask + if (req.body.parent_task_id) { + await this.resetParentTaskManualProgress(req.body.parent_task_id); + } const result = await db.query("SELECT get_single_task($1) AS task;", [req.body.id]); const [data] = result.rows; diff --git a/worklenz-backend/src/socket.io/commands/on-get-task-subtasks-count.ts b/worklenz-backend/src/socket.io/commands/on-get-task-subtasks-count.ts new file mode 100644 index 00000000..ce20d5d1 --- /dev/null +++ b/worklenz-backend/src/socket.io/commands/on-get-task-subtasks-count.ts @@ -0,0 +1,39 @@ +import { Socket } from "socket.io"; +import db from "../../config/db"; +import { SocketEvents } from "../events"; +import { log_error } from "../util"; + +/** + * Socket handler to retrieve the number of subtasks for a given task + * Used to validate on the client side whether a task should show progress inputs + */ +export async function on_get_task_subtasks_count(io: any, socket: Socket, taskId: string) { + try { + if (!taskId) { + return; + } + + // Get the count of subtasks for this task + const result = await db.query( + "SELECT COUNT(*) as subtask_count FROM tasks WHERE parent_task_id = $1 AND archived IS FALSE", + [taskId] + ); + + const subtaskCount = parseInt(result.rows[0]?.subtask_count || "0"); + + // Emit the subtask count back to the client + socket.emit( + "TASK_SUBTASKS_COUNT", + { + task_id: taskId, + subtask_count: subtaskCount, + has_subtasks: subtaskCount > 0 + } + ); + + console.log(`Emitted subtask count for task ${taskId}: ${subtaskCount}`); + + } catch (error) { + log_error(`Error getting subtask count for task ${taskId}: ${error}`); + } +} \ 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 d6c5e606..1860260e 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 @@ -6,10 +6,56 @@ import { SocketEvents } from "../events"; import { log_error, notifyProjectUpdates } from "../util"; import { getTaskDetails, logTotalMinutes } from "../../services/activity-logs/activity-logs.service"; -export async function on_time_estimation_change(_io: Server, socket: Socket, data?: string) { +/** + * Recursively updates all ancestor tasks' progress when a subtask changes + * @param io Socket.io instance + * @param socket Socket instance for emitting events + * @param projectId Project ID for room broadcasting + * @param taskId The task ID to update (starts with the parent task) + */ +async function updateTaskAncestors(io: any, socket: Socket, projectId: string, taskId: string | null) { + if (!taskId) return; + + try { + // Get the current task's progress ratio + const progressRatio = await db.query( + "SELECT get_task_complete_ratio($1) as ratio", + [taskId] + ); + + const ratio = progressRatio?.rows[0]?.ratio?.ratio || 0; + console.log(`Updated task ${taskId} progress after time estimation change: ${ratio}`); + + // Emit the updated progress + socket.emit( + SocketEvents.TASK_PROGRESS_UPDATED.toString(), + { + task_id: taskId, + progress_value: ratio + } + ); + + // Find this task's parent to continue the recursive update + const parentResult = await db.query( + "SELECT parent_task_id FROM tasks WHERE id = $1", + [taskId] + ); + + const parentTaskId = parentResult.rows[0]?.parent_task_id; + + // If there's a parent, recursively update it + if (parentTaskId) { + await updateTaskAncestors(io, socket, projectId, parentTaskId); + } + } catch (error) { + log_error(`Error updating ancestor task ${taskId}: ${error}`); + } +} + +export async function on_time_estimation_change(io: Server, socket: Socket, data?: string) { try { // (SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id) AS total_minutes_spent, - const q = `UPDATE tasks SET total_minutes = $2 WHERE id = $1 RETURNING total_minutes;`; + const q = `UPDATE tasks SET total_minutes = $2 WHERE id = $1 RETURNING total_minutes, project_id, parent_task_id;`; const body = JSON.parse(data as string); const hours = body.total_hours || 0; @@ -19,7 +65,10 @@ export async function on_time_estimation_change(_io: Server, socket: Socket, dat const task_data = await getTaskDetails(body.task_id, "total_minutes"); const result0 = await db.query(q, [body.task_id, totalMinutes]); - const [data0] = result0.rows; + const [taskData] = result0.rows; + + const projectId = taskData.project_id; + const parentTaskId = taskData.parent_task_id; const result = await db.query("SELECT SUM(time_spent) AS total_minutes_spent FROM task_work_log WHERE task_id = $1;", [body.task_id]); const [dd] = result.rows; @@ -31,6 +80,22 @@ export async function on_time_estimation_change(_io: Server, socket: Socket, dat total_minutes_spent: dd.total_minutes_spent || 0 }; socket.emit(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), TasksController.updateTaskViewModel(d)); + + // If this is a subtask in time-based mode, update parent task progress + if (parentTaskId) { + const projectSettingsResult = await db.query( + "SELECT use_time_progress FROM projects WHERE id = $1", + [projectId] + ); + + const useTimeProgress = projectSettingsResult.rows[0]?.use_time_progress; + + if (useTimeProgress) { + // Recalculate parent task progress when subtask time estimation changes + await updateTaskAncestors(io, socket, projectId, parentTaskId); + } + } + notifyProjectUpdates(socket, d.id); logTotalMinutes({ 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 90d3ca3a..cac1cb43 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 @@ -10,6 +10,52 @@ interface UpdateTaskProgressData { parent_task_id: string | null; } +/** + * Recursively updates all ancestor tasks' progress when a subtask changes + * @param io Socket.io instance + * @param socket Socket instance for emitting events + * @param projectId Project ID for room broadcasting + * @param taskId The task ID to update (starts with the parent task) + */ +async function updateTaskAncestors(io: any, socket: Socket, projectId: string, taskId: string | null) { + if (!taskId) return; + + try { + // Get the current task's progress ratio + const progressRatio = await db.query( + "SELECT get_task_complete_ratio($1) as ratio", + [taskId] + ); + + const ratio = progressRatio?.rows[0]?.ratio?.ratio || 0; + console.log(`Updated task ${taskId} progress: ${ratio}`); + + // Emit the updated progress + socket.emit( + SocketEvents.TASK_PROGRESS_UPDATED.toString(), + { + task_id: taskId, + progress_value: ratio + } + ); + + // Find this task's parent to continue the recursive update + const parentResult = await db.query( + "SELECT parent_task_id FROM tasks WHERE id = $1", + [taskId] + ); + + const parentTaskId = parentResult.rows[0]?.parent_task_id; + + // If there's a parent, recursively update it + if (parentTaskId) { + await updateTaskAncestors(io, socket, projectId, parentTaskId); + } + } catch (error) { + log_error(`Error updating ancestor task ${taskId}: ${error}`); + } +} + export async function on_update_task_progress(io: any, socket: Socket, data: string) { try { const parsedData = JSON.parse(data) as UpdateTaskProgressData; @@ -25,7 +71,7 @@ export async function on_update_task_progress(io: any, socket: Socket, data: str [task_id] ); - const subtaskCount = parseInt(subTasksResult.rows[0]?.subtask_count || '0'); + const subtaskCount = parseInt(subTasksResult.rows[0]?.subtask_count || "0"); // If this is a parent task, we shouldn't set manual progress if (subtaskCount > 0) { @@ -35,7 +81,7 @@ 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 FROM tasks WHERE id = $1", [task_id] ); @@ -53,14 +99,13 @@ export async function on_update_task_progress(io: any, socket: Socket, data: str // Log the progress change using the activity logs service await logProgressChange({ task_id, - old_value: currentProgress !== null ? currentProgress.toString() : '0', + old_value: currentProgress !== null ? currentProgress.toString() : "0", new_value: progress_value.toString(), socket }); - if (projectId) { // Emit the update to all clients in the project room - io.to(projectId).emit( + socket.emit( SocketEvents.TASK_PROGRESS_UPDATED.toString(), { task_id, @@ -68,26 +113,10 @@ export async function on_update_task_progress(io: any, socket: Socket, data: str } ); - console.log(`Emitted progress update for task ${task_id} to project room ${projectId}`); + log(`Emitted progress update for task ${task_id} to project room ${projectId}`, null); - // If this is a subtask, update the parent task's progress - if (parent_task_id) { - const progressRatio = await db.query( - "SELECT get_task_complete_ratio($1) as ratio", - [parent_task_id] - ); - - console.log(`Updated parent task ${parent_task_id} progress: ${progressRatio?.rows[0]?.ratio}`); - - // Emit the parent task's updated progress - io.to(projectId).emit( - SocketEvents.TASK_PROGRESS_UPDATED.toString(), - { - task_id: parent_task_id, - progress_value: progressRatio?.rows[0]?.ratio - } - ); - } + // Recursively update all ancestors in the task hierarchy + await updateTaskAncestors(io, socket, projectId, parent_task_id); // Notify that project updates are available notifyProjectUpdates(socket, task_id); @@ -95,4 +124,4 @@ export async function on_update_task_progress(io: any, socket: Socket, data: str } catch (error) { log_error(error); } -} \ No newline at end of file +} diff --git a/worklenz-backend/src/socket.io/commands/on-update-task-weight.ts b/worklenz-backend/src/socket.io/commands/on-update-task-weight.ts index e6a68d1d..664d0806 100644 --- a/worklenz-backend/src/socket.io/commands/on-update-task-weight.ts +++ b/worklenz-backend/src/socket.io/commands/on-update-task-weight.ts @@ -40,14 +40,14 @@ export async function on_update_task_weight(io: any, socket: Socket, data: strin // Log the weight change using the activity logs service await logWeightChange({ task_id, - old_value: currentWeight !== null ? currentWeight.toString() : '100', + old_value: currentWeight !== null ? currentWeight.toString() : "100", new_value: weight.toString(), socket }); if (projectId) { // Emit the update to all clients in the project room - io.to(projectId).emit( + socket.emit( SocketEvents.TASK_PROGRESS_UPDATED.toString(), { task_id, @@ -63,11 +63,11 @@ export async function on_update_task_weight(io: any, socket: Socket, data: strin ); // Emit the parent task's updated progress - io.to(projectId).emit( + socket.emit( SocketEvents.TASK_PROGRESS_UPDATED.toString(), { task_id: parent_task_id, - progress_value: progressRatio?.rows[0]?.ratio + progress_value: progressRatio?.rows[0]?.ratio?.ratio || 0 } ); } diff --git a/worklenz-backend/src/socket.io/events.ts b/worklenz-backend/src/socket.io/events.ts index c59b0eff..a8e19a83 100644 --- a/worklenz-backend/src/socket.io/events.ts +++ b/worklenz-backend/src/socket.io/events.ts @@ -63,4 +63,8 @@ export enum SocketEvents { UPDATE_TASK_PROGRESS, UPDATE_TASK_WEIGHT, TASK_PROGRESS_UPDATED, + + // Task subtasks count events + GET_TASK_SUBTASKS_COUNT, + TASK_SUBTASKS_COUNT, } diff --git a/worklenz-backend/src/socket.io/index.ts b/worklenz-backend/src/socket.io/index.ts index b77a68ea..3c5e50b5 100644 --- a/worklenz-backend/src/socket.io/index.ts +++ b/worklenz-backend/src/socket.io/index.ts @@ -54,6 +54,7 @@ import { on_task_custom_column_update } from "./commands/on_custom_column_update import { on_custom_column_pinned_change } from "./commands/on_custom_column_pinned_change"; 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"; export function register(io: any, socket: Socket) { log(socket.id, "client registered"); @@ -110,6 +111,7 @@ export function register(io: any, socket: Socket) { socket.on(SocketEvents.CUSTOM_COLUMN_PINNED_CHANGE.toString(), data => on_custom_column_pinned_change(io, socket, data)); 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.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 07f51adc..ebb1e694 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 @@ -5,7 +5,7 @@ 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 } from 'react'; +import { useEffect, useState } from 'react'; import { useSocket } from '@/socket/socketContext'; interface TaskDrawerProgressProps { @@ -17,16 +17,48 @@ 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 isSubTask = !!task?.parent_task_id; - const hasSubTasks = task?.sub_tasks_count > 0; + const hasSubTasks = task?.sub_tasks_count > 0 || confirmedHasSubtasks === true; - // Show manual progress input only for tasks without subtasks (not parent tasks) - // Parent tasks get their progress calculated from subtasks + // Additional debug logging + console.log(`TaskDrawerProgress for task ${task.id} (${task.name}): hasSubTasks=${hasSubTasks}, count=${task.sub_tasks_count}, confirmedHasSubtasks=${confirmedHasSubtasks}`); + + // 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; // Only show weight input for subtasks in weighted progress mode - const showTaskWeightInput = project?.use_weighted_progress && isSubTask; + const showTaskWeightInput = project?.use_weighted_progress && isSubTask && !hasSubTasks; useEffect(() => { // Listen for progress updates from the server @@ -53,8 +85,13 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => { }; }, [socket, connected, task.id, form]); + // One last check before rendering + if (hasSubTasks) { + return null; + } + const handleProgressChange = (value: number | null) => { - if (connected && task.id && value !== null) { + if (connected && task.id && value !== null && !hasSubTasks) { // Ensure parent_task_id is not undefined const parent_task_id = task.parent_task_id || null; @@ -67,13 +104,6 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => { }) ); - // If this task has subtasks, request recalculation of its progress - if (hasSubTasks) { - setTimeout(() => { - socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id); - }, 100); - } - // If this is a subtask, request the parent's progress to be updated in UI if (parent_task_id) { setTimeout(() => { @@ -84,7 +114,7 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => { }; const handleWeightChange = (value: number | null) => { - if (connected && task.id && value !== null) { + if (connected && task.id && value !== null && !hasSubTasks) { // Ensure parent_task_id is not undefined const parent_task_id = task.parent_task_id || null; @@ -116,6 +146,11 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => { return null; // Don't show any progress inputs if not applicable } + // Final safety check + if (hasSubTasks) { + return null; + } + return ( <> {showTaskWeightInput && ( diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx index abdb386d..fc5d66d4 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx @@ -33,6 +33,42 @@ interface TaskDetailsFormProps { taskFormViewModel?: ITaskFormViewModel | null; } +// Custom wrapper that enforces stricter rules for displaying progress input +interface ConditionalProgressInputProps { + task: ITaskViewModel; + form: any; // Using any for the form as the exact type may be complex +} + +const ConditionalProgressInput = ({ task, form }: ConditionalProgressInputProps) => { + const { project } = useAppSelector(state => state.projectReducer); + const hasSubTasks = task?.sub_tasks_count > 0; + const isSubTask = !!task?.parent_task_id; + + // Add more aggressive logging and checks + console.log(`Task ${task.id} status: hasSubTasks=${hasSubTasks}, isSubTask=${isSubTask}, modes: time=${project?.use_time_progress}, manual=${project?.use_manual_progress}, weighted=${project?.use_weighted_progress}`); + + // STRICT RULE: Never show progress input for parent tasks with subtasks + // This is the most important check and must be done first + if (hasSubTasks) { + console.log(`Task ${task.id} has ${task.sub_tasks_count} subtasks. Hiding progress input.`); + return null; + } + + // Only for tasks without subtasks, determine which input to show based on project mode + if (project?.use_time_progress) { + // In time-based mode, show progress input ONLY for tasks without subtasks + return ; + } else if (project?.use_manual_progress) { + // In manual mode, show progress input ONLY for tasks without subtasks + return ; + } else if (project?.use_weighted_progress && isSubTask) { + // In weighted mode, show weight input for subtasks + return ; + } + + return null; +}; + const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) => { const { t } = useTranslation('task-drawer/task-drawer'); const [form] = Form.useForm(); @@ -121,8 +157,11 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) => - {(project?.use_manual_progress || project?.use_weighted_progress) && (taskFormViewModel?.task) && ( - + {taskFormViewModel?.task && ( + )} diff --git a/worklenz-frontend/src/features/tasks/tasks.slice.ts b/worklenz-frontend/src/features/tasks/tasks.slice.ts index dbc2f955..320a5cd1 100644 --- a/worklenz-frontend/src/features/tasks/tasks.slice.ts +++ b/worklenz-frontend/src/features/tasks/tasks.slice.ts @@ -572,14 +572,29 @@ const taskSlice = createSlice({ ) => { const { taskId, progress, totalTasksCount, completedCount } = action.payload; - for (const group of state.taskGroups) { - const task = group.tasks.find(task => task.id === taskId); - if (task) { - task.complete_ratio = progress; - task.total_tasks_count = totalTasksCount; - task.completed_count = completedCount; - break; + // Helper function to find and update a task at any nesting level + const findAndUpdateTask = (tasks: IProjectTask[]) => { + for (const task of tasks) { + if (task.id === taskId) { + task.complete_ratio = progress; + task.total_tasks_count = totalTasksCount; + task.completed_count = completedCount; + return true; + } + + // Check subtasks if they exist + if (task.sub_tasks && task.sub_tasks.length > 0) { + const found = findAndUpdateTask(task.sub_tasks); + if (found) return true; + } } + return false; + }; + + // Try to find and update the task in any task group + for (const group of state.taskGroups) { + const found = findAndUpdateTask(group.tasks); + if (found) break; } }, diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx index dbf0f242..ecf3f847 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx @@ -54,7 +54,7 @@ const ProjectViewTaskList = () => { - {(taskGroups.length === 0 && !loadingGroups) ? ( + {(taskGroups && taskGroups.length === 0 && !loadingGroups) ? ( ) : ( diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-progress-cell/task-list-progress-cell.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-progress-cell/task-list-progress-cell.tsx index 4589e5aa..1db3a56c 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-progress-cell/task-list-progress-cell.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-progress-cell/task-list-progress-cell.tsx @@ -1,20 +1,54 @@ +import React from 'react'; import { Progress, Tooltip } from 'antd'; import './task-list-progress-cell.css'; import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; +import { useAppSelector } from '@/hooks/useAppSelector'; type TaskListProgressCellProps = { task: IProjectTask; }; const TaskListProgressCell = ({ task }: TaskListProgressCellProps) => { - return task.is_sub_task ? null : ( - + const { project } = useAppSelector(state => state.projectReducer); + const isManualProgressEnabled = project?.use_manual_progress; + const isSubtask = task.is_sub_task; + const hasManualProgress = task.manual_progress; + + // Handle different cases: + // 1. For subtasks when manual progress is enabled, show the progress + // 2. For parent tasks, always show progress + // 3. For subtasks when manual progress is not enabled, don't show progress (null) + + if (isSubtask && !isManualProgressEnabled) { + return null; // Don't show progress for subtasks when manual progress is disabled + } + + // For parent tasks, show completion ratio with task count tooltip + if (!isSubtask) { + return ( + + = 100 ? 9 : 7} + /> + + ); + } + + // For subtasks with manual progress enabled, show the progress + return ( + = 100 ? 9 : 7} + strokeWidth={(task.progress || 0) >= 100 ? 9 : 7} /> ); diff --git a/worklenz-frontend/src/shared/socket-events.ts b/worklenz-frontend/src/shared/socket-events.ts index f1b71d2d..33bcc0e8 100644 --- a/worklenz-frontend/src/shared/socket-events.ts +++ b/worklenz-frontend/src/shared/socket-events.ts @@ -63,4 +63,8 @@ export enum SocketEvents { UPDATE_TASK_PROGRESS, UPDATE_TASK_WEIGHT, TASK_PROGRESS_UPDATED, + + // Task subtasks count events + GET_TASK_SUBTASKS_COUNT, + TASK_SUBTASKS_COUNT, } diff --git a/worklenz-frontend/src/types/project/projectTasksViewModel.types.ts b/worklenz-frontend/src/types/project/projectTasksViewModel.types.ts index 94e93c4c..4ab36c27 100644 --- a/worklenz-frontend/src/types/project/projectTasksViewModel.types.ts +++ b/worklenz-frontend/src/types/project/projectTasksViewModel.types.ts @@ -16,6 +16,7 @@ export interface ITaskStatusCategory { } export interface IProjectTask { + manual_progress: any; due_time?: string; id?: string; name?: string;