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/20250422132400-manual-task-progress.sql b/worklenz-backend/database/migrations/20250422132400-manual-task-progress.sql new file mode 100644 index 00000000..c45d34af --- /dev/null +++ b/worklenz-backend/database/migrations/20250422132400-manual-task-progress.sql @@ -0,0 +1,78 @@ +-- Migration: Add manual task progress +-- Date: 2025-04-22 +-- Version: 1.0.0 + +BEGIN; + +-- Add manual progress fields to tasks table +ALTER TABLE tasks +ADD COLUMN IF NOT EXISTS manual_progress BOOLEAN DEFAULT FALSE, +ADD COLUMN IF NOT EXISTS progress_value INTEGER DEFAULT NULL, +ADD COLUMN IF NOT EXISTS weight INTEGER DEFAULT NULL; + +-- Update function to consider manual progress +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; +BEGIN + -- Check if manual progress is set + SELECT manual_progress, progress_value + FROM tasks + WHERE id = _task_id + INTO _is_manual, _manual_value; + + -- If manual progress is enabled and has a value, use it directly + 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; + + -- Otherwise calculate automatically as before + 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 WHERE parent_task_id = _task_id AND archived IS FALSE INTO _sub_tasks_count; + + 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 for the parent task + + IF _total_tasks > 0 THEN + _ratio = (_total_completed / _total_tasks) * 100; + ELSE + _ratio = _parent_task_done * 100; + END IF; + + RETURN JSON_BUILD_OBJECT( + 'ratio', _ratio, + 'total_completed', _total_completed, + 'total_tasks', _total_tasks, + 'is_manual', FALSE + ); +END +$$; + +COMMIT; \ 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 new file mode 100644 index 00000000..b43b8a75 --- /dev/null +++ b/worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql @@ -0,0 +1,660 @@ +-- Migration: Enhance manual task progress with subtask support +-- Date: 2025-04-23 +-- Version: 1.0.0 + +BEGIN; + +-- Update function to consider subtask manual progress when calculating parent task progress +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; + + -- If manual progress is enabled and has a value, use it directly + 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; + + -- Get all subtasks + SELECT COUNT(*) + FROM tasks + WHERE parent_task_id = _task_id AND archived IS FALSE + INTO _sub_tasks_count; + + -- If there are no subtasks, just use the parent task's status + IF _sub_tasks_count = 0 THEN + SELECT (CASE + 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; + 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_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; + 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 +$$; + +CREATE OR REPLACE FUNCTION update_project(_body json) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _user_id UUID; + _team_id UUID; + _client_id UUID; + _project_id UUID; + _project_manager_team_member_id UUID; + _client_name TEXT; + _project_name TEXT; +BEGIN + -- need a test, can be throw errors + _client_name = TRIM((_body ->> 'client_name')::TEXT); + _project_name = TRIM((_body ->> 'name')::TEXT); + + -- add inside the controller + _user_id = (_body ->> 'user_id')::UUID; + _team_id = (_body ->> 'team_id')::UUID; + _project_manager_team_member_id = (_body ->> 'team_member_id')::UUID; + + -- cache exists client if exists + SELECT id FROM clients WHERE LOWER(name) = LOWER(_client_name) AND team_id = _team_id INTO _client_id; + + -- insert client if not exists + IF is_null_or_empty(_client_id) IS TRUE AND is_null_or_empty(_client_name) IS FALSE + THEN + INSERT INTO clients (name, team_id) VALUES (_client_name, _team_id) RETURNING id INTO _client_id; + END IF; + + -- check whether the project name is already in + IF EXISTS( + SELECT name FROM projects WHERE LOWER(name) = LOWER(_project_name) + AND team_id = _team_id AND id != (_body ->> 'id')::UUID + ) + THEN + RAISE 'PROJECT_EXISTS_ERROR:%', _project_name; + END IF; + + -- update the project + UPDATE projects + SET name = _project_name, + notes = (_body ->> 'notes')::TEXT, + color_code = (_body ->> 'color_code')::TEXT, + status_id = (_body ->> 'status_id')::UUID, + health_id = (_body ->> 'health_id')::UUID, + key = (_body ->> 'key')::TEXT, + start_date = (_body ->> 'start_date')::TIMESTAMPTZ, + end_date = (_body ->> 'end_date')::TIMESTAMPTZ, + client_id = _client_id, + folder_id = (_body ->> 'folder_id')::UUID, + category_id = (_body ->> 'category_id')::UUID, + updated_at = CURRENT_TIMESTAMP, + estimated_working_days = (_body ->> 'working_days')::INTEGER, + estimated_man_days = (_body ->> 'man_days')::INTEGER, + hours_per_day = (_body ->> 'hours_per_day')::INTEGER, + use_manual_progress = COALESCE((_body ->> 'use_manual_progress')::BOOLEAN, FALSE), + use_weighted_progress = COALESCE((_body ->> 'use_weighted_progress')::BOOLEAN, FALSE), + use_time_progress = COALESCE((_body ->> 'use_time_progress')::BOOLEAN, FALSE) + WHERE id = (_body ->> 'id')::UUID + AND team_id = _team_id + RETURNING id INTO _project_id; + + UPDATE project_members SET project_access_level_id = (SELECT id FROM project_access_levels WHERE key = 'MEMBER') WHERE project_id = _project_id; + + IF NOT (_project_manager_team_member_id IS NULL) + THEN + PERFORM update_project_manager(_project_manager_team_member_id, _project_id::UUID); + END IF; + + RETURN JSON_BUILD_OBJECT( + 'id', _project_id, + 'name', (_body ->> 'name')::TEXT, + 'project_manager_id', _project_manager_team_member_id::UUID + ); +END; +$$; + +-- 3. Also modify the create_project function to handle the new fields during project creation +CREATE OR REPLACE FUNCTION create_project(_body json) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _project_id UUID; + _user_id UUID; + _team_id UUID; + _team_member_id UUID; + _client_id UUID; + _client_name TEXT; + _project_name TEXT; + _project_created_log TEXT; + _project_member_added_log TEXT; + _project_created_log_id UUID; + _project_manager_team_member_id UUID; + _project_key TEXT; +BEGIN + _client_name = TRIM((_body ->> 'client_name')::TEXT); + _project_name = TRIM((_body ->> 'name')::TEXT); + _project_key = TRIM((_body ->> 'key')::TEXT); + _project_created_log = (_body ->> 'project_created_log')::TEXT; + _project_member_added_log = (_body ->> 'project_member_added_log')::TEXT; + _user_id = (_body ->> 'user_id')::UUID; + _team_id = (_body ->> 'team_id')::UUID; + _project_manager_team_member_id = (_body ->> 'project_manager_id')::UUID; + + SELECT id FROM team_members WHERE user_id = _user_id AND team_id = _team_id INTO _team_member_id; + + -- cache exists client if exists + SELECT id FROM clients WHERE LOWER(name) = LOWER(_client_name) AND team_id = _team_id INTO _client_id; + + -- insert client if not exists + IF is_null_or_empty(_client_id) IS TRUE AND is_null_or_empty(_client_name) IS FALSE + THEN + INSERT INTO clients (name, team_id) VALUES (_client_name, _team_id) RETURNING id INTO _client_id; + END IF; + + -- check whether the project name is already in + IF EXISTS(SELECT name FROM projects WHERE LOWER(name) = LOWER(_project_name) AND team_id = _team_id) + THEN + RAISE 'PROJECT_EXISTS_ERROR:%', _project_name; + END IF; + + -- create the project + INSERT + INTO projects (name, key, color_code, start_date, end_date, team_id, notes, owner_id, status_id, health_id, folder_id, + category_id, estimated_working_days, estimated_man_days, hours_per_day, + use_manual_progress, use_weighted_progress, use_time_progress, client_id) + VALUES (_project_name, + UPPER(_project_key), + (_body ->> 'color_code')::TEXT, + (_body ->> 'start_date')::TIMESTAMPTZ, + (_body ->> 'end_date')::TIMESTAMPTZ, + _team_id, + (_body ->> 'notes')::TEXT, + _user_id, + (_body ->> 'status_id')::UUID, + (_body ->> 'health_id')::UUID, + (_body ->> 'folder_id')::UUID, + (_body ->> 'category_id')::UUID, + (_body ->> 'working_days')::INTEGER, + (_body ->> 'man_days')::INTEGER, + (_body ->> 'hours_per_day')::INTEGER, + COALESCE((_body ->> 'use_manual_progress')::BOOLEAN, FALSE), + COALESCE((_body ->> 'use_weighted_progress')::BOOLEAN, FALSE), + COALESCE((_body ->> 'use_time_progress')::BOOLEAN, FALSE), + _client_id) + RETURNING id INTO _project_id; + + -- register the project log + INSERT INTO project_logs (project_id, team_id, team_member_id, description) + VALUES (_project_id, _team_id, _team_member_id, _project_created_log) + RETURNING id INTO _project_created_log_id; + + -- add the team member in the project as a user + INSERT INTO project_members (project_id, team_member_id, project_access_level_id) + VALUES (_project_id, _team_member_id, + (SELECT id FROM project_access_levels WHERE key = 'MEMBER')); + + -- register the project log + INSERT INTO project_logs (project_id, team_id, team_member_id, description) + VALUES (_project_id, _team_id, _team_member_id, _project_member_added_log); + + -- insert default project columns + PERFORM insert_task_list_columns(_project_id); + + -- add project manager role if exists + IF NOT is_null_or_empty(_project_manager_team_member_id) THEN + PERFORM update_project_manager(_project_manager_team_member_id, _project_id); + END IF; + + RETURN JSON_BUILD_OBJECT( + 'id', _project_id, + 'name', _project_name, + 'project_created_log_id', _project_created_log_id + ); +END; +$$; + +-- 4. Update the getById function to include the new fields in the response +CREATE OR REPLACE FUNCTION getProjectById(_project_id UUID, _team_id UUID) RETURNS JSON + LANGUAGE plpgsql +AS +$$ +DECLARE + _result JSON; +BEGIN + SELECT ROW_TO_JSON(rec) INTO _result + FROM (SELECT p.id, + p.name, + p.key, + p.color_code, + p.start_date, + p.end_date, + c.name AS client_name, + c.id AS client_id, + p.notes, + p.created_at, + p.updated_at, + ts.name AS status, + ts.color_code AS status_color, + ts.icon AS status_icon, + ts.id AS status_id, + h.name AS health, + h.color_code AS health_color, + h.icon AS health_icon, + h.id AS health_id, + pc.name AS category_name, + pc.color_code AS category_color, + pc.id AS category_id, + p.phase_label, + p.estimated_man_days AS man_days, + p.estimated_working_days AS working_days, + p.hours_per_day, + p.use_manual_progress, + p.use_weighted_progress, + -- Additional fields + COALESCE((SELECT ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))) + FROM (SELECT pm.id, + pm.project_id, + tm.id AS team_member_id, + tm.user_id, + u.name, + u.email, + u.avatar_url, + u.phone_number, + pal.name AS access_level, + pal.key AS access_level_key, + pal.id AS access_level_id, + EXISTS(SELECT 1 + FROM project_members + INNER JOIN project_access_levels ON + project_members.project_access_level_id = project_access_levels.id + WHERE project_id = p.id + AND project_access_levels.key = 'PROJECT_MANAGER' + AND team_member_id = tm.id) AS is_project_manager + FROM project_members pm + INNER JOIN team_members tm ON pm.team_member_id = tm.id + INNER JOIN users u ON tm.user_id = u.id + INNER JOIN project_access_levels pal ON pm.project_access_level_id = pal.id + WHERE pm.project_id = p.id) t), '[]'::JSON) AS members, + (SELECT COUNT(DISTINCT (id)) + FROM tasks + WHERE archived IS FALSE + AND project_id = p.id) AS task_count, + (SELECT ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))) + FROM (SELECT project_members.id, + project_members.project_id, + team_members.id AS team_member_id, + team_members.user_id, + users.name, + users.email, + users.avatar_url, + project_access_levels.name AS access_level, + project_access_levels.key AS access_level_key, + project_access_levels.id AS access_level_id + FROM project_members + INNER JOIN team_members ON project_members.team_member_id = team_members.id + INNER JOIN users ON team_members.user_id = users.id + INNER JOIN project_access_levels + ON project_members.project_access_level_id = project_access_levels.id + WHERE project_id = p.id + AND project_access_levels.key = 'PROJECT_MANAGER' + LIMIT 1) t) AS project_manager, + + (SELECT EXISTS(SELECT 1 + FROM project_subscribers + WHERE project_id = p.id + AND user_id = (SELECT user_id + FROM project_members + WHERE team_member_id = (SELECT id + FROM team_members + WHERE user_id IN + (SELECT user_id FROM is_member_of_project_cte)) + AND project_id = p.id))) AS subscribed, + (SELECT name + FROM users + WHERE id = + (SELECT owner_id FROM projects WHERE id = p.id)) AS project_owner, + (SELECT default_view + FROM project_members + WHERE project_id = p.id + AND team_member_id IN (SELECT id FROM is_member_of_project_cte)) AS team_member_default_view, + (SELECT EXISTS(SELECT user_id + FROM archived_projects + WHERE user_id IN (SELECT user_id FROM is_member_of_project_cte) + AND project_id = p.id)) AS archived, + + (SELECT EXISTS(SELECT user_id + FROM favorite_projects + WHERE user_id IN (SELECT user_id FROM is_member_of_project_cte) + AND project_id = p.id)) AS favorite + + FROM projects p + LEFT JOIN sys_project_statuses ts ON p.status_id = ts.id + LEFT JOIN sys_project_healths h ON p.health_id = h.id + LEFT JOIN project_categories pc ON p.category_id = pc.id + LEFT JOIN clients c ON p.client_id = c.id, + LATERAL (SELECT id, user_id + FROM team_members + WHERE id = (SELECT team_member_id + FROM project_members + WHERE project_id = p.id + AND team_member_id IN (SELECT id + FROM team_members + WHERE team_id = _team_id) + LIMIT 1)) is_member_of_project_cte + + WHERE p.id = _project_id + AND p.team_id = _team_id) rec; + + RETURN _result; +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, +ADD COLUMN IF NOT EXISTS use_weighted_progress BOOLEAN DEFAULT FALSE, +ADD COLUMN IF NOT EXISTS use_time_progress BOOLEAN DEFAULT FALSE; + +COMMIT; \ No newline at end of file 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/projects-controller.ts b/worklenz-backend/src/controllers/projects-controller.ts index e739bfb1..9a2f2d74 100644 --- a/worklenz-backend/src/controllers/projects-controller.ts +++ b/worklenz-backend/src/controllers/projects-controller.ts @@ -408,6 +408,9 @@ export default class ProjectsController extends WorklenzControllerBase { sps.color_code AS status_color, sps.icon AS status_icon, (SELECT name FROM clients WHERE id = projects.client_id) AS client_name, + projects.use_manual_progress, + projects.use_weighted_progress, + projects.use_time_progress, (SELECT COALESCE(ROW_TO_JSON(pm), '{}'::JSON) FROM (SELECT team_member_id AS id, 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 new file mode 100644 index 00000000..ac550fd7 --- /dev/null +++ b/worklenz-backend/src/socket.io/commands/on-update-task-progress.ts @@ -0,0 +1,105 @@ +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; + progress_value: number; + parent_task_id: string | null; +} + +export async function on_update_task_progress(io: any, socket: Socket, data: string) { + try { + log(socket.id, `${SocketEvents.UPDATE_TASK_PROGRESS}: ${data}`); + + 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 + SET progress_value = $1, manual_progress = true, updated_at = NOW() + WHERE id = $2`, + [progress_value, task_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 + io.to(projectId).emit( + SocketEvents.TASK_PROGRESS_UPDATED.toString(), + { + task_id, + progress_value + } + ); + + 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( + "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 + notifyProjectUpdates(socket, task_id); + } + } catch (error) { + log_error(error); + } +} \ No newline at end of file diff --git a/worklenz-backend/src/socket.io/commands/on-update-task-weight.ts b/worklenz-backend/src/socket.io/commands/on-update-task-weight.ts new file mode 100644 index 00000000..e6a68d1d --- /dev/null +++ b/worklenz-backend/src/socket.io/commands/on-update-task-weight.ts @@ -0,0 +1,81 @@ +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; + weight: number; + parent_task_id: string | null; +} + +export async function on_update_task_weight(io: any, socket: Socket, data: string) { + try { + + const parsedData = JSON.parse(data) as UpdateTaskWeightData; + const { task_id, weight, parent_task_id } = parsedData; + + if (!task_id || weight === undefined) { + 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 + SET weight = $1, updated_at = NOW() + WHERE id = $2`, + [weight, task_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 + io.to(projectId).emit( + SocketEvents.TASK_PROGRESS_UPDATED.toString(), + { + task_id, + weight + } + ); + + // If this is a subtask, update the parent task's progress + if (parent_task_id) { + const progressRatio = await db.query( + "SELECT get_task_complete_ratio($1) as ratio", + [parent_task_id] + ); + + // 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 + notifyProjectUpdates(socket, task_id); + } + } catch (error) { + log_error(error); + } +} \ No newline at end of file diff --git a/worklenz-backend/src/socket.io/events.ts b/worklenz-backend/src/socket.io/events.ts index 398ff030..c59b0eff 100644 --- a/worklenz-backend/src/socket.io/events.ts +++ b/worklenz-backend/src/socket.io/events.ts @@ -57,4 +57,10 @@ export enum SocketEvents { TASK_ASSIGNEES_CHANGE, TASK_CUSTOM_COLUMN_UPDATE, CUSTOM_COLUMN_PINNED_CHANGE, + TEAM_MEMBER_ROLE_CHANGE, + + // Task progress events + UPDATE_TASK_PROGRESS, + UPDATE_TASK_WEIGHT, + TASK_PROGRESS_UPDATED, } diff --git a/worklenz-backend/src/socket.io/index.ts b/worklenz-backend/src/socket.io/index.ts index 29c4b147..b77a68ea 100644 --- a/worklenz-backend/src/socket.io/index.ts +++ b/worklenz-backend/src/socket.io/index.ts @@ -52,6 +52,8 @@ import { on_task_recurring_change } from "./commands/on-task-recurring-change"; import { on_task_assignees_change } from "./commands/on-task-assignees-change"; import { on_task_custom_column_update } from "./commands/on_custom_column_update"; import { on_custom_column_pinned_change } from "./commands/on_custom_column_pinned_change"; +import { on_update_task_progress } from "./commands/on-update-task-progress"; +import { on_update_task_weight } from "./commands/on-update-task-weight"; export function register(io: any, socket: Socket) { log(socket.id, "client registered"); @@ -106,6 +108,8 @@ export function register(io: any, socket: Socket) { socket.on(SocketEvents.TASK_ASSIGNEES_CHANGE.toString(), data => on_task_assignees_change(io, socket, data)); socket.on(SocketEvents.TASK_CUSTOM_COLUMN_UPDATE.toString(), data => on_task_custom_column_update(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_WEIGHT.toString(), data => on_update_task_weight(io, socket, data)); // socket.io built-in event socket.on("disconnect", (reason) => on_disconnect(io, socket, reason)); diff --git a/worklenz-frontend/public/locales/en/project-drawer.json b/worklenz-frontend/public/locales/en/project-drawer.json index d72138d6..c9d89238 100644 --- a/worklenz-frontend/public/locales/en/project-drawer.json +++ b/worklenz-frontend/public/locales/en/project-drawer.json @@ -38,5 +38,14 @@ "createClient": "Create client", "searchInputPlaceholder": "Search by name or email", "hoursPerDayValidationMessage": "Hours per day must be a number between 1 and 24", - "noPermission": "No permission" + "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", + "manualProgressTooltip": "Allow manual progress updates for tasks without subtasks", + "weightedProgress": "Weighted Progress", + "weightedProgressTooltip": "Calculate progress based on subtask weights", + "timeProgress": "Time-based Progress", + "timeProgressTooltip": "Calculate progress based on estimated time" } diff --git a/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json index 003fa112..d957b891 100644 --- a/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json @@ -22,7 +22,15 @@ "hide-start-date": "Hide Start Date", "show-start-date": "Show Start Date", "hours": "Hours", - "minutes": "Minutes" + "minutes": "Minutes", + "progressValue": "Progress Value", + "progressValueTooltip": "Set the progress percentage (0-100%)", + "progressValueRequired": "Please enter a progress value", + "progressValueRange": "Progress must be between 0 and 100", + "taskWeight": "Task Weight", + "taskWeightTooltip": "Set the weight of this subtask (percentage)", + "taskWeightRequired": "Please enter a task weight", + "taskWeightRange": "Weight must be between 0 and 100" }, "labels": { "labelInputPlaceholder": "Search or create", diff --git a/worklenz-frontend/public/locales/es/project-drawer.json b/worklenz-frontend/public/locales/es/project-drawer.json index 2dc114cc..abe5a856 100644 --- a/worklenz-frontend/public/locales/es/project-drawer.json +++ b/worklenz-frontend/public/locales/es/project-drawer.json @@ -38,5 +38,14 @@ "createClient": "Crear cliente", "searchInputPlaceholder": "Busca por nombre o email", "hoursPerDayValidationMessage": "Las horas por día deben ser un número entre 1 y 24", - "noPermission": "Sin permiso" + "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", + "manualProgressTooltip": "Permitir actualizaciones manuales de progreso para tareas sin subtareas", + "weightedProgress": "Progreso Ponderado", + "weightedProgressTooltip": "Calcular el progreso basado en los pesos de las subtareas", + "timeProgress": "Progreso Basado en Tiempo", + "timeProgressTooltip": "Calcular el progreso basado en el tiempo estimado" } diff --git a/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json index 387968e9..d61bfd47 100644 --- a/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json @@ -22,7 +22,15 @@ "hide-start-date": "Ocultar fecha de inicio", "show-start-date": "Mostrar fecha de inicio", "hours": "Horas", - "minutes": "Minutos" + "minutes": "Minutos", + "progressValue": "Valor de Progreso", + "progressValueTooltip": "Establecer el porcentaje de progreso (0-100%)", + "progressValueRequired": "Por favor, introduce un valor de progreso", + "progressValueRange": "El progreso debe estar entre 0 y 100", + "taskWeight": "Peso de la Tarea", + "taskWeightTooltip": "Establecer el peso de esta subtarea (porcentaje)", + "taskWeightRequired": "Por favor, introduce un peso para la tarea", + "taskWeightRange": "El peso debe estar entre 0 y 100" }, "labels": { "labelInputPlaceholder": "Buscar o crear", diff --git a/worklenz-frontend/public/locales/pt/project-drawer.json b/worklenz-frontend/public/locales/pt/project-drawer.json index 55022c4e..b7ff40be 100644 --- a/worklenz-frontend/public/locales/pt/project-drawer.json +++ b/worklenz-frontend/public/locales/pt/project-drawer.json @@ -38,5 +38,14 @@ "createClient": "Criar cliente", "searchInputPlaceholder": "Pesquise por nome ou email", "hoursPerDayValidationMessage": "As horas por dia devem ser um número entre 1 e 24", - "noPermission": "Sem permissão" + "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", + "manualProgressTooltip": "Permitir atualizações manuais de progresso para tarefas sem subtarefas", + "weightedProgress": "Progresso Ponderado", + "weightedProgressTooltip": "Calcular o progresso com base nos pesos das subtarefas", + "timeProgress": "Progresso Baseado em Tempo", + "timeProgressTooltip": "Calcular o progresso com base no tempo estimado" } diff --git a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json index d6e8fef6..0f0324c9 100644 --- a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json @@ -22,7 +22,15 @@ "hide-start-date": "Ocultar data de início", "show-start-date": "Mostrar data de início", "hours": "Horas", - "minutes": "Minutos" + "minutes": "Minutos", + "progressValue": "Valor de Progresso", + "progressValueTooltip": "Definir a porcentagem de progresso (0-100%)", + "progressValueRequired": "Por favor, insira um valor de progresso", + "progressValueRange": "O progresso deve estar entre 0 e 100", + "taskWeight": "Peso da Tarefa", + "taskWeightTooltip": "Definir o peso desta subtarefa (porcentagem)", + "taskWeightRequired": "Por favor, insira um peso para a tarefa", + "taskWeightRange": "O peso deve estar entre 0 e 100" }, "labels": { "labelInputPlaceholder": "Pesquisar ou criar", diff --git a/worklenz-frontend/src/api/projects/projects.api.service.ts b/worklenz-frontend/src/api/projects/projects.api.service.ts index a817e76e..0297dd22 100644 --- a/worklenz-frontend/src/api/projects/projects.api.service.ts +++ b/worklenz-frontend/src/api/projects/projects.api.service.ts @@ -10,6 +10,11 @@ import { IProjectManager } from '@/types/project/projectManager.types'; const rootUrl = `${API_BASE_URL}/projects`; +interface UpdateProjectPayload { + id: string; + [key: string]: any; +} + export const projectsApiService = { getProjects: async ( index: number, @@ -78,13 +83,11 @@ export const projectsApiService = { return response.data; }, - updateProject: async ( - id: string, - project: IProjectViewModel - ): Promise> => { + updateProject: async (payload: UpdateProjectPayload): Promise> => { + const { id, ...data } = payload; const q = toQueryString({ current_project_id: id }); - const url = `${rootUrl}/${id}${q}`; - const response = await apiClient.put>(`${url}`, project); + const url = `${API_BASE_URL}/projects/${id}${q}`; + const response = await apiClient.patch>(url, data); return response.data; }, 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 35732ac3..f5b5cb98 100644 --- a/worklenz-frontend/src/components/projects/project-drawer/project-drawer.tsx +++ b/worklenz-frontend/src/components/projects/project-drawer/project-drawer.tsx @@ -14,6 +14,7 @@ import { Popconfirm, Skeleton, Space, + Switch, Tooltip, Typography, } from 'antd'; @@ -46,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'; @@ -60,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( @@ -96,6 +101,9 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => { working_days: project?.working_days || 0, man_days: project?.man_days || 0, hours_per_day: project?.hours_per_day || 8, + use_manual_progress: project?.use_manual_progress || false, + use_weighted_progress: project?.use_weighted_progress || false, + use_time_progress: project?.use_time_progress || false, }), [project, projectStatuses, projectHealths] ); @@ -155,6 +163,9 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => { man_days: parseInt(values.man_days), hours_per_day: parseInt(values.hours_per_day), project_manager: selectedProjectManager, + use_manual_progress: values.use_manual_progress || false, + use_weighted_progress: values.use_weighted_progress || false, + use_time_progress: values.use_time_progress || false, }; const action = @@ -169,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 @@ -184,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; } @@ -213,7 +235,16 @@ 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, }); setSelectedProjectManager(project.project_manager || null); setLoading(false); @@ -284,6 +315,49 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => { setIsFormValid(isValid); }; + // Progress calculation method handlers + const handleManualProgressChange = (checked: boolean) => { + if (checked) { + form.setFieldsValue({ + use_manual_progress: true, + use_weighted_progress: false, + use_time_progress: false, + }); + } else { + form.setFieldsValue({ + use_manual_progress: false, + }); + } + }; + + const handleWeightedProgressChange = (checked: boolean) => { + if (checked) { + form.setFieldsValue({ + use_manual_progress: false, + use_weighted_progress: true, + use_time_progress: false, + }); + } else { + form.setFieldsValue({ + use_weighted_progress: false, + }); + } + }; + + const handleTimeProgressChange = (checked: boolean) => { + if (checked) { + form.setFieldsValue({ + use_manual_progress: false, + use_weighted_progress: false, + use_time_progress: true, + }); + } else { + form.setFieldsValue({ + use_time_progress: false, + }); + } + }; + return ( void }) => { } > {!isEditable && ( - + )}
void }) => { - + { + onChange={date => { const endDate = form.getFieldValue('end_date'); if (date && endDate) { const days = calculateWorkingDays(date, endDate); @@ -411,14 +477,11 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => { }} /> - + { + onChange={date => { const startDate = form.getFieldValue('start_date'); if (startDate && date) { const days = calculateWorkingDays(startDate, date); @@ -429,22 +492,51 @@ 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')} + + + {t('manualProgress')} + + - ) + ); }; diff --git a/worklenz-frontend/src/shared/socket-events.ts b/worklenz-frontend/src/shared/socket-events.ts index 8caf509d..f1b71d2d 100644 --- a/worklenz-frontend/src/shared/socket-events.ts +++ b/worklenz-frontend/src/shared/socket-events.ts @@ -58,4 +58,9 @@ export enum SocketEvents { TASK_CUSTOM_COLUMN_UPDATE, CUSTOM_COLUMN_PINNED_CHANGE, TEAM_MEMBER_ROLE_CHANGE, + + // Task progress events + UPDATE_TASK_PROGRESS, + UPDATE_TASK_WEIGHT, + TASK_PROGRESS_UPDATED, } diff --git a/worklenz-frontend/src/types/project/project-view-model.types.ts b/worklenz-frontend/src/types/project/project-view-model.types.ts new file mode 100644 index 00000000..715f3b81 --- /dev/null +++ b/worklenz-frontend/src/types/project/project-view-model.types.ts @@ -0,0 +1,11 @@ +export interface IProjectViewModel { + id: string; + name: string; + description: string; + team_id: string; + created_at: string; + updated_at: string; + use_manual_progress: boolean; + use_weighted_progress: boolean; + use_time_progress: boolean; +} \ No newline at end of file diff --git a/worklenz-frontend/src/types/project/projectViewModel.types.ts b/worklenz-frontend/src/types/project/projectViewModel.types.ts index 9da91c69..a35d59b3 100644 --- a/worklenz-frontend/src/types/project/projectViewModel.types.ts +++ b/worklenz-frontend/src/types/project/projectViewModel.types.ts @@ -41,4 +41,28 @@ export interface IProjectViewModel extends IProject { team_member_default_view?: string; working_days?: number; + + id?: string; + name?: string; + description?: string; + notes?: string; + color_code?: string; + status_id?: string; + status_name?: string; + status_color_dark?: string; + health_name?: string; + health_color?: string; + health_color_dark?: string; + category_color_dark?: string; + client_id?: string; + total_tasks?: number; + completed_tasks?: number; + tasks_progress?: number; + man_days?: number; + hours_per_day?: number; + default_view?: string; + task_key_prefix?: string; + use_manual_progress?: boolean; + use_weighted_progress?: boolean; + use_time_progress?: boolean; } 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 3f804921..9c5da9bf 100644 --- a/worklenz-frontend/src/types/tasks/task.types.ts +++ b/worklenz-frontend/src/types/tasks/task.types.ts @@ -17,42 +17,28 @@ export interface ITaskAssignee { } export interface ITask { - assignees?: ITaskAssignee[] | string[]; - assignees_ids?: any[]; - description?: string; - done?: boolean; - end?: string | Date; - end_date?: string | Date; - id?: string; - name?: string; - resize_valid?: boolean; - start?: string | Date; - start_date?: string | Date; - _start?: Date; - _end?: Date; - color_code?: string; - priority?: string; - priority_id?: string; - status?: string; - status_id?: string; - project_id?: string; - reporter_id?: string; - created_at?: string; - updated_at?: string; - show_handles?: boolean; - min?: number; - max?: number; - total_hours?: number; - total_minutes?: number; - name_color?: string; - sub_tasks_count?: number; - is_sub_task?: boolean; - parent_task_name?: string; - parent_task_id?: string; - show_sub_tasks?: boolean; - sub_tasks?: ISubTask[]; - archived?: boolean; - subscribers?: IUser[]; + id: string; + name: string; + description: string; + status_id: string; + priority: string; + start_date: string; + end_date: string; + total_hours: number; + total_minutes: number; + billable: boolean; + phase_id: string; + parent_task_id: string | null; + project_id: string; + team_id: string; + task_key: string; + labels: string[]; + assignees: string[]; + names: string[]; + sub_tasks_count: number; + manual_progress: boolean; + progress_value: number | null; + weight: number | null; } export interface IProjectMemberViewModel extends IProjectMember { @@ -65,24 +51,19 @@ export interface IProjectMemberViewModel extends IProjectMember { } export interface ITaskViewModel extends ITask { - task_key?: string; created_from_now?: string; updated_from_now?: string; reporter?: string; - start_date?: string; - end_date?: string; - sub_tasks_count?: number; is_sub_task?: boolean; status_color?: string; status_color_dark?: string; attachments_count?: number; complete_ratio?: number; - names?: InlineMember[]; - labels?: ITaskLabel[]; + assignee_names?: InlineMember[]; + task_labels?: ITaskLabel[]; timer_start_time?: number; - phase_id?: string; - billable?: boolean; recurring?: boolean; + task_level?: number; } export interface ITaskTeamMember extends ITeamMember {