From 6128c64c31f59e5cd27b1ace3d0fa4746dac2854 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Wed, 30 Apr 2025 15:24:07 +0530 Subject: [PATCH] Add task progress tracking methods and enhance UI components - Introduced a comprehensive guide for users on task progress tracking methods, including manual, weighted, and time-based progress. - Implemented backend support for progress calculations, including SQL functions and migrations to accommodate new progress features. - Enhanced frontend components to support progress input and display, including updates to task and project drawers. - Added localization for new progress-related terms and validation messages. - Integrated real-time updates for task progress and weight changes through socket events. --- docs/task-progress-guide-for-users.md | 173 ++++++ docs/task-progress-methods.md | 550 ++++++++++++++++++ ...20250423000000-subtask-manual-progress.sql | 136 +++++ ...add-progress-and-weight-activity-types.sql | 157 +++++ .../src/controllers/tasks-controller-base.ts | 50 +- .../src/controllers/tasks-controller-v2.ts | 45 +- .../activity-logs/activity-logs.service.ts | 26 + .../src/services/activity-logs/interfaces.ts | 2 + .../commands/on-get-task-progress.ts | 4 + .../commands/on-update-task-progress.ts | 43 +- .../commands/on-update-task-weight.ts | 21 +- .../public/locales/en/project-drawer.json | 2 + .../public/locales/es/project-drawer.json | 2 + .../public/locales/pt/project-drawer.json | 2 + .../project-drawer/project-drawer.tsx | 122 +++- .../activity-log/task-drawer-activity-log.tsx | 26 + .../task-drawer-progress.tsx | 154 +++-- .../shared/info-tab/subtask-table.tsx | 5 +- .../shared/info-tab/task-drawer-info-tab.tsx | 20 +- .../components/task-drawer/task-drawer.tsx | 36 +- .../projectView/project-view-header.tsx | 3 +- .../task-list-task-cell.tsx | 34 +- .../tasks/task-activity-logs-get-request.ts | 2 + .../src/types/tasks/task.types.ts | 1 + 24 files changed, 1466 insertions(+), 150 deletions(-) create mode 100644 docs/task-progress-guide-for-users.md create mode 100644 docs/task-progress-methods.md create mode 100644 worklenz-backend/database/migrations/20250424000000-add-progress-and-weight-activity-types.sql diff --git a/docs/task-progress-guide-for-users.md b/docs/task-progress-guide-for-users.md new file mode 100644 index 00000000..081ca16e --- /dev/null +++ b/docs/task-progress-guide-for-users.md @@ -0,0 +1,173 @@ +# WorkLenz Task Progress Guide for Users + +## Introduction + +WorkLenz offers three different ways to track and calculate task progress, each designed for different project management needs. This guide explains how each method works and when to use them. + +## Available Progress Tracking Methods + +WorkLenz provides these progress tracking methods: + +1. **Manual Progress** - Directly input progress percentages for tasks +2. **Weighted Progress** - Assign importance levels (weights) to tasks +3. **Time-based Progress** - Calculate progress based on estimated time + +Only one method can be enabled at a time for a project. If none are enabled, progress will be calculated based on task completion status. + +## How to Select a Progress Method + +1. Open the project drawer by clicking on the project settings icon or creating a new project +2. In the project settings, find the "Progress Calculation Method" section +3. Select your preferred method +4. Save your changes + +## Manual Progress Method + +### How It Works + +- You directly enter progress percentages (0-100%) for tasks without subtasks +- Parent task progress is calculated as the average of all subtask progress values +- Progress is updated in real-time as you adjust values + +### When to Use Manual Progress + +- For creative or subjective work where completion can't be measured objectively +- When task progress doesn't follow a linear path +- For projects where team members need flexibility in reporting progress + +### Example + +If you have a parent task with three subtasks: +- Subtask A: 30% complete +- Subtask B: 60% complete +- Subtask C: 90% complete + +The parent task will show as 60% complete (average of 30%, 60%, and 90%). + +## Weighted Progress Method + +### How It Works + +- You assign "weight" values to tasks to indicate their importance +- More important tasks have higher weights and influence the overall progress more +- You still enter manual progress percentages for tasks without subtasks +- Parent task progress is calculated using a weighted average + +### When to Use Weighted Progress + +- When some tasks are more important or time-consuming than others +- For projects where all tasks aren't equal +- When you want key deliverables to have more impact on overall progress + +### 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) + +The parent task will be approximately 42% 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 +- Only explicitly set weights for tasks that should have different importance +- Weights are only relevant for subtasks, not for independent tasks + +## Time-based Progress Method + +### How It Works + +- Use the task's time estimate as its "weight" in the progress calculation +- You still enter manual progress percentages for tasks without subtasks +- Tasks with longer time estimates have more influence on overall progress +- Parent task progress is calculated based on time-weighted averages + +### When to Use Time-based Progress + +- For projects with well-defined time estimates +- When task importance correlates with its duration +- For billing or time-tracking focused projects +- When you already maintain accurate time estimates + +### Example + +If you have a parent task with three subtasks: +- Subtask A: 40% complete, Estimated Time 2.5 hours +- Subtask B: 80% complete, Estimated Time 1 hour +- Subtask C: 10% complete, Estimated Time 4 hours + +The parent task will be approximately 29% complete, with the lengthy Subtask C pulling down the overall progress despite Subtask B being mostly complete. + +### Important Notes About Time Estimates + +- Tasks without time estimates don't influence progress calculations +- Time is converted to minutes internally (a 2-hour task = 120 minutes) +- Setting a time estimate to 0 removes that task from progress calculations +- Time estimates serve dual purposes: scheduling/resource planning and progress weighting + +## Default Progress Method + +If none of the special progress methods are enabled, WorkLenz uses a simple completion-based approach: + +### How It Works + +- Tasks are either 0% (not done) or 100% (done) +- Parent task progress = (completed tasks / total tasks) × 100% +- Both the parent task and all subtasks count in this calculation + +### When to Use Default Progress + +- For simple projects with clear task completion criteria +- When binary task status (done/not done) is sufficient +- For teams new to project management who want simplicity + +### Example + +If you have a parent task with four subtasks and two of the subtasks are marked complete: +- Parent task: Not done +- 2 subtasks: Done +- 2 subtasks: Not done + +The parent task will show as 40% complete (2 completed out of 5 total tasks). + +## Best Practices + +1. **Choose the Right Method for Your Project** + - Consider your team's workflow and reporting needs + - Match the method to your project's complexity + +2. **Be Consistent** + - Stick with one method throughout the project + - Changing methods mid-project can cause confusion + +3. **For Manual Progress** + - Update progress regularly + - Establish guidelines for progress reporting + +4. **For Weighted Progress** + - Assign weights based on objective criteria + - Don't overuse extreme weights + +5. **For Time-based Progress** + - Keep time estimates accurate and up to date + - Consider using time tracking to validate estimates + +## Frequently Asked Questions + +**Q: Can I change the progress method mid-project?** +A: Yes, but it may cause progress values to change significantly. It's best to select a method at the project start. + +**Q: What happens to task progress when I mark a task complete?** +A: When a task is marked complete, its progress automatically becomes 100%, regardless of the progress method. + +**Q: How do I enter progress for a task?** +A: Open the task drawer, go to the Info tab, and use the progress slider for tasks without subtasks. + +**Q: Can different projects use different progress methods?** +A: Yes, each project can have its own progress method. + +**Q: What if I don't see progress fields in my task drawer?** +A: Progress input is only visible for tasks without subtasks. Parent tasks' progress is automatically calculated. \ No newline at end of file diff --git a/docs/task-progress-methods.md b/docs/task-progress-methods.md new file mode 100644 index 00000000..5a75ac95 --- /dev/null +++ b/docs/task-progress-methods.md @@ -0,0 +1,550 @@ +# 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. Only one progress method can be enabled at a time. If none of these methods are enabled, progress will be calculated based on task completion status as described in the "Default Progress Tracking" section below. + +## 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 +})); +``` + +## 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 +- Manual progress input is still required for tasks without subtasks +- Default weight is 100 if not specified + +**Calculation Logic:** +- For tasks without subtasks: Uses the manually entered progress value +- 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 values + +**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 +- Manual progress input is still required for tasks without subtasks +- No separate socket handler needed as it's calculated automatically + +**Calculation Logic:** +- For tasks without subtasks: Uses the manually entered progress value +- 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. + +## Manual Progress Input Implementation + +Regardless of which progress tracking method is selected for a project, tasks without subtasks (leaf tasks) require manual progress input. This section details how manual progress input is implemented and used across all progress tracking methods. + +### UI Component + +The manual progress input component is implemented in `worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx` and includes: + +1. **Progress Slider**: A slider UI control that allows users to set progress values from 0% to 100% +2. **Progress Input Field**: A numeric input field that accepts direct entry of progress percentage +3. **Progress Display**: Visual representation of the current progress value + +The component is conditionally rendered in the task drawer for tasks that don't have subtasks. + +**Usage Across Progress Methods:** +- In **Manual Progress Mode**: Only the progress slider/input is shown +- In **Weighted Progress Mode**: Both the progress slider/input and weight input are shown +- In **Time-based Progress Mode**: The progress slider/input is shown alongside time estimate fields + +### Progress Update Flow + +When a user updates a task's progress manually, the following process occurs: + +1. **User Input**: User adjusts the progress slider or enters a value in the input field +2. **UI Event Handler**: The UI component captures the change event and validates the input +3. **Socket Event Emission**: The component emits a `UPDATE_TASK_PROGRESS` socket event with: + ```typescript + { + task_id: task.id, + progress_value: value, // The new progress value (0-100) + parent_task_id: task.parent_task_id // For recalculation + } + ``` +4. **Server Processing**: The socket event handler on the server: + - Updates the task's `progress_value` in the database + - Sets the `manual_progress` flag to true + - Triggers recalculation of parent task progress + +### Progress Calculation Across Methods + +The calculation of progress differs based on the active progress method: + +1. **For Leaf Tasks (no subtasks)** in all methods: + - Progress is always the manually entered value (`progress_value`) + - If the task is marked as completed, progress is automatically set to 100% + +2. **For Parent Tasks**: + - **Manual Progress Mode**: Simple average of all subtask progress values + - **Weighted Progress Mode**: Weighted average where each subtask's progress is multiplied by its weight + - **Time-based Progress Mode**: Weighted average where each subtask's progress is multiplied by its estimated time + - **Default Mode**: Percentage of completed tasks (including parent) vs. total tasks + +### Detailed Calculation for Weighted Progress Method + +In Weighted Progress mode, both the manual progress input and weight assignment are critical components: + +1. **Manual Progress Input**: + - For leaf tasks (without subtasks), users must manually input progress percentages (0-100%) + - If a leaf task is marked as complete, its progress is automatically set to 100% + - 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) + - 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 + +3. **Parent Task Calculation**: + The weighted progress formula is: + ``` + ParentProgress = ∑(SubtaskProgress * SubtaskWeight) / ∑(SubtaskWeight) + ``` + + **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 + + Calculation: + ``` + ParentProgress = ((50 * 200) + (75 * 100) + (25 * 300)) / (200 + 100 + 300) + ParentProgress = (10000 + 7500 + 7500) / 600 + ParentProgress = 25000 / 600 + ParentProgress = 41.67% + ``` + + 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 + + Calculation: + ``` + ParentProgress = ((40 * 50) + (80 * 0)) / (50 + 0) + ParentProgress = 2000 / 50 + ParentProgress = 40% + ``` + + 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) + + Calculation: + ``` + ParentProgress = ((30 * 60) + (70 * 100) + (90 * 100)) / (60 + 100 + 100) + ParentProgress = (1800 + 7000 + 9000) / 260 + ParentProgress = 17800 / 260 + ParentProgress = 68.46% + ``` + + Note that Subtasks B and C have more influence than Subtask A because they have higher default weights. + +6. **All Zero Weights Edge Case**: + If all subtasks have zero weight, the progress is calculated as 0%: + ``` + ParentProgress = SUM(progress_value * 0) / SUM(0) = 0 / 0 = undefined + ``` + + The SQL implementation handles this with `NULLIF` and `COALESCE` to return 0% in this case. + +4. **Actual SQL Implementation**: + The database function implements the weighted calculation as follows: + ```sql + 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; + ``` + + This SQL implementation: + - Gets all non-archived subtasks of the parent task + - For each subtask, determines its progress value: + - If manual progress is set, uses that value + - Otherwise, uses 100% if the task is done or 0% if not done + - Uses COALESCE to default weight to 100 if not specified + - Calculates the weighted average, handling the case where sum of weights might be zero + - Returns 0 if there are no subtasks with weights + +### Detailed Calculation for Time-based Progress Method + +In Time-based Progress mode, the task's estimated time serves as its weight in progress calculations: + +1. **Manual Progress Input**: + - As with weighted progress, leaf tasks require manual progress input + - Progress is entered as a percentage (0-100%) + - Completed tasks are automatically set to 100% progress + +2. **Time Estimation**: + - Each task has an estimated time in hours and minutes + - These values are stored in `total_hours` and `total_minutes` fields + - Time estimates effectively function as weights in progress calculations + - Tasks with longer estimated durations have more influence on parent task progress + - Tasks with zero or no time estimate don't contribute to the parent's progress calculation + +3. **Parent Task Calculation**: + The time-based progress formula is: + ``` + ParentProgress = ∑(SubtaskProgress * SubtaskEstimatedMinutes) / ∑(SubtaskEstimatedMinutes) + ``` + where `SubtaskEstimatedMinutes = (SubtaskHours * 60) + SubtaskMinutes` + + **Example Calculation**: + Consider a parent task with three subtasks: + - Subtask A: Progress 40%, Estimated Time 2h 30m (150 minutes) + - Subtask B: Progress 80%, Estimated Time 1h (60 minutes) + - Subtask C: Progress 10%, Estimated Time 4h (240 minutes) + + Calculation: + ``` + ParentProgress = ((40 * 150) + (80 * 60) + (10 * 240)) / (150 + 60 + 240) + ParentProgress = (6000 + 4800 + 2400) / 450 + ParentProgress = 13200 / 450 + ParentProgress = 29.33% + ``` + + Note how Subtask C, with its large time estimate, significantly pulls down the overall progress despite Subtask B being mostly complete. + +4. **Zero Time Estimate Handling**: + Tasks with zero time estimate are excluded from the calculation: + - Subtask A: Progress 40%, Estimated Time 3h (180 minutes) + - Subtask B: Progress 80%, Estimated Time 0h (0 minutes) + + Calculation: + ``` + ParentProgress = ((40 * 180) + (80 * 0)) / (180 + 0) + ParentProgress = 7200 / 180 + ParentProgress = 40% + ``` + + In this case, only Subtask A influences the parent task progress because Subtask B has no time estimate. + +5. **All Zero Time Estimates Edge Case**: + If all subtasks have zero time estimates, the progress is calculated as 0%: + ``` + ParentProgress = SUM(progress_value * 0) / SUM(0) = 0 / 0 = undefined + ``` + + The SQL implementation handles this with `NULLIF` and `COALESCE` to return 0% in this case. + +6. **Actual SQL Implementation**: + The SQL function for this calculation first converts hours to minutes for consistent measurement: + ```sql + 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_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; + ``` + + This implementation: + - Gets all non-archived subtasks of the parent task + - Determines each subtask's progress value (manual or completion-based) + - Calculates total minutes by converting hours to minutes and adding them together + - Uses COALESCE to treat NULL time estimates as 0 minutes + - Uses NULLIF to handle cases where all time estimates are zero + - Returns 0% progress if there are no subtasks with time estimates + +### Common Implementation Considerations + +For both weighted and time-based progress calculation: + +1. **Null Handling**: + - Tasks with NULL progress values are treated as 0% progress (unless completed) + - Tasks with NULL weights default to 100 in weighted mode + - Tasks with NULL time estimates are treated as 0 minutes in time-based mode + +2. **Progress Propagation**: + - When a leaf task's progress changes, all ancestor tasks are recalculated + - Progress updates are propagated through socket events to all connected clients + - The recalculation happens server-side to ensure consistency + +3. **Edge Cases**: + - If all subtasks have zero weight/time, the system falls back to a simple average + - If a parent task has no subtasks, its own manual progress value is used + - If a task is archived, it's excluded from parent task calculations + +### Database Implementation + +The manual progress value is stored in the `tasks` table with these relevant fields: + +```sql +tasks ( + -- other fields + progress_value FLOAT, -- The manually entered progress value (0-100) + manual_progress BOOLEAN, -- Flag indicating if progress was manually set + weight INTEGER DEFAULT 100, -- For weighted progress calculation + total_hours INTEGER, -- For time-based progress calculation + total_minutes INTEGER -- For time-based progress calculation +) +``` + +### Integration with Parent Task Calculation + +When a subtask's progress is updated manually, the parent task's progress is automatically recalculated based on the active progress method: + +```typescript +// Pseudocode for parent task recalculation +function recalculateParentTaskProgress(taskId, parentTaskId) { + if (!parentTaskId) return; + + // Get project settings to determine active progress method + const project = getProjectByTaskId(taskId); + + if (project.use_manual_progress) { + // Calculate average of all subtask progress values + updateParentProgress(parentTaskId, calculateAverageProgress(parentTaskId)); + } + else if (project.use_weighted_progress) { + // Calculate weighted average using subtask weights + updateParentProgress(parentTaskId, calculateWeightedProgress(parentTaskId)); + } + else if (project.use_time_progress) { + // Calculate weighted average using time estimates + updateParentProgress(parentTaskId, calculateTimeBasedProgress(parentTaskId)); + } + else { + // Default: Calculate based on task completion + updateParentProgress(parentTaskId, calculateCompletionBasedProgress(parentTaskId)); + } + + // If this parent has a parent, continue recalculation up the tree + const grandparentId = getParentTaskId(parentTaskId); + if (grandparentId) { + recalculateParentTaskProgress(parentTaskId, grandparentId); + } +} +``` + +This recursive approach ensures that changes to any task's progress are properly propagated up the task hierarchy. + +## 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 + +### 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 + +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 022f5061..b43b8a75 100644 --- a/worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql +++ b/worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql @@ -515,6 +515,142 @@ BEGIN END $$; +CREATE OR REPLACE FUNCTION public.get_task_form_view_model(_user_id UUID, _team_id UUID, _task_id UUID, _project_id UUID) RETURNS JSON + LANGUAGE plpgsql +AS +$$ +DECLARE + _task JSON; + _priorities JSON; + _projects JSON; + _statuses JSON; + _team_members JSON; + _assignees JSON; + _phases JSON; +BEGIN + + -- Select task info + SELECT COALESCE(ROW_TO_JSON(rec), '{}'::JSON) + INTO _task + FROM (WITH RECURSIVE task_hierarchy AS ( + -- Base case: Start with the given task + SELECT id, + parent_task_id, + 0 AS level + FROM tasks + WHERE id = _task_id + + UNION ALL + + -- Recursive case: Traverse up to parent tasks + SELECT t.id, + t.parent_task_id, + th.level + 1 AS level + FROM tasks t + INNER JOIN task_hierarchy th ON t.id = th.parent_task_id + WHERE th.parent_task_id IS NOT NULL) + SELECT id, + name, + description, + start_date, + end_date, + done, + total_minutes, + priority_id, + project_id, + created_at, + updated_at, + status_id, + parent_task_id, + sort_order, + (SELECT phase_id FROM task_phase WHERE task_id = tasks.id) AS phase_id, + CONCAT((SELECT key FROM projects WHERE id = tasks.project_id), '-', task_no) AS task_key, + (SELECT start_time + FROM task_timers + WHERE task_id = tasks.id + AND user_id = _user_id) AS timer_start_time, + parent_task_id IS NOT NULL AS is_sub_task, + (SELECT COUNT('*') + FROM tasks + WHERE parent_task_id = tasks.id + AND archived IS FALSE) AS sub_tasks_count, + (SELECT COUNT(*) + FROM tasks_with_status_view tt + WHERE (tt.parent_task_id = tasks.id OR tt.task_id = tasks.id) + AND tt.is_done IS TRUE) + AS completed_count, + (SELECT COUNT(*) FROM task_attachments WHERE task_id = tasks.id) AS attachments_count, + (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(r))), '[]'::JSON) + FROM (SELECT task_labels.label_id AS id, + (SELECT name FROM team_labels WHERE id = task_labels.label_id), + (SELECT color_code FROM team_labels WHERE id = task_labels.label_id) + FROM task_labels + WHERE task_id = tasks.id + ORDER BY name) r) AS labels, + (SELECT color_code + FROM sys_task_status_categories + WHERE id = (SELECT category_id FROM task_statuses WHERE id = tasks.status_id)) AS status_color, + (SELECT COUNT(*) FROM tasks WHERE parent_task_id = _task_id) AS sub_tasks_count, + (SELECT name FROM users WHERE id = tasks.reporter_id) AS reporter, + (SELECT get_task_assignees(tasks.id)) AS assignees, + (SELECT id FROM team_members WHERE user_id = _user_id AND team_id = _team_id) AS team_member_id, + billable, + schedule_id, + progress_value, + weight, + (SELECT MAX(level) FROM task_hierarchy) AS task_level + FROM tasks + WHERE id = _task_id) rec; + + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) + INTO _priorities + FROM (SELECT id, name FROM task_priorities ORDER BY value) rec; + + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) + INTO _phases + FROM (SELECT id, name FROM project_phases WHERE project_id = _project_id ORDER BY name) rec; + + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) + INTO _projects + FROM (SELECT id, name + FROM projects + WHERE team_id = _team_id + AND (CASE + WHEN (is_owner(_user_id, _team_id) OR is_admin(_user_id, _team_id) IS TRUE) THEN TRUE + ELSE is_member_of_project(projects.id, _user_id, _team_id) END) + ORDER BY name) rec; + + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) + INTO _statuses + FROM (SELECT id, name FROM task_statuses WHERE project_id = _project_id) rec; + + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) + INTO _team_members + FROM (SELECT team_members.id, + (SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = team_members.id), + (SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = team_members.id), + (SELECT avatar_url + FROM team_member_info_view + WHERE team_member_info_view.team_member_id = team_members.id) + FROM team_members + LEFT JOIN users u ON team_members.user_id = u.id + WHERE team_id = _team_id + AND team_members.active IS TRUE) rec; + + SELECT get_task_assignees(_task_id) INTO _assignees; + + RETURN JSON_BUILD_OBJECT( + 'task', _task, + 'priorities', _priorities, + 'projects', _projects, + 'statuses', _statuses, + 'team_members', _team_members, + 'assignees', _assignees, + 'phases', _phases + ); +END; +$$; + -- Add use_manual_progress, use_weighted_progress, and use_time_progress to projects table if they don't exist ALTER TABLE projects ADD COLUMN IF NOT EXISTS use_manual_progress BOOLEAN DEFAULT FALSE, diff --git a/worklenz-backend/database/migrations/20250424000000-add-progress-and-weight-activity-types.sql b/worklenz-backend/database/migrations/20250424000000-add-progress-and-weight-activity-types.sql new file mode 100644 index 00000000..53eafe20 --- /dev/null +++ b/worklenz-backend/database/migrations/20250424000000-add-progress-and-weight-activity-types.sql @@ -0,0 +1,157 @@ +-- Migration: Add progress and weight activity types support +-- Date: 2025-04-24 +-- Version: 1.0.0 + +BEGIN; + +-- Update the get_activity_logs_by_task function to handle progress and weight attribute types +CREATE OR REPLACE FUNCTION get_activity_logs_by_task(_task_id uuid) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _result JSON; +BEGIN + SELECT ROW_TO_JSON(rec) + INTO _result + FROM (SELECT (SELECT tasks.created_at FROM tasks WHERE tasks.id = _task_id), + (SELECT name + FROM users + WHERE id = (SELECT reporter_id FROM tasks WHERE id = _task_id)), + (SELECT avatar_url + FROM users + WHERE id = (SELECT reporter_id FROM tasks WHERE id = _task_id)), + (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec2))), '[]'::JSON) + FROM (SELECT task_id, + created_at, + attribute_type, + log_type, + + -- Case for previous value + (CASE + WHEN (attribute_type = 'status') + THEN (SELECT name FROM task_statuses WHERE id = old_value::UUID) + WHEN (attribute_type = 'priority') + THEN (SELECT name FROM task_priorities WHERE id = old_value::UUID) + WHEN (attribute_type = 'phase' AND old_value <> 'Unmapped') + THEN (SELECT name FROM project_phases WHERE id = old_value::UUID) + WHEN (attribute_type = 'progress' OR attribute_type = 'weight') + THEN old_value + ELSE (old_value) END) AS previous, + + -- Case for current value + (CASE + WHEN (attribute_type = 'assignee') + THEN (SELECT name FROM users WHERE id = new_value::UUID) + WHEN (attribute_type = 'label') + THEN (SELECT name FROM team_labels WHERE id = new_value::UUID) + WHEN (attribute_type = 'status') + THEN (SELECT name FROM task_statuses WHERE id = new_value::UUID) + WHEN (attribute_type = 'priority') + THEN (SELECT name FROM task_priorities WHERE id = new_value::UUID) + WHEN (attribute_type = 'phase' AND new_value <> 'Unmapped') + THEN (SELECT name FROM project_phases WHERE id = new_value::UUID) + WHEN (attribute_type = 'progress' OR attribute_type = 'weight') + THEN new_value + ELSE (new_value) END) AS current, + + -- Case for assigned user + (CASE + WHEN (attribute_type = 'assignee') + THEN (SELECT ROW_TO_JSON(rec) + FROM (SELECT (CASE + WHEN (new_value IS NOT NULL) + THEN (SELECT name FROM users WHERE users.id = new_value::UUID) + ELSE (next_string) END) AS name, + (SELECT avatar_url FROM users WHERE users.id = new_value::UUID)) rec) + ELSE (NULL) END) AS assigned_user, + + -- Case for label data + (CASE + WHEN (attribute_type = 'label') + THEN (SELECT ROW_TO_JSON(rec) + FROM (SELECT (SELECT name FROM team_labels WHERE id = new_value::UUID), + (SELECT color_code FROM team_labels WHERE id = new_value::UUID)) rec) + ELSE (NULL) END) AS label_data, + + -- Case for previous status + (CASE + WHEN (attribute_type = 'status') + THEN (SELECT ROW_TO_JSON(rec) + FROM (SELECT (SELECT name FROM task_statuses WHERE id = old_value::UUID), + (SELECT color_code + FROM sys_task_status_categories + WHERE id = (SELECT category_id FROM task_statuses WHERE id = old_value::UUID)), + (SELECT color_code_dark + FROM sys_task_status_categories + WHERE id = (SELECT category_id FROM task_statuses WHERE id = old_value::UUID))) rec) + ELSE (NULL) END) AS previous_status, + + -- Case for next status + (CASE + WHEN (attribute_type = 'status') + THEN (SELECT ROW_TO_JSON(rec) + FROM (SELECT (SELECT name FROM task_statuses WHERE id = new_value::UUID), + (SELECT color_code + FROM sys_task_status_categories + WHERE id = (SELECT category_id FROM task_statuses WHERE id = new_value::UUID)), + (SELECT color_code_dark + FROM sys_task_status_categories + WHERE id = (SELECT category_id FROM task_statuses WHERE id = new_value::UUID))) rec) + ELSE (NULL) END) AS next_status, + + -- Case for previous priority + (CASE + WHEN (attribute_type = 'priority') + THEN (SELECT ROW_TO_JSON(rec) + FROM (SELECT (SELECT name FROM task_priorities WHERE id = old_value::UUID), + (SELECT color_code FROM task_priorities WHERE id = old_value::UUID)) rec) + ELSE (NULL) END) AS previous_priority, + + -- Case for next priority + (CASE + WHEN (attribute_type = 'priority') + THEN (SELECT ROW_TO_JSON(rec) + FROM (SELECT (SELECT name FROM task_priorities WHERE id = new_value::UUID), + (SELECT color_code FROM task_priorities WHERE id = new_value::UUID)) rec) + ELSE (NULL) END) AS next_priority, + + -- Case for previous phase + (CASE + WHEN (attribute_type = 'phase' AND old_value <> 'Unmapped') + THEN (SELECT ROW_TO_JSON(rec) + FROM (SELECT (SELECT name FROM project_phases WHERE id = old_value::UUID), + (SELECT color_code FROM project_phases WHERE id = old_value::UUID)) rec) + ELSE (NULL) END) AS previous_phase, + + -- Case for next phase + (CASE + WHEN (attribute_type = 'phase' AND new_value <> 'Unmapped') + THEN (SELECT ROW_TO_JSON(rec) + FROM (SELECT (SELECT name FROM project_phases WHERE id = new_value::UUID), + (SELECT color_code FROM project_phases WHERE id = new_value::UUID)) rec) + ELSE (NULL) END) AS next_phase, + + -- Case for done by + (SELECT ROW_TO_JSON(rec) + FROM (SELECT (SELECT name FROM users WHERE users.id = tal.user_id), + (SELECT avatar_url FROM users WHERE users.id = tal.user_id)) rec) AS done_by, + + -- Add log text for progress and weight + (CASE + WHEN (attribute_type = 'progress') + THEN 'updated the progress of' + WHEN (attribute_type = 'weight') + THEN 'updated the weight of' + ELSE '' + END) AS log_text + + + FROM task_activity_logs tal + WHERE task_id = _task_id + ORDER BY created_at DESC) rec2) AS logs) rec; + RETURN _result; +END; +$$; + +COMMIT; \ No newline at end of file diff --git a/worklenz-backend/src/controllers/tasks-controller-base.ts b/worklenz-backend/src/controllers/tasks-controller-base.ts index 293ae3d8..1fe89210 100644 --- a/worklenz-backend/src/controllers/tasks-controller-base.ts +++ b/worklenz-backend/src/controllers/tasks-controller-base.ts @@ -32,7 +32,51 @@ export default class TasksControllerBase extends WorklenzControllerBase { } public static updateTaskViewModel(task: any) { - task.progress = ~~(task.total_minutes_spent / task.total_minutes * 100); + console.log(`Processing task ${task.id} (${task.name})`); + console.log(` manual_progress: ${task.manual_progress}, progress_value: ${task.progress_value}`); + console.log(` project_use_manual_progress: ${task.project_use_manual_progress}, project_use_weighted_progress: ${task.project_use_weighted_progress}`); + console.log(` has subtasks: ${task.sub_tasks_count > 0}`); + + // For parent tasks (with subtasks), always use calculated progress from subtasks + if (task.sub_tasks_count > 0) { + // For parent tasks without manual progress, calculate from subtasks (already done via db function) + console.log(` Parent task with subtasks: complete_ratio=${task.complete_ratio}`); + + // Ensure progress matches complete_ratio for consistency + task.progress = task.complete_ratio || 0; + + // Important: Parent tasks should not have manual progress + // If they somehow do, reset it + if (task.manual_progress) { + console.log(` WARNING: Parent task ${task.id} had manual_progress set to true, resetting`); + task.manual_progress = false; + task.progress_value = null; + } + } + // For tasks without subtasks, respect manual progress if set + else if (task.manual_progress === true && task.progress_value !== null && task.progress_value !== undefined) { + // For manually set progress, use that value directly + task.progress = parseInt(task.progress_value); + task.complete_ratio = parseInt(task.progress_value); + + console.log(` Using manual progress: progress=${task.progress}, complete_ratio=${task.complete_ratio}`); + } + // For tasks with no subtasks and no manual progress, calculate based on time + else { + task.progress = task.total_minutes_spent && task.total_minutes + ? ~~(task.total_minutes_spent / task.total_minutes * 100) + : 0; + + // Set complete_ratio to match progress + task.complete_ratio = task.progress; + + console.log(` Calculated time-based progress: progress=${task.progress}, complete_ratio=${task.complete_ratio}`); + } + + // Ensure numeric values + task.progress = parseInt(task.progress) || 0; + task.complete_ratio = parseInt(task.complete_ratio) || 0; + task.overdue = task.total_minutes < task.total_minutes_spent; task.time_spent = {hours: ~~(task.total_minutes_spent / 60), minutes: task.total_minutes_spent % 60}; @@ -73,9 +117,9 @@ export default class TasksControllerBase extends WorklenzControllerBase { if (task.timer_start_time) task.timer_start_time = moment(task.timer_start_time).valueOf(); + // Set completed_count and total_tasks_count regardless of progress calculation method const totalCompleted = (+task.completed_sub_tasks + +task.parent_task_completed) || 0; - const totalTasks = +task.sub_tasks_count || 0; // if needed add +1 for parent - task.complete_ratio = TasksControllerBase.calculateTaskCompleteRatio(totalCompleted, totalTasks); + const totalTasks = +task.sub_tasks_count || 0; task.completed_count = totalCompleted; task.total_tasks_count = totalTasks; diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index 3e1290f1..131be72a 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -192,6 +192,12 @@ export default class TasksControllerV2 extends TasksControllerBase { t.archived, t.description, t.sort_order, + t.progress_value, + t.manual_progress, + t.weight, + (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 phase_id FROM task_phase WHERE task_id = t.id) AS phase_id, (SELECT name @@ -334,7 +340,7 @@ export default class TasksControllerV2 extends TasksControllerBase { return g; }, {}); - this.updateMapByGroup(tasks, groupBy, map); + await this.updateMapByGroup(tasks, groupBy, map); const updatedGroups = Object.keys(map).map(key => { const group = map[key]; @@ -353,11 +359,27 @@ export default class TasksControllerV2 extends TasksControllerBase { return res.status(200).send(new ServerResponse(true, updatedGroups)); } - public static updateMapByGroup(tasks: any[], groupBy: string, map: { [p: string]: ITaskGroup }) { + public static async updateMapByGroup(tasks: any[], groupBy: string, map: { [p: string]: ITaskGroup }) { let index = 0; const unmapped = []; for (const task of tasks) { task.index = index++; + + // For tasks with subtasks, get the complete ratio from the database function + if (task.sub_tasks_count > 0) { + try { + 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.completed_count = data.info.total_completed; + task.total_tasks_count = data.info.total_tasks; + } + } catch (error) { + // Proceed with default calculation if database call fails + } + } + TasksControllerV2.updateTaskViewModel(task); if (groupBy === GroupBy.STATUS) { map[task.status]?.tasks.push(task); @@ -395,7 +417,7 @@ export default class TasksControllerV2 extends TasksControllerBase { @HandleExceptions() public static async getTasksOnly(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const isSubTasks = !!req.query.parent_task; - + // Add customColumns flag to query params req.query.customColumns = "true"; @@ -410,7 +432,24 @@ export default class TasksControllerV2 extends TasksControllerBase { [data] = result.rows; } else { // else we return a flat list of tasks data = [...result.rows]; + for (const task of data) { + // For tasks with subtasks, get the complete ratio from the database function + if (task.sub_tasks_count > 0) { + try { + 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.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}`); + } + } catch (error) { + // Proceed with default calculation if database call fails + } + } + TasksControllerV2.updateTaskViewModel(task); } } diff --git a/worklenz-backend/src/services/activity-logs/activity-logs.service.ts b/worklenz-backend/src/services/activity-logs/activity-logs.service.ts index 85d20977..cf049f7e 100644 --- a/worklenz-backend/src/services/activity-logs/activity-logs.service.ts +++ b/worklenz-backend/src/services/activity-logs/activity-logs.service.ts @@ -204,3 +204,29 @@ export async function logPhaseChange(activityLog: IActivityLog) { insertToActivityLogs(activityLog); } } + +export async function logProgressChange(activityLog: IActivityLog) { + const { task_id, new_value, old_value } = activityLog; + if (!task_id || !activityLog.socket) return; + + if (old_value !== new_value) { + activityLog.user_id = getLoggedInUserIdFromSocket(activityLog.socket); + activityLog.attribute_type = IActivityLogAttributeTypes.PROGRESS; + activityLog.log_type = IActivityLogChangeType.UPDATE; + + insertToActivityLogs(activityLog); + } +} + +export async function logWeightChange(activityLog: IActivityLog) { + const { task_id, new_value, old_value } = activityLog; + if (!task_id || !activityLog.socket) return; + + if (old_value !== new_value) { + activityLog.user_id = getLoggedInUserIdFromSocket(activityLog.socket); + activityLog.attribute_type = IActivityLogAttributeTypes.WEIGHT; + activityLog.log_type = IActivityLogChangeType.UPDATE; + + insertToActivityLogs(activityLog); + } +} diff --git a/worklenz-backend/src/services/activity-logs/interfaces.ts b/worklenz-backend/src/services/activity-logs/interfaces.ts index c0c43143..bab97e11 100644 --- a/worklenz-backend/src/services/activity-logs/interfaces.ts +++ b/worklenz-backend/src/services/activity-logs/interfaces.ts @@ -29,6 +29,8 @@ export enum IActivityLogAttributeTypes { COMMENT = "comment", ARCHIVE = "archive", PHASE = "phase", + PROGRESS = "progress", + WEIGHT = "weight", } export enum IActivityLogChangeType { diff --git a/worklenz-backend/src/socket.io/commands/on-get-task-progress.ts b/worklenz-backend/src/socket.io/commands/on-get-task-progress.ts index 586bfc9f..2471a149 100644 --- a/worklenz-backend/src/socket.io/commands/on-get-task-progress.ts +++ b/worklenz-backend/src/socket.io/commands/on-get-task-progress.ts @@ -5,6 +5,8 @@ import TasksControllerV2 from "../../controllers/tasks-controller-v2"; export async function on_get_task_progress(_io: Server, socket: Socket, taskId?: string) { try { + console.log(`GET_TASK_PROGRESS requested for task: ${taskId}`); + const task: any = {}; task.id = taskId; @@ -13,6 +15,8 @@ export async function on_get_task_progress(_io: Server, socket: Socket, taskId?: task.complete_ratio = info.ratio; task.completed_count = info.total_completed; task.total_tasks_count = info.total_tasks; + + console.log(`Sending task progress for task ${taskId}: complete_ratio=${task.complete_ratio}`); } return socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task); 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 4d399530..ac550fd7 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 @@ -2,6 +2,7 @@ import { Socket } from "socket.io"; import db from "../../config/db"; import { SocketEvents } from "../events"; import { log, log_error, notifyProjectUpdates } from "../util"; +import { logProgressChange } from "../../services/activity-logs/activity-logs.service"; interface UpdateTaskProgressData { task_id: string; @@ -16,10 +17,38 @@ export async function on_update_task_progress(io: any, socket: Socket, data: str const parsedData = JSON.parse(data) as UpdateTaskProgressData; const { task_id, progress_value, parent_task_id } = parsedData; + console.log(`Updating progress for task ${task_id}: new value = ${progress_value}`); + if (!task_id || progress_value === undefined) { return; } + // Check if this is a parent task (has subtasks) + const subTasksResult = await db.query( + "SELECT COUNT(*) as subtask_count FROM tasks WHERE parent_task_id = $1", + [task_id] + ); + + const subtaskCount = parseInt(subTasksResult.rows[0]?.subtask_count || '0'); + + // If this is a parent task, we shouldn't set manual progress + if (subtaskCount > 0) { + console.log(`Cannot set manual progress on parent task ${task_id} with ${subtaskCount} subtasks`); + return; + } + + // Get the current progress value to log the change + const currentProgressResult = await db.query( + "SELECT progress_value, project_id, team_id FROM tasks WHERE id = $1", + [task_id] + ); + + const currentProgress = currentProgressResult.rows[0]?.progress_value; + const projectId = currentProgressResult.rows[0]?.project_id; + const teamId = currentProgressResult.rows[0]?.team_id; + + console.log(`Previous progress for task ${task_id}: ${currentProgress}; New: ${progress_value}`); + // Update the task progress in the database await db.query( `UPDATE tasks @@ -28,9 +57,13 @@ export async function on_update_task_progress(io: any, socket: Socket, data: str [progress_value, task_id] ); - // Get the project ID for the task - const projectResult = await db.query("SELECT project_id FROM tasks WHERE id = $1", [task_id]); - const projectId = projectResult.rows[0]?.project_id; + // Log the progress change using the activity logs service + await logProgressChange({ + task_id, + 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 @@ -42,6 +75,8 @@ 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}`); + // If this is a subtask, update the parent task's progress if (parent_task_id) { const progressRatio = await db.query( @@ -49,6 +84,8 @@ export async function on_update_task_progress(io: any, socket: Socket, data: str [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(), 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 146aa3ec..e6a68d1d 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 @@ -2,6 +2,7 @@ import { Socket } from "socket.io"; import db from "../../config/db"; import { SocketEvents } from "../events"; import { log, log_error, notifyProjectUpdates } from "../util"; +import { logWeightChange } from "../../services/activity-logs/activity-logs.service"; interface UpdateTaskWeightData { task_id: string; @@ -11,7 +12,6 @@ interface UpdateTaskWeightData { export async function on_update_task_weight(io: any, socket: Socket, data: string) { try { - log(socket.id, `${SocketEvents.UPDATE_TASK_WEIGHT}: ${data}`); const parsedData = JSON.parse(data) as UpdateTaskWeightData; const { task_id, weight, parent_task_id } = parsedData; @@ -20,6 +20,15 @@ export async function on_update_task_weight(io: any, socket: Socket, data: strin return; } + // Get the current weight value to log the change + const currentWeightResult = await db.query( + "SELECT weight, project_id FROM tasks WHERE id = $1", + [task_id] + ); + + const currentWeight = currentWeightResult.rows[0]?.weight; + const projectId = currentWeightResult.rows[0]?.project_id; + // Update the task weight in the database await db.query( `UPDATE tasks @@ -28,9 +37,13 @@ export async function on_update_task_weight(io: any, socket: Socket, data: strin [weight, task_id] ); - // Get the project ID for the task - const projectResult = await db.query("SELECT project_id FROM tasks WHERE id = $1", [task_id]); - const projectId = projectResult.rows[0]?.project_id; + // Log the weight change using the activity logs service + await logWeightChange({ + task_id, + old_value: currentWeight !== null ? currentWeight.toString() : '100', + new_value: weight.toString(), + socket + }); if (projectId) { // Emit the update to all clients in the project room diff --git a/worklenz-frontend/public/locales/en/project-drawer.json b/worklenz-frontend/public/locales/en/project-drawer.json index 53d4ea7e..c9d89238 100644 --- a/worklenz-frontend/public/locales/en/project-drawer.json +++ b/worklenz-frontend/public/locales/en/project-drawer.json @@ -38,6 +38,8 @@ "createClient": "Create client", "searchInputPlaceholder": "Search by name or email", "hoursPerDayValidationMessage": "Hours per day must be a number between 1 and 24", + "workingDaysValidationMessage": "Working days must be a positive number", + "manDaysValidationMessage": "Man days must be a positive number", "noPermission": "No permission", "progressSettings": "Progress Settings", "manualProgress": "Manual Progress", diff --git a/worklenz-frontend/public/locales/es/project-drawer.json b/worklenz-frontend/public/locales/es/project-drawer.json index 411d6f69..abe5a856 100644 --- a/worklenz-frontend/public/locales/es/project-drawer.json +++ b/worklenz-frontend/public/locales/es/project-drawer.json @@ -38,6 +38,8 @@ "createClient": "Crear cliente", "searchInputPlaceholder": "Busca por nombre o email", "hoursPerDayValidationMessage": "Las horas por día deben ser un número entre 1 y 24", + "workingDaysValidationMessage": "Los días de trabajo deben ser un número positivo", + "manDaysValidationMessage": "Los días hombre deben ser un número positivo", "noPermission": "Sin permiso", "progressSettings": "Configuración de Progreso", "manualProgress": "Progreso Manual", diff --git a/worklenz-frontend/public/locales/pt/project-drawer.json b/worklenz-frontend/public/locales/pt/project-drawer.json index 471f8ed5..b7ff40be 100644 --- a/worklenz-frontend/public/locales/pt/project-drawer.json +++ b/worklenz-frontend/public/locales/pt/project-drawer.json @@ -38,6 +38,8 @@ "createClient": "Criar cliente", "searchInputPlaceholder": "Pesquise por nome ou email", "hoursPerDayValidationMessage": "As horas por dia devem ser um número entre 1 e 24", + "workingDaysValidationMessage": "Os dias de trabalho devem ser um número positivo", + "manDaysValidationMessage": "Os dias de homem devem ser um número positivo", "noPermission": "Sem permissão", "progressSettings": "Configurações de Progresso", "manualProgress": "Progresso Manual", diff --git a/worklenz-frontend/src/components/projects/project-drawer/project-drawer.tsx b/worklenz-frontend/src/components/projects/project-drawer/project-drawer.tsx index 27d24cb3..f5b5cb98 100644 --- a/worklenz-frontend/src/components/projects/project-drawer/project-drawer.tsx +++ b/worklenz-frontend/src/components/projects/project-drawer/project-drawer.tsx @@ -47,7 +47,11 @@ import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse import { calculateTimeDifference } from '@/utils/calculate-time-difference'; import { formatDateTimeWithLocale } from '@/utils/format-date-time-with-locale'; import logger from '@/utils/errorLogger'; -import { setProjectData, toggleProjectDrawer, setProjectId as setDrawerProjectId } from '@/features/project/project-drawer.slice'; +import { + setProjectData, + toggleProjectDrawer, + setProjectId as setDrawerProjectId, +} from '@/features/project/project-drawer.slice'; import useIsProjectManager from '@/hooks/useIsProjectManager'; import { useAuthService } from '@/hooks/useAuth'; import { evt_projects_create } from '@/shared/worklenz-analytics-events'; @@ -61,7 +65,7 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => { const [form] = Form.useForm(); const [loading, setLoading] = useState(true); const currentSession = useAuthService().getCurrentSession(); - + // State const [editMode, setEditMode] = useState(false); const [selectedProjectManager, setSelectedProjectManager] = useState( @@ -176,7 +180,9 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => { dispatch(toggleProjectDrawer()); if (!editMode) { trackMixpanelEvent(evt_projects_create); - navigate(`/worklenz/projects/${response.data.body.id}?tab=tasks-list&pinned_tab=tasks-list`); + navigate( + `/worklenz/projects/${response.data.body.id}?tab=tasks-list&pinned_tab=tasks-list` + ); } refetchProjects(); window.location.reload(); // Refresh the page @@ -191,8 +197,17 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => { logger.error('Error saving project', error); } }; - const calculateWorkingDays = (startDate: dayjs.Dayjs | null, endDate: dayjs.Dayjs | null): number => { - if (!startDate || !endDate || !startDate.isValid() || !endDate.isValid() || startDate.isAfter(endDate)) { + const calculateWorkingDays = ( + startDate: dayjs.Dayjs | null, + endDate: dayjs.Dayjs | null + ): number => { + if ( + !startDate || + !endDate || + !startDate.isValid() || + !endDate.isValid() || + startDate.isAfter(endDate) + ) { return 0; } @@ -220,7 +235,13 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => { ...project, start_date: project.start_date ? dayjs(project.start_date) : null, end_date: project.end_date ? dayjs(project.end_date) : null, - working_days: form.getFieldValue('start_date') && form.getFieldValue('end_date') ? calculateWorkingDays(form.getFieldValue('start_date'), form.getFieldValue('end_date')) : project.working_days || 0, + working_days: + form.getFieldValue('start_date') && form.getFieldValue('end_date') + ? calculateWorkingDays( + form.getFieldValue('start_date'), + form.getFieldValue('end_date') + ) + : project.working_days || 0, use_manual_progress: project.use_manual_progress || false, use_weighted_progress: project.use_weighted_progress || false, use_time_progress: project.use_time_progress || false, @@ -382,12 +403,7 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => { } > {!isEditable && ( - + )}
void }) => { - + { + onChange={date => { const endDate = form.getFieldValue('end_date'); if (date && endDate) { const days = calculateWorkingDays(date, endDate); @@ -464,14 +477,11 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => { }} /> - + { + onChange={date => { const startDate = form.getFieldValue('start_date'); if (startDate && date) { const days = calculateWorkingDays(startDate, date); @@ -483,12 +493,48 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => { - - + { + if (value === undefined || value >= 0) { + return Promise.resolve(); + } + return Promise.reject(new Error(t('workingDaysValidationMessage', { min: 0 }))); + }, + }, + ]} + > + - - + { + if (value === undefined || value >= 0) { + return Promise.resolve(); + } + return Promise.reject(new Error(t('manDaysValidationMessage', { min: 0 }))); + }, + }, + ]} + > + { + const value = parseInt(e.target.value, 10); + if (value < 0) { + form.setFieldsValue({ man_days: 0 }); + } + }} + /> void }) => { if (value === undefined || (value >= 0 && value <= 24)) { return Promise.resolve(); } - return Promise.reject(new Error(t('hoursPerDayValidationMessage', { min: 0, max: 24 }))); + return Promise.reject( + new Error(t('hoursPerDayValidationMessage', { min: 0, max: 24 })) + ); }, }, ]} > - + { + const value = parseInt(e.target.value, 10); + if (value < 0) { + form.setFieldsValue({ hours_per_day: 8 }); + } + }} + /> {t('progressSettings')} - + void }) => { } valuePropName="checked" > - @@ -540,7 +598,7 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => { } valuePropName="checked" > - @@ -558,7 +616,7 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => { } valuePropName="checked" > - diff --git a/worklenz-frontend/src/components/task-drawer/shared/activity-log/task-drawer-activity-log.tsx b/worklenz-frontend/src/components/task-drawer/shared/activity-log/task-drawer-activity-log.tsx index da198294..a36ed339 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/activity-log/task-drawer-activity-log.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/activity-log/task-drawer-activity-log.tsx @@ -111,6 +111,32 @@ const TaskDrawerActivityLog = () => { ); + + case IActivityLogAttributeTypes.PROGRESS: + return ( + + + {activity.previous || '0'}% + +   + + {activity.current || '0'}% + + + ); + + case IActivityLogAttributeTypes.WEIGHT: + return ( + + + Weight: {activity.previous || '100'} + +   + + Weight: {activity.current || '100'} + + + ); default: return ( 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 2b63892a..07f51adc 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 @@ -21,13 +21,16 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => { const isSubTask = !!task?.parent_task_id; const hasSubTasks = task?.sub_tasks_count > 0; - // Determine which progress input to show based on project settings - const showManualProgressInput = project?.use_manual_progress && !hasSubTasks && !isSubTask; + // Show manual progress input only for tasks without subtasks (not parent tasks) + // Parent tasks get their progress calculated from subtasks + const showManualProgressInput = !hasSubTasks; + + // Only show weight input for subtasks in weighted progress mode const showTaskWeightInput = project?.use_weighted_progress && isSubTask; useEffect(() => { // Listen for progress updates from the server - socket?.on(SocketEvents.TASK_PROGRESS_UPDATED.toString(), (data) => { + const handleProgressUpdate = (data: any) => { if (data.task_id === task.id) { if (data.progress_value !== undefined) { form.setFieldsValue({ progress_value: data.progress_value }); @@ -36,34 +39,74 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => { form.setFieldsValue({ weight: data.weight }); } } - }); + }; + + socket?.on(SocketEvents.TASK_PROGRESS_UPDATED.toString(), handleProgressUpdate); + + // When the component mounts, explicitly request the latest progress for this task + if (connected && task.id) { + socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id); + } return () => { - socket?.off(SocketEvents.TASK_PROGRESS_UPDATED.toString()); + socket?.off(SocketEvents.TASK_PROGRESS_UPDATED.toString(), handleProgressUpdate); }; - }, [socket, task.id, form]); + }, [socket, connected, task.id, form]); const handleProgressChange = (value: number | null) => { if (connected && task.id && value !== null) { - socket?.emit(SocketEvents.UPDATE_TASK_PROGRESS.toString(), JSON.stringify({ - task_id: task.id, - progress_value: value, - parent_task_id: task.parent_task_id - })); + // Ensure parent_task_id is not undefined + const parent_task_id = task.parent_task_id || null; + + socket?.emit( + SocketEvents.UPDATE_TASK_PROGRESS.toString(), + JSON.stringify({ + task_id: task.id, + progress_value: value, + parent_task_id: parent_task_id, + }) + ); + + // 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(() => { + socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), parent_task_id); + }, 100); + } } }; const handleWeightChange = (value: number | null) => { if (connected && task.id && value !== null) { - socket?.emit(SocketEvents.UPDATE_TASK_WEIGHT.toString(), JSON.stringify({ - task_id: task.id, - weight: value, - parent_task_id: task.parent_task_id - })); + // Ensure parent_task_id is not undefined + const parent_task_id = task.parent_task_id || null; + + socket?.emit( + SocketEvents.UPDATE_TASK_WEIGHT.toString(), + JSON.stringify({ + task_id: task.id, + weight: value, + parent_task_id: parent_task_id, + }) + ); + + // If this is a subtask, request the parent's progress to be updated in UI + if (parent_task_id) { + setTimeout(() => { + socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), parent_task_id); + }, 100); + } } }; - const percentFormatter = (value: number | undefined) => value ? `${value}%` : '0%'; + const percentFormatter = (value: number | undefined) => (value ? `${value}%` : '0%'); const percentParser = (value: string | undefined) => { const parsed = parseInt(value?.replace('%', '') || '0', 10); return isNaN(parsed) ? 0 : parsed; @@ -75,43 +118,6 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => { return ( <> - {showManualProgressInput && ( - - {t('taskInfoTab.details.progressValue')} - - - - - } - rules={[ - { - required: true, - message: t('taskInfoTab.details.progressValueRequired'), - }, - { - type: 'number', - min: 0, - max: 100, - message: t('taskInfoTab.details.progressValueRange'), - }, - ]} - > - { - const value = percentParser(e.target.value); - handleProgressChange(value); - }} - /> - - )} - {showTaskWeightInput && ( { } rules={[ - { - required: true, - message: t('taskInfoTab.details.taskWeightRequired'), - }, { type: 'number', min: 0, @@ -141,15 +143,47 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => { max={100} formatter={percentFormatter} parser={percentParser} - onBlur={(e) => { + onBlur={e => { const value = percentParser(e.target.value); handleWeightChange(value); }} /> )} + {showManualProgressInput && ( + + {t('taskInfoTab.details.progressValue')} + + + + + } + rules={[ + { + type: 'number', + min: 0, + max: 100, + message: t('taskInfoTab.details.progressValueRange'), + }, + ]} + > + { + const value = percentParser(e.target.value); + handleProgressChange(value); + }} + /> + + )} ); }; -export default TaskDrawerProgress; \ No newline at end of file +export default TaskDrawerProgress; diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/subtask-table.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/subtask-table.tsx index 79f3dae8..5d63c177 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/subtask-table.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/subtask-table.tsx @@ -210,10 +210,11 @@ const SubTaskTable = ({ subTasks, loadingSubTasks, refreshSubTasks, t }: SubTask icon={} okText="Yes" cancelText="No" - onConfirm={() => handleDeleteSubTask(record.id)} + onPopupClick={(e) => e.stopPropagation()} + onConfirm={(e) => {handleDeleteSubTask(record.id)}} > - - ) + ); }; diff --git a/worklenz-frontend/src/types/tasks/task-activity-logs-get-request.ts b/worklenz-frontend/src/types/tasks/task-activity-logs-get-request.ts index f6cb37b3..3464ce82 100644 --- a/worklenz-frontend/src/types/tasks/task-activity-logs-get-request.ts +++ b/worklenz-frontend/src/types/tasks/task-activity-logs-get-request.ts @@ -31,6 +31,8 @@ export enum IActivityLogAttributeTypes { ATTACHMENT = 'attachment', COMMENT = 'comment', ARCHIVE = 'archive', + PROGRESS = 'progress', + WEIGHT = 'weight', } export interface IActivityLog { diff --git a/worklenz-frontend/src/types/tasks/task.types.ts b/worklenz-frontend/src/types/tasks/task.types.ts index 621bd6ad..9c5da9bf 100644 --- a/worklenz-frontend/src/types/tasks/task.types.ts +++ b/worklenz-frontend/src/types/tasks/task.types.ts @@ -63,6 +63,7 @@ export interface ITaskViewModel extends ITask { task_labels?: ITaskLabel[]; timer_start_time?: number; recurring?: boolean; + task_level?: number; } export interface ITaskTeamMember extends ITeamMember {