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 (
+
+
+ );
+ }
+
+ // For subtasks with manual progress enabled, show the progress
+ return (
+
);
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;