Merge pull request #103 from chamikaJ/fix/custom-progress-methods

Fix/custom progress methods
This commit is contained in:
Chamika J
2025-05-02 13:21:11 +05:30
committed by GitHub
20 changed files with 1183 additions and 101 deletions

View File

@@ -62,17 +62,17 @@ The parent task will show as 60% complete (average of 30%, 60%, and 90%).
### Example ### Example
If you have a parent task with three subtasks: If you have a parent task with three subtasks:
- Subtask A: 50% complete, Weight 200 (critical task) - Subtask A: 50% complete, Weight 60% (important task)
- Subtask B: 75% complete, Weight 100 (standard task) - Subtask B: 75% complete, Weight 20% (less important task)
- Subtask C: 25% complete, Weight 300 (major 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 ### Important Notes About Weights
- Default weight is 100 if not specified - Default weight is 100% if not specified
- You can set weights from 0 to any reasonable number (typically 1-1000) - Weights range from 0% to 100%
- Setting a weight to 0 removes that task from progress calculations - Setting a weight to 0% removes that task from progress calculations
- Only explicitly set weights for tasks that should have different importance - Only explicitly set weights for tasks that should have different importance
- Weights are only relevant for subtasks, not for independent tasks - Weights are only relevant for subtasks, not for independent tasks

View File

@@ -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 - The UI shows a weight input for subtasks in the task drawer
- Manual progress input is still required for tasks without subtasks - Manual progress input is still required for tasks without subtasks
- Default weight is 100 if not specified - Default weight is 100 if not specified
- Weight values range from 0 to 100%
**Calculation Logic:** **Calculation Logic:**
- For tasks without subtasks: Uses the manually entered progress value - 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) - If a leaf task's progress is not manually set, it defaults to 0% (or 100% if completed)
2. **Weight Assignment**: 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 - 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**: 3. **Parent Task Calculation**:
The weighted progress formula is: The weighted progress formula is:
@@ -237,24 +237,24 @@ In Weighted Progress mode, both the manual progress input and weight assignment
**Example Calculation**: **Example Calculation**:
Consider a parent task with three subtasks: Consider a parent task with three subtasks:
- Subtask A: Progress 50%, Weight 200 - Subtask A: Progress 50%, Weight 60%
- Subtask B: Progress 75%, Weight 100 - Subtask B: Progress 75%, Weight 20%
- Subtask C: Progress 25%, Weight 300 - Subtask C: Progress 25%, Weight 100%
Calculation: Calculation:
``` ```
ParentProgress = ((50 * 200) + (75 * 100) + (25 * 300)) / (200 + 100 + 300) ParentProgress = ((50 * 60) + (75 * 20) + (25 * 100)) / (60 + 20 + 100)
ParentProgress = (10000 + 7500 + 7500) / 600 ParentProgress = (3000 + 1500 + 2500) / 180
ParentProgress = 25000 / 600 ParentProgress = 7000 / 180
ParentProgress = 41.67% 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. 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**: 4. **Zero Weight Handling**:
Tasks with zero weight are excluded from the calculation: Tasks with zero weight are excluded from the calculation:
- Subtask A: Progress 40%, Weight 50 - Subtask A: Progress 40%, Weight 50%
- Subtask B: Progress 80%, Weight 0 - Subtask B: Progress 80%, Weight 0%
Calculation: Calculation:
``` ```
@@ -263,13 +263,13 @@ In Weighted Progress mode, both the manual progress input and weight assignment
ParentProgress = 40% 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**: 5. **Default Weight Behavior**:
When weights aren't explicitly assigned to some tasks: When weights aren't explicitly assigned to some tasks:
- Subtask A: Progress 30%, Weight 60 (explicitly set) - Subtask A: Progress 30%, Weight 60% (explicitly set)
- Subtask B: Progress 70%, Weight not set (defaults to 100) - Subtask B: Progress 70%, Weight not set (defaults to 100%)
- Subtask C: Progress 90%, Weight not set (defaults to 100) - Subtask C: Progress 90%, Weight not set (defaults to 100%)
Calculation: Calculation:
``` ```

244
task-progress-methods.md Normal file
View File

@@ -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 : (
<Tooltip title={`${task.completed_count || 0} / ${task.total_tasks_count || 0}`}>
<Progress
percent={task.complete_ratio || 0}
type="circle"
size={24}
style={{ cursor: 'default' }}
/>
</Tooltip>
);
};
```
**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.

View File

@@ -39,8 +39,14 @@ BEGIN
INTO _use_manual_progress, _use_weighted_progress, _use_time_progress; INTO _use_manual_progress, _use_weighted_progress, _use_time_progress;
END IF; END IF;
-- If manual progress is enabled and has a value, use it directly -- Get all subtasks
IF _is_manual IS TRUE AND _manual_value IS NOT NULL THEN 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( RETURN JSON_BUILD_OBJECT(
'ratio', _manual_value, 'ratio', _manual_value,
'total_completed', 0, 'total_completed', 0,
@@ -49,12 +55,6 @@ BEGIN
); );
END IF; 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 there are no subtasks, just use the parent task's status
IF _sub_tasks_count = 0 THEN IF _sub_tasks_count = 0 THEN
SELECT (CASE SELECT (CASE
@@ -145,7 +145,7 @@ BEGIN
ELSE 0 ELSE 0
END END
END AS progress_value, END AS progress_value,
COALESCE(total_hours * 60 + total_minutes, 0) AS estimated_minutes COALESCE(total_minutes, 0) AS estimated_minutes
FROM tasks t FROM tasks t
WHERE t.parent_task_id = _task_id WHERE t.parent_task_id = _task_id
AND t.archived IS FALSE AND t.archived IS FALSE
@@ -350,8 +350,8 @@ BEGIN
RETURNING id INTO _project_id; RETURNING id INTO _project_id;
-- register the project log -- register the project log
INSERT INTO project_logs (project_id, team_id, team_member_id, description) INSERT INTO project_logs (project_id, team_id, description)
VALUES (_project_id, _team_id, _team_member_id, _project_created_log) VALUES (_project_id, _team_id, _project_created_log)
RETURNING id INTO _project_created_log_id; RETURNING id INTO _project_created_log_id;
-- add the team member in the project as a user -- add the team member in the project as a user
@@ -360,8 +360,8 @@ BEGIN
(SELECT id FROM project_access_levels WHERE key = 'MEMBER')); (SELECT id FROM project_access_levels WHERE key = 'MEMBER'));
-- register the project log -- register the project log
INSERT INTO project_logs (project_id, team_id, team_member_id, description) INSERT INTO project_logs (project_id, team_id, description)
VALUES (_project_id, _team_id, _team_member_id, _project_member_added_log); VALUES (_project_id, _team_id, _project_member_added_log);
-- insert default project columns -- insert default project columns
PERFORM insert_task_list_columns(_project_id); 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_weighted_progress BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS use_time_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; COMMIT;

View File

@@ -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;

View File

@@ -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;

View File

@@ -97,8 +97,11 @@ export default class TasksControllerV2 extends TasksControllerBase {
try { try {
const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [taskId]); const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [taskId]);
const [data] = result.rows; const [data] = result.rows;
data.info.ratio = +data.info.ratio.toFixed(); if (data && data.info && data.info.ratio !== undefined) {
return data.info; data.info.ratio = +((data.info.ratio || 0).toFixed());
return data.info;
}
return null;
} catch (error) { } catch (error) {
return null; 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_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_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 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 phase_id FROM task_phase WHERE task_id = t.id) AS phase_id,
(SELECT name (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 result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [task.id]);
const [data] = result.rows; const [data] = result.rows;
if (data && data.info) { 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.completed_count = data.info.total_completed;
task.total_tasks_count = data.info.total_tasks; 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 result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [task.id]);
const [ratioData] = result.rows; const [ratioData] = result.rows;
if (ratioData && ratioData.info) { 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.completed_count = ratioData.info.total_completed;
task.total_tasks_count = ratioData.info.total_tasks; task.total_tasks_count = ratioData.info.total_tasks;
console.log(`Updated task ${task.id} (${task.name}) from DB: complete_ratio=${task.complete_ratio}`); 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)); return res.status(200).send(new ServerResponse(true, task));
} }
@HandleExceptions()
public static async resetParentTaskManualProgress(parentTaskId: string): Promise<void> {
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() @HandleExceptions()
public static async convertToSubtask(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> { public static async convertToSubtask(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
@@ -521,6 +572,11 @@ export default class TasksControllerV2 extends TasksControllerBase {
? [req.body.id, req.body.to_group_id] ? [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]; : [req.body.id, req.body.project_id, req.body.parent_task_id, req.body.to_group_id];
await db.query(q, params); 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 result = await db.query("SELECT get_single_task($1) AS task;", [req.body.id]);
const [data] = result.rows; const [data] = result.rows;

View File

@@ -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}`);
}
}

View File

@@ -6,10 +6,56 @@ import { SocketEvents } from "../events";
import { log_error, notifyProjectUpdates } from "../util"; import { log_error, notifyProjectUpdates } from "../util";
import { getTaskDetails, logTotalMinutes } from "../../services/activity-logs/activity-logs.service"; 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 { try {
// (SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id) AS total_minutes_spent, // (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 body = JSON.parse(data as string);
const hours = body.total_hours || 0; 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 task_data = await getTaskDetails(body.task_id, "total_minutes");
const result0 = await db.query(q, [body.task_id, totalMinutes]); 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 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; 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 total_minutes_spent: dd.total_minutes_spent || 0
}; };
socket.emit(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), TasksController.updateTaskViewModel(d)); 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); notifyProjectUpdates(socket, d.id);
logTotalMinutes({ logTotalMinutes({

View File

@@ -10,6 +10,52 @@ interface UpdateTaskProgressData {
parent_task_id: string | null; 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) { export async function on_update_task_progress(io: any, socket: Socket, data: string) {
try { try {
const parsedData = JSON.parse(data) as UpdateTaskProgressData; 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] [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 this is a parent task, we shouldn't set manual progress
if (subtaskCount > 0) { 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 // Get the current progress value to log the change
const currentProgressResult = await db.query( 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] [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 // Log the progress change using the activity logs service
await logProgressChange({ await logProgressChange({
task_id, task_id,
old_value: currentProgress !== null ? currentProgress.toString() : '0', old_value: currentProgress !== null ? currentProgress.toString() : "0",
new_value: progress_value.toString(), new_value: progress_value.toString(),
socket socket
}); });
if (projectId) { if (projectId) {
// Emit the update to all clients in the project room // Emit the update to all clients in the project room
io.to(projectId).emit( socket.emit(
SocketEvents.TASK_PROGRESS_UPDATED.toString(), SocketEvents.TASK_PROGRESS_UPDATED.toString(),
{ {
task_id, 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 // Recursively update all ancestors in the task hierarchy
if (parent_task_id) { await updateTaskAncestors(io, socket, projectId, 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
}
);
}
// Notify that project updates are available // Notify that project updates are available
notifyProjectUpdates(socket, task_id); notifyProjectUpdates(socket, task_id);
@@ -95,4 +124,4 @@ export async function on_update_task_progress(io: any, socket: Socket, data: str
} catch (error) { } catch (error) {
log_error(error); log_error(error);
} }
} }

View File

@@ -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 // Log the weight change using the activity logs service
await logWeightChange({ await logWeightChange({
task_id, task_id,
old_value: currentWeight !== null ? currentWeight.toString() : '100', old_value: currentWeight !== null ? currentWeight.toString() : "100",
new_value: weight.toString(), new_value: weight.toString(),
socket socket
}); });
if (projectId) { if (projectId) {
// Emit the update to all clients in the project room // Emit the update to all clients in the project room
io.to(projectId).emit( socket.emit(
SocketEvents.TASK_PROGRESS_UPDATED.toString(), SocketEvents.TASK_PROGRESS_UPDATED.toString(),
{ {
task_id, 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 // Emit the parent task's updated progress
io.to(projectId).emit( socket.emit(
SocketEvents.TASK_PROGRESS_UPDATED.toString(), SocketEvents.TASK_PROGRESS_UPDATED.toString(),
{ {
task_id: parent_task_id, task_id: parent_task_id,
progress_value: progressRatio?.rows[0]?.ratio progress_value: progressRatio?.rows[0]?.ratio?.ratio || 0
} }
); );
} }

View File

@@ -63,4 +63,8 @@ export enum SocketEvents {
UPDATE_TASK_PROGRESS, UPDATE_TASK_PROGRESS,
UPDATE_TASK_WEIGHT, UPDATE_TASK_WEIGHT,
TASK_PROGRESS_UPDATED, TASK_PROGRESS_UPDATED,
// Task subtasks count events
GET_TASK_SUBTASKS_COUNT,
TASK_SUBTASKS_COUNT,
} }

View File

@@ -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_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_progress } from "./commands/on-update-task-progress";
import { on_update_task_weight } from "./commands/on-update-task-weight"; 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) { export function register(io: any, socket: Socket) {
log(socket.id, "client registered"); 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.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_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.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.io built-in event
socket.on("disconnect", (reason) => on_disconnect(io, socket, reason)); socket.on("disconnect", (reason) => on_disconnect(io, socket, reason));

View File

@@ -5,7 +5,7 @@ import { useAppSelector } from '@/hooks/useAppSelector';
import { ITaskViewModel } from '@/types/tasks/task.types'; import { ITaskViewModel } from '@/types/tasks/task.types';
import Flex from 'antd/lib/flex'; import Flex from 'antd/lib/flex';
import { SocketEvents } from '@/shared/socket-events'; import { SocketEvents } from '@/shared/socket-events';
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { useSocket } from '@/socket/socketContext'; import { useSocket } from '@/socket/socketContext';
interface TaskDrawerProgressProps { interface TaskDrawerProgressProps {
@@ -17,16 +17,48 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => {
const { t } = useTranslation('task-drawer/task-drawer'); const { t } = useTranslation('task-drawer/task-drawer');
const { project } = useAppSelector(state => state.projectReducer); const { project } = useAppSelector(state => state.projectReducer);
const { socket, connected } = useSocket(); const { socket, connected } = useSocket();
const [confirmedHasSubtasks, setConfirmedHasSubtasks] = useState<boolean | null>(null);
const isSubTask = !!task?.parent_task_id; 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) // Additional debug logging
// Parent tasks get their progress calculated from subtasks 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; const showManualProgressInput = !hasSubTasks;
// Only show weight input for subtasks in weighted progress mode // 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(() => { useEffect(() => {
// Listen for progress updates from the server // Listen for progress updates from the server
@@ -53,8 +85,13 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => {
}; };
}, [socket, connected, task.id, form]); }, [socket, connected, task.id, form]);
// One last check before rendering
if (hasSubTasks) {
return null;
}
const handleProgressChange = (value: number | 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 // Ensure parent_task_id is not undefined
const parent_task_id = task.parent_task_id || null; 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 this is a subtask, request the parent's progress to be updated in UI
if (parent_task_id) { if (parent_task_id) {
setTimeout(() => { setTimeout(() => {
@@ -84,7 +114,7 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => {
}; };
const handleWeightChange = (value: number | null) => { 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 // Ensure parent_task_id is not undefined
const parent_task_id = task.parent_task_id || null; 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 return null; // Don't show any progress inputs if not applicable
} }
// Final safety check
if (hasSubTasks) {
return null;
}
return ( return (
<> <>
{showTaskWeightInput && ( {showTaskWeightInput && (

View File

@@ -33,6 +33,42 @@ interface TaskDetailsFormProps {
taskFormViewModel?: ITaskFormViewModel | null; 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 <TaskDrawerProgress task={{...task, sub_tasks_count: hasSubTasks ? 1 : 0}} form={form} />;
} else if (project?.use_manual_progress) {
// In manual mode, show progress input ONLY for tasks without subtasks
return <TaskDrawerProgress task={{...task, sub_tasks_count: hasSubTasks ? 1 : 0}} form={form} />;
} else if (project?.use_weighted_progress && isSubTask) {
// In weighted mode, show weight input for subtasks
return <TaskDrawerProgress task={{...task, sub_tasks_count: hasSubTasks ? 1 : 0}} form={form} />;
}
return null;
};
const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) => { const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) => {
const { t } = useTranslation('task-drawer/task-drawer'); const { t } = useTranslation('task-drawer/task-drawer');
const [form] = Form.useForm(); const [form] = Form.useForm();
@@ -121,8 +157,11 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
<TaskDrawerEstimation t={t} task={taskFormViewModel?.task as ITaskViewModel} form={form} /> <TaskDrawerEstimation t={t} task={taskFormViewModel?.task as ITaskViewModel} form={form} />
{(project?.use_manual_progress || project?.use_weighted_progress) && (taskFormViewModel?.task) && ( {taskFormViewModel?.task && (
<TaskDrawerProgress task={taskFormViewModel?.task as ITaskViewModel} form={form} /> <ConditionalProgressInput
task={taskFormViewModel?.task as ITaskViewModel}
form={form}
/>
)} )}
<Form.Item name="priority" label={t('taskInfoTab.details.priority')}> <Form.Item name="priority" label={t('taskInfoTab.details.priority')}>

View File

@@ -572,14 +572,29 @@ const taskSlice = createSlice({
) => { ) => {
const { taskId, progress, totalTasksCount, completedCount } = action.payload; const { taskId, progress, totalTasksCount, completedCount } = action.payload;
for (const group of state.taskGroups) { // Helper function to find and update a task at any nesting level
const task = group.tasks.find(task => task.id === taskId); const findAndUpdateTask = (tasks: IProjectTask[]) => {
if (task) { for (const task of tasks) {
task.complete_ratio = progress; if (task.id === taskId) {
task.total_tasks_count = totalTasksCount; task.complete_ratio = progress;
task.completed_count = completedCount; task.total_tasks_count = totalTasksCount;
break; 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;
} }
}, },

View File

@@ -54,7 +54,7 @@ const ProjectViewTaskList = () => {
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}> <Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
<TaskListFilters position="list" /> <TaskListFilters position="list" />
{(taskGroups.length === 0 && !loadingGroups) ? ( {(taskGroups && taskGroups.length === 0 && !loadingGroups) ? (
<Empty description="No tasks group found" /> <Empty description="No tasks group found" />
) : ( ) : (
<Skeleton active loading={loadingGroups} className='mt-4 p-4'> <Skeleton active loading={loadingGroups} className='mt-4 p-4'>

View File

@@ -1,20 +1,54 @@
import React from 'react';
import { Progress, Tooltip } from 'antd'; import { Progress, Tooltip } from 'antd';
import './task-list-progress-cell.css'; import './task-list-progress-cell.css';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { useAppSelector } from '@/hooks/useAppSelector';
type TaskListProgressCellProps = { type TaskListProgressCellProps = {
task: IProjectTask; task: IProjectTask;
}; };
const TaskListProgressCell = ({ task }: TaskListProgressCellProps) => { const TaskListProgressCell = ({ task }: TaskListProgressCellProps) => {
return task.is_sub_task ? null : ( const { project } = useAppSelector(state => state.projectReducer);
<Tooltip title={`${task.completed_count || 0} / ${task.total_tasks_count || 0}`}> 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 (
<Tooltip title={`${task.completed_count || 0} / ${task.total_tasks_count || 0}`}>
<Progress
percent={task.complete_ratio || 0}
type="circle"
size={24}
style={{ cursor: 'default' }}
strokeWidth={(task.complete_ratio || 0) >= 100 ? 9 : 7}
/>
</Tooltip>
);
}
// For subtasks with manual progress enabled, show the progress
return (
<Tooltip
title={hasManualProgress ? `Manual: ${task.progress_value || 0}%` : `${task.progress || 0}%`}
>
<Progress <Progress
percent={task.complete_ratio || 0} percent={hasManualProgress ? (task.progress_value || 0) : (task.progress || 0)}
type="circle" type="circle"
size={24} size={22} // Slightly smaller for subtasks
style={{ cursor: 'default' }} style={{ cursor: 'default' }}
strokeWidth={(task.complete_ratio || 0) >= 100 ? 9 : 7} strokeWidth={(task.progress || 0) >= 100 ? 9 : 7}
/> />
</Tooltip> </Tooltip>
); );

View File

@@ -63,4 +63,8 @@ export enum SocketEvents {
UPDATE_TASK_PROGRESS, UPDATE_TASK_PROGRESS,
UPDATE_TASK_WEIGHT, UPDATE_TASK_WEIGHT,
TASK_PROGRESS_UPDATED, TASK_PROGRESS_UPDATED,
// Task subtasks count events
GET_TASK_SUBTASKS_COUNT,
TASK_SUBTASKS_COUNT,
} }

View File

@@ -16,6 +16,7 @@ export interface ITaskStatusCategory {
} }
export interface IProjectTask { export interface IProjectTask {
manual_progress: any;
due_time?: string; due_time?: string;
id?: string; id?: string;
name?: string; name?: string;