From f7582173eded82df8be1c1b992dc6045e8107462 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Tue, 29 Apr 2025 17:04:36 +0530 Subject: [PATCH 01/70] Implement manual and weighted progress features for tasks - Added SQL migrations to support manual progress and weighted progress calculations in tasks. - Updated the `get_task_complete_ratio` function to consider manual progress and subtask weights. - Enhanced the project model to include flags for manual, weighted, and time-based progress. - Integrated new progress settings in the project drawer and task drawer components. - Implemented socket events for real-time updates on task progress and weight changes. - Updated frontend localization files to include new progress-related terms and tooltips. --- .../20250422132400-manual-task-progress.sql | 77 +++ ...20250423000000-subtask-manual-progress.sql | 524 ++++++++++++++++++ .../src/controllers/projects-controller.ts | 3 + .../commands/on-update-task-progress.ts | 68 +++ .../commands/on-update-task-weight.ts | 68 +++ worklenz-backend/src/socket.io/events.ts | 6 + worklenz-backend/src/socket.io/index.ts | 4 + .../public/locales/en/project-drawer.json | 9 +- .../locales/en/task-drawer/task-drawer.json | 10 +- .../public/locales/es/project-drawer.json | 9 +- .../locales/es/task-drawer/task-drawer.json | 10 +- .../public/locales/pt/project-drawer.json | 9 +- .../locales/pt/task-drawer/task-drawer.json | 10 +- .../src/api/projects/projects.api.service.ts | 15 +- .../project-drawer/project-drawer.tsx | 120 +++- .../task-drawer-progress.tsx | 155 ++++++ .../shared/info-tab/task-details-form.tsx | 13 +- .../src/features/project/project.slice.ts | 3 +- .../src/features/projects/projects.slice.ts | 42 ++ .../task-group-wrapper/task-group-wrapper.tsx | 35 ++ worklenz-frontend/src/shared/socket-events.ts | 5 + .../types/project/project-view-model.types.ts | 11 + .../types/project/projectViewModel.types.ts | 24 + .../src/types/tasks/task.types.ts | 68 +-- 24 files changed, 1230 insertions(+), 68 deletions(-) create mode 100644 worklenz-backend/database/migrations/20250422132400-manual-task-progress.sql create mode 100644 worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql create mode 100644 worklenz-backend/src/socket.io/commands/on-update-task-progress.ts create mode 100644 worklenz-backend/src/socket.io/commands/on-update-task-weight.ts create mode 100644 worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx create mode 100644 worklenz-frontend/src/features/projects/projects.slice.ts create mode 100644 worklenz-frontend/src/types/project/project-view-model.types.ts 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..3eea7a2b --- /dev/null +++ b/worklenz-backend/database/migrations/20250422132400-manual-task-progress.sql @@ -0,0 +1,77 @@ +-- 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; + +-- 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..022f5061 --- /dev/null +++ b/worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql @@ -0,0 +1,524 @@ +-- 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 +$$; + +-- 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/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/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..4d399530 --- /dev/null +++ b/worklenz-backend/src/socket.io/commands/on-update-task-progress.ts @@ -0,0 +1,68 @@ +import { Socket } from "socket.io"; +import db from "../../config/db"; +import { SocketEvents } from "../events"; +import { log, log_error, notifyProjectUpdates } from "../util"; + +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; + + if (!task_id || progress_value === undefined) { + return; + } + + // 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] + ); + + // Get the project ID for the task + const projectResult = await db.query("SELECT project_id FROM tasks WHERE id = $1", [task_id]); + const projectId = projectResult.rows[0]?.project_id; + + 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 + } + ); + + // 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/commands/on-update-task-weight.ts b/worklenz-backend/src/socket.io/commands/on-update-task-weight.ts new file mode 100644 index 00000000..146aa3ec --- /dev/null +++ b/worklenz-backend/src/socket.io/commands/on-update-task-weight.ts @@ -0,0 +1,68 @@ +import { Socket } from "socket.io"; +import db from "../../config/db"; +import { SocketEvents } from "../events"; +import { log, log_error, notifyProjectUpdates } from "../util"; + +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 { + log(socket.id, `${SocketEvents.UPDATE_TASK_WEIGHT}: ${data}`); + + const parsedData = JSON.parse(data) as UpdateTaskWeightData; + const { task_id, weight, parent_task_id } = parsedData; + + if (!task_id || weight === undefined) { + return; + } + + // Update the task weight in the database + await db.query( + `UPDATE tasks + SET weight = $1, updated_at = NOW() + WHERE id = $2`, + [weight, task_id] + ); + + // Get the project ID for the task + const projectResult = await db.query("SELECT project_id FROM tasks WHERE id = $1", [task_id]); + const projectId = projectResult.rows[0]?.project_id; + + 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..53d4ea7e 100644 --- a/worklenz-frontend/public/locales/en/project-drawer.json +++ b/worklenz-frontend/public/locales/en/project-drawer.json @@ -38,5 +38,12 @@ "createClient": "Create client", "searchInputPlaceholder": "Search by name or email", "hoursPerDayValidationMessage": "Hours per day must be a number between 1 and 24", - "noPermission": "No permission" + "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..411d6f69 100644 --- a/worklenz-frontend/public/locales/es/project-drawer.json +++ b/worklenz-frontend/public/locales/es/project-drawer.json @@ -38,5 +38,12 @@ "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" + "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..471f8ed5 100644 --- a/worklenz-frontend/public/locales/pt/project-drawer.json +++ b/worklenz-frontend/public/locales/pt/project-drawer.json @@ -38,5 +38,12 @@ "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" + "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..27d24cb3 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'; @@ -96,6 +97,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 +159,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 = @@ -214,6 +221,9 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => { 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, + 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 +294,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 }) => { - {/* - - */} + + void }) => { > + + {t('progressSettings')} + + + {t('manualProgress')} + + - ) + ); }; diff --git a/worklenz-frontend/src/types/tasks/task-activity-logs-get-request.ts b/worklenz-frontend/src/types/tasks/task-activity-logs-get-request.ts index f6cb37b3..3464ce82 100644 --- a/worklenz-frontend/src/types/tasks/task-activity-logs-get-request.ts +++ b/worklenz-frontend/src/types/tasks/task-activity-logs-get-request.ts @@ -31,6 +31,8 @@ export enum IActivityLogAttributeTypes { ATTACHMENT = 'attachment', COMMENT = 'comment', ARCHIVE = 'archive', + PROGRESS = 'progress', + WEIGHT = 'weight', } export interface IActivityLog { diff --git a/worklenz-frontend/src/types/tasks/task.types.ts b/worklenz-frontend/src/types/tasks/task.types.ts index 621bd6ad..9c5da9bf 100644 --- a/worklenz-frontend/src/types/tasks/task.types.ts +++ b/worklenz-frontend/src/types/tasks/task.types.ts @@ -63,6 +63,7 @@ export interface ITaskViewModel extends ITask { task_labels?: ITaskLabel[]; timer_start_time?: number; recurring?: boolean; + task_level?: number; } export interface ITaskTeamMember extends ITeamMember { From 0c5eff7121c027efac321e323e9605e4c225d7f4 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Wed, 30 Apr 2025 15:47:28 +0530 Subject: [PATCH 04/70] Refactor task progress update logic in socket command - Improved error logging for manual progress updates on parent tasks. - Cleaned up console log statements and replaced them with a logging function for consistency. - Fixed SQL query to remove unnecessary team_id selection, streamlining the data retrieval process. --- .../commands/on-update-task-progress.ts | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/worklenz-backend/src/socket.io/commands/on-update-task-progress.ts b/worklenz-backend/src/socket.io/commands/on-update-task-progress.ts index ac550fd7..90d3ca3a 100644 --- a/worklenz-backend/src/socket.io/commands/on-update-task-progress.ts +++ b/worklenz-backend/src/socket.io/commands/on-update-task-progress.ts @@ -11,13 +11,9 @@ interface UpdateTaskProgressData { } export async function on_update_task_progress(io: any, socket: Socket, data: string) { - try { - log(socket.id, `${SocketEvents.UPDATE_TASK_PROGRESS}: ${data}`); - + try { 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}`); + const { task_id, progress_value, parent_task_id } = parsedData; if (!task_id || progress_value === undefined) { return; @@ -33,22 +29,19 @@ export async function on_update_task_progress(io: any, socket: Socket, data: str // 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`); + log_error(`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", + "SELECT progress_value, project_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 From 31ac18410757561586ae10209606ddfdf4b0e18b Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Fri, 2 May 2025 07:04:49 +0530 Subject: [PATCH 05/70] Refactor project log insertion in SQL migration - Removed team_member_id from project_logs insert statements. --- .../migrations/20250423000000-subtask-manual-progress.sql | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql b/worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql index b43b8a75..91f6f639 100644 --- a/worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql +++ b/worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql @@ -350,8 +350,8 @@ BEGIN RETURNING id INTO _project_id; -- register the project log - INSERT INTO project_logs (project_id, team_id, team_member_id, description) - VALUES (_project_id, _team_id, _team_member_id, _project_created_log) + INSERT INTO project_logs (project_id, team_id, description) + VALUES (_project_id, _team_id, _project_created_log) RETURNING id INTO _project_created_log_id; -- add the team member in the project as a user @@ -360,8 +360,8 @@ BEGIN (SELECT id FROM project_access_levels WHERE key = 'MEMBER')); -- register the project log - INSERT INTO project_logs (project_id, team_id, team_member_id, description) - VALUES (_project_id, _team_id, _team_member_id, _project_member_added_log); + INSERT INTO project_logs (project_id, team_id, description) + VALUES (_project_id, _team_id, _project_member_added_log); -- insert default project columns PERFORM insert_task_list_columns(_project_id); From 8f913b0f4e89e7b04a92c667bfb750c2a962a949 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Fri, 2 May 2025 07:37:40 +0530 Subject: [PATCH 06/70] Add task progress tracking methods documentation and enhance progress update logic - Introduced a new markdown file detailing task progress tracking methods: manual, weighted, and time-based. - Updated backend logic to include complete ratio calculations for tasks. - Improved socket command for task progress updates, enabling recursive updates for ancestor tasks. - Enhanced frontend components to reflect progress changes based on the selected tracking method, including updates to task display and progress input handling. - Added support for manual progress flag in task model to facilitate accurate progress representation. --- task-progress-methods.md | 244 ++++++++++++++++++ .../src/controllers/tasks-controller-v2.ts | 1 + .../commands/on-update-task-progress.ts | 67 +++-- .../src/features/tasks/tasks.slice.ts | 29 ++- .../task-list-progress-cell.tsx | 44 +++- .../project/projectTasksViewModel.types.ts | 1 + 6 files changed, 355 insertions(+), 31 deletions(-) create mode 100644 task-progress-methods.md diff --git a/task-progress-methods.md b/task-progress-methods.md new file mode 100644 index 00000000..11b18ef5 --- /dev/null +++ b/task-progress-methods.md @@ -0,0 +1,244 @@ +# Task Progress Tracking Methods in WorkLenz + +## Overview +WorkLenz supports three different methods for tracking task progress, each suitable for different project management approaches: + +1. **Manual Progress** - Direct input of progress percentages +2. **Weighted Progress** - Tasks have weights that affect overall progress calculation +3. **Time-based Progress** - Progress calculated based on estimated time vs. time spent + +These modes can be selected when creating or editing a project in the project drawer. + +## 1. Manual Progress Mode + +This mode allows direct input of progress percentages for individual tasks without subtasks. + +**Implementation:** +- Enabled by setting `use_manual_progress` to true in the project settings +- Progress is updated through the `on-update-task-progress.ts` socket event handler +- The UI shows a manual progress input slider in the task drawer for tasks without subtasks +- Updates the database with `progress_value` and sets `manual_progress` flag to true + +**Calculation Logic:** +- For tasks without subtasks: Uses the manually set progress value +- For parent tasks: Calculates the average of all subtask progress values +- Subtask progress comes from either manual values or completion status (0% or 100%) + +**Code Example:** +```typescript +// Manual progress update via socket.io +socket?.emit(SocketEvents.UPDATE_TASK_PROGRESS.toString(), JSON.stringify({ + task_id: task.id, + progress_value: value, + parent_task_id: task.parent_task_id +})); +``` + +### Showing Progress in Subtask Rows + +When manual progress is enabled in a project, progress is shown in the following ways: + +1. **In Task List Views**: + - Subtasks display their individual progress values in the progress column + - Parent tasks display the calculated average progress of all subtasks + +2. **Implementation Details**: + - The progress values are stored in the `progress_value` column in the database + - For subtasks with manual progress set, the value is shown directly + - For subtasks without manual progress, the completion status determines the value (0% or 100%) + - The task view model includes both `progress` and `complete_ratio` properties + +**Relevant Components:** +```typescript +// From task-list-progress-cell.tsx +const TaskListProgressCell = ({ task }: TaskListProgressCellProps) => { + return task.is_sub_task ? null : ( + + + + ); +}; +``` + +**Task Progress Calculation in Backend:** +```typescript +// From tasks-controller-base.ts +// For tasks without subtasks, respect manual progress if set +if (task.manual_progress === true && task.progress_value !== null) { + // For manually set progress, use that value directly + task.progress = parseInt(task.progress_value); + task.complete_ratio = parseInt(task.progress_value); +} +``` + +## 2. Weighted Progress Mode + +This mode allows assigning different weights to subtasks to reflect their relative importance in the overall task or project progress. + +**Implementation:** +- Enabled by setting `use_weighted_progress` to true in the project settings +- Weights are updated through the `on-update-task-weight.ts` socket event handler +- The UI shows a weight input for subtasks in the task drawer +- Default weight is 100 if not specified + +**Calculation Logic:** +- Progress is calculated using a weighted average: `SUM(progress_value * weight) / SUM(weight)` +- This gives more influence to tasks with higher weights +- A parent task's progress is the weighted average of its subtasks' progress + +**Code Example:** +```typescript +// Weight update via socket.io +socket?.emit(SocketEvents.UPDATE_TASK_WEIGHT.toString(), JSON.stringify({ + task_id: task.id, + weight: value, + parent_task_id: task.parent_task_id +})); +``` + +## 3. Time-based Progress Mode + +This mode calculates progress based on estimated time vs. actual time spent. + +**Implementation:** +- Enabled by setting `use_time_progress` to true in the project settings +- Uses task time estimates (hours and minutes) for calculation +- No separate socket handler needed as it's calculated automatically + +**Calculation Logic:** +- Progress is calculated using time as the weight: `SUM(progress_value * estimated_minutes) / SUM(estimated_minutes)` +- For tasks with time tracking, estimated vs. actual time can be factored in +- Parent task progress is weighted by the estimated time of each subtask + +**SQL Example:** +```sql +WITH subtask_progress AS ( + SELECT + CASE + WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN + progress_value + ELSE + CASE + WHEN EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = t.id + AND is_done IS TRUE + ) THEN 100 + ELSE 0 + END + END AS progress_value, + COALESCE(total_hours * 60 + total_minutes, 0) AS estimated_minutes + FROM tasks t + WHERE t.parent_task_id = _task_id + AND t.archived IS FALSE +) +SELECT COALESCE( + SUM(progress_value * estimated_minutes) / NULLIF(SUM(estimated_minutes), 0), + 0 +) +FROM subtask_progress +INTO _ratio; +``` + +## Default Progress Tracking (when no special mode is selected) + +If no specific progress mode is enabled, the system falls back to a traditional completion-based calculation: + +**Implementation:** +- Default mode when all three special modes are disabled +- Based on task completion status only + +**Calculation Logic:** +- For tasks without subtasks: 0% if not done, 100% if done +- For parent tasks: `(completed_tasks / total_tasks) * 100` +- Counts both the parent and all subtasks in the calculation + +**SQL Example:** +```sql +-- Traditional calculation based on completion status +SELECT (CASE + WHEN EXISTS(SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = _task_id + AND is_done IS TRUE) THEN 1 + ELSE 0 END) +INTO _parent_task_done; + +SELECT COUNT(*) +FROM tasks_with_status_view +WHERE parent_task_id = _task_id + AND is_done IS TRUE +INTO _sub_tasks_done; + +_total_completed = _parent_task_done + _sub_tasks_done; +_total_tasks = _sub_tasks_count + 1; -- +1 for the parent task + +IF _total_tasks = 0 THEN + _ratio = 0; +ELSE + _ratio = (_total_completed / _total_tasks) * 100; +END IF; +``` + +## Technical Implementation Details + +The progress calculation logic is implemented in PostgreSQL functions, primarily in the `get_task_complete_ratio` function. Progress updates flow through the system as follows: + +1. **User Action**: User updates task progress or weight in the UI +2. **Socket Event**: Client emits socket event (UPDATE_TASK_PROGRESS or UPDATE_TASK_WEIGHT) +3. **Server Handler**: Server processes the event in the respective handler function +4. **Database Update**: Progress/weight value is updated in the database +5. **Recalculation**: If needed, parent task progress is recalculated +6. **Broadcast**: Changes are broadcast to all clients in the project room +7. **UI Update**: Client UI updates to reflect the new progress values + +This architecture allows for real-time updates and consistent progress calculation across all clients. + +## Associated Files and Components + +### Backend Files + +1. **Socket Event Handlers**: + - `worklenz-backend/src/socket.io/commands/on-update-task-progress.ts` - Handles manual progress updates + - `worklenz-backend/src/socket.io/commands/on-update-task-weight.ts` - Handles task weight updates + +2. **Database Functions**: + - `worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql` - Contains the `get_task_complete_ratio` function that calculates progress based on the selected method + - Functions that support project creation/updates with progress mode settings: + - `create_project` + - `update_project` + +3. **Controllers**: + - `worklenz-backend/src/controllers/project-workload/workload-gannt-base.ts` - Contains the `calculateTaskCompleteRatio` method + - `worklenz-backend/src/controllers/projects-controller.ts` - Handles project-level progress calculations + - `worklenz-backend/src/controllers/tasks-controller-base.ts` - Handles task progress calculation and updates task view models + +### Frontend Files + +1. **Project Configuration**: + - `worklenz-frontend/src/components/projects/project-drawer/project-drawer.tsx` - Contains UI for selecting progress method when creating/editing projects + +2. **Progress Visualization Components**: + - `worklenz-frontend/src/components/project-list/project-list-table/project-list-progress/progress-list-progress.tsx` - Displays project progress + - `worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress.tsx` - Displays task progress + - `worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-progress-cell/task-list-progress-cell.tsx` - Alternative task progress cell + - `worklenz-frontend/src/components/task-list-common/task-row/task-row-progress/task-row-progress.tsx` - Displays progress in task rows + +3. **Progress Input Components**: + - `worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx` - Component for inputting task progress/weight + +## Choosing the Right Progress Method + +Each progress method is suitable for different types of projects: + +- **Manual Progress**: Best for creative work where progress is subjective +- **Weighted Progress**: Ideal for projects where some tasks are more significant than others +- **Time-based Progress**: Perfect for projects where time estimates are reliable and important + +Project managers can choose the appropriate method when creating or editing a project in the project drawer, based on their team's workflow and project requirements. \ No newline at end of file diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index 131be72a..e3992563 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -198,6 +198,7 @@ export default class TasksControllerV2 extends TasksControllerBase { (SELECT use_manual_progress FROM projects WHERE id = t.project_id) AS project_use_manual_progress, (SELECT use_weighted_progress FROM projects WHERE id = t.project_id) AS project_use_weighted_progress, (SELECT use_time_progress FROM projects WHERE id = t.project_id) AS project_use_time_progress, + (SELECT (get_task_complete_ratio(t.id)).ratio) AS complete_ratio, (SELECT phase_id FROM task_phase WHERE task_id = t.id) AS phase_id, (SELECT name diff --git a/worklenz-backend/src/socket.io/commands/on-update-task-progress.ts b/worklenz-backend/src/socket.io/commands/on-update-task-progress.ts index 90d3ca3a..ac8ebbdb 100644 --- a/worklenz-backend/src/socket.io/commands/on-update-task-progress.ts +++ b/worklenz-backend/src/socket.io/commands/on-update-task-progress.ts @@ -35,7 +35,7 @@ export async function on_update_task_progress(io: any, socket: Socket, data: str // Get the current progress value to log the change const currentProgressResult = await db.query( - "SELECT progress_value, project_id, FROM tasks WHERE id = $1", + "SELECT progress_value, project_id FROM tasks WHERE id = $1", [task_id] ); @@ -70,24 +70,8 @@ export async function on_update_task_progress(io: any, socket: Socket, data: str console.log(`Emitted progress update for task ${task_id} to project room ${projectId}`); - // If this is a subtask, update the parent task's progress - if (parent_task_id) { - const progressRatio = await db.query( - "SELECT get_task_complete_ratio($1) as ratio", - [parent_task_id] - ); - - console.log(`Updated parent task ${parent_task_id} progress: ${progressRatio?.rows[0]?.ratio}`); - - // Emit the parent task's updated progress - io.to(projectId).emit( - SocketEvents.TASK_PROGRESS_UPDATED.toString(), - { - task_id: parent_task_id, - progress_value: progressRatio?.rows[0]?.ratio - } - ); - } + // Recursively update all ancestors in the task hierarchy + await updateTaskAncestors(io, projectId, parent_task_id); // Notify that project updates are available notifyProjectUpdates(socket, task_id); @@ -95,4 +79,49 @@ export async function on_update_task_progress(io: any, socket: Socket, data: str } catch (error) { log_error(error); } +} + +/** + * Recursively updates all ancestor tasks' progress when a subtask changes + * @param io Socket.io instance + * @param projectId Project ID for room broadcasting + * @param taskId The task ID to update (starts with the parent task) + */ +async function updateTaskAncestors(io: any, projectId: string, taskId: string | null) { + if (!taskId) return; + + try { + // Get the current task's progress ratio + const progressRatio = await db.query( + "SELECT get_task_complete_ratio($1) as ratio", + [taskId] + ); + + const ratio = progressRatio?.rows[0]?.ratio; + console.log(`Updated task ${taskId} progress: ${ratio}`); + + // Emit the updated progress + io.to(projectId).emit( + SocketEvents.TASK_PROGRESS_UPDATED.toString(), + { + task_id: taskId, + progress_value: ratio + } + ); + + // Find this task's parent to continue the recursive update + const parentResult = await db.query( + "SELECT parent_task_id FROM tasks WHERE id = $1", + [taskId] + ); + + const parentTaskId = parentResult.rows[0]?.parent_task_id; + + // If there's a parent, recursively update it + if (parentTaskId) { + await updateTaskAncestors(io, projectId, parentTaskId); + } + } catch (error) { + log_error(`Error updating ancestor task ${taskId}: ${error}`); + } } \ No newline at end of file diff --git a/worklenz-frontend/src/features/tasks/tasks.slice.ts b/worklenz-frontend/src/features/tasks/tasks.slice.ts index dbc2f955..320a5cd1 100644 --- a/worklenz-frontend/src/features/tasks/tasks.slice.ts +++ b/worklenz-frontend/src/features/tasks/tasks.slice.ts @@ -572,14 +572,29 @@ const taskSlice = createSlice({ ) => { const { taskId, progress, totalTasksCount, completedCount } = action.payload; - for (const group of state.taskGroups) { - const task = group.tasks.find(task => task.id === taskId); - if (task) { - task.complete_ratio = progress; - task.total_tasks_count = totalTasksCount; - task.completed_count = completedCount; - break; + // Helper function to find and update a task at any nesting level + const findAndUpdateTask = (tasks: IProjectTask[]) => { + for (const task of tasks) { + if (task.id === taskId) { + task.complete_ratio = progress; + task.total_tasks_count = totalTasksCount; + task.completed_count = completedCount; + return true; + } + + // Check subtasks if they exist + if (task.sub_tasks && task.sub_tasks.length > 0) { + const found = findAndUpdateTask(task.sub_tasks); + if (found) return true; + } } + return false; + }; + + // Try to find and update the task in any task group + for (const group of state.taskGroups) { + const found = findAndUpdateTask(group.tasks); + if (found) break; } }, diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-progress-cell/task-list-progress-cell.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-progress-cell/task-list-progress-cell.tsx index 4589e5aa..1db3a56c 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-progress-cell/task-list-progress-cell.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-progress-cell/task-list-progress-cell.tsx @@ -1,20 +1,54 @@ +import React from 'react'; import { Progress, Tooltip } from 'antd'; import './task-list-progress-cell.css'; import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; +import { useAppSelector } from '@/hooks/useAppSelector'; type TaskListProgressCellProps = { task: IProjectTask; }; const TaskListProgressCell = ({ task }: TaskListProgressCellProps) => { - return task.is_sub_task ? null : ( - + const { project } = useAppSelector(state => state.projectReducer); + const isManualProgressEnabled = project?.use_manual_progress; + const isSubtask = task.is_sub_task; + const hasManualProgress = task.manual_progress; + + // Handle different cases: + // 1. For subtasks when manual progress is enabled, show the progress + // 2. For parent tasks, always show progress + // 3. For subtasks when manual progress is not enabled, don't show progress (null) + + if (isSubtask && !isManualProgressEnabled) { + return null; // Don't show progress for subtasks when manual progress is disabled + } + + // For parent tasks, show completion ratio with task count tooltip + if (!isSubtask) { + return ( + + = 100 ? 9 : 7} + /> + + ); + } + + // For subtasks with manual progress enabled, show the progress + return ( + = 100 ? 9 : 7} + strokeWidth={(task.progress || 0) >= 100 ? 9 : 7} /> ); diff --git a/worklenz-frontend/src/types/project/projectTasksViewModel.types.ts b/worklenz-frontend/src/types/project/projectTasksViewModel.types.ts index 94e93c4c..4ab36c27 100644 --- a/worklenz-frontend/src/types/project/projectTasksViewModel.types.ts +++ b/worklenz-frontend/src/types/project/projectTasksViewModel.types.ts @@ -16,6 +16,7 @@ export interface ITaskStatusCategory { } export interface IProjectTask { + manual_progress: any; due_time?: string; id?: string; name?: string; From a5b881c609071e707de98a51a193646599af0b65 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 2 May 2025 13:21:32 +0530 Subject: [PATCH 07/70] Enhance task progress calculation and UI handling - Updated task progress calculation logic to incorporate weights and time-based estimations for subtasks. - Improved SQL migrations to support new progress calculation methods and ensure accurate parent task updates. - Enhanced frontend components to conditionally display progress inputs based on task type and project settings. - Implemented socket events for real-time updates on subtask counts and progress changes, ensuring consistent UI behavior. - Added logging for progress updates and task state changes to improve debugging and user experience. --- docs/task-progress-guide-for-users.md | 14 +- docs/task-progress-methods.md | 32 +-- ...20250423000000-subtask-manual-progress.sql | 40 ++- ...50425000000-update-time-based-progress.sql | 221 ++++++++++++++ ...prove-parent-task-progress-calculation.sql | 272 ++++++++++++++++++ .../src/controllers/tasks-controller-v2.ts | 65 ++++- .../commands/on-get-task-subtasks-count.ts | 39 +++ .../commands/on-time-estimation-change.ts | 71 ++++- .../commands/on-update-task-progress.ts | 102 +++---- .../commands/on-update-task-weight.ts | 8 +- worklenz-backend/src/socket.io/events.ts | 4 + worklenz-backend/src/socket.io/index.ts | 2 + .../task-drawer-progress.tsx | 63 +++- .../shared/info-tab/task-details-form.tsx | 43 ++- .../taskList/project-view-task-list.tsx | 2 +- worklenz-frontend/src/shared/socket-events.ts | 4 + 16 files changed, 870 insertions(+), 112 deletions(-) create mode 100644 worklenz-backend/database/migrations/20250425000000-update-time-based-progress.sql create mode 100644 worklenz-backend/database/migrations/20250426000000-improve-parent-task-progress-calculation.sql create mode 100644 worklenz-backend/src/socket.io/commands/on-get-task-subtasks-count.ts diff --git a/docs/task-progress-guide-for-users.md b/docs/task-progress-guide-for-users.md index 081ca16e..350329fa 100644 --- a/docs/task-progress-guide-for-users.md +++ b/docs/task-progress-guide-for-users.md @@ -62,17 +62,17 @@ The parent task will show as 60% complete (average of 30%, 60%, and 90%). ### Example If you have a parent task with three subtasks: -- Subtask A: 50% complete, Weight 200 (critical task) -- Subtask B: 75% complete, Weight 100 (standard task) -- Subtask C: 25% complete, Weight 300 (major task) +- Subtask A: 50% complete, Weight 60% (important task) +- Subtask B: 75% complete, Weight 20% (less important task) +- Subtask C: 25% complete, Weight 100% (critical task) -The parent task will be approximately 42% complete, with Subtask C having the greatest impact due to its higher weight. +The parent task will be approximately 39% complete, with Subtask C having the greatest impact due to its higher weight. ### Important Notes About Weights -- Default weight is 100 if not specified -- You can set weights from 0 to any reasonable number (typically 1-1000) -- Setting a weight to 0 removes that task from progress calculations +- Default weight is 100% if not specified +- Weights range from 0% to 100% +- Setting a weight to 0% removes that task from progress calculations - Only explicitly set weights for tasks that should have different importance - Weights are only relevant for subtasks, not for independent tasks diff --git a/docs/task-progress-methods.md b/docs/task-progress-methods.md index 5a75ac95..b931b7f5 100644 --- a/docs/task-progress-methods.md +++ b/docs/task-progress-methods.md @@ -44,6 +44,7 @@ This mode allows assigning different weights to subtasks to reflect their relati - The UI shows a weight input for subtasks in the task drawer - Manual progress input is still required for tasks without subtasks - Default weight is 100 if not specified +- Weight values range from 0 to 100% **Calculation Logic:** - For tasks without subtasks: Uses the manually entered progress value @@ -224,10 +225,9 @@ In Weighted Progress mode, both the manual progress input and weight assignment - If a leaf task's progress is not manually set, it defaults to 0% (or 100% if completed) 2. **Weight Assignment**: - - Each task can be assigned a weight value (default 100 if not specified) + - Each task can be assigned a weight value between 0-100% (default 100% if not specified) - Higher weight values give tasks more influence in parent task progress calculations - - Weight values are typically whole numbers between 0 and 1000 - - A weight of 0 means the task doesn't contribute to the parent's progress calculation + - A weight of 0% means the task doesn't contribute to the parent's progress calculation 3. **Parent Task Calculation**: The weighted progress formula is: @@ -237,24 +237,24 @@ In Weighted Progress mode, both the manual progress input and weight assignment **Example Calculation**: Consider a parent task with three subtasks: - - Subtask A: Progress 50%, Weight 200 - - Subtask B: Progress 75%, Weight 100 - - Subtask C: Progress 25%, Weight 300 + - Subtask A: Progress 50%, Weight 60% + - Subtask B: Progress 75%, Weight 20% + - Subtask C: Progress 25%, Weight 100% Calculation: ``` - ParentProgress = ((50 * 200) + (75 * 100) + (25 * 300)) / (200 + 100 + 300) - ParentProgress = (10000 + 7500 + 7500) / 600 - ParentProgress = 25000 / 600 - ParentProgress = 41.67% + ParentProgress = ((50 * 60) + (75 * 20) + (25 * 100)) / (60 + 20 + 100) + ParentProgress = (3000 + 1500 + 2500) / 180 + ParentProgress = 7000 / 180 + ParentProgress = 38.89% ``` Notice that Subtask C, despite having the lowest progress, has a significant impact on the parent task progress due to its higher weight. 4. **Zero Weight Handling**: Tasks with zero weight are excluded from the calculation: - - Subtask A: Progress 40%, Weight 50 - - Subtask B: Progress 80%, Weight 0 + - Subtask A: Progress 40%, Weight 50% + - Subtask B: Progress 80%, Weight 0% Calculation: ``` @@ -263,13 +263,13 @@ In Weighted Progress mode, both the manual progress input and weight assignment ParentProgress = 40% ``` - In this case, only Subtask A influences the parent task progress because Subtask B has a weight of 0. + In this case, only Subtask A influences the parent task progress because Subtask B has a weight of 0%. 5. **Default Weight Behavior**: When weights aren't explicitly assigned to some tasks: - - Subtask A: Progress 30%, Weight 60 (explicitly set) - - Subtask B: Progress 70%, Weight not set (defaults to 100) - - Subtask C: Progress 90%, Weight not set (defaults to 100) + - Subtask A: Progress 30%, Weight 60% (explicitly set) + - Subtask B: Progress 70%, Weight not set (defaults to 100%) + - Subtask C: Progress 90%, Weight not set (defaults to 100%) Calculation: ``` diff --git a/worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql b/worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql index 91f6f639..8898e599 100644 --- a/worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql +++ b/worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql @@ -39,8 +39,14 @@ BEGIN INTO _use_manual_progress, _use_weighted_progress, _use_time_progress; END IF; - -- If manual progress is enabled and has a value, use it directly - IF _is_manual IS TRUE AND _manual_value IS NOT NULL THEN + -- Get all subtasks + SELECT COUNT(*) + FROM tasks + WHERE parent_task_id = _task_id AND archived IS FALSE + INTO _sub_tasks_count; + + -- If manual progress is enabled and has a value AND there are no subtasks, use it directly + IF _is_manual IS TRUE AND _manual_value IS NOT NULL AND _sub_tasks_count = 0 THEN RETURN JSON_BUILD_OBJECT( 'ratio', _manual_value, 'total_completed', 0, @@ -49,12 +55,6 @@ BEGIN ); END IF; - -- Get all subtasks - SELECT COUNT(*) - FROM tasks - WHERE parent_task_id = _task_id AND archived IS FALSE - INTO _sub_tasks_count; - -- If there are no subtasks, just use the parent task's status IF _sub_tasks_count = 0 THEN SELECT (CASE @@ -145,7 +145,7 @@ BEGIN ELSE 0 END END AS progress_value, - COALESCE(total_hours * 60 + total_minutes, 0) AS estimated_minutes + COALESCE(total_minutes, 0) AS estimated_minutes FROM tasks t WHERE t.parent_task_id = _task_id AND t.archived IS FALSE @@ -657,4 +657,26 @@ ADD COLUMN IF NOT EXISTS use_manual_progress BOOLEAN DEFAULT FALSE, ADD COLUMN IF NOT EXISTS use_weighted_progress BOOLEAN DEFAULT FALSE, ADD COLUMN IF NOT EXISTS use_time_progress BOOLEAN DEFAULT FALSE; +-- Add a trigger to reset manual progress when a task gets a new subtask +CREATE OR REPLACE FUNCTION reset_parent_task_manual_progress() RETURNS TRIGGER AS +$$ +BEGIN + -- When a task gets a new subtask (parent_task_id is set), reset the parent's manual_progress flag + IF NEW.parent_task_id IS NOT NULL THEN + UPDATE tasks + SET manual_progress = false + WHERE id = NEW.parent_task_id + AND manual_progress = true; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Create the trigger on the tasks table +DROP TRIGGER IF EXISTS reset_parent_manual_progress_trigger ON tasks; +CREATE TRIGGER reset_parent_manual_progress_trigger +AFTER INSERT OR UPDATE OF parent_task_id ON tasks +FOR EACH ROW +EXECUTE FUNCTION reset_parent_task_manual_progress(); + COMMIT; \ No newline at end of file diff --git a/worklenz-backend/database/migrations/20250425000000-update-time-based-progress.sql b/worklenz-backend/database/migrations/20250425000000-update-time-based-progress.sql new file mode 100644 index 00000000..b817ddd7 --- /dev/null +++ b/worklenz-backend/database/migrations/20250425000000-update-time-based-progress.sql @@ -0,0 +1,221 @@ +-- Migration: Update time-based progress mode to work for all tasks +-- Date: 2025-04-25 +-- Version: 1.0.0 + +BEGIN; + +-- Update function to use time-based progress for all tasks +CREATE OR REPLACE FUNCTION get_task_complete_ratio(_task_id uuid) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _parent_task_done FLOAT = 0; + _sub_tasks_done FLOAT = 0; + _sub_tasks_count FLOAT = 0; + _total_completed FLOAT = 0; + _total_tasks FLOAT = 0; + _ratio FLOAT = 0; + _is_manual BOOLEAN = FALSE; + _manual_value INTEGER = NULL; + _project_id UUID; + _use_manual_progress BOOLEAN = FALSE; + _use_weighted_progress BOOLEAN = FALSE; + _use_time_progress BOOLEAN = FALSE; +BEGIN + -- Check if manual progress is set for this task + SELECT manual_progress, progress_value, project_id + FROM tasks + WHERE id = _task_id + INTO _is_manual, _manual_value, _project_id; + + -- Check if the project uses manual progress + IF _project_id IS NOT NULL THEN + SELECT COALESCE(use_manual_progress, FALSE), + COALESCE(use_weighted_progress, FALSE), + COALESCE(use_time_progress, FALSE) + FROM projects + WHERE id = _project_id + INTO _use_manual_progress, _use_weighted_progress, _use_time_progress; + END IF; + + -- Get all subtasks + SELECT COUNT(*) + FROM tasks + WHERE parent_task_id = _task_id AND archived IS FALSE + INTO _sub_tasks_count; + + -- Always respect manual progress value if set + IF _is_manual IS TRUE AND _manual_value IS NOT NULL THEN + RETURN JSON_BUILD_OBJECT( + 'ratio', _manual_value, + 'total_completed', 0, + 'total_tasks', 0, + 'is_manual', TRUE + ); + END IF; + + -- If there are no subtasks, just use the parent task's status (unless in time-based mode) + IF _sub_tasks_count = 0 THEN + -- Use time-based estimation for tasks without subtasks if enabled + IF _use_time_progress IS TRUE THEN + -- For time-based tasks without subtasks, we still need some progress calculation + -- If the task is completed, return 100% + -- Otherwise, use the progress value if set manually, or 0 + SELECT + CASE + WHEN EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = _task_id + AND is_done IS TRUE + ) THEN 100 + ELSE COALESCE(_manual_value, 0) + END + INTO _ratio; + ELSE + -- Traditional calculation for non-time-based tasks + SELECT (CASE + WHEN EXISTS(SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = _task_id + AND is_done IS TRUE) THEN 1 + ELSE 0 END) + INTO _parent_task_done; + + _ratio = _parent_task_done * 100; + END IF; + ELSE + -- If project uses manual progress, calculate based on subtask manual progress values + IF _use_manual_progress IS TRUE THEN + WITH subtask_progress AS ( + SELECT + CASE + -- If subtask has manual progress, use that value + WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN + progress_value + -- Otherwise use completion status (0 or 100) + ELSE + CASE + WHEN EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = t.id + AND is_done IS TRUE + ) THEN 100 + ELSE 0 + END + END AS progress_value + FROM tasks t + WHERE t.parent_task_id = _task_id + AND t.archived IS FALSE + ) + SELECT COALESCE(AVG(progress_value), 0) + FROM subtask_progress + INTO _ratio; + -- If project uses weighted progress, calculate based on subtask weights + ELSIF _use_weighted_progress IS TRUE THEN + WITH subtask_progress AS ( + SELECT + CASE + -- If subtask has manual progress, use that value + WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN + progress_value + -- Otherwise use completion status (0 or 100) + ELSE + CASE + WHEN EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = t.id + AND is_done IS TRUE + ) THEN 100 + ELSE 0 + END + END AS progress_value, + COALESCE(weight, 100) AS weight + FROM tasks t + WHERE t.parent_task_id = _task_id + AND t.archived IS FALSE + ) + SELECT COALESCE( + SUM(progress_value * weight) / NULLIF(SUM(weight), 0), + 0 + ) + FROM subtask_progress + INTO _ratio; + -- If project uses time-based progress, calculate based on estimated time + ELSIF _use_time_progress IS TRUE THEN + WITH subtask_progress AS ( + SELECT + CASE + -- If subtask has manual progress, use that value + WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN + progress_value + -- Otherwise use completion status (0 or 100) + ELSE + CASE + WHEN EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = t.id + AND is_done IS TRUE + ) THEN 100 + ELSE 0 + END + END AS progress_value, + COALESCE(total_minutes, 0) AS estimated_minutes + FROM tasks t + WHERE t.parent_task_id = _task_id + AND t.archived IS FALSE + ) + SELECT COALESCE( + SUM(progress_value * estimated_minutes) / NULLIF(SUM(estimated_minutes), 0), + 0 + ) + FROM subtask_progress + INTO _ratio; + ELSE + -- Traditional calculation based on completion status + SELECT (CASE + WHEN EXISTS(SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = _task_id + AND is_done IS TRUE) THEN 1 + ELSE 0 END) + INTO _parent_task_done; + + SELECT COUNT(*) + FROM tasks_with_status_view + WHERE parent_task_id = _task_id + AND is_done IS TRUE + INTO _sub_tasks_done; + + _total_completed = _parent_task_done + _sub_tasks_done; + _total_tasks = _sub_tasks_count + 1; -- +1 for the parent task + + IF _total_tasks = 0 THEN + _ratio = 0; + ELSE + _ratio = (_total_completed / _total_tasks) * 100; + END IF; + END IF; + END IF; + + -- Ensure ratio is between 0 and 100 + IF _ratio < 0 THEN + _ratio = 0; + ELSIF _ratio > 100 THEN + _ratio = 100; + END IF; + + RETURN JSON_BUILD_OBJECT( + 'ratio', _ratio, + 'total_completed', _total_completed, + 'total_tasks', _total_tasks, + 'is_manual', _is_manual + ); +END +$$; + +COMMIT; \ No newline at end of file diff --git a/worklenz-backend/database/migrations/20250426000000-improve-parent-task-progress-calculation.sql b/worklenz-backend/database/migrations/20250426000000-improve-parent-task-progress-calculation.sql new file mode 100644 index 00000000..7ef0015c --- /dev/null +++ b/worklenz-backend/database/migrations/20250426000000-improve-parent-task-progress-calculation.sql @@ -0,0 +1,272 @@ +-- Migration: Improve parent task progress calculation using weights and time estimation +-- Date: 2025-04-26 +-- Version: 1.0.0 + +BEGIN; + +-- Update function to better calculate parent task progress based on subtask weights or time estimations +CREATE OR REPLACE FUNCTION get_task_complete_ratio(_task_id uuid) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _parent_task_done FLOAT = 0; + _sub_tasks_done FLOAT = 0; + _sub_tasks_count FLOAT = 0; + _total_completed FLOAT = 0; + _total_tasks FLOAT = 0; + _ratio FLOAT = 0; + _is_manual BOOLEAN = FALSE; + _manual_value INTEGER = NULL; + _project_id UUID; + _use_manual_progress BOOLEAN = FALSE; + _use_weighted_progress BOOLEAN = FALSE; + _use_time_progress BOOLEAN = FALSE; +BEGIN + -- Check if manual progress is set for this task + SELECT manual_progress, progress_value, project_id + FROM tasks + WHERE id = _task_id + INTO _is_manual, _manual_value, _project_id; + + -- Check if the project uses manual progress + IF _project_id IS NOT NULL THEN + SELECT COALESCE(use_manual_progress, FALSE), + COALESCE(use_weighted_progress, FALSE), + COALESCE(use_time_progress, FALSE) + FROM projects + WHERE id = _project_id + INTO _use_manual_progress, _use_weighted_progress, _use_time_progress; + END IF; + + -- Get all subtasks + SELECT COUNT(*) + FROM tasks + WHERE parent_task_id = _task_id AND archived IS FALSE + INTO _sub_tasks_count; + + -- Only respect manual progress for tasks without subtasks + IF _is_manual IS TRUE AND _manual_value IS NOT NULL AND _sub_tasks_count = 0 THEN + RETURN JSON_BUILD_OBJECT( + 'ratio', _manual_value, + 'total_completed', 0, + 'total_tasks', 0, + 'is_manual', TRUE + ); + END IF; + + -- If there are no subtasks, just use the parent task's status + IF _sub_tasks_count = 0 THEN + -- For tasks without subtasks in time-based mode + IF _use_time_progress IS TRUE THEN + SELECT + CASE + WHEN EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = _task_id + AND is_done IS TRUE + ) THEN 100 + ELSE COALESCE(_manual_value, 0) + END + INTO _ratio; + ELSE + -- Traditional calculation for non-time-based tasks + SELECT (CASE + WHEN EXISTS(SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = _task_id + AND is_done IS TRUE) THEN 1 + ELSE 0 END) + INTO _parent_task_done; + + _ratio = _parent_task_done * 100; + END IF; + ELSE + -- For parent tasks with subtasks, always use the appropriate calculation based on project mode + -- If project uses manual progress, calculate based on subtask manual progress values + IF _use_manual_progress IS TRUE THEN + WITH subtask_progress AS ( + SELECT + CASE + -- If subtask has manual progress, use that value + WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN + progress_value + -- Otherwise use completion status (0 or 100) + ELSE + CASE + WHEN EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = t.id + AND is_done IS TRUE + ) THEN 100 + ELSE 0 + END + END AS progress_value + FROM tasks t + WHERE t.parent_task_id = _task_id + AND t.archived IS FALSE + ) + SELECT COALESCE(AVG(progress_value), 0) + FROM subtask_progress + INTO _ratio; + -- If project uses weighted progress, calculate based on subtask weights + ELSIF _use_weighted_progress IS TRUE THEN + WITH subtask_progress AS ( + SELECT + CASE + -- If subtask has manual progress, use that value + WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN + progress_value + -- Otherwise use completion status (0 or 100) + ELSE + CASE + WHEN EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = t.id + AND is_done IS TRUE + ) THEN 100 + ELSE 0 + END + END AS progress_value, + COALESCE(weight, 100) AS weight -- Default weight is 100 if not specified + FROM tasks t + WHERE t.parent_task_id = _task_id + AND t.archived IS FALSE + ) + SELECT COALESCE( + SUM(progress_value * weight) / NULLIF(SUM(weight), 0), + 0 + ) + FROM subtask_progress + INTO _ratio; + -- If project uses time-based progress, calculate based on estimated time (total_minutes) + ELSIF _use_time_progress IS TRUE THEN + WITH subtask_progress AS ( + SELECT + CASE + -- If subtask has manual progress, use that value + WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN + progress_value + -- Otherwise use completion status (0 or 100) + ELSE + CASE + WHEN EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = t.id + AND is_done IS TRUE + ) THEN 100 + ELSE 0 + END + END AS progress_value, + COALESCE(total_minutes, 0) AS estimated_minutes -- Use time estimation for weighting + FROM tasks t + WHERE t.parent_task_id = _task_id + AND t.archived IS FALSE + ) + SELECT COALESCE( + SUM(progress_value * estimated_minutes) / NULLIF(SUM(estimated_minutes), 0), + 0 + ) + FROM subtask_progress + INTO _ratio; + ELSE + -- Traditional calculation based on completion status when no special mode is enabled + SELECT (CASE + WHEN EXISTS(SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = _task_id + AND is_done IS TRUE) THEN 1 + ELSE 0 END) + INTO _parent_task_done; + + SELECT COUNT(*) + FROM tasks_with_status_view + WHERE parent_task_id = _task_id + AND is_done IS TRUE + INTO _sub_tasks_done; + + _total_completed = _parent_task_done + _sub_tasks_done; + _total_tasks = _sub_tasks_count + 1; -- +1 for the parent task + + IF _total_tasks = 0 THEN + _ratio = 0; + ELSE + _ratio = (_total_completed / _total_tasks) * 100; + END IF; + END IF; + END IF; + + -- Ensure ratio is between 0 and 100 + IF _ratio < 0 THEN + _ratio = 0; + ELSIF _ratio > 100 THEN + _ratio = 100; + END IF; + + RETURN JSON_BUILD_OBJECT( + 'ratio', _ratio, + 'total_completed', _total_completed, + 'total_tasks', _total_tasks, + 'is_manual', _is_manual + ); +END +$$; + +-- Make sure we recalculate parent task progress when subtask progress changes +CREATE OR REPLACE FUNCTION update_parent_task_progress() RETURNS TRIGGER AS +$$ +DECLARE + _parent_task_id UUID; +BEGIN + -- Check if this is a subtask + IF NEW.parent_task_id IS NOT NULL THEN + _parent_task_id := NEW.parent_task_id; + + -- Force any parent task with subtasks to NOT use manual progress + UPDATE tasks + SET manual_progress = FALSE + WHERE id = _parent_task_id; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Create trigger for updates to task progress +DROP TRIGGER IF EXISTS update_parent_task_progress_trigger ON tasks; +CREATE TRIGGER update_parent_task_progress_trigger +AFTER UPDATE OF progress_value, weight, total_minutes ON tasks +FOR EACH ROW +EXECUTE FUNCTION update_parent_task_progress(); + +-- Create a function to ensure parent tasks never have manual progress when they have subtasks +CREATE OR REPLACE FUNCTION ensure_parent_task_without_manual_progress() RETURNS TRIGGER AS +$$ +BEGIN + -- If this is a new subtask being created or a task is being converted to a subtask + IF NEW.parent_task_id IS NOT NULL THEN + -- Force the parent task to NOT use manual progress + UPDATE tasks + SET manual_progress = FALSE + WHERE id = NEW.parent_task_id; + + -- Log that we've reset manual progress for a parent task + RAISE NOTICE 'Reset manual progress for parent task % because it has subtasks', NEW.parent_task_id; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Create trigger for when tasks are created or updated with a parent_task_id +DROP TRIGGER IF EXISTS ensure_parent_task_without_manual_progress_trigger ON tasks; +CREATE TRIGGER ensure_parent_task_without_manual_progress_trigger +AFTER INSERT OR UPDATE OF parent_task_id ON tasks +FOR EACH ROW +EXECUTE FUNCTION ensure_parent_task_without_manual_progress(); + +COMMIT; \ No newline at end of file diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index e3992563..286157cb 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -97,8 +97,11 @@ export default class TasksControllerV2 extends TasksControllerBase { try { const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [taskId]); const [data] = result.rows; - data.info.ratio = +data.info.ratio.toFixed(); - return data.info; + if (data && data.info && data.info.ratio !== undefined) { + data.info.ratio = +((data.info.ratio || 0).toFixed()); + return data.info; + } + return null; } catch (error) { return null; } @@ -198,7 +201,7 @@ export default class TasksControllerV2 extends TasksControllerBase { (SELECT use_manual_progress FROM projects WHERE id = t.project_id) AS project_use_manual_progress, (SELECT use_weighted_progress FROM projects WHERE id = t.project_id) AS project_use_weighted_progress, (SELECT use_time_progress FROM projects WHERE id = t.project_id) AS project_use_time_progress, - (SELECT (get_task_complete_ratio(t.id)).ratio) AS complete_ratio, + (SELECT get_task_complete_ratio(t.id)->>'ratio') AS complete_ratio, (SELECT phase_id FROM task_phase WHERE task_id = t.id) AS phase_id, (SELECT name @@ -372,7 +375,7 @@ export default class TasksControllerV2 extends TasksControllerBase { const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [task.id]); const [data] = result.rows; if (data && data.info) { - task.complete_ratio = +data.info.ratio.toFixed(); + task.complete_ratio = +(data.info.ratio || 0).toFixed(); task.completed_count = data.info.total_completed; task.total_tasks_count = data.info.total_tasks; } @@ -441,7 +444,7 @@ export default class TasksControllerV2 extends TasksControllerBase { const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [task.id]); const [ratioData] = result.rows; if (ratioData && ratioData.info) { - task.complete_ratio = +ratioData.info.ratio.toFixed(); + task.complete_ratio = +(ratioData.info.ratio || 0).toFixed(); task.completed_count = ratioData.info.total_completed; task.total_tasks_count = ratioData.info.total_tasks; console.log(`Updated task ${task.id} (${task.name}) from DB: complete_ratio=${task.complete_ratio}`); @@ -483,6 +486,53 @@ export default class TasksControllerV2 extends TasksControllerBase { return res.status(200).send(new ServerResponse(true, task)); } + @HandleExceptions() + public static async resetParentTaskManualProgress(parentTaskId: string): Promise { + try { + // Check if this task has subtasks + const subTasksResult = await db.query( + "SELECT COUNT(*) as subtask_count FROM tasks WHERE parent_task_id = $1 AND archived IS FALSE", + [parentTaskId] + ); + + const subtaskCount = parseInt(subTasksResult.rows[0]?.subtask_count || "0"); + + // If it has subtasks, reset the manual_progress flag to false + if (subtaskCount > 0) { + await db.query( + "UPDATE tasks SET manual_progress = false WHERE id = $1", + [parentTaskId] + ); + console.log(`Reset manual progress for parent task ${parentTaskId} with ${subtaskCount} subtasks`); + + // Get the project settings to determine which calculation method to use + const projectResult = await db.query( + "SELECT project_id FROM tasks WHERE id = $1", + [parentTaskId] + ); + + const projectId = projectResult.rows[0]?.project_id; + + if (projectId) { + // Recalculate the parent task's progress based on its subtasks + const progressResult = await db.query( + "SELECT get_task_complete_ratio($1) AS ratio", + [parentTaskId] + ); + + const progressRatio = progressResult.rows[0]?.ratio?.ratio || 0; + + // Emit the updated progress value to all clients + // Note: We don't have socket context here, so we can't directly emit + // This will be picked up on the next client refresh + console.log(`Recalculated progress for parent task ${parentTaskId}: ${progressRatio}%`); + } + } + } catch (error) { + log_error(`Error resetting parent task manual progress: ${error}`); + } + } + @HandleExceptions() public static async convertToSubtask(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { @@ -522,6 +572,11 @@ export default class TasksControllerV2 extends TasksControllerBase { ? [req.body.id, req.body.to_group_id] : [req.body.id, req.body.project_id, req.body.parent_task_id, req.body.to_group_id]; await db.query(q, params); + + // Reset the parent task's manual progress when converting a task to a subtask + if (req.body.parent_task_id) { + await this.resetParentTaskManualProgress(req.body.parent_task_id); + } const result = await db.query("SELECT get_single_task($1) AS task;", [req.body.id]); const [data] = result.rows; diff --git a/worklenz-backend/src/socket.io/commands/on-get-task-subtasks-count.ts b/worklenz-backend/src/socket.io/commands/on-get-task-subtasks-count.ts new file mode 100644 index 00000000..ce20d5d1 --- /dev/null +++ b/worklenz-backend/src/socket.io/commands/on-get-task-subtasks-count.ts @@ -0,0 +1,39 @@ +import { Socket } from "socket.io"; +import db from "../../config/db"; +import { SocketEvents } from "../events"; +import { log_error } from "../util"; + +/** + * Socket handler to retrieve the number of subtasks for a given task + * Used to validate on the client side whether a task should show progress inputs + */ +export async function on_get_task_subtasks_count(io: any, socket: Socket, taskId: string) { + try { + if (!taskId) { + return; + } + + // Get the count of subtasks for this task + const result = await db.query( + "SELECT COUNT(*) as subtask_count FROM tasks WHERE parent_task_id = $1 AND archived IS FALSE", + [taskId] + ); + + const subtaskCount = parseInt(result.rows[0]?.subtask_count || "0"); + + // Emit the subtask count back to the client + socket.emit( + "TASK_SUBTASKS_COUNT", + { + task_id: taskId, + subtask_count: subtaskCount, + has_subtasks: subtaskCount > 0 + } + ); + + console.log(`Emitted subtask count for task ${taskId}: ${subtaskCount}`); + + } catch (error) { + log_error(`Error getting subtask count for task ${taskId}: ${error}`); + } +} \ No newline at end of file diff --git a/worklenz-backend/src/socket.io/commands/on-time-estimation-change.ts b/worklenz-backend/src/socket.io/commands/on-time-estimation-change.ts index d6c5e606..1860260e 100644 --- a/worklenz-backend/src/socket.io/commands/on-time-estimation-change.ts +++ b/worklenz-backend/src/socket.io/commands/on-time-estimation-change.ts @@ -6,10 +6,56 @@ import { SocketEvents } from "../events"; import { log_error, notifyProjectUpdates } from "../util"; import { getTaskDetails, logTotalMinutes } from "../../services/activity-logs/activity-logs.service"; -export async function on_time_estimation_change(_io: Server, socket: Socket, data?: string) { +/** + * Recursively updates all ancestor tasks' progress when a subtask changes + * @param io Socket.io instance + * @param socket Socket instance for emitting events + * @param projectId Project ID for room broadcasting + * @param taskId The task ID to update (starts with the parent task) + */ +async function updateTaskAncestors(io: any, socket: Socket, projectId: string, taskId: string | null) { + if (!taskId) return; + + try { + // Get the current task's progress ratio + const progressRatio = await db.query( + "SELECT get_task_complete_ratio($1) as ratio", + [taskId] + ); + + const ratio = progressRatio?.rows[0]?.ratio?.ratio || 0; + console.log(`Updated task ${taskId} progress after time estimation change: ${ratio}`); + + // Emit the updated progress + socket.emit( + SocketEvents.TASK_PROGRESS_UPDATED.toString(), + { + task_id: taskId, + progress_value: ratio + } + ); + + // Find this task's parent to continue the recursive update + const parentResult = await db.query( + "SELECT parent_task_id FROM tasks WHERE id = $1", + [taskId] + ); + + const parentTaskId = parentResult.rows[0]?.parent_task_id; + + // If there's a parent, recursively update it + if (parentTaskId) { + await updateTaskAncestors(io, socket, projectId, parentTaskId); + } + } catch (error) { + log_error(`Error updating ancestor task ${taskId}: ${error}`); + } +} + +export async function on_time_estimation_change(io: Server, socket: Socket, data?: string) { try { // (SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id) AS total_minutes_spent, - const q = `UPDATE tasks SET total_minutes = $2 WHERE id = $1 RETURNING total_minutes;`; + const q = `UPDATE tasks SET total_minutes = $2 WHERE id = $1 RETURNING total_minutes, project_id, parent_task_id;`; const body = JSON.parse(data as string); const hours = body.total_hours || 0; @@ -19,7 +65,10 @@ export async function on_time_estimation_change(_io: Server, socket: Socket, dat const task_data = await getTaskDetails(body.task_id, "total_minutes"); const result0 = await db.query(q, [body.task_id, totalMinutes]); - const [data0] = result0.rows; + const [taskData] = result0.rows; + + const projectId = taskData.project_id; + const parentTaskId = taskData.parent_task_id; const result = await db.query("SELECT SUM(time_spent) AS total_minutes_spent FROM task_work_log WHERE task_id = $1;", [body.task_id]); const [dd] = result.rows; @@ -31,6 +80,22 @@ export async function on_time_estimation_change(_io: Server, socket: Socket, dat total_minutes_spent: dd.total_minutes_spent || 0 }; socket.emit(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), TasksController.updateTaskViewModel(d)); + + // If this is a subtask in time-based mode, update parent task progress + if (parentTaskId) { + const projectSettingsResult = await db.query( + "SELECT use_time_progress FROM projects WHERE id = $1", + [projectId] + ); + + const useTimeProgress = projectSettingsResult.rows[0]?.use_time_progress; + + if (useTimeProgress) { + // Recalculate parent task progress when subtask time estimation changes + await updateTaskAncestors(io, socket, projectId, parentTaskId); + } + } + notifyProjectUpdates(socket, d.id); logTotalMinutes({ diff --git a/worklenz-backend/src/socket.io/commands/on-update-task-progress.ts b/worklenz-backend/src/socket.io/commands/on-update-task-progress.ts index ac8ebbdb..cac1cb43 100644 --- a/worklenz-backend/src/socket.io/commands/on-update-task-progress.ts +++ b/worklenz-backend/src/socket.io/commands/on-update-task-progress.ts @@ -10,6 +10,52 @@ interface UpdateTaskProgressData { parent_task_id: string | null; } +/** + * Recursively updates all ancestor tasks' progress when a subtask changes + * @param io Socket.io instance + * @param socket Socket instance for emitting events + * @param projectId Project ID for room broadcasting + * @param taskId The task ID to update (starts with the parent task) + */ +async function updateTaskAncestors(io: any, socket: Socket, projectId: string, taskId: string | null) { + if (!taskId) return; + + try { + // Get the current task's progress ratio + const progressRatio = await db.query( + "SELECT get_task_complete_ratio($1) as ratio", + [taskId] + ); + + const ratio = progressRatio?.rows[0]?.ratio?.ratio || 0; + console.log(`Updated task ${taskId} progress: ${ratio}`); + + // Emit the updated progress + socket.emit( + SocketEvents.TASK_PROGRESS_UPDATED.toString(), + { + task_id: taskId, + progress_value: ratio + } + ); + + // Find this task's parent to continue the recursive update + const parentResult = await db.query( + "SELECT parent_task_id FROM tasks WHERE id = $1", + [taskId] + ); + + const parentTaskId = parentResult.rows[0]?.parent_task_id; + + // If there's a parent, recursively update it + if (parentTaskId) { + await updateTaskAncestors(io, socket, projectId, parentTaskId); + } + } catch (error) { + log_error(`Error updating ancestor task ${taskId}: ${error}`); + } +} + export async function on_update_task_progress(io: any, socket: Socket, data: string) { try { const parsedData = JSON.parse(data) as UpdateTaskProgressData; @@ -25,7 +71,7 @@ export async function on_update_task_progress(io: any, socket: Socket, data: str [task_id] ); - const subtaskCount = parseInt(subTasksResult.rows[0]?.subtask_count || '0'); + const subtaskCount = parseInt(subTasksResult.rows[0]?.subtask_count || "0"); // If this is a parent task, we shouldn't set manual progress if (subtaskCount > 0) { @@ -53,14 +99,13 @@ export async function on_update_task_progress(io: any, socket: Socket, data: str // Log the progress change using the activity logs service await logProgressChange({ task_id, - old_value: currentProgress !== null ? currentProgress.toString() : '0', + old_value: currentProgress !== null ? currentProgress.toString() : "0", new_value: progress_value.toString(), socket }); - if (projectId) { // Emit the update to all clients in the project room - io.to(projectId).emit( + socket.emit( SocketEvents.TASK_PROGRESS_UPDATED.toString(), { task_id, @@ -68,10 +113,10 @@ export async function on_update_task_progress(io: any, socket: Socket, data: str } ); - console.log(`Emitted progress update for task ${task_id} to project room ${projectId}`); + log(`Emitted progress update for task ${task_id} to project room ${projectId}`, null); // Recursively update all ancestors in the task hierarchy - await updateTaskAncestors(io, projectId, parent_task_id); + await updateTaskAncestors(io, socket, projectId, parent_task_id); // Notify that project updates are available notifyProjectUpdates(socket, task_id); @@ -80,48 +125,3 @@ export async function on_update_task_progress(io: any, socket: Socket, data: str log_error(error); } } - -/** - * Recursively updates all ancestor tasks' progress when a subtask changes - * @param io Socket.io instance - * @param projectId Project ID for room broadcasting - * @param taskId The task ID to update (starts with the parent task) - */ -async function updateTaskAncestors(io: any, projectId: string, taskId: string | null) { - if (!taskId) return; - - try { - // Get the current task's progress ratio - const progressRatio = await db.query( - "SELECT get_task_complete_ratio($1) as ratio", - [taskId] - ); - - const ratio = progressRatio?.rows[0]?.ratio; - console.log(`Updated task ${taskId} progress: ${ratio}`); - - // Emit the updated progress - io.to(projectId).emit( - SocketEvents.TASK_PROGRESS_UPDATED.toString(), - { - task_id: taskId, - progress_value: ratio - } - ); - - // Find this task's parent to continue the recursive update - const parentResult = await db.query( - "SELECT parent_task_id FROM tasks WHERE id = $1", - [taskId] - ); - - const parentTaskId = parentResult.rows[0]?.parent_task_id; - - // If there's a parent, recursively update it - if (parentTaskId) { - await updateTaskAncestors(io, projectId, parentTaskId); - } - } catch (error) { - log_error(`Error updating ancestor task ${taskId}: ${error}`); - } -} \ No newline at end of file diff --git a/worklenz-backend/src/socket.io/commands/on-update-task-weight.ts b/worklenz-backend/src/socket.io/commands/on-update-task-weight.ts index e6a68d1d..664d0806 100644 --- a/worklenz-backend/src/socket.io/commands/on-update-task-weight.ts +++ b/worklenz-backend/src/socket.io/commands/on-update-task-weight.ts @@ -40,14 +40,14 @@ export async function on_update_task_weight(io: any, socket: Socket, data: strin // Log the weight change using the activity logs service await logWeightChange({ task_id, - old_value: currentWeight !== null ? currentWeight.toString() : '100', + old_value: currentWeight !== null ? currentWeight.toString() : "100", new_value: weight.toString(), socket }); if (projectId) { // Emit the update to all clients in the project room - io.to(projectId).emit( + socket.emit( SocketEvents.TASK_PROGRESS_UPDATED.toString(), { task_id, @@ -63,11 +63,11 @@ export async function on_update_task_weight(io: any, socket: Socket, data: strin ); // Emit the parent task's updated progress - io.to(projectId).emit( + socket.emit( SocketEvents.TASK_PROGRESS_UPDATED.toString(), { task_id: parent_task_id, - progress_value: progressRatio?.rows[0]?.ratio + progress_value: progressRatio?.rows[0]?.ratio?.ratio || 0 } ); } diff --git a/worklenz-backend/src/socket.io/events.ts b/worklenz-backend/src/socket.io/events.ts index c59b0eff..a8e19a83 100644 --- a/worklenz-backend/src/socket.io/events.ts +++ b/worklenz-backend/src/socket.io/events.ts @@ -63,4 +63,8 @@ export enum SocketEvents { UPDATE_TASK_PROGRESS, UPDATE_TASK_WEIGHT, TASK_PROGRESS_UPDATED, + + // Task subtasks count events + GET_TASK_SUBTASKS_COUNT, + TASK_SUBTASKS_COUNT, } diff --git a/worklenz-backend/src/socket.io/index.ts b/worklenz-backend/src/socket.io/index.ts index b77a68ea..3c5e50b5 100644 --- a/worklenz-backend/src/socket.io/index.ts +++ b/worklenz-backend/src/socket.io/index.ts @@ -54,6 +54,7 @@ import { on_task_custom_column_update } from "./commands/on_custom_column_update import { on_custom_column_pinned_change } from "./commands/on_custom_column_pinned_change"; import { on_update_task_progress } from "./commands/on-update-task-progress"; import { on_update_task_weight } from "./commands/on-update-task-weight"; +import { on_get_task_subtasks_count } from "./commands/on-get-task-subtasks-count"; export function register(io: any, socket: Socket) { log(socket.id, "client registered"); @@ -110,6 +111,7 @@ export function register(io: any, socket: Socket) { socket.on(SocketEvents.CUSTOM_COLUMN_PINNED_CHANGE.toString(), data => on_custom_column_pinned_change(io, socket, data)); socket.on(SocketEvents.UPDATE_TASK_PROGRESS.toString(), data => on_update_task_progress(io, socket, data)); socket.on(SocketEvents.UPDATE_TASK_WEIGHT.toString(), data => on_update_task_weight(io, socket, data)); + socket.on(SocketEvents.GET_TASK_SUBTASKS_COUNT.toString(), (taskId) => on_get_task_subtasks_count(io, socket, taskId)); // socket.io built-in event socket.on("disconnect", (reason) => on_disconnect(io, socket, reason)); diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx index 07f51adc..ebb1e694 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx @@ -5,7 +5,7 @@ import { useAppSelector } from '@/hooks/useAppSelector'; import { ITaskViewModel } from '@/types/tasks/task.types'; import Flex from 'antd/lib/flex'; import { SocketEvents } from '@/shared/socket-events'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useSocket } from '@/socket/socketContext'; interface TaskDrawerProgressProps { @@ -17,16 +17,48 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => { const { t } = useTranslation('task-drawer/task-drawer'); const { project } = useAppSelector(state => state.projectReducer); const { socket, connected } = useSocket(); + const [confirmedHasSubtasks, setConfirmedHasSubtasks] = useState(null); const isSubTask = !!task?.parent_task_id; - const hasSubTasks = task?.sub_tasks_count > 0; + const hasSubTasks = task?.sub_tasks_count > 0 || confirmedHasSubtasks === true; - // Show manual progress input only for tasks without subtasks (not parent tasks) - // Parent tasks get their progress calculated from subtasks + // Additional debug logging + console.log(`TaskDrawerProgress for task ${task.id} (${task.name}): hasSubTasks=${hasSubTasks}, count=${task.sub_tasks_count}, confirmedHasSubtasks=${confirmedHasSubtasks}`); + + // HIGHEST PRIORITY CHECK: Never show progress inputs for parent tasks with subtasks + // This check happens before any other logic to ensure consistency + if (hasSubTasks) { + console.error(`REJECTED: Progress input for parent task ${task.id} with ${task.sub_tasks_count} subtasks. confirmedHasSubtasks=${confirmedHasSubtasks}`); + return null; + } + + // Double-check by directly querying for subtasks from the server + useEffect(() => { + if (connected && task.id) { + socket?.emit(SocketEvents.GET_TASK_SUBTASKS_COUNT.toString(), task.id); + } + + // Listen for the subtask count response + const handleSubtasksCount = (data: any) => { + if (data.task_id === task.id) { + console.log(`Received subtask count for task ${task.id}: ${data.subtask_count}, has_subtasks=${data.has_subtasks}`); + setConfirmedHasSubtasks(data.has_subtasks); + } + }; + + socket?.on(SocketEvents.TASK_SUBTASKS_COUNT.toString(), handleSubtasksCount); + + return () => { + socket?.off(SocketEvents.TASK_SUBTASKS_COUNT.toString(), handleSubtasksCount); + }; + }, [socket, connected, task.id]); + + // Never show manual progress input for parent tasks (tasks with subtasks) + // Only show progress input for tasks without subtasks const showManualProgressInput = !hasSubTasks; // Only show weight input for subtasks in weighted progress mode - const showTaskWeightInput = project?.use_weighted_progress && isSubTask; + const showTaskWeightInput = project?.use_weighted_progress && isSubTask && !hasSubTasks; useEffect(() => { // Listen for progress updates from the server @@ -53,8 +85,13 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => { }; }, [socket, connected, task.id, form]); + // One last check before rendering + if (hasSubTasks) { + return null; + } + const handleProgressChange = (value: number | null) => { - if (connected && task.id && value !== null) { + if (connected && task.id && value !== null && !hasSubTasks) { // Ensure parent_task_id is not undefined const parent_task_id = task.parent_task_id || null; @@ -67,13 +104,6 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => { }) ); - // If this task has subtasks, request recalculation of its progress - if (hasSubTasks) { - setTimeout(() => { - socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id); - }, 100); - } - // If this is a subtask, request the parent's progress to be updated in UI if (parent_task_id) { setTimeout(() => { @@ -84,7 +114,7 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => { }; const handleWeightChange = (value: number | null) => { - if (connected && task.id && value !== null) { + if (connected && task.id && value !== null && !hasSubTasks) { // Ensure parent_task_id is not undefined const parent_task_id = task.parent_task_id || null; @@ -116,6 +146,11 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => { return null; // Don't show any progress inputs if not applicable } + // Final safety check + if (hasSubTasks) { + return null; + } + return ( <> {showTaskWeightInput && ( diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx index abdb386d..fc5d66d4 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx @@ -33,6 +33,42 @@ interface TaskDetailsFormProps { taskFormViewModel?: ITaskFormViewModel | null; } +// Custom wrapper that enforces stricter rules for displaying progress input +interface ConditionalProgressInputProps { + task: ITaskViewModel; + form: any; // Using any for the form as the exact type may be complex +} + +const ConditionalProgressInput = ({ task, form }: ConditionalProgressInputProps) => { + const { project } = useAppSelector(state => state.projectReducer); + const hasSubTasks = task?.sub_tasks_count > 0; + const isSubTask = !!task?.parent_task_id; + + // Add more aggressive logging and checks + console.log(`Task ${task.id} status: hasSubTasks=${hasSubTasks}, isSubTask=${isSubTask}, modes: time=${project?.use_time_progress}, manual=${project?.use_manual_progress}, weighted=${project?.use_weighted_progress}`); + + // STRICT RULE: Never show progress input for parent tasks with subtasks + // This is the most important check and must be done first + if (hasSubTasks) { + console.log(`Task ${task.id} has ${task.sub_tasks_count} subtasks. Hiding progress input.`); + return null; + } + + // Only for tasks without subtasks, determine which input to show based on project mode + if (project?.use_time_progress) { + // In time-based mode, show progress input ONLY for tasks without subtasks + return ; + } else if (project?.use_manual_progress) { + // In manual mode, show progress input ONLY for tasks without subtasks + return ; + } else if (project?.use_weighted_progress && isSubTask) { + // In weighted mode, show weight input for subtasks + return ; + } + + return null; +}; + const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) => { const { t } = useTranslation('task-drawer/task-drawer'); const [form] = Form.useForm(); @@ -121,8 +157,11 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) => - {(project?.use_manual_progress || project?.use_weighted_progress) && (taskFormViewModel?.task) && ( - + {taskFormViewModel?.task && ( + )} diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx index dbf0f242..ecf3f847 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx @@ -54,7 +54,7 @@ const ProjectViewTaskList = () => { - {(taskGroups.length === 0 && !loadingGroups) ? ( + {(taskGroups && taskGroups.length === 0 && !loadingGroups) ? ( ) : ( diff --git a/worklenz-frontend/src/shared/socket-events.ts b/worklenz-frontend/src/shared/socket-events.ts index f1b71d2d..33bcc0e8 100644 --- a/worklenz-frontend/src/shared/socket-events.ts +++ b/worklenz-frontend/src/shared/socket-events.ts @@ -63,4 +63,8 @@ export enum SocketEvents { UPDATE_TASK_PROGRESS, UPDATE_TASK_WEIGHT, TASK_PROGRESS_UPDATED, + + // Task subtasks count events + GET_TASK_SUBTASKS_COUNT, + TASK_SUBTASKS_COUNT, } From a368b979d5979f230ba5c41c641a8e495f792a1d Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 2 May 2025 17:05:16 +0530 Subject: [PATCH 08/70] Implement task completion prompt and enhance progress handling - Added logic to prompt users to mark tasks as done when progress reaches 100%, integrating with the socket events for real-time updates. - Updated backend functions to check task statuses and determine if a prompt is necessary based on the task's current state. - Enhanced frontend components to display a modal for confirming task completion, improving user experience and clarity in task management. - Refactored socket event handling to include new events for retrieving "done" statuses, ensuring accurate task status updates across the application. --- docs/task-progress-guide-for-users.md | 42 +++++++++ ...prove-parent-task-progress-calculation.sql | 17 ++++ .../commands/on-get-done-statuses.ts | 49 +++++++++++ .../commands/on-time-estimation-change.ts | 22 ++++- .../commands/on-update-task-progress.ts | 47 +++++++++- worklenz-backend/src/socket.io/events.ts | 3 + worklenz-backend/src/socket.io/index.ts | 3 +- .../task-drawer-progress.tsx | 87 ++++++++++++------- worklenz-frontend/src/shared/socket-events.ts | 3 + 9 files changed, 239 insertions(+), 34 deletions(-) create mode 100644 worklenz-backend/src/socket.io/commands/on-get-done-statuses.ts diff --git a/docs/task-progress-guide-for-users.md b/docs/task-progress-guide-for-users.md index 350329fa..4ff27ae7 100644 --- a/docs/task-progress-guide-for-users.md +++ b/docs/task-progress-guide-for-users.md @@ -76,6 +76,25 @@ The parent task will be approximately 39% complete, with Subtask C having the gr - Only explicitly set weights for tasks that should have different importance - Weights are only relevant for subtasks, not for independent tasks +### Detailed Weighted Progress Calculation Example + +To understand how weighted progress works with different weight values, consider this example: + +For a parent task with two subtasks: +- Subtask A: 80% complete, Weight 50% +- Subtask B: 40% complete, Weight 100% + +The calculation works as follows: + +1. Each subtask's contribution is: (weight × progress) ÷ (sum of all weights) +2. For Subtask A: (50 × 80%) ÷ (50 + 100) = 26.7% +3. For Subtask B: (100 × 40%) ÷ (50 + 100) = 26.7% +4. Total parent progress: 26.7% + 26.7% = 53.3% + +The parent task would be approximately 53% complete. + +This shows how the subtask with twice the weight (Subtask B) has twice the influence on the overall progress calculation, even though it has a lower completion percentage. + ## Time-based Progress Method ### How It Works @@ -108,6 +127,29 @@ The parent task will be approximately 29% complete, with the lengthy Subtask C p - Setting a time estimate to 0 removes that task from progress calculations - Time estimates serve dual purposes: scheduling/resource planning and progress weighting +### Detailed Time-based Progress Calculation Example + +To understand how time-based progress works with different time estimates, consider this example: + +For 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 calculation works as follows: + +1. Convert hours to minutes: A = 150 min, B = 60 min, C = 240 min +2. Total estimated time: 150 + 60 + 240 = 450 minutes +3. Each subtask's contribution is: (time estimate × progress) ÷ (total time) +4. For Subtask A: (150 × 40%) ÷ 450 = 13.3% +5. For Subtask B: (60 × 80%) ÷ 450 = 10.7% +6. For Subtask C: (240 × 10%) ÷ 450 = 5.3% +7. Total parent progress: 13.3% + 10.7% + 5.3% = 29.3% + +The parent task would be approximately 29% complete. + +This demonstrates how tasks with longer time estimates (like Subtask C) have more influence on the overall progress calculation. Even though Subtask B is 80% complete, its shorter time estimate means it contributes less to the overall progress than the partially-completed but longer Subtask A. + ## Default Progress Method If none of the special progress methods are enabled, WorkLenz uses a simple completion-based approach: diff --git a/worklenz-backend/database/migrations/20250426000000-improve-parent-task-progress-calculation.sql b/worklenz-backend/database/migrations/20250426000000-improve-parent-task-progress-calculation.sql index 7ef0015c..e1d5d1f2 100644 --- a/worklenz-backend/database/migrations/20250426000000-improve-parent-task-progress-calculation.sql +++ b/worklenz-backend/database/migrations/20250426000000-improve-parent-task-progress-calculation.sql @@ -221,6 +221,8 @@ CREATE OR REPLACE FUNCTION update_parent_task_progress() RETURNS TRIGGER AS $$ DECLARE _parent_task_id UUID; + _project_id UUID; + _ratio FLOAT; BEGIN -- Check if this is a subtask IF NEW.parent_task_id IS NOT NULL THEN @@ -232,6 +234,21 @@ BEGIN WHERE id = _parent_task_id; END IF; + -- If this task has progress value of 100 and doesn't have subtasks, we might want to prompt the user + -- to mark it as done. We'll annotate this in a way that the socket handler can detect. + IF NEW.progress_value = 100 OR NEW.weight = 100 OR NEW.total_minutes > 0 THEN + -- Check if task has status in "done" category + SELECT project_id FROM tasks WHERE id = NEW.id INTO _project_id; + + -- Get the progress ratio for this task + SELECT get_task_complete_ratio(NEW.id)->>'ratio' INTO _ratio; + + IF _ratio::FLOAT >= 100 THEN + -- Log that this task is at 100% progress + RAISE NOTICE 'Task % progress is at 100%%, may need status update', NEW.id; + END IF; + END IF; + RETURN NEW; END; $$ LANGUAGE plpgsql; diff --git a/worklenz-backend/src/socket.io/commands/on-get-done-statuses.ts b/worklenz-backend/src/socket.io/commands/on-get-done-statuses.ts new file mode 100644 index 00000000..aa22d4cf --- /dev/null +++ b/worklenz-backend/src/socket.io/commands/on-get-done-statuses.ts @@ -0,0 +1,49 @@ +import { Socket } from "socket.io"; +import db from "../../config/db"; +import { log_error } from "../util"; + +// Define a type for the callback function +type DoneStatusesCallback = (statuses: Array<{ + id: string; + name: string; + sort_order: number; + color_code: string; +}>) => void; + +/** + * Socket handler to get task statuses in the "done" category for a project + * Used when prompting users to mark a task as done when progress reaches 100% + */ +export async function on_get_done_statuses( + io: any, + socket: Socket, + projectId: string, + callback: DoneStatusesCallback +) { + try { + if (!projectId) { + return callback([]); + } + + // Query to get all statuses in the "done" category for the project + const result = await db.query(` + SELECT ts.id, ts.name, ts.sort_order, ts.color_code + FROM task_statuses ts + INNER JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id + WHERE ts.project_id = $1 + AND stsc.is_done = TRUE + ORDER BY ts.sort_order ASC + `, [projectId]); + + const doneStatuses = result.rows; + + console.log(`Found ${doneStatuses.length} "done" statuses for project ${projectId}`); + + // Use callback to return the result + callback(doneStatuses); + + } catch (error) { + log_error(`Error getting "done" statuses for project ${projectId}: ${error}`); + callback([]); + } +} \ No newline at end of file diff --git a/worklenz-backend/src/socket.io/commands/on-time-estimation-change.ts b/worklenz-backend/src/socket.io/commands/on-time-estimation-change.ts index 1860260e..32517845 100644 --- a/worklenz-backend/src/socket.io/commands/on-time-estimation-change.ts +++ b/worklenz-backend/src/socket.io/commands/on-time-estimation-change.ts @@ -26,12 +26,32 @@ async function updateTaskAncestors(io: any, socket: Socket, projectId: string, t const ratio = progressRatio?.rows[0]?.ratio?.ratio || 0; console.log(`Updated task ${taskId} progress after time estimation change: ${ratio}`); + // Check if this task needs a "done" status prompt + let shouldPromptForDone = false; + + if (ratio >= 100) { + // Get the task's current status + const taskStatusResult = await db.query(` + SELECT ts.id, stsc.is_done + FROM tasks t + JOIN task_statuses ts ON t.status_id = ts.id + JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id + WHERE t.id = $1 + `, [taskId]); + + // If the task isn't already in a "done" category, we should prompt the user + if (taskStatusResult.rows.length > 0 && !taskStatusResult.rows[0].is_done) { + shouldPromptForDone = true; + } + } + // Emit the updated progress socket.emit( SocketEvents.TASK_PROGRESS_UPDATED.toString(), { task_id: taskId, - progress_value: ratio + progress_value: ratio, + should_prompt_for_done: shouldPromptForDone } ); diff --git a/worklenz-backend/src/socket.io/commands/on-update-task-progress.ts b/worklenz-backend/src/socket.io/commands/on-update-task-progress.ts index cac1cb43..c04d37d4 100644 --- a/worklenz-backend/src/socket.io/commands/on-update-task-progress.ts +++ b/worklenz-backend/src/socket.io/commands/on-update-task-progress.ts @@ -30,12 +30,32 @@ async function updateTaskAncestors(io: any, socket: Socket, projectId: string, t const ratio = progressRatio?.rows[0]?.ratio?.ratio || 0; console.log(`Updated task ${taskId} progress: ${ratio}`); + // Check if this task needs a "done" status prompt + let shouldPromptForDone = false; + + if (ratio >= 100) { + // Get the task's current status + const taskStatusResult = await db.query(` + SELECT ts.id, stsc.is_done + FROM tasks t + JOIN task_statuses ts ON t.status_id = ts.id + JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id + WHERE t.id = $1 + `, [taskId]); + + // If the task isn't already in a "done" category, we should prompt the user + if (taskStatusResult.rows.length > 0 && !taskStatusResult.rows[0].is_done) { + shouldPromptForDone = true; + } + } + // Emit the updated progress socket.emit( SocketEvents.TASK_PROGRESS_UPDATED.toString(), { task_id: taskId, - progress_value: ratio + progress_value: ratio, + should_prompt_for_done: shouldPromptForDone } ); @@ -81,12 +101,13 @@ export async function on_update_task_progress(io: any, socket: Socket, data: str // Get the current progress value to log the change const currentProgressResult = await db.query( - "SELECT progress_value, project_id FROM tasks WHERE id = $1", + "SELECT progress_value, project_id, status_id FROM tasks WHERE id = $1", [task_id] ); const currentProgress = currentProgressResult.rows[0]?.progress_value; const projectId = currentProgressResult.rows[0]?.project_id; + const statusId = currentProgressResult.rows[0]?.status_id; // Update the task progress in the database await db.query( @@ -103,13 +124,33 @@ export async function on_update_task_progress(io: any, socket: Socket, data: str new_value: progress_value.toString(), socket }); + if (projectId) { + // Check if progress is 100% and the task isn't already in a "done" status category + let shouldPromptForDone = false; + + if (progress_value >= 100) { + // Check if the task's current status is in a "done" category + const statusCategoryResult = await db.query(` + SELECT stsc.is_done + FROM task_statuses ts + JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id + WHERE ts.id = $1 + `, [statusId]); + + // If the task isn't already in a "done" category, we should prompt the user + if (statusCategoryResult.rows.length > 0 && !statusCategoryResult.rows[0].is_done) { + shouldPromptForDone = true; + } + } + // Emit the update to all clients in the project room socket.emit( SocketEvents.TASK_PROGRESS_UPDATED.toString(), { task_id, - progress_value + progress_value, + should_prompt_for_done: shouldPromptForDone } ); diff --git a/worklenz-backend/src/socket.io/events.ts b/worklenz-backend/src/socket.io/events.ts index a8e19a83..c0a58008 100644 --- a/worklenz-backend/src/socket.io/events.ts +++ b/worklenz-backend/src/socket.io/events.ts @@ -67,4 +67,7 @@ export enum SocketEvents { // Task subtasks count events GET_TASK_SUBTASKS_COUNT, TASK_SUBTASKS_COUNT, + + // Task completion events + GET_DONE_STATUSES, } diff --git a/worklenz-backend/src/socket.io/index.ts b/worklenz-backend/src/socket.io/index.ts index 3c5e50b5..04927214 100644 --- a/worklenz-backend/src/socket.io/index.ts +++ b/worklenz-backend/src/socket.io/index.ts @@ -55,6 +55,7 @@ import { on_custom_column_pinned_change } from "./commands/on_custom_column_pinn import { on_update_task_progress } from "./commands/on-update-task-progress"; import { on_update_task_weight } from "./commands/on-update-task-weight"; import { on_get_task_subtasks_count } from "./commands/on-get-task-subtasks-count"; +import { on_get_done_statuses } from "./commands/on-get-done-statuses"; export function register(io: any, socket: Socket) { log(socket.id, "client registered"); @@ -72,7 +73,6 @@ export function register(io: any, socket: Socket) { socket.on(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), data => on_time_estimation_change(io, socket, data)); socket.on(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), data => on_task_description_change(io, socket, data)); socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), data => on_get_task_progress(io, socket, data)); - socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), data => on_get_task_progress(io, socket, data)); socket.on(SocketEvents.TASK_TIMER_START.toString(), data => on_task_timer_start(io, socket, data)); socket.on(SocketEvents.TASK_TIMER_STOP.toString(), data => on_task_timer_stop(io, socket, data)); socket.on(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), data => on_task_sort_order_change(io, socket, data)); @@ -112,6 +112,7 @@ export function register(io: any, socket: Socket) { socket.on(SocketEvents.UPDATE_TASK_PROGRESS.toString(), data => on_update_task_progress(io, socket, data)); socket.on(SocketEvents.UPDATE_TASK_WEIGHT.toString(), data => on_update_task_weight(io, socket, data)); socket.on(SocketEvents.GET_TASK_SUBTASKS_COUNT.toString(), (taskId) => on_get_task_subtasks_count(io, socket, taskId)); + socket.on(SocketEvents.GET_DONE_STATUSES.toString(), (projectId, callback) => on_get_done_statuses(io, socket, projectId, callback)); // socket.io built-in event socket.on("disconnect", (reason) => on_disconnect(io, socket, reason)); diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx index ebb1e694..75faea5b 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx @@ -1,11 +1,11 @@ -import { Form, InputNumber, Tooltip } from 'antd'; +import { Form, InputNumber, Tooltip, Modal } from 'antd'; import { useTranslation } from 'react-i18next'; import { QuestionCircleOutlined } from '@ant-design/icons'; import { useAppSelector } from '@/hooks/useAppSelector'; import { ITaskViewModel } from '@/types/tasks/task.types'; import Flex from 'antd/lib/flex'; import { SocketEvents } from '@/shared/socket-events'; -import { useEffect, useState } from 'react'; +import { useState, useEffect } from 'react'; import { useSocket } from '@/socket/socketContext'; interface TaskDrawerProgressProps { @@ -17,42 +17,20 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => { const { t } = useTranslation('task-drawer/task-drawer'); const { project } = useAppSelector(state => state.projectReducer); const { socket, connected } = useSocket(); - const [confirmedHasSubtasks, setConfirmedHasSubtasks] = useState(null); + const [isCompletionModalVisible, setIsCompletionModalVisible] = useState(false); const isSubTask = !!task?.parent_task_id; - const hasSubTasks = task?.sub_tasks_count > 0 || confirmedHasSubtasks === true; + // Safe handling of sub_tasks_count which might be undefined in some cases + const hasSubTasks = (task?.sub_tasks_count || 0) > 0; - // Additional debug logging - console.log(`TaskDrawerProgress for task ${task.id} (${task.name}): hasSubTasks=${hasSubTasks}, count=${task.sub_tasks_count}, confirmedHasSubtasks=${confirmedHasSubtasks}`); + // Log task details for debugging + console.log(`TaskDrawerProgress: task=${task?.id}, sub_tasks_count=${task?.sub_tasks_count}, hasSubTasks=${hasSubTasks}`); // HIGHEST PRIORITY CHECK: Never show progress inputs for parent tasks with subtasks - // This check happens before any other logic to ensure consistency if (hasSubTasks) { - console.error(`REJECTED: Progress input for parent task ${task.id} with ${task.sub_tasks_count} subtasks. confirmedHasSubtasks=${confirmedHasSubtasks}`); return null; } - // Double-check by directly querying for subtasks from the server - useEffect(() => { - if (connected && task.id) { - socket?.emit(SocketEvents.GET_TASK_SUBTASKS_COUNT.toString(), task.id); - } - - // Listen for the subtask count response - const handleSubtasksCount = (data: any) => { - if (data.task_id === task.id) { - console.log(`Received subtask count for task ${task.id}: ${data.subtask_count}, has_subtasks=${data.has_subtasks}`); - setConfirmedHasSubtasks(data.has_subtasks); - } - }; - - socket?.on(SocketEvents.TASK_SUBTASKS_COUNT.toString(), handleSubtasksCount); - - return () => { - socket?.off(SocketEvents.TASK_SUBTASKS_COUNT.toString(), handleSubtasksCount); - }; - }, [socket, connected, task.id]); - // Never show manual progress input for parent tasks (tasks with subtasks) // Only show progress input for tasks without subtasks const showManualProgressInput = !hasSubTasks; @@ -70,6 +48,11 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => { if (data.weight !== undefined) { form.setFieldsValue({ weight: data.weight }); } + + // Check if we should prompt the user to mark the task as done + if (data.should_prompt_for_done) { + setIsCompletionModalVisible(true); + } } }; @@ -92,6 +75,11 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => { const handleProgressChange = (value: number | null) => { if (connected && task.id && value !== null && !hasSubTasks) { + // Check if progress is set to 100% to show completion confirmation + if (value === 100) { + setIsCompletionModalVisible(true); + } + // Ensure parent_task_id is not undefined const parent_task_id = task.parent_task_id || null; @@ -136,6 +124,36 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => { } }; + const handleMarkTaskAsComplete = () => { + // Close the modal + setIsCompletionModalVisible(false); + + // Find a "Done" status for this project + if (connected && task.id) { + // Emit socket event to get "done" category statuses + socket?.emit(SocketEvents.GET_DONE_STATUSES.toString(), task.project_id, (doneStatuses: any[]) => { + if (doneStatuses && doneStatuses.length > 0) { + // Use the first "done" status + const doneStatusId = doneStatuses[0].id; + + // Emit socket event to update the task status + socket?.emit( + SocketEvents.TASK_STATUS_CHANGE.toString(), + JSON.stringify({ + task_id: task.id, + status_id: doneStatusId, + project_id: task.project_id + }) + ); + + console.log(`Task ${task.id} marked as done with status ${doneStatusId}`); + } else { + console.error(`No "done" statuses found for project ${task.project_id}`); + } + }); + } + }; + const percentFormatter = (value: number | undefined) => (value ? `${value}%` : '0%'); const percentParser = (value: string | undefined) => { const parsed = parseInt(value?.replace('%', '') || '0', 10); @@ -217,6 +235,17 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => { /> )} + + setIsCompletionModalVisible(false)} + okText="Yes, mark as done" + cancelText="No, keep current status" + > +

You've set the progress to 100%. Would you like to update the task status to "Done"?

+
); }; diff --git a/worklenz-frontend/src/shared/socket-events.ts b/worklenz-frontend/src/shared/socket-events.ts index 33bcc0e8..2c952c18 100644 --- a/worklenz-frontend/src/shared/socket-events.ts +++ b/worklenz-frontend/src/shared/socket-events.ts @@ -67,4 +67,7 @@ export enum SocketEvents { // Task subtasks count events GET_TASK_SUBTASKS_COUNT, TASK_SUBTASKS_COUNT, + + // Task completion events + GET_DONE_STATUSES, } From 75391641fda99a0210db80901536b284d3fe8ffe Mon Sep 17 00:00:00 2001 From: MRNafisiA Date: Fri, 2 May 2025 15:53:27 +0330 Subject: [PATCH 09/70] increase the memory limit to prevent crashing during build time. --- worklenz-frontend/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worklenz-frontend/Dockerfile b/worklenz-frontend/Dockerfile index a32f879e..46a87fa7 100644 --- a/worklenz-frontend/Dockerfile +++ b/worklenz-frontend/Dockerfile @@ -12,7 +12,7 @@ COPY . . RUN echo "window.VITE_API_URL='${VITE_API_URL:-http://backend:3000}';" > ./public/env-config.js && \ echo "window.VITE_SOCKET_URL='${VITE_SOCKET_URL:-ws://backend:3000}';" >> ./public/env-config.js -RUN npm run build +RUN NODE_OPTIONS="--max-old-space-size=4096" npm run build FROM node:22-alpine AS production From 21ab2f8a8281d4d30a941b9b36301f4f856cb209 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 5 May 2025 11:47:17 +0530 Subject: [PATCH 10/70] Enhance task status change handling and progress updates - Updated SQL queries to retrieve color codes for task statuses from the correct table, ensuring accurate data representation. - Added logic to automatically set task progress to 100% when a task is marked as done, improving task completion handling. - Enhanced frontend components to manage task status changes and reflect updates in real-time, including handling parent task progress. - Integrated logging for task status changes and progress updates to improve traceability and debugging. --- .../commands/on-get-done-statuses.ts | 2 +- .../commands/on-task-status-change.ts | 45 +++++++++- .../task-drawer-progress.tsx | 82 +++++++++++++------ 3 files changed, 99 insertions(+), 30 deletions(-) diff --git a/worklenz-backend/src/socket.io/commands/on-get-done-statuses.ts b/worklenz-backend/src/socket.io/commands/on-get-done-statuses.ts index aa22d4cf..4783f4f5 100644 --- a/worklenz-backend/src/socket.io/commands/on-get-done-statuses.ts +++ b/worklenz-backend/src/socket.io/commands/on-get-done-statuses.ts @@ -27,7 +27,7 @@ export async function on_get_done_statuses( // Query to get all statuses in the "done" category for the project const result = await db.query(` - SELECT ts.id, ts.name, ts.sort_order, ts.color_code + SELECT ts.id, ts.name, ts.sort_order, stsc.color_code FROM task_statuses ts INNER JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id WHERE ts.project_id = $1 diff --git a/worklenz-backend/src/socket.io/commands/on-task-status-change.ts b/worklenz-backend/src/socket.io/commands/on-task-status-change.ts index cce0531c..0d003b59 100644 --- a/worklenz-backend/src/socket.io/commands/on-task-status-change.ts +++ b/worklenz-backend/src/socket.io/commands/on-task-status-change.ts @@ -4,10 +4,11 @@ import db from "../../config/db"; import {NotificationsService} from "../../services/notifications/notifications.service"; import {TASK_STATUS_COLOR_ALPHA} from "../../shared/constants"; import {SocketEvents} from "../events"; -import {getLoggedInUserIdFromSocket, log_error, notifyProjectUpdates} from "../util"; +import {getLoggedInUserIdFromSocket, log, log_error, notifyProjectUpdates} from "../util"; import TasksControllerV2 from "../../controllers/tasks-controller-v2"; -import {getTaskDetails, logStatusChange} from "../../services/activity-logs/activity-logs.service"; +import {getTaskDetails, logProgressChange, logStatusChange} from "../../services/activity-logs/activity-logs.service"; import { assignMemberIfNot } from "./on-quick-assign-or-remove"; +import logger from "../../utils/logger"; export async function on_task_status_change(_io: Server, socket: Socket, data?: string) { try { @@ -49,6 +50,46 @@ export async function on_task_status_change(_io: Server, socket: Socket, data?: }); } + // Check if the new status is in a "done" category + if (changeResponse.status_category?.is_done) { + // Get current progress value + const progressResult = await db.query(` + SELECT progress_value, manual_progress + FROM tasks + WHERE id = $1 + `, [body.task_id]); + + const currentProgress = progressResult.rows[0]?.progress_value; + const isManualProgress = progressResult.rows[0]?.manual_progress; + + // Only update if not already 100% + if (currentProgress !== 100) { + // Update progress to 100% + await db.query(` + UPDATE tasks + SET progress_value = 100, manual_progress = TRUE + WHERE id = $1 + `, [body.task_id]); + + log(`Task ${body.task_id} moved to done status - progress automatically set to 100%`, null); + + // Log the progress change to activity logs + await logProgressChange({ + task_id: body.task_id, + old_value: currentProgress !== null ? currentProgress.toString() : "0", + new_value: "100", + socket + }); + + // If this is a subtask, update parent task progress + if (body.parent_task) { + setTimeout(() => { + socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), body.parent_task); + }, 100); + } + } + } + const info = await TasksControllerV2.getTaskCompleteRatio(body.parent_task || body.task_id); socket.emit(SocketEvents.TASK_STATUS_CHANGE.toString(), { diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx index 75faea5b..f260800e 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx @@ -7,6 +7,14 @@ import Flex from 'antd/lib/flex'; import { SocketEvents } from '@/shared/socket-events'; import { useState, useEffect } from 'react'; import { useSocket } from '@/socket/socketContext'; +import { useAuthService } from '@/hooks/useAuth'; +import logger from '@/utils/errorLogger'; +import { ITaskListStatusChangeResponse } from '@/types/tasks/task-list-status.types'; +import { setTaskStatus } from '@/features/task-drawer/task-drawer.slice'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { updateBoardTaskStatus } from '@/features/board/board-slice'; +import { updateTaskStatus } from '@/features/tasks/tasks.slice'; +import useTabSearchParam from '@/hooks/useTabSearchParam'; interface TaskDrawerProgressProps { task: ITaskViewModel; @@ -15,17 +23,18 @@ interface TaskDrawerProgressProps { const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => { const { t } = useTranslation('task-drawer/task-drawer'); + const dispatch = useAppDispatch(); + const { tab } = useTabSearchParam(); + const { project } = useAppSelector(state => state.projectReducer); const { socket, connected } = useSocket(); const [isCompletionModalVisible, setIsCompletionModalVisible] = useState(false); + const currentSession = useAuthService().getCurrentSession(); const isSubTask = !!task?.parent_task_id; // Safe handling of sub_tasks_count which might be undefined in some cases const hasSubTasks = (task?.sub_tasks_count || 0) > 0; - // Log task details for debugging - console.log(`TaskDrawerProgress: task=${task?.id}, sub_tasks_count=${task?.sub_tasks_count}, hasSubTasks=${hasSubTasks}`); - // HIGHEST PRIORITY CHECK: Never show progress inputs for parent tasks with subtasks if (hasSubTasks) { return null; @@ -48,7 +57,7 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => { if (data.weight !== undefined) { form.setFieldsValue({ weight: data.weight }); } - + // Check if we should prompt the user to mark the task as done if (data.should_prompt_for_done) { setIsCompletionModalVisible(true); @@ -79,7 +88,7 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => { if (value === 100) { setIsCompletionModalVisible(true); } - + // Ensure parent_task_id is not undefined const parent_task_id = task.parent_task_id || null; @@ -114,7 +123,7 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => { parent_task_id: parent_task_id, }) ); - + // If this is a subtask, request the parent's progress to be updated in UI if (parent_task_id) { setTimeout(() => { @@ -127,30 +136,49 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => { const handleMarkTaskAsComplete = () => { // Close the modal setIsCompletionModalVisible(false); - + // Find a "Done" status for this project if (connected && task.id) { // Emit socket event to get "done" category statuses - socket?.emit(SocketEvents.GET_DONE_STATUSES.toString(), task.project_id, (doneStatuses: any[]) => { - if (doneStatuses && doneStatuses.length > 0) { - // Use the first "done" status - const doneStatusId = doneStatuses[0].id; - - // Emit socket event to update the task status - socket?.emit( - SocketEvents.TASK_STATUS_CHANGE.toString(), - JSON.stringify({ - task_id: task.id, - status_id: doneStatusId, - project_id: task.project_id - }) - ); - - console.log(`Task ${task.id} marked as done with status ${doneStatusId}`); - } else { - console.error(`No "done" statuses found for project ${task.project_id}`); + socket?.emit( + SocketEvents.GET_DONE_STATUSES.toString(), + task.project_id, + (doneStatuses: any[]) => { + if (doneStatuses && doneStatuses.length > 0) { + // Use the first "done" status + const doneStatusId = doneStatuses[0].id; + + // Emit socket event to update the task status + socket?.emit( + SocketEvents.TASK_STATUS_CHANGE.toString(), + JSON.stringify({ + task_id: task.id, + status_id: doneStatusId, + project_id: task.project_id, + team_id: currentSession?.team_id, + parent_task: task.parent_task_id || null, + }) + ); + socket?.once( + SocketEvents.TASK_STATUS_CHANGE.toString(), + (data: ITaskListStatusChangeResponse) => { + dispatch(setTaskStatus(data)); + + if (tab === 'tasks-list') { + dispatch(updateTaskStatus(data)); + } + if (tab === 'board') { + dispatch(updateBoardTaskStatus(data)); + } + if (data.parent_task) + socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), data.parent_task); + } + ); + } else { + logger.error(`No "done" statuses found for project ${task.project_id}`); + } } - }); + ); } }; @@ -235,7 +263,7 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => { />
)} - + Date: Mon, 5 May 2025 16:59:43 +0530 Subject: [PATCH 11/70] Enhance task progress tracking and UI updates - Updated SQL migration to insert default task statuses ('To Do', 'Doing', 'Done') upon project creation, improving task management. - Enhanced socket commands to emit progress updates for subtasks, ensuring real-time synchronization of task progress. - Refactored frontend components to handle progress calculations and updates more effectively, including improved logging for debugging. - Removed deprecated members reports components to streamline the codebase and improve maintainability. --- ...20250423000000-subtask-manual-progress.sql | 19 ++++--- .../commands/on-get-task-subtasks-count.ts | 50 +++++++++++++++++++ .../path/to/members-reports-drawer.tsx | 49 ------------------ .../path/to/members-reports-time-logs-tab.tsx | 41 --------------- .../task-drawer-progress.tsx | 21 +++++++- .../shared/info-tab/task-details-form.tsx | 5 +- .../src/features/tasks/tasks.slice.ts | 16 ++++++ .../task-list-progress-cell.tsx | 2 +- .../task-list-table/task-list-table.tsx | 1 - .../project/projectTasksViewModel.types.ts | 4 ++ 10 files changed, 105 insertions(+), 103 deletions(-) delete mode 100644 worklenz-frontend/path/to/members-reports-drawer.tsx delete mode 100644 worklenz-frontend/path/to/members-reports-time-logs-tab.tsx diff --git a/worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql b/worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql index 8898e599..b4650dc7 100644 --- a/worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql +++ b/worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql @@ -354,14 +354,19 @@ BEGIN VALUES (_project_id, _team_id, _project_created_log) RETURNING id INTO _project_created_log_id; - -- add the team member in the project as a user - 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')); + -- insert the project creator as a project member + INSERT INTO project_members (team_member_id, project_access_level_id, project_id, role_id) + VALUES (_team_member_id, (SELECT id FROM project_access_levels WHERE key = 'ADMIN'), + _project_id, + (SELECT id FROM roles WHERE team_id = _team_id AND default_role IS TRUE)); - -- register the project log - INSERT INTO project_logs (project_id, team_id, description) - VALUES (_project_id, _team_id, _project_member_added_log); + -- insert statuses + INSERT INTO task_statuses (name, project_id, team_id, category_id, sort_order) + VALUES ('To Do', _project_id, _team_id, (SELECT id FROM sys_task_status_categories WHERE is_todo IS TRUE), 0); + INSERT INTO task_statuses (name, project_id, team_id, category_id, sort_order) + VALUES ('Doing', _project_id, _team_id, (SELECT id FROM sys_task_status_categories WHERE is_doing IS TRUE), 1); + INSERT INTO task_statuses (name, project_id, team_id, category_id, sort_order) + VALUES ('Done', _project_id, _team_id, (SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE), 2); -- insert default project columns PERFORM insert_task_list_columns(_project_id); diff --git a/worklenz-backend/src/socket.io/commands/on-get-task-subtasks-count.ts b/worklenz-backend/src/socket.io/commands/on-get-task-subtasks-count.ts index ce20d5d1..c0c14cfe 100644 --- a/worklenz-backend/src/socket.io/commands/on-get-task-subtasks-count.ts +++ b/worklenz-backend/src/socket.io/commands/on-get-task-subtasks-count.ts @@ -33,6 +33,56 @@ export async function on_get_task_subtasks_count(io: any, socket: Socket, taskId console.log(`Emitted subtask count for task ${taskId}: ${subtaskCount}`); + // If there are subtasks, also get their progress information + if (subtaskCount > 0) { + // Get all subtasks for this parent task with their progress information + const subtasksResult = await db.query(` + SELECT + t.id, + t.progress_value, + t.manual_progress, + t.weight, + CASE + WHEN t.manual_progress = TRUE THEN t.progress_value + ELSE COALESCE( + (SELECT (CASE WHEN tl.total_minutes > 0 THEN + (tl.total_minutes_spent / tl.total_minutes * 100) + ELSE 0 END) + FROM ( + SELECT + t2.id, + t2.total_minutes, + COALESCE(SUM(twl.time_spent), 0) as total_minutes_spent + FROM tasks t2 + LEFT JOIN task_work_log twl ON t2.id = twl.task_id + WHERE t2.id = t.id + GROUP BY t2.id, t2.total_minutes + ) tl + ), 0) + END as calculated_progress + FROM tasks t + WHERE t.parent_task_id = $1 AND t.archived IS FALSE + `, [taskId]); + + // Emit progress updates for each subtask + for (const subtask of subtasksResult.rows) { + const progressValue = subtask.manual_progress ? + subtask.progress_value : + Math.floor(subtask.calculated_progress); + + socket.emit( + SocketEvents.TASK_PROGRESS_UPDATED.toString(), + { + task_id: subtask.id, + progress_value: progressValue, + weight: subtask.weight + } + ); + } + + console.log(`Emitted progress updates for ${subtasksResult.rows.length} subtasks of task ${taskId}`); + } + } catch (error) { log_error(`Error getting subtask count for task ${taskId}: ${error}`); } diff --git a/worklenz-frontend/path/to/members-reports-drawer.tsx b/worklenz-frontend/path/to/members-reports-drawer.tsx deleted file mode 100644 index b9671dc1..00000000 --- a/worklenz-frontend/path/to/members-reports-drawer.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import MembersReportsTimeLogsTab from './members-reports-time-logs-tab'; - -type MembersReportsDrawerProps = { - memberId: string | null; - exportTimeLogs: () => void; -}; - -const MembersReportsDrawer = ({ memberId, exportTimeLogs }: MembersReportsDrawerProps) => { - return ( - - - {selectedMember.name} - - - - - - - - - - ) - } - > - {selectedMember && } - {selectedMember && } - {selectedMember && } - - ); -}; - -export default MembersReportsDrawer; \ No newline at end of file diff --git a/worklenz-frontend/path/to/members-reports-time-logs-tab.tsx b/worklenz-frontend/path/to/members-reports-time-logs-tab.tsx deleted file mode 100644 index a86c66ba..00000000 --- a/worklenz-frontend/path/to/members-reports-time-logs-tab.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React, { useState } from 'react'; -import { Flex, Skeleton } from 'antd'; -import { useTranslation } from 'react-i18next'; -import { useTimeLogs } from '../contexts/TimeLogsContext'; -import { BillableFilter } from './BillableFilter'; -import { TimeLogCard } from './TimeLogCard'; -import { EmptyListPlaceholder } from './EmptyListPlaceholder'; -import { TaskDrawer } from './TaskDrawer'; -import MembersReportsDrawer from './members-reports-drawer'; - -const MembersReportsTimeLogsTab: React.FC = () => { - const { t } = useTranslation(); - const { timeLogsData, billable, setBillable, exportTimeLogs, exporting } = useTimeLogs(); - - return ( - - - - - - - {timeLogsData.length > 0 ? ( - - {timeLogsData.map((logs, index) => ( - - ))} - - ) : ( - - )} - - - {createPortal(, document.body)} - - - ); -}; - -export default MembersReportsTimeLogsTab; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx index f260800e..2b588dbf 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx @@ -13,7 +13,7 @@ import { ITaskListStatusChangeResponse } from '@/types/tasks/task-list-status.ty import { setTaskStatus } from '@/features/task-drawer/task-drawer.slice'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { updateBoardTaskStatus } from '@/features/board/board-slice'; -import { updateTaskStatus } from '@/features/tasks/tasks.slice'; +import { updateTaskProgress, updateTaskStatus } from '@/features/tasks/tasks.slice'; import useTabSearchParam from '@/hooks/useTabSearchParam'; interface TaskDrawerProgressProps { @@ -101,11 +101,28 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => { }) ); + socket?.once(SocketEvents.GET_TASK_PROGRESS.toString(), (data: any) => { + dispatch( + updateTaskProgress({ + taskId: task.id, + progress: data.complete_ratio, + totalTasksCount: data.total_tasks_count, + completedCount: data.completed_count, + }) + ); + }); + + if (task.id) { + setTimeout(() => { + socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id); + }, 500); + } + // If this is a subtask, request the parent's progress to be updated in UI if (parent_task_id) { setTimeout(() => { socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), parent_task_id); - }, 100); + }, 500); } } }; diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx index fc5d66d4..f9792485 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx @@ -28,6 +28,7 @@ import TaskDrawerPrioritySelector from './details/task-drawer-priority-selector/ import TaskDrawerBillable from './details/task-drawer-billable/task-drawer-billable'; import TaskDrawerProgress from './details/task-drawer-progress/task-drawer-progress'; import { useAppSelector } from '@/hooks/useAppSelector'; +import logger from '@/utils/errorLogger'; interface TaskDetailsFormProps { taskFormViewModel?: ITaskFormViewModel | null; @@ -45,12 +46,12 @@ const ConditionalProgressInput = ({ task, form }: ConditionalProgressInputProps) const isSubTask = !!task?.parent_task_id; // Add more aggressive logging and checks - console.log(`Task ${task.id} status: hasSubTasks=${hasSubTasks}, isSubTask=${isSubTask}, modes: time=${project?.use_time_progress}, manual=${project?.use_manual_progress}, weighted=${project?.use_weighted_progress}`); + logger.debug(`Task ${task.id} status: hasSubTasks=${hasSubTasks}, isSubTask=${isSubTask}, modes: time=${project?.use_time_progress}, manual=${project?.use_manual_progress}, weighted=${project?.use_weighted_progress}`); // STRICT RULE: Never show progress input for parent tasks with subtasks // This is the most important check and must be done first if (hasSubTasks) { - console.log(`Task ${task.id} has ${task.sub_tasks_count} subtasks. Hiding progress input.`); + logger.debug(`Task ${task.id} has ${task.sub_tasks_count} subtasks. Hiding progress input.`); return null; } diff --git a/worklenz-frontend/src/features/tasks/tasks.slice.ts b/worklenz-frontend/src/features/tasks/tasks.slice.ts index 320a5cd1..cd443dbf 100644 --- a/worklenz-frontend/src/features/tasks/tasks.slice.ts +++ b/worklenz-frontend/src/features/tasks/tasks.slice.ts @@ -21,6 +21,7 @@ import { ITaskLabel, ITaskLabelFilter } from '@/types/tasks/taskLabel.types'; import { ITaskPhaseChangeResponse } from '@/types/tasks/task-phase-change-response'; import { produce } from 'immer'; import { tasksCustomColumnsService } from '@/api/tasks/tasks-custom-columns.service'; +import { SocketEvents } from '@/shared/socket-events'; export enum IGroupBy { STATUS = 'status', @@ -192,6 +193,20 @@ export const fetchSubTasks = createAsyncThunk( return []; } + // Request subtask progress data when expanding the task + // This will trigger the socket to emit TASK_PROGRESS_UPDATED events for all subtasks + try { + // Get access to the socket from the state + const socket = (getState() as any).socketReducer?.socket; + if (socket?.connected) { + // Request subtask count and progress information + socket.emit(SocketEvents.GET_TASK_SUBTASKS_COUNT.toString(), taskId); + } + } catch (error) { + console.error('Error requesting subtask progress:', error); + // Non-critical error, continue with fetching subtasks + } + const selectedMembers = taskReducer.taskAssignees .filter(member => member.selected) .map(member => member.id) @@ -577,6 +592,7 @@ const taskSlice = createSlice({ for (const task of tasks) { if (task.id === taskId) { task.complete_ratio = progress; + task.progress_value = progress; task.total_tasks_count = totalTasksCount; task.completed_count = completedCount; return true; diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-progress-cell/task-list-progress-cell.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-progress-cell/task-list-progress-cell.tsx index 1db3a56c..96d7b05b 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-progress-cell/task-list-progress-cell.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-progress-cell/task-list-progress-cell.tsx @@ -10,7 +10,7 @@ type TaskListProgressCellProps = { const TaskListProgressCell = ({ task }: TaskListProgressCellProps) => { const { project } = useAppSelector(state => state.projectReducer); - const isManualProgressEnabled = project?.use_manual_progress; + const isManualProgressEnabled = (task.project_use_manual_progress || task.project_use_weighted_progress || task.project_use_time_progress);; const isSubtask = task.is_sub_task; const hasManualProgress = task.manual_progress; diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table.tsx index c47ecd75..059454c3 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table.tsx @@ -1548,7 +1548,6 @@ const TaskListTable: React.FC = ({ taskList, tableId, active }; const handleCustomColumnSettings = (columnKey: string) => { - console.log('columnKey', columnKey); if (!columnKey) return; setEditColumnKey(columnKey); dispatch(setCustomColumnModalAttributes({modalType: 'edit', columnId: columnKey})); diff --git a/worklenz-frontend/src/types/project/projectTasksViewModel.types.ts b/worklenz-frontend/src/types/project/projectTasksViewModel.types.ts index 4ab36c27..ec30a0e3 100644 --- a/worklenz-frontend/src/types/project/projectTasksViewModel.types.ts +++ b/worklenz-frontend/src/types/project/projectTasksViewModel.types.ts @@ -90,6 +90,10 @@ export interface IProjectTask { isVisible?: boolean; estimated_string?: string; custom_column_values?: Record; + progress_value?: number; + project_use_manual_progress?: boolean; + project_use_time_progress?: boolean; + project_use_weighted_progress?: boolean; } export interface IProjectTasksViewModel { From 5d04718394573421e703693cf1748ad7a261c79e Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Tue, 6 May 2025 10:14:42 +0530 Subject: [PATCH 12/70] Add task progress confirmation prompts and localization updates - Introduced new localization entries for task progress confirmation prompts in English, Spanish, and Portuguese, enhancing user experience. - Updated frontend components to utilize localized strings for task completion modals, ensuring consistency across languages. - Implemented logic to restrict task progress input to a maximum of 100%, improving data integrity and user feedback during task updates. --- .../locales/en/task-drawer/task-drawer.json | 6 ++++ .../locales/es/task-drawer/task-drawer.json | 6 ++++ .../locales/pt/task-drawer/task-drawer.json | 6 ++++ .../task-drawer-progress.tsx | 34 +++++++++++++++---- .../task-drawer-status-dropdown.tsx | 2 +- 5 files changed, 47 insertions(+), 7 deletions(-) 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 d957b891..e013b4f2 100644 --- a/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json @@ -82,5 +82,11 @@ }, "taskActivityLogTab": { "title": "Activity Log" + }, + "taskProgress": { + "markAsDoneTitle": "Mark Task as Done?", + "confirmMarkAsDone": "Yes, mark as done", + "cancelMarkAsDone": "No, keep current status", + "markAsDoneDescription": "You've set the progress to 100%. Would you like to update the task status to \"Done\"?" } } 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 d61bfd47..8b3ef220 100644 --- a/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json @@ -82,5 +82,11 @@ }, "taskActivityLogTab": { "title": "Registro de actividad" + }, + "taskProgress": { + "markAsDoneTitle": "¿Marcar Tarea como Completada?", + "confirmMarkAsDone": "Sí, marcar como completada", + "cancelMarkAsDone": "No, mantener estado actual", + "markAsDoneDescription": "Has establecido el progreso al 100%. ¿Quieres actualizar el estado de la tarea a \"Completada\"?" } } \ No newline at end of file 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 0f0324c9..7a3933f2 100644 --- a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json @@ -82,5 +82,11 @@ }, "taskActivityLogTab": { "title": "Registro de atividade" + }, + "taskProgress": { + "markAsDoneTitle": "Marcar Tarefa como Concluída?", + "confirmMarkAsDone": "Sim, marcar como concluída", + "cancelMarkAsDone": "Não, manter status atual", + "markAsDoneDescription": "Você definiu o progresso como 100%. Deseja atualizar o status da tarefa para \"Concluída\"?" } } \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx index 2b588dbf..df1ce2ea 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx @@ -242,9 +242,20 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => { formatter={percentFormatter} parser={percentParser} onBlur={e => { - const value = percentParser(e.target.value); + let value = percentParser(e.target.value); + // Ensure value doesn't exceed 100 + if (value > 100) { + value = 100; + form.setFieldsValue({ weight: 100 }); + } handleWeightChange(value); }} + onChange={value => { + if (value !== null && value > 100) { + form.setFieldsValue({ weight: 100 }); + handleWeightChange(100); + } + }} /> )} @@ -274,22 +285,33 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => { formatter={percentFormatter} parser={percentParser} onBlur={e => { - const value = percentParser(e.target.value); + let value = percentParser(e.target.value); + // Ensure value doesn't exceed 100 + if (value > 100) { + value = 100; + form.setFieldsValue({ progress_value: 100 }); + } handleProgressChange(value); }} + onChange={value => { + if (value !== null && value > 100) { + form.setFieldsValue({ progress_value: 100 }); + handleProgressChange(100); + } + }} /> )} setIsCompletionModalVisible(false)} - okText="Yes, mark as done" - cancelText="No, keep current status" + okText={t('taskProgress.confirmMarkAsDone', 'Yes, mark as done')} + cancelText={t('taskProgress.cancelMarkAsDone', 'No, keep current status')} > -

You've set the progress to 100%. Would you like to update the task status to "Done"?

+

{t('taskProgress.markAsDoneDescription', 'You\'ve set the progress to 100%. Would you like to update the task status to "Done"?')}

); diff --git a/worklenz-frontend/src/components/task-drawer/task-drawer-status-dropdown/task-drawer-status-dropdown.tsx b/worklenz-frontend/src/components/task-drawer/task-drawer-status-dropdown/task-drawer-status-dropdown.tsx index d9784f90..5378acb3 100644 --- a/worklenz-frontend/src/components/task-drawer/task-drawer-status-dropdown/task-drawer-status-dropdown.tsx +++ b/worklenz-frontend/src/components/task-drawer/task-drawer-status-dropdown/task-drawer-status-dropdown.tsx @@ -46,6 +46,7 @@ const TaskDrawerStatusDropdown = ({ statuses, task, teamId }: TaskDrawerStatusDr SocketEvents.TASK_STATUS_CHANGE.toString(), (data: ITaskListStatusChangeResponse) => { dispatch(setTaskStatus(data)); + socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id); if (tab === 'tasks-list') { dispatch(updateTaskStatus(data)); @@ -65,7 +66,6 @@ const TaskDrawerStatusDropdown = ({ statuses, task, teamId }: TaskDrawerStatusDr ); } } - }; const options = useMemo( From 0fc79d9ae554bc6c2378265a19a4f31b9ce011b6 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Tue, 6 May 2025 15:05:25 +0530 Subject: [PATCH 13/70] Enhance task progress calculation and update logic - Updated SQL migration to fix multilevel subtask progress calculation, ensuring accurate parent task updates based on subtasks. - Refactored backend functions to recursively recalculate task progress values, improving data integrity across task hierarchies. - Enhanced frontend components to refresh task progress values when tasks are updated, ensuring real-time synchronization. - Integrated logging for task progress updates to improve traceability and debugging. --- ...50425000000-update-time-based-progress.sql | 172 ++++---- ...ultilevel-subtask-progress-calculation.sql | 166 ++++++++ .../consolidated-progress-migrations.sql | 372 ++++++++++++++++++ .../src/controllers/tasks-controller-v2.ts | 164 +++++++- .../commands/on-update-task-progress.ts | 13 +- .../commands/on-update-task-weight.ts | 40 +- .../project-drawer/project-drawer.tsx | 2 +- 7 files changed, 830 insertions(+), 99 deletions(-) create mode 100644 worklenz-backend/database/migrations/20250506000000-fix-multilevel-subtask-progress-calculation.sql create mode 100644 worklenz-backend/database/migrations/consolidated-progress-migrations.sql diff --git a/worklenz-backend/database/migrations/20250425000000-update-time-based-progress.sql b/worklenz-backend/database/migrations/20250425000000-update-time-based-progress.sql index b817ddd7..7b02bef7 100644 --- a/worklenz-backend/database/migrations/20250425000000-update-time-based-progress.sql +++ b/worklenz-backend/database/migrations/20250425000000-update-time-based-progress.sql @@ -22,12 +22,19 @@ DECLARE _use_manual_progress BOOLEAN = FALSE; _use_weighted_progress BOOLEAN = FALSE; _use_time_progress BOOLEAN = FALSE; + _task_complete BOOLEAN = FALSE; BEGIN -- Check if manual progress is set for this task - SELECT manual_progress, progress_value, project_id + SELECT manual_progress, progress_value, project_id, + EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = tasks.id + AND is_done IS TRUE + ) AS is_complete FROM tasks WHERE id = _task_id - INTO _is_manual, _manual_value, _project_id; + INTO _is_manual, _manual_value, _project_id, _task_complete; -- Check if the project uses manual progress IF _project_id IS NOT NULL THEN @@ -45,8 +52,21 @@ BEGIN WHERE parent_task_id = _task_id AND archived IS FALSE INTO _sub_tasks_count; - -- Always respect manual progress value if set - IF _is_manual IS TRUE AND _manual_value IS NOT NULL THEN + -- If task is complete, always return 100% + IF _task_complete IS TRUE THEN + RETURN JSON_BUILD_OBJECT( + 'ratio', 100, + 'total_completed', 1, + 'total_tasks', 1, + 'is_manual', FALSE + ); + END IF; + + -- Use manual progress value in two cases: + -- 1. When task has manual_progress = TRUE and progress_value is set + -- 2. When project has use_manual_progress = TRUE and progress_value is set + IF (_is_manual IS TRUE AND _manual_value IS NOT NULL) OR + (_use_manual_progress IS TRUE AND _manual_value IS NOT NULL) THEN RETURN JSON_BUILD_OBJECT( 'ratio', _manual_value, 'total_completed', 0, @@ -64,23 +84,13 @@ BEGIN -- Otherwise, use the progress value if set manually, or 0 SELECT CASE - WHEN EXISTS( - SELECT 1 - FROM tasks_with_status_view - WHERE tasks_with_status_view.task_id = _task_id - AND is_done IS TRUE - ) THEN 100 + WHEN _task_complete IS TRUE THEN 100 ELSE COALESCE(_manual_value, 0) END INTO _ratio; ELSE -- Traditional calculation for non-time-based tasks - SELECT (CASE - WHEN EXISTS(SELECT 1 - FROM tasks_with_status_view - WHERE tasks_with_status_view.task_id = _task_id - AND is_done IS TRUE) THEN 1 - ELSE 0 END) + SELECT (CASE WHEN _task_complete IS TRUE THEN 1 ELSE 0 END) INTO _parent_task_done; _ratio = _parent_task_done * 100; @@ -90,99 +100,111 @@ BEGIN 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 + t.id, + t.manual_progress, + t.progress_value, + EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = t.id + AND is_done IS TRUE + ) AS is_complete FROM tasks t WHERE t.parent_task_id = _task_id AND t.archived IS FALSE + ), + subtask_with_values AS ( + SELECT + CASE + -- For completed tasks, always use 100% + WHEN is_complete IS TRUE THEN 100 + -- For tasks with progress value set, use it regardless of manual_progress flag + WHEN progress_value IS NOT NULL THEN progress_value + -- Default to 0 for incomplete tasks with no progress value + ELSE 0 + END AS progress_value + FROM subtask_progress ) SELECT COALESCE(AVG(progress_value), 0) - FROM subtask_progress + FROM subtask_with_values 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 + t.id, + t.manual_progress, + t.progress_value, + EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = t.id + AND is_done IS TRUE + ) AS is_complete, + COALESCE(t.weight, 100) AS weight FROM tasks t WHERE t.parent_task_id = _task_id AND t.archived IS FALSE + ), + subtask_with_values AS ( + SELECT + CASE + -- For completed tasks, always use 100% + WHEN is_complete IS TRUE THEN 100 + -- For tasks with progress value set, use it regardless of manual_progress flag + WHEN progress_value IS NOT NULL THEN progress_value + -- Default to 0 for incomplete tasks with no progress value + ELSE 0 + END AS progress_value, + weight + FROM subtask_progress ) SELECT COALESCE( SUM(progress_value * weight) / NULLIF(SUM(weight), 0), 0 ) - FROM subtask_progress + FROM subtask_with_values INTO _ratio; -- If project uses time-based progress, calculate based on estimated time ELSIF _use_time_progress IS TRUE THEN WITH subtask_progress AS ( SELECT - CASE - -- If subtask has manual progress, use that value - WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN - progress_value - -- Otherwise use completion status (0 or 100) - ELSE - CASE - WHEN EXISTS( - SELECT 1 - FROM tasks_with_status_view - WHERE tasks_with_status_view.task_id = t.id - AND is_done IS TRUE - ) THEN 100 - ELSE 0 - END - END AS progress_value, - COALESCE(total_minutes, 0) AS estimated_minutes + t.id, + t.manual_progress, + t.progress_value, + EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = t.id + AND is_done IS TRUE + ) AS is_complete, + COALESCE(t.total_minutes, 0) AS estimated_minutes FROM tasks t WHERE t.parent_task_id = _task_id AND t.archived IS FALSE + ), + subtask_with_values AS ( + SELECT + CASE + -- For completed tasks, always use 100% + WHEN is_complete IS TRUE THEN 100 + -- For tasks with progress value set, use it regardless of manual_progress flag + WHEN progress_value IS NOT NULL THEN progress_value + -- Default to 0 for incomplete tasks with no progress value + ELSE 0 + END AS progress_value, + estimated_minutes + FROM subtask_progress ) SELECT COALESCE( SUM(progress_value * estimated_minutes) / NULLIF(SUM(estimated_minutes), 0), 0 ) - FROM subtask_progress + FROM subtask_with_values 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) + SELECT (CASE WHEN _task_complete IS TRUE THEN 1 ELSE 0 END) INTO _parent_task_done; SELECT COUNT(*) diff --git a/worklenz-backend/database/migrations/20250506000000-fix-multilevel-subtask-progress-calculation.sql b/worklenz-backend/database/migrations/20250506000000-fix-multilevel-subtask-progress-calculation.sql new file mode 100644 index 00000000..1a8d4cba --- /dev/null +++ b/worklenz-backend/database/migrations/20250506000000-fix-multilevel-subtask-progress-calculation.sql @@ -0,0 +1,166 @@ +-- Migration: Fix multilevel subtask progress calculation for weighted and manual progress +-- Date: 2025-05-06 +-- Version: 1.0.0 + +BEGIN; + +-- Update the trigger function to recursively recalculate parent task progress up the entire hierarchy +CREATE OR REPLACE FUNCTION update_parent_task_progress() RETURNS TRIGGER AS +$$ +DECLARE + _parent_task_id UUID; + _project_id UUID; + _ratio FLOAT; +BEGIN + -- Check if this is a subtask + IF NEW.parent_task_id IS NOT NULL THEN + _parent_task_id := NEW.parent_task_id; + + -- Force any parent task with subtasks to NOT use manual progress + UPDATE tasks + SET manual_progress = FALSE + WHERE id = _parent_task_id; + + -- Calculate and update the parent's progress value + SELECT (get_task_complete_ratio(_parent_task_id)->>'ratio')::FLOAT INTO _ratio; + + -- Update the parent's progress value + UPDATE tasks + SET progress_value = _ratio + WHERE id = _parent_task_id; + + -- Recursively propagate changes up the hierarchy by using a recursive CTE + WITH RECURSIVE task_hierarchy AS ( + -- Base case: Start with the parent task + SELECT + id, + parent_task_id + FROM tasks + WHERE id = _parent_task_id + + UNION ALL + + -- Recursive case: Go up to each ancestor + SELECT + t.id, + t.parent_task_id + FROM tasks t + JOIN task_hierarchy th ON t.id = th.parent_task_id + WHERE t.id IS NOT NULL + ) + -- For each ancestor, recalculate its progress + UPDATE tasks + SET + manual_progress = FALSE, + progress_value = (SELECT (get_task_complete_ratio(task_hierarchy.id)->>'ratio')::FLOAT) + FROM task_hierarchy + WHERE tasks.id = task_hierarchy.id + AND task_hierarchy.parent_task_id IS NOT NULL; + + -- Log the recalculation for debugging + RAISE NOTICE 'Updated progress for task % to %', _parent_task_id, _ratio; + END IF; + + -- If this task has progress value of 100 and doesn't have subtasks, we might want to prompt the user + -- to mark it as done. We'll annotate this in a way that the socket handler can detect. + IF NEW.progress_value = 100 OR NEW.weight = 100 OR NEW.total_minutes > 0 THEN + -- Check if task has status in "done" category + SELECT project_id FROM tasks WHERE id = NEW.id INTO _project_id; + + -- Get the progress ratio for this task + SELECT (get_task_complete_ratio(NEW.id)->>'ratio')::FLOAT INTO _ratio; + + IF _ratio >= 100 THEN + -- Log that this task is at 100% progress + RAISE NOTICE 'Task % progress is at 100%%, may need status update', NEW.id; + END IF; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Update existing trigger or create a new one to handle more changes +DROP TRIGGER IF EXISTS update_parent_task_progress_trigger ON tasks; +CREATE TRIGGER update_parent_task_progress_trigger +AFTER UPDATE OF progress_value, weight, total_minutes, parent_task_id, manual_progress ON tasks +FOR EACH ROW +EXECUTE FUNCTION update_parent_task_progress(); + +-- Also add a trigger for when a new task is inserted +DROP TRIGGER IF EXISTS update_parent_task_progress_on_insert_trigger ON tasks; +CREATE TRIGGER update_parent_task_progress_on_insert_trigger +AFTER INSERT ON tasks +FOR EACH ROW +WHEN (NEW.parent_task_id IS NOT NULL) +EXECUTE FUNCTION update_parent_task_progress(); + +-- Add a comment to explain the fix +COMMENT ON FUNCTION update_parent_task_progress() IS +'This function recursively updates progress values for all ancestors when a task''s progress changes. +The previous version only updated the immediate parent, which led to incorrect progress values for +higher-level parent tasks when using weighted or manual progress calculations with multi-level subtasks.'; + +-- Add a function to immediately recalculate all task progress values in the correct order +-- This will fix existing data where parent tasks don't have proper progress values +CREATE OR REPLACE FUNCTION recalculate_all_task_progress() RETURNS void AS +$$ +BEGIN + -- First, reset manual_progress flag for all tasks that have subtasks + UPDATE tasks AS t + SET manual_progress = FALSE + WHERE EXISTS ( + SELECT 1 + FROM tasks + WHERE parent_task_id = t.id + AND archived IS FALSE + ); + + -- Start recalculation from leaf tasks (no subtasks) and propagate upward + -- This ensures calculations are done in the right order + WITH RECURSIVE task_hierarchy AS ( + -- Base case: Start with all leaf tasks (no subtasks) + SELECT + id, + parent_task_id, + 0 AS level + FROM tasks + WHERE NOT EXISTS ( + SELECT 1 FROM tasks AS sub + WHERE sub.parent_task_id = tasks.id + AND sub.archived IS FALSE + ) + AND archived IS FALSE + + UNION ALL + + -- Recursive case: Move up to parent tasks, but only after processing all their children + SELECT + t.id, + t.parent_task_id, + th.level + 1 + FROM tasks t + JOIN task_hierarchy th ON t.id = th.parent_task_id + WHERE t.archived IS FALSE + ) + -- Sort by level to ensure we calculate in the right order (leaves first, then parents) + -- This ensures we're using already updated progress values + UPDATE tasks + SET progress_value = (SELECT (get_task_complete_ratio(tasks.id)->>'ratio')::FLOAT) + FROM ( + SELECT id, level + FROM task_hierarchy + ORDER BY level + ) AS ordered_tasks + WHERE tasks.id = ordered_tasks.id + AND (manual_progress IS FALSE OR manual_progress IS NULL); + + -- Log the completion of the recalculation + RAISE NOTICE 'Finished recalculating all task progress values'; +END; +$$ LANGUAGE plpgsql; + +-- Execute the function to fix existing data +SELECT recalculate_all_task_progress(); + +COMMIT; \ No newline at end of file diff --git a/worklenz-backend/database/migrations/consolidated-progress-migrations.sql b/worklenz-backend/database/migrations/consolidated-progress-migrations.sql new file mode 100644 index 00000000..7efe5b3e --- /dev/null +++ b/worklenz-backend/database/migrations/consolidated-progress-migrations.sql @@ -0,0 +1,372 @@ +-- CONSOLIDATED MIGRATION FILE +-- Contains all progress-related migrations from April-May 2025 +-- Generated on: (current date) + +-- ============================================================================= +-- Migration: Add manual task progress +-- Date: 2025-04-22 +-- Version: 1.0.0 +-- File: 20250422132400-manual-task-progress.sql +-- ============================================================================= + +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; + +-- ============================================================================= +-- Migration: Subtask manual progress +-- Date: 2025-04-23 +-- Version: 1.0.0 +-- File: 20250423000000-subtask-manual-progress.sql +-- ============================================================================= + +-- Note: Contents extracted from the file description (actual file not available) +-- This migration likely extends the manual progress feature to support subtasks + +-- ============================================================================= +-- Migration: Add progress and weight activity types +-- Date: 2025-04-24 +-- Version: 1.0.0 +-- File: 20250424000000-add-progress-and-weight-activity-types.sql +-- ============================================================================= + +-- Note: Contents extracted from the file description (actual file not available) +-- This migration likely adds new activity types for tracking progress and weight changes + +-- ============================================================================= +-- Migration: Update time-based progress mode to work for all tasks +-- Date: 2025-04-25 +-- Version: 1.0.0 +-- File: 20250425000000-update-time-based-progress.sql +-- ============================================================================= + +BEGIN; + +-- Update function to use time-based progress for all tasks +CREATE OR REPLACE FUNCTION get_task_complete_ratio(_task_id uuid) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _parent_task_done FLOAT = 0; + _sub_tasks_done FLOAT = 0; + _sub_tasks_count FLOAT = 0; + _total_completed FLOAT = 0; + _total_tasks FLOAT = 0; + _ratio FLOAT = 0; + _is_manual BOOLEAN = FALSE; + _manual_value INTEGER = NULL; + _project_id UUID; + _use_manual_progress BOOLEAN = FALSE; + _use_weighted_progress BOOLEAN = FALSE; + _use_time_progress BOOLEAN = FALSE; + _task_complete BOOLEAN = FALSE; +BEGIN + -- Check if manual progress is set for this task + SELECT manual_progress, progress_value, project_id, + EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = tasks.id + AND is_done IS TRUE + ) AS is_complete + FROM tasks + WHERE id = _task_id + INTO _is_manual, _manual_value, _project_id, _task_complete; + + -- Check if the project uses manual progress + IF _project_id IS NOT NULL THEN + SELECT COALESCE(use_manual_progress, FALSE), + COALESCE(use_weighted_progress, FALSE), + COALESCE(use_time_progress, FALSE) + FROM projects + WHERE id = _project_id + INTO _use_manual_progress, _use_weighted_progress, _use_time_progress; + END IF; + + -- Get all subtasks + SELECT COUNT(*) + FROM tasks + WHERE parent_task_id = _task_id AND archived IS FALSE + INTO _sub_tasks_count; + + -- If task is complete, always return 100% + IF _task_complete IS TRUE THEN + RETURN JSON_BUILD_OBJECT( + 'ratio', 100, + 'total_completed', 1, + 'total_tasks', 1, + 'is_manual', FALSE + ); + END IF; + + -- Use manual progress value in two cases: + -- 1. When task has manual_progress = TRUE and progress_value is set + -- 2. When project has use_manual_progress = TRUE and progress_value is set + IF (_is_manual IS TRUE AND _manual_value IS NOT NULL) OR + (_use_manual_progress IS TRUE AND _manual_value IS NOT NULL) THEN + RETURN JSON_BUILD_OBJECT( + 'ratio', _manual_value, + 'total_completed', 0, + 'total_tasks', 0, + 'is_manual', TRUE + ); + END IF; + + -- If there are no subtasks, just use the parent task's status (unless in time-based mode) + IF _sub_tasks_count = 0 THEN + -- Use time-based estimation for tasks without subtasks if enabled + IF _use_time_progress IS TRUE THEN + -- For time-based tasks without subtasks, we still need some progress calculation + -- If the task is completed, return 100% + -- Otherwise, use the progress value if set manually, or 0 + SELECT + CASE + WHEN _task_complete IS TRUE THEN 100 + ELSE COALESCE(_manual_value, 0) + END + INTO _ratio; + ELSE + -- Traditional calculation for non-time-based tasks + SELECT (CASE WHEN _task_complete IS TRUE THEN 1 ELSE 0 END) + INTO _parent_task_done; + + _ratio = _parent_task_done * 100; + END IF; + ELSE + -- If project uses manual progress, calculate based on subtask manual progress values + IF _use_manual_progress IS TRUE THEN + WITH subtask_progress AS ( + SELECT + t.id, + t.manual_progress, + t.progress_value, + EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = t.id + AND is_done IS TRUE + ) AS is_complete + FROM tasks t + WHERE t.parent_task_id = _task_id + AND t.archived IS FALSE + ), + subtask_with_values AS ( + SELECT + CASE + -- For completed tasks, always use 100% + WHEN is_complete IS TRUE THEN 100 + -- For tasks with progress value set, use it regardless of manual_progress flag + WHEN progress_value IS NOT NULL THEN progress_value + -- Default to 0 for incomplete tasks with no progress value + ELSE 0 + END AS progress_value + FROM subtask_progress + ) + SELECT COALESCE(AVG(progress_value), 0) + FROM subtask_with_values + INTO _ratio; + -- If project uses weighted progress, calculate based on subtask weights + ELSIF _use_weighted_progress IS TRUE THEN + WITH subtask_progress AS ( + SELECT + t.id, + t.manual_progress, + t.progress_value, + EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = t.id + AND is_done IS TRUE + ) AS is_complete, + COALESCE(t.weight, 100) AS weight + FROM tasks t + WHERE t.parent_task_id = _task_id + AND t.archived IS FALSE + ), + subtask_with_values AS ( + SELECT + CASE + -- For completed tasks, always use 100% + WHEN is_complete IS TRUE THEN 100 + -- For tasks with progress value set, use it regardless of manual_progress flag + WHEN progress_value IS NOT NULL THEN progress_value + -- Default to 0 for incomplete tasks with no progress value + ELSE 0 + END AS progress_value, + weight + FROM subtask_progress + ) + SELECT COALESCE( + SUM(progress_value * weight) / NULLIF(SUM(weight), 0), + 0 + ) + FROM subtask_with_values + 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 + t.id, + t.manual_progress, + t.progress_value, + EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = t.id + AND is_done IS TRUE + ) AS is_complete, + COALESCE(t.total_minutes, 0) AS estimated_minutes + FROM tasks t + WHERE t.parent_task_id = _task_id + AND t.archived IS FALSE + ), + subtask_with_values AS ( + SELECT + CASE + -- For completed tasks, always use 100% + WHEN is_complete IS TRUE THEN 100 + -- For tasks with progress value set, use it regardless of manual_progress flag + WHEN progress_value IS NOT NULL THEN progress_value + -- Default to 0 for incomplete tasks with no progress value + ELSE 0 + END AS progress_value, + estimated_minutes + FROM subtask_progress + ) + SELECT COALESCE( + SUM(progress_value * estimated_minutes) / NULLIF(SUM(estimated_minutes), 0), + 0 + ) + FROM subtask_with_values + INTO _ratio; + ELSE + -- Traditional calculation based on completion status + SELECT (CASE WHEN _task_complete IS TRUE THEN 1 ELSE 0 END) + INTO _parent_task_done; + + SELECT COUNT(*) + FROM tasks_with_status_view + WHERE parent_task_id = _task_id + AND is_done IS TRUE + INTO _sub_tasks_done; + + _total_completed = _parent_task_done + _sub_tasks_done; + _total_tasks = _sub_tasks_count + 1; -- +1 for the parent task + + IF _total_tasks = 0 THEN + _ratio = 0; + ELSE + _ratio = (_total_completed / _total_tasks) * 100; + END IF; + END IF; + END IF; + + -- Ensure ratio is between 0 and 100 + IF _ratio < 0 THEN + _ratio = 0; + ELSIF _ratio > 100 THEN + _ratio = 100; + END IF; + + RETURN JSON_BUILD_OBJECT( + 'ratio', _ratio, + 'total_completed', _total_completed, + 'total_tasks', _total_tasks, + 'is_manual', _is_manual + ); +END +$$; + +COMMIT; + +-- ============================================================================= +-- Migration: Improve parent task progress calculation +-- Date: 2025-04-26 +-- Version: 1.0.0 +-- File: 20250426000000-improve-parent-task-progress-calculation.sql +-- ============================================================================= + +-- Note: Contents extracted from the file description (actual file not available) +-- This migration likely improves how parent task progress is calculated from subtasks + +-- ============================================================================= +-- Migration: Fix multilevel subtask progress calculation +-- Date: 2025-05-06 +-- Version: 1.0.0 +-- File: 20250506000000-fix-multilevel-subtask-progress-calculation.sql +-- ============================================================================= + +-- Note: Contents extracted from the file description (actual file not available) +-- This migration likely fixes progress calculation for multilevel nested subtasks \ No newline at end of file diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index 286157cb..3284560f 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -97,12 +97,14 @@ export default class TasksControllerV2 extends TasksControllerBase { try { const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [taskId]); const [data] = result.rows; + console.log("data", data); if (data && data.info && data.info.ratio !== undefined) { data.info.ratio = +((data.info.ratio || 0).toFixed()); return data.info; } return null; } catch (error) { + log_error(`Error in getTaskCompleteRatio: ${error}`); return null; } } @@ -325,6 +327,11 @@ export default class TasksControllerV2 extends TasksControllerBase { @HandleExceptions() public static async getList(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + // Before doing anything else, refresh task progress values for this project + if (req.params.id) { + await this.refreshProjectTaskProgressValues(req.params.id); + } + const isSubTasks = !!req.query.parent_task; const groupBy = (req.query.group || GroupBy.STATUS) as string; @@ -366,25 +373,25 @@ export default class TasksControllerV2 extends TasksControllerBase { public static async updateMapByGroup(tasks: any[], groupBy: string, map: { [p: string]: ITaskGroup }) { let index = 0; const unmapped = []; + + // First, ensure we have the latest progress values for all tasks for (const task of tasks) { - task.index = index++; - - // For tasks with subtasks, get the complete ratio from the database function + // For any task with subtasks, ensure we have the latest progress values 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 || 0).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 + const info = await this.getTaskCompleteRatio(task.id); + if (info) { + task.complete_ratio = info.ratio; + task.progress_value = info.ratio; // Ensure progress_value reflects the calculated ratio + console.log(`Updated task ${task.name} (${task.id}): complete_ratio=${task.complete_ratio}`); } } - + } + + // Now group the tasks with their updated progress values + for (const task of tasks) { + task.index = index++; TasksControllerV2.updateTaskViewModel(task); + if (groupBy === GroupBy.STATUS) { map[task.status]?.tasks.push(task); } else if (groupBy === GroupBy.PRIORITY) { @@ -420,6 +427,11 @@ export default class TasksControllerV2 extends TasksControllerBase { @HandleExceptions() public static async getTasksOnly(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + // Before doing anything else, refresh task progress values for this project + if (req.params.id) { + await this.refreshProjectTaskProgressValues(req.params.id); + } + const isSubTasks = !!req.query.parent_task; // Add customColumns flag to query params @@ -819,4 +831,128 @@ export default class TasksControllerV2 extends TasksControllerBase { value })); } + + public static async refreshProjectTaskProgressValues(projectId: string): Promise { + try { + console.log(`Refreshing progress values for project ${projectId}`); + + // Run the recalculate_all_task_progress function only for tasks in this project + const query = ` + DO $$ + BEGIN + -- First, reset manual_progress flag for all tasks that have subtasks within this project + UPDATE tasks AS t + SET manual_progress = FALSE + WHERE project_id = $1 + AND EXISTS ( + SELECT 1 + FROM tasks + WHERE parent_task_id = t.id + AND archived IS FALSE + ); + + -- Start recalculation from leaf tasks (no subtasks) and propagate upward + -- This ensures calculations are done in the right order + WITH RECURSIVE task_hierarchy AS ( + -- Base case: Start with all leaf tasks (no subtasks) in this project + SELECT + id, + parent_task_id, + 0 AS level + FROM tasks + WHERE project_id = $1 + AND NOT EXISTS ( + SELECT 1 FROM tasks AS sub + WHERE sub.parent_task_id = tasks.id + AND sub.archived IS FALSE + ) + AND archived IS FALSE + + UNION ALL + + -- Recursive case: Move up to parent tasks, but only after processing all their children + SELECT + t.id, + t.parent_task_id, + th.level + 1 + FROM tasks t + JOIN task_hierarchy th ON t.id = th.parent_task_id + WHERE t.archived IS FALSE + ) + -- Sort by level to ensure we calculate in the right order (leaves first, then parents) + UPDATE tasks + SET progress_value = (SELECT (get_task_complete_ratio(tasks.id)->>'ratio')::FLOAT) + FROM ( + SELECT id, level + FROM task_hierarchy + ORDER BY level + ) AS ordered_tasks + WHERE tasks.id = ordered_tasks.id + AND tasks.project_id = $1 + AND (manual_progress IS FALSE OR manual_progress IS NULL); + END $$; + `; + + const result = await db.query(query, [projectId]); + console.log(`Finished refreshing progress values for project ${projectId}`); + } catch (error) { + log_error('Error refreshing project task progress values', error); + } + } + + public static async updateTaskProgress(taskId: string): Promise { + try { + // Calculate the task's progress using get_task_complete_ratio + const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [taskId]); + const [data] = result.rows; + + if (data && data.info && data.info.ratio !== undefined) { + const progressValue = +((data.info.ratio || 0).toFixed()); + + // Update the task's progress_value in the database + await db.query( + "UPDATE tasks SET progress_value = $1 WHERE id = $2", + [progressValue, taskId] + ); + + console.log(`Updated progress for task ${taskId} to ${progressValue}%`); + + // If this task has a parent, update the parent's progress as well + const parentResult = await db.query( + "SELECT parent_task_id FROM tasks WHERE id = $1", + [taskId] + ); + + if (parentResult.rows.length > 0 && parentResult.rows[0].parent_task_id) { + await this.updateTaskProgress(parentResult.rows[0].parent_task_id); + } + } + } catch (error) { + log_error(`Error updating task progress: ${error}`); + } + } + + // Add this method to update progress when a task's weight is changed + public static async updateTaskWeight(taskId: string, weight: number): Promise { + try { + // Update the task's weight + await db.query( + "UPDATE tasks SET weight = $1 WHERE id = $2", + [weight, taskId] + ); + + // Get the parent task ID + const parentResult = await db.query( + "SELECT parent_task_id FROM tasks WHERE id = $1", + [taskId] + ); + + // If this task has a parent, update the parent's progress + if (parentResult.rows.length > 0 && parentResult.rows[0].parent_task_id) { + await this.updateTaskProgress(parentResult.rows[0].parent_task_id); + } + } catch (error) { + log_error(`Error updating task weight: ${error}`); + } + } } diff --git a/worklenz-backend/src/socket.io/commands/on-update-task-progress.ts b/worklenz-backend/src/socket.io/commands/on-update-task-progress.ts index c04d37d4..239b7ff8 100644 --- a/worklenz-backend/src/socket.io/commands/on-update-task-progress.ts +++ b/worklenz-backend/src/socket.io/commands/on-update-task-progress.ts @@ -3,6 +3,7 @@ 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"; +import TasksControllerV2 from "../../controllers/tasks-controller-v2"; interface UpdateTaskProgressData { task_id: string; @@ -21,6 +22,9 @@ async function updateTaskAncestors(io: any, socket: Socket, projectId: string, t if (!taskId) return; try { + // Use the new controller method to update the task progress + await TasksControllerV2.updateTaskProgress(taskId); + // Get the current task's progress ratio const progressRatio = await db.query( "SELECT get_task_complete_ratio($1) as ratio", @@ -156,8 +160,13 @@ export async function on_update_task_progress(io: any, socket: Socket, data: str log(`Emitted progress update for task ${task_id} to project room ${projectId}`, null); - // Recursively update all ancestors in the task hierarchy - await updateTaskAncestors(io, socket, projectId, parent_task_id); + // If this task has a parent, use our controller to update all ancestors + if (parent_task_id) { + // Use the controller method to update the parent task's progress + await TasksControllerV2.updateTaskProgress(parent_task_id); + // Also use the existing method for socket notifications + await updateTaskAncestors(io, socket, projectId, parent_task_id); + } // Notify that project updates are available notifyProjectUpdates(socket, task_id); diff --git a/worklenz-backend/src/socket.io/commands/on-update-task-weight.ts b/worklenz-backend/src/socket.io/commands/on-update-task-weight.ts index 664d0806..7d0f65bf 100644 --- a/worklenz-backend/src/socket.io/commands/on-update-task-weight.ts +++ b/worklenz-backend/src/socket.io/commands/on-update-task-weight.ts @@ -3,6 +3,7 @@ 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"; +import TasksControllerV2 from "../../controllers/tasks-controller-v2"; interface UpdateTaskWeightData { task_id: string; @@ -29,13 +30,8 @@ export async function on_update_task_weight(io: any, socket: Socket, data: strin 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] - ); + // Update the task weight using our controller method + await TasksControllerV2.updateTaskWeight(task_id, weight); // Log the weight change using the activity logs service await logWeightChange({ @@ -57,6 +53,10 @@ export async function on_update_task_weight(io: any, socket: Socket, data: strin // If this is a subtask, update the parent task's progress if (parent_task_id) { + // Use the controller to update the parent task progress + await TasksControllerV2.updateTaskProgress(parent_task_id); + + // Get the updated progress to emit to clients const progressRatio = await db.query( "SELECT get_task_complete_ratio($1) as ratio", [parent_task_id] @@ -70,6 +70,32 @@ export async function on_update_task_weight(io: any, socket: Socket, data: strin progress_value: progressRatio?.rows[0]?.ratio?.ratio || 0 } ); + + // We also need to update any grandparent tasks + const grandparentResult = await db.query( + "SELECT parent_task_id FROM tasks WHERE id = $1", + [parent_task_id] + ); + + const grandparentId = grandparentResult.rows[0]?.parent_task_id; + + if (grandparentId) { + await TasksControllerV2.updateTaskProgress(grandparentId); + + // Emit the grandparent's updated progress + const grandparentProgressRatio = await db.query( + "SELECT get_task_complete_ratio($1) as ratio", + [grandparentId] + ); + + socket.emit( + SocketEvents.TASK_PROGRESS_UPDATED.toString(), + { + task_id: grandparentId, + progress_value: grandparentProgressRatio?.rows[0]?.ratio?.ratio || 0 + } + ); + } } // Notify that project updates are available 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 f5b5cb98..7bae3717 100644 --- a/worklenz-frontend/src/components/projects/project-drawer/project-drawer.tsx +++ b/worklenz-frontend/src/components/projects/project-drawer/project-drawer.tsx @@ -266,7 +266,7 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => { setLoading(true); resetForm(); dispatch(setProjectData({} as IProjectViewModel)); - dispatch(setProjectId(null)); + // dispatch(setProjectId(null)); dispatch(setDrawerProjectId(null)); dispatch(toggleProjectDrawer()); onClose(); From cabc97afc0f543a524cf838e5c1ddb2730358f20 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 7 May 2025 12:02:25 +0530 Subject: [PATCH 14/70] Enhance team update logic and error handling - Refactored the team update function in the Admin Center controller to improve error handling and response messages. - Implemented concurrent updates for team member roles using Promise.all, enhancing performance and user experience. - Updated the frontend API service to accept a structured body for team updates, ensuring consistency in data handling. - Enhanced the settings drawer component to manage team member roles more effectively, improving the overall user interface. --- .../consolidated-progress-migrations.sql | 401 +++++++++++++++--- .../controllers/admin-center-controller.ts | 41 +- .../admin-center/admin-center.api.service.ts | 4 +- .../teams/settings-drawer/settings-drawer.tsx | 90 ++-- 4 files changed, 428 insertions(+), 108 deletions(-) diff --git a/worklenz-backend/database/migrations/consolidated-progress-migrations.sql b/worklenz-backend/database/migrations/consolidated-progress-migrations.sql index 7efe5b3e..832b93c5 100644 --- a/worklenz-backend/database/migrations/consolidated-progress-migrations.sql +++ b/worklenz-backend/database/migrations/consolidated-progress-migrations.sql @@ -1,14 +1,3 @@ --- CONSOLIDATED MIGRATION FILE --- Contains all progress-related migrations from April-May 2025 --- Generated on: (current date) - --- ============================================================================= --- Migration: Add manual task progress --- Date: 2025-04-22 --- Version: 1.0.0 --- File: 20250422132400-manual-task-progress.sql --- ============================================================================= - BEGIN; -- Add manual progress fields to tasks table @@ -17,6 +6,12 @@ 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; +-- Add progress-related fields to projects table +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; + -- Update function to consider manual progress CREATE OR REPLACE FUNCTION get_task_complete_ratio(_task_id uuid) RETURNS json LANGUAGE plpgsql @@ -31,12 +26,26 @@ DECLARE _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 - SELECT manual_progress, progress_value + SELECT manual_progress, progress_value, project_id FROM tasks WHERE id = _task_id - INTO _is_manual, _manual_value; + 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 @@ -82,35 +91,193 @@ BEGIN END $$; +-- Update project functions to handle progress-related fields +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; +$$; + +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, description) + VALUES (_project_id, _team_id, _project_created_log) + RETURNING id INTO _project_created_log_id; + + -- insert the project creator as a project member + INSERT INTO project_members (team_member_id, project_access_level_id, project_id, role_id) + VALUES (_team_member_id, (SELECT id FROM project_access_levels WHERE key = 'ADMIN'), + _project_id, + (SELECT id FROM roles WHERE team_id = _team_id AND default_role IS TRUE)); + + -- insert statuses + INSERT INTO task_statuses (name, project_id, team_id, category_id, sort_order) + VALUES ('To Do', _project_id, _team_id, (SELECT id FROM sys_task_status_categories WHERE is_todo IS TRUE), 0); + INSERT INTO task_statuses (name, project_id, team_id, category_id, sort_order) + VALUES ('Doing', _project_id, _team_id, (SELECT id FROM sys_task_status_categories WHERE is_doing IS TRUE), 1); + INSERT INTO task_statuses (name, project_id, team_id, category_id, sort_order) + VALUES ('Done', _project_id, _team_id, (SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE), 2); + + -- 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; +$$; + COMMIT; --- ============================================================================= --- Migration: Subtask manual progress --- Date: 2025-04-23 --- Version: 1.0.0 --- File: 20250423000000-subtask-manual-progress.sql --- ============================================================================= - --- Note: Contents extracted from the file description (actual file not available) --- This migration likely extends the manual progress feature to support subtasks - --- ============================================================================= --- Migration: Add progress and weight activity types --- Date: 2025-04-24 --- Version: 1.0.0 --- File: 20250424000000-add-progress-and-weight-activity-types.sql --- ============================================================================= - --- Note: Contents extracted from the file description (actual file not available) --- This migration likely adds new activity types for tracking progress and weight changes - --- ============================================================================= --- Migration: Update time-based progress mode to work for all tasks --- Date: 2025-04-25 --- Version: 1.0.0 --- File: 20250425000000-update-time-based-progress.sql --- ============================================================================= - BEGIN; -- Update function to use time-based progress for all tasks @@ -349,24 +516,142 @@ BEGIN END $$; +CREATE OR REPLACE FUNCTION public.get_task_form_view_model(_user_id UUID, _team_id UUID, _task_id UUID, _project_id UUID) RETURNS JSON + LANGUAGE plpgsql +AS +$$ +DECLARE + _task JSON; + _priorities JSON; + _projects JSON; + _statuses JSON; + _team_members JSON; + _assignees JSON; + _phases JSON; +BEGIN + + -- Select task info + SELECT COALESCE(ROW_TO_JSON(rec), '{}'::JSON) + INTO _task + FROM (WITH RECURSIVE task_hierarchy AS ( + -- Base case: Start with the given task + SELECT id, + parent_task_id, + 0 AS level + FROM tasks + WHERE id = _task_id + + UNION ALL + + -- Recursive case: Traverse up to parent tasks + SELECT t.id, + t.parent_task_id, + th.level + 1 AS level + FROM tasks t + INNER JOIN task_hierarchy th ON t.id = th.parent_task_id + WHERE th.parent_task_id IS NOT NULL) + SELECT id, + name, + description, + start_date, + end_date, + done, + total_minutes, + priority_id, + project_id, + created_at, + updated_at, + status_id, + parent_task_id, + sort_order, + (SELECT phase_id FROM task_phase WHERE task_id = tasks.id) AS phase_id, + CONCAT((SELECT key FROM projects WHERE id = tasks.project_id), '-', task_no) AS task_key, + (SELECT start_time + FROM task_timers + WHERE task_id = tasks.id + AND user_id = _user_id) AS timer_start_time, + parent_task_id IS NOT NULL AS is_sub_task, + (SELECT COUNT('*') + FROM tasks + WHERE parent_task_id = tasks.id + AND archived IS FALSE) AS sub_tasks_count, + (SELECT COUNT(*) + FROM tasks_with_status_view tt + WHERE (tt.parent_task_id = tasks.id OR tt.task_id = tasks.id) + AND tt.is_done IS TRUE) + AS completed_count, + (SELECT COUNT(*) FROM task_attachments WHERE task_id = tasks.id) AS attachments_count, + (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(r))), '[]'::JSON) + FROM (SELECT task_labels.label_id AS id, + (SELECT name FROM team_labels WHERE id = task_labels.label_id), + (SELECT color_code FROM team_labels WHERE id = task_labels.label_id) + FROM task_labels + WHERE task_id = tasks.id + ORDER BY name) r) AS labels, + (SELECT color_code + FROM sys_task_status_categories + WHERE id = (SELECT category_id FROM task_statuses WHERE id = tasks.status_id)) AS status_color, + (SELECT COUNT(*) FROM tasks WHERE parent_task_id = _task_id) AS sub_tasks_count, + (SELECT name FROM users WHERE id = tasks.reporter_id) AS reporter, + (SELECT get_task_assignees(tasks.id)) AS assignees, + (SELECT id FROM team_members WHERE user_id = _user_id AND team_id = _team_id) AS team_member_id, + billable, + schedule_id, + progress_value, + weight, + (SELECT MAX(level) FROM task_hierarchy) AS task_level + FROM tasks + WHERE id = _task_id) rec; + + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) + INTO _priorities + FROM (SELECT id, name FROM task_priorities ORDER BY value) rec; + + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) + INTO _phases + FROM (SELECT id, name FROM project_phases WHERE project_id = _project_id ORDER BY name) rec; + + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) + INTO _projects + FROM (SELECT id, name + FROM projects + WHERE team_id = _team_id + AND (CASE + WHEN (is_owner(_user_id, _team_id) OR is_admin(_user_id, _team_id) IS TRUE) THEN TRUE + ELSE is_member_of_project(projects.id, _user_id, _team_id) END) + ORDER BY name) rec; + + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) + INTO _statuses + FROM (SELECT id, name FROM task_statuses WHERE project_id = _project_id) rec; + + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) + INTO _team_members + FROM (SELECT team_members.id, + (SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = team_members.id), + (SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = team_members.id), + (SELECT avatar_url + FROM team_member_info_view + WHERE team_member_info_view.team_member_id = team_members.id) + FROM team_members + LEFT JOIN users u ON team_members.user_id = u.id + WHERE team_id = _team_id + AND team_members.active IS TRUE) rec; + + SELECT get_task_assignees(_task_id) INTO _assignees; + + RETURN JSON_BUILD_OBJECT( + 'task', _task, + 'priorities', _priorities, + 'projects', _projects, + 'statuses', _statuses, + 'team_members', _team_members, + 'assignees', _assignees, + 'phases', _phases + ); +END; +$$; + COMMIT; --- ============================================================================= --- Migration: Improve parent task progress calculation --- Date: 2025-04-26 --- Version: 1.0.0 --- File: 20250426000000-improve-parent-task-progress-calculation.sql --- ============================================================================= --- Note: Contents extracted from the file description (actual file not available) --- This migration likely improves how parent task progress is calculated from subtasks - --- ============================================================================= --- Migration: Fix multilevel subtask progress calculation --- Date: 2025-05-06 --- Version: 1.0.0 --- File: 20250506000000-fix-multilevel-subtask-progress-calculation.sql --- ============================================================================= - --- Note: Contents extracted from the file description (actual file not available) --- This migration likely fixes progress calculation for multilevel nested subtasks \ No newline at end of file diff --git a/worklenz-backend/src/controllers/admin-center-controller.ts b/worklenz-backend/src/controllers/admin-center-controller.ts index 3c50c858..6b2f5362 100644 --- a/worklenz-backend/src/controllers/admin-center-controller.ts +++ b/worklenz-backend/src/controllers/admin-center-controller.ts @@ -5,7 +5,7 @@ import db from "../config/db"; import {ServerResponse} from "../models/server-response"; import WorklenzControllerBase from "./worklenz-controller-base"; import HandleExceptions from "../decorators/handle-exceptions"; -import {calculateMonthDays, getColor, megabytesToBytes} from "../shared/utils"; +import {calculateMonthDays, getColor, log_error, megabytesToBytes} from "../shared/utils"; import moment from "moment"; import {calculateStorage} from "../shared/s3"; import {checkTeamSubscriptionStatus, getActiveTeamMemberCount, getCurrentProjectsCount, getFreePlanSettings, getOwnerIdByTeam, getTeamMemberCount, getUsedStorage} from "../shared/paddle-utils"; @@ -255,22 +255,33 @@ export default class AdminCenterController extends WorklenzControllerBase { const {id} = req.params; const {name, teamMembers} = req.body; - const updateNameQuery = `UPDATE teams - SET name = $1 - WHERE id = $2;`; - await db.query(updateNameQuery, [name, id]); + try { + // Update team name + const updateNameQuery = `UPDATE teams SET name = $1 WHERE id = $2 RETURNING id;`; + const nameResult = await db.query(updateNameQuery, [name, id]); + + if (!nameResult.rows.length) { + return res.status(404).send(new ServerResponse(false, null, "Team not found")); + } - if (teamMembers.length) { - teamMembers.forEach(async (element: { role_name: string; user_id: string; }) => { - const q = `UPDATE team_members - SET role_id = (SELECT id FROM roles WHERE roles.team_id = $1 AND name = $2) - WHERE user_id = $3 - AND team_id = $1;`; - await db.query(q, [id, element.role_name, element.user_id]); - }); + // Update team member roles if provided + if (teamMembers?.length) { + // Use Promise.all to handle all role updates concurrently + await Promise.all(teamMembers.map(async (member: { role_name: string; user_id: string; }) => { + const roleQuery = ` + UPDATE team_members + SET role_id = (SELECT id FROM roles WHERE roles.team_id = $1 AND name = $2) + WHERE user_id = $3 AND team_id = $1 + RETURNING id;`; + await db.query(roleQuery, [id, member.role_name, member.user_id]); + })); + } + + return res.status(200).send(new ServerResponse(true, null, "Team updated successfully")); + } catch (error) { + log_error("Error updating team:", error); + return res.status(500).send(new ServerResponse(false, null, "Failed to update team")); } - - return res.status(200).send(new ServerResponse(true, [], "Team updated successfully")); } @HandleExceptions() diff --git a/worklenz-frontend/src/api/admin-center/admin-center.api.service.ts b/worklenz-frontend/src/api/admin-center/admin-center.api.service.ts index 857efb91..4d45b222 100644 --- a/worklenz-frontend/src/api/admin-center/admin-center.api.service.ts +++ b/worklenz-frontend/src/api/admin-center/admin-center.api.service.ts @@ -112,11 +112,11 @@ export const adminCenterApiService = { async updateTeam( team_id: string, - team_members: IOrganizationUser[] + body: {name: string, teamMembers: IOrganizationUser[]} ): Promise> { const response = await apiClient.put>( `${rootUrl}/organization/team/${team_id}`, - team_members + body ); return response.data; }, diff --git a/worklenz-frontend/src/components/admin-center/teams/settings-drawer/settings-drawer.tsx b/worklenz-frontend/src/components/admin-center/teams/settings-drawer/settings-drawer.tsx index 8a1efc35..b08cee23 100644 --- a/worklenz-frontend/src/components/admin-center/teams/settings-drawer/settings-drawer.tsx +++ b/worklenz-frontend/src/components/admin-center/teams/settings-drawer/settings-drawer.tsx @@ -13,21 +13,17 @@ import { } from 'antd'; import React, { useState } from 'react'; import { useAppDispatch } from '@/hooks/useAppDispatch'; -import { toggleSettingDrawer, updateTeam } from '@/features/teams/teamSlice'; -import { TeamsType } from '@/types/admin-center/team.types'; import './settings-drawer.css'; -import CustomAvatar from '@/components/CustomAvatar'; -import { teamsApiService } from '@/api/teams/teams.api.service'; import logger from '@/utils/errorLogger'; import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service'; import { IOrganizationTeam, IOrganizationTeamMember, } from '@/types/admin-center/admin-center.types'; -import Avatars from '@/components/avatars/avatars'; -import { AvatarNamesMap } from '@/shared/constants'; import SingleAvatar from '@/components/common/single-avatar/single-avatar'; import { useTranslation } from 'react-i18next'; +import { API_BASE_URL } from '@/shared/constants'; +import apiClient from '@/api/api-client'; interface SettingTeamDrawerProps { teamId: string; @@ -68,26 +64,30 @@ const SettingTeamDrawer: React.FC = ({ }; const handleFormSubmit = async (values: any) => { - console.log(values); - // const newTeam: TeamsType = { - // teamId: teamId, - // teamName: values.name, - // membersCount: team?.membersCount || 1, - // members: team?.members || ['Raveesha Dilanka'], - // owner: values.name, - // created: team?.created || new Date(), - // isActive: false, - // }; - // dispatch(updateTeam(newTeam)); - // dispatch(toggleSettingDrawer()); - // form.resetFields(); - // message.success('Team updated!'); + try { + setUpdatingTeam(true); + + const body = { + name: values.name, + teamMembers: teamData?.team_members || [] + }; + + const response = await adminCenterApiService.updateTeam(teamId, body); + + if (response.done) { + setIsSettingDrawerOpen(false); + } + } catch (error) { + logger.error('Error updating team', error); + } finally { + setUpdatingTeam(false); + } }; const roleOptions = [ - { value: 'Admin', label: t('admin') }, - { value: 'Member', label: t('member') }, - { value: 'Owner', label: t('owner') }, + { key: 'Admin', value: 'Admin', label: t('admin') }, + { key: 'Member', value: 'Member', label: t('member') }, + { key: 'Owner', value: 'Owner', label: t('owner'), disabled: true }, ]; const columns: TableProps['columns'] = [ @@ -104,16 +104,40 @@ const SettingTeamDrawer: React.FC = ({ { title: t('role'), key: 'role', - render: (_, record: IOrganizationTeamMember) => ( -
- +
+ ); + }, }, ]; From ec4d3e738a7f57962d0165a47807d8b7d005d4cc Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 7 May 2025 13:50:34 +0530 Subject: [PATCH 15/70] Enhance team member role management and localization updates - Added a new SQL field to indicate pending invitations for team members, improving role management logic. - Updated the settings drawer component to display tooltips for roles that cannot be changed, enhancing user experience. - Introduced new localization entries for pending invitations and role change restrictions in English, Spanish, and Portuguese, ensuring consistency across languages. --- .../controllers/admin-center-controller.ts | 6 +++- .../public/locales/en/admin-center/teams.json | 4 ++- .../public/locales/es/admin-center/teams.json | 4 ++- .../public/locales/pt/admin-center/teams.json | 4 ++- .../teams/settings-drawer/settings-drawer.tsx | 34 ++++++++++++++----- .../types/admin-center/admin-center.types.ts | 1 + 6 files changed, 40 insertions(+), 13 deletions(-) diff --git a/worklenz-backend/src/controllers/admin-center-controller.ts b/worklenz-backend/src/controllers/admin-center-controller.ts index 6b2f5362..be334aff 100644 --- a/worklenz-backend/src/controllers/admin-center-controller.ts +++ b/worklenz-backend/src/controllers/admin-center-controller.ts @@ -232,7 +232,11 @@ export default class AdminCenterController extends WorklenzControllerBase { FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id), role_id, - r.name AS role_name + r.name AS role_name, + EXISTS(SELECT email + FROM email_invitations + WHERE team_member_id = tm.id + AND email_invitations.team_id = tm.team_id) AS pending_invitation FROM team_members tm LEFT JOIN users u on tm.user_id = u.id LEFT JOIN roles r on tm.role_id = r.id diff --git a/worklenz-frontend/public/locales/en/admin-center/teams.json b/worklenz-frontend/public/locales/en/admin-center/teams.json index e03f8515..bf829a87 100644 --- a/worklenz-frontend/public/locales/en/admin-center/teams.json +++ b/worklenz-frontend/public/locales/en/admin-center/teams.json @@ -29,5 +29,7 @@ "role": "Role", "owner": "Owner", "admin": "Admin", - "member": "Member" + "member": "Member", + "cannotChangeOwnerRole": "Owner role cannot be changed", + "pendingInvitation": "Pending invitation" } diff --git a/worklenz-frontend/public/locales/es/admin-center/teams.json b/worklenz-frontend/public/locales/es/admin-center/teams.json index 98e3b188..13453656 100644 --- a/worklenz-frontend/public/locales/es/admin-center/teams.json +++ b/worklenz-frontend/public/locales/es/admin-center/teams.json @@ -29,5 +29,7 @@ "role": "Rol", "owner": "Propietario", "admin": "Administrador", - "member": "Miembro" + "member": "Miembro", + "cannotChangeOwnerRole": "El rol de Propietario no puede ser cambiado", + "pendingInvitation": "Invitación pendiente" } diff --git a/worklenz-frontend/public/locales/pt/admin-center/teams.json b/worklenz-frontend/public/locales/pt/admin-center/teams.json index fea4c874..6a71b491 100644 --- a/worklenz-frontend/public/locales/pt/admin-center/teams.json +++ b/worklenz-frontend/public/locales/pt/admin-center/teams.json @@ -29,5 +29,7 @@ "role": "Rol", "owner": "Propietario", "admin": "Administrador", - "member": "Miembro" + "member": "Miembro", + "cannotChangeOwnerRole": "A função de Proprietário não pode ser alterada", + "pendingInvitation": "Convite pendente" } diff --git a/worklenz-frontend/src/components/admin-center/teams/settings-drawer/settings-drawer.tsx b/worklenz-frontend/src/components/admin-center/teams/settings-drawer/settings-drawer.tsx index b08cee23..ed2d850f 100644 --- a/worklenz-frontend/src/components/admin-center/teams/settings-drawer/settings-drawer.tsx +++ b/worklenz-frontend/src/components/admin-center/teams/settings-drawer/settings-drawer.tsx @@ -10,6 +10,7 @@ import { Table, TableProps, Typography, + Tooltip, } from 'antd'; import React, { useState } from 'react'; import { useAppDispatch } from '@/hooks/useAppDispatch'; @@ -22,8 +23,6 @@ import { } from '@/types/admin-center/admin-center.types'; import SingleAvatar from '@/components/common/single-avatar/single-avatar'; import { useTranslation } from 'react-i18next'; -import { API_BASE_URL } from '@/shared/constants'; -import apiClient from '@/api/api-client'; interface SettingTeamDrawerProps { teamId: string; @@ -126,15 +125,32 @@ const SettingTeamDrawer: React.FC = ({ } }; + const isDisabled = record.role_name === 'Owner' || record.pending_invitation; + const tooltipTitle = record.role_name === 'Owner' + ? t('cannotChangeOwnerRole') + : record.pending_invitation + ? t('pendingInvitation') + : ''; + + const selectComponent = ( + + {isDisabled ? ( + + {selectComponent} + + ) : ( + selectComponent + )} ); }, diff --git a/worklenz-frontend/src/types/admin-center/admin-center.types.ts b/worklenz-frontend/src/types/admin-center/admin-center.types.ts index d2e29012..e9eba701 100644 --- a/worklenz-frontend/src/types/admin-center/admin-center.types.ts +++ b/worklenz-frontend/src/types/admin-center/admin-center.types.ts @@ -35,6 +35,7 @@ export interface IOrganizationTeamMember { role_id?: string; role_name?: string; created_at?: string; + pending_invitation?: boolean; } export interface IOrganizationTeam { From 2b82ff699e9f5a31848ada1d3605c7a11a1d5af3 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 7 May 2025 15:48:02 +0530 Subject: [PATCH 16/70] Enhance task list loading state management - Introduced a local loading state to improve user experience by displaying a skeleton loader while data is being fetched. - Refactored data loading logic to utilize Promise.all for concurrent dispatching of task-related data, ensuring efficient data retrieval. - Updated the rendering logic to conditionally display the skeleton loader based on the new loading state, enhancing UI responsiveness. --- .../taskList/project-view-task-list.tsx | 40 +++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx index ecf3f847..fcd4931a 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import Flex from 'antd/es/flex'; import Skeleton from 'antd/es/skeleton'; import { useSearchParams } from 'react-router-dom'; @@ -17,6 +17,8 @@ const ProjectViewTaskList = () => { const dispatch = useAppDispatch(); const { projectView } = useTabSearchParam(); const [searchParams, setSearchParams] = useSearchParams(); + // Add local loading state to immediately show skeleton + const [isLoading, setIsLoading] = useState(true); const { projectId } = useAppSelector(state => state.projectReducer); const { taskGroups, loadingGroups, groupBy, archived, fields, search } = useAppSelector( @@ -38,26 +40,40 @@ const ProjectViewTaskList = () => { }, [projectView, searchParams, setSearchParams]); useEffect(() => { - if (projectId && groupBy) { - if (!loadingColumns) dispatch(fetchTaskListColumns(projectId)); - if (!loadingPhases) dispatch(fetchPhasesByProjectId(projectId)); - if (!loadingGroups && projectView === 'list') { - dispatch(fetchTaskGroups(projectId)); + // Set loading state based on all loading conditions + setIsLoading(loadingGroups || loadingColumns || loadingPhases || loadingStatusCategories); + }, [loadingGroups, loadingColumns, loadingPhases, loadingStatusCategories]); + + useEffect(() => { + const loadData = async () => { + if (projectId && groupBy) { + const promises = []; + + if (!loadingColumns) promises.push(dispatch(fetchTaskListColumns(projectId))); + if (!loadingPhases) promises.push(dispatch(fetchPhasesByProjectId(projectId))); + if (!loadingGroups && projectView === 'list') { + promises.push(dispatch(fetchTaskGroups(projectId))); + } + if (!statusCategories.length) { + promises.push(dispatch(fetchStatusesCategories())); + } + + // Wait for all data to load + await Promise.all(promises); } - } - if (!statusCategories.length) { - dispatch(fetchStatusesCategories()); - } + }; + + loadData(); }, [dispatch, projectId, groupBy, fields, search, archived]); return ( - {(taskGroups && taskGroups.length === 0 && !loadingGroups) ? ( + {(taskGroups && taskGroups.length === 0 && !isLoading) ? ( ) : ( - + )} From 583fec04d7ee61de07d5bf3c6472869f5d8a9061 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 7 May 2025 15:57:19 +0530 Subject: [PATCH 17/70] Update package dependencies and add tinyglobby - Upgraded dependencies: antd to version 5.24.9, axios to version 1.9.0, dompurify to version 3.2.5, and vite to version 6.3.5. - Updated various rc-* packages to their latest versions for improved functionality and performance. - Added tinyglobby as a new dependency for enhanced file globbing capabilities in the project. --- worklenz-frontend/package-lock.json | 168 ++++++++++++++++++++-------- worklenz-frontend/package.json | 8 +- 2 files changed, 126 insertions(+), 50 deletions(-) diff --git a/worklenz-frontend/package-lock.json b/worklenz-frontend/package-lock.json index f75f2fce..50940a18 100644 --- a/worklenz-frontend/package-lock.json +++ b/worklenz-frontend/package-lock.json @@ -22,12 +22,12 @@ "@tanstack/react-table": "^8.20.6", "@tanstack/react-virtual": "^3.11.2", "@tinymce/tinymce-react": "^5.1.1", - "antd": "^5.24.1", - "axios": "^1.7.9", + "antd": "^5.24.9", + "axios": "^1.9.0", "chart.js": "^4.4.7", "chartjs-plugin-datalabels": "^2.2.0", "date-fns": "^4.1.0", - "dompurify": "^3.2.4", + "dompurify": "^3.2.5", "gantt-task-react": "^0.3.9", "html2canvas": "^1.4.1", "i18next": "^23.16.8", @@ -70,7 +70,7 @@ "tailwindcss": "^3.4.17", "terser": "^5.39.0", "typescript": "^5.7.3", - "vite": "^6.2.5", + "vite": "^6.3.5", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.0.5" } @@ -2665,9 +2665,9 @@ } }, "node_modules/antd": { - "version": "5.24.6", - "resolved": "https://registry.npmjs.org/antd/-/antd-5.24.6.tgz", - "integrity": "sha512-xIlTa/1CTbgkZsdU/dOXkYvJXb9VoiMwsaCzpKFH2zAEY3xqOfwQ57/DdG7lAdrWP7QORtSld4UA6suxzuTHXw==", + "version": "5.24.9", + "resolved": "https://registry.npmjs.org/antd/-/antd-5.24.9.tgz", + "integrity": "sha512-liB+Y/JwD5/KSKbK1Z1EVAbWcoWYvWJ1s97AbbT+mOdigpJQuWwH7kG8IXNEljI7onvj0DdD43TXhSRLUu9AMA==", "license": "MIT", "dependencies": { "@ant-design/colors": "^7.2.0", @@ -2692,13 +2692,13 @@ "rc-drawer": "~7.2.0", "rc-dropdown": "~4.2.1", "rc-field-form": "~2.7.0", - "rc-image": "~7.11.1", - "rc-input": "~1.7.3", - "rc-input-number": "~9.4.0", - "rc-mentions": "~2.19.1", + "rc-image": "~7.12.0", + "rc-input": "~1.8.0", + "rc-input-number": "~9.5.0", + "rc-mentions": "~2.20.0", "rc-menu": "~9.16.1", "rc-motion": "^2.9.5", - "rc-notification": "~5.6.3", + "rc-notification": "~5.6.4", "rc-pagination": "~5.1.0", "rc-picker": "~4.11.3", "rc-progress": "~4.0.0", @@ -2710,8 +2710,8 @@ "rc-steps": "~6.0.1", "rc-switch": "~4.1.0", "rc-table": "~7.50.4", - "rc-tabs": "~15.5.1", - "rc-textarea": "~1.9.0", + "rc-tabs": "~15.6.1", + "rc-textarea": "~1.10.0", "rc-tooltip": "~6.4.0", "rc-tree": "~5.13.1", "rc-tree-select": "~5.27.0", @@ -2839,9 +2839,9 @@ } }, "node_modules/axios": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz", - "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", + "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -5657,9 +5657,9 @@ "license": "MIT" }, "node_modules/rc-image": { - "version": "7.11.1", - "resolved": "https://registry.npmjs.org/rc-image/-/rc-image-7.11.1.tgz", - "integrity": "sha512-XuoWx4KUXg7hNy5mRTy1i8c8p3K8boWg6UajbHpDXS5AlRVucNfTi5YxTtPBTBzegxAZpvuLfh3emXFt6ybUdA==", + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/rc-image/-/rc-image-7.12.0.tgz", + "integrity": "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.11.2", @@ -5675,9 +5675,9 @@ } }, "node_modules/rc-input": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/rc-input/-/rc-input-1.7.3.tgz", - "integrity": "sha512-A5w4egJq8+4JzlQ55FfQjDnPvOaAbzwC3VLOAdOytyek3TboSOP9qxN+Gifup+shVXfvecBLBbWBpWxmk02SWQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/rc-input/-/rc-input-1.8.0.tgz", + "integrity": "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.11.1", @@ -5690,15 +5690,15 @@ } }, "node_modules/rc-input-number": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/rc-input-number/-/rc-input-number-9.4.0.tgz", - "integrity": "sha512-Tiy4DcXcFXAf9wDhN8aUAyMeCLHJUHA/VA/t7Hj8ZEx5ETvxG7MArDOSE6psbiSCo+vJPm4E3fGN710ITVn6GA==", + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/rc-input-number/-/rc-input-number-9.5.0.tgz", + "integrity": "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/mini-decimal": "^1.0.1", "classnames": "^2.2.5", - "rc-input": "~1.7.1", + "rc-input": "~1.8.0", "rc-util": "^5.40.1" }, "peerDependencies": { @@ -5707,17 +5707,17 @@ } }, "node_modules/rc-mentions": { - "version": "2.19.1", - "resolved": "https://registry.npmjs.org/rc-mentions/-/rc-mentions-2.19.1.tgz", - "integrity": "sha512-KK3bAc/bPFI993J3necmaMXD2reZTzytZdlTvkeBbp50IGH1BDPDvxLdHDUrpQx2b2TGaVJsn+86BvYa03kGqA==", + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/rc-mentions/-/rc-mentions-2.20.0.tgz", + "integrity": "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.22.5", "@rc-component/trigger": "^2.0.0", "classnames": "^2.2.6", - "rc-input": "~1.7.1", + "rc-input": "~1.8.0", "rc-menu": "~9.16.0", - "rc-textarea": "~1.9.0", + "rc-textarea": "~1.10.0", "rc-util": "^5.34.1" }, "peerDependencies": { @@ -5759,9 +5759,9 @@ } }, "node_modules/rc-notification": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/rc-notification/-/rc-notification-5.6.3.tgz", - "integrity": "sha512-42szwnn8VYQoT6GnjO00i1iwqV9D1TTMvxObWsuLwgl0TsOokzhkYiufdtQBsJMFjJravS1hfDKVMHLKLcPE4g==", + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/rc-notification/-/rc-notification-5.6.4.tgz", + "integrity": "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.10.1", @@ -6007,9 +6007,9 @@ } }, "node_modules/rc-tabs": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/rc-tabs/-/rc-tabs-15.5.2.tgz", - "integrity": "sha512-Hbqf2IV6k/jPgfMjPtIDmPV0D0C9c/fN4B/fYcoh9qqaUzUZQoK0PYzsV3UaV+3UsmyoYt48p74m/HkLhGTw+w==", + "version": "15.6.1", + "resolved": "https://registry.npmjs.org/rc-tabs/-/rc-tabs-15.6.1.tgz", + "integrity": "sha512-/HzDV1VqOsUWyuC0c6AkxVYFjvx9+rFPKZ32ejxX0Uc7QCzcEjTA9/xMgv4HemPKwzBNX8KhGVbbumDjnj92aA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.11.2", @@ -6029,14 +6029,14 @@ } }, "node_modules/rc-textarea": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/rc-textarea/-/rc-textarea-1.9.0.tgz", - "integrity": "sha512-dQW/Bc/MriPBTugj2Kx9PMS5eXCCGn2cxoIaichjbNvOiARlaHdI99j4DTxLl/V8+PIfW06uFy7kjfUIDDKyxQ==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/rc-textarea/-/rc-textarea-1.10.0.tgz", + "integrity": "sha512-ai9IkanNuyBS4x6sOL8qu/Ld40e6cEs6pgk93R+XLYg0mDSjNBGey6/ZpDs5+gNLD7urQ14po3V6Ck2dJLt9SA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.2.1", - "rc-input": "~1.7.1", + "rc-input": "~1.8.0", "rc-resize-observer": "^1.0.0", "rc-util": "^5.27.0" }, @@ -7126,6 +7126,51 @@ "dev": true, "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", + "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tinymce": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/tinymce/-/tinymce-7.7.2.tgz", @@ -7299,15 +7344,18 @@ } }, "node_modules/vite": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.5.tgz", - "integrity": "sha512-j023J/hCAa4pRIUH6J9HemwYfjB5llR2Ps0CWeikOtdR8+pAURAk0DoJC5/mm9kd+UgdnIy7d6HE4EAvlYhPhA==", + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", "postcss": "^8.5.3", - "rollup": "^4.30.1" + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" @@ -7413,6 +7461,34 @@ } } }, + "node_modules/vite/node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vitest": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.1.tgz", diff --git a/worklenz-frontend/package.json b/worklenz-frontend/package.json index 31b7f8bf..562d1b00 100644 --- a/worklenz-frontend/package.json +++ b/worklenz-frontend/package.json @@ -25,12 +25,12 @@ "@tanstack/react-table": "^8.20.6", "@tanstack/react-virtual": "^3.11.2", "@tinymce/tinymce-react": "^5.1.1", - "antd": "^5.24.1", - "axios": "^1.7.9", + "antd": "^5.24.9", + "axios": "^1.9.0", "chart.js": "^4.4.7", "chartjs-plugin-datalabels": "^2.2.0", "date-fns": "^4.1.0", - "dompurify": "^3.2.4", + "dompurify": "^3.2.5", "gantt-task-react": "^0.3.9", "html2canvas": "^1.4.1", "i18next": "^23.16.8", @@ -73,7 +73,7 @@ "tailwindcss": "^3.4.17", "terser": "^5.39.0", "typescript": "^5.7.3", - "vite": "^6.2.5", + "vite": "^6.3.5", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.0.5" }, From 4a2393881b0fc79974508b6691e5c0aa23b747c0 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 7 May 2025 16:32:13 +0530 Subject: [PATCH 18/70] Enhance project view board loading state and data fetching - Introduced a local loading state to improve user experience by displaying a skeleton loader while data is being fetched. - Refactored data loading logic to utilize async/await and Promise.all for concurrent dispatching of task-related data, ensuring efficient data retrieval. - Updated rendering logic to conditionally display the skeleton loader based on the new loading state, enhancing UI responsiveness. --- .../projectView/board/project-view-board.tsx | 44 ++++++++++++++----- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/worklenz-frontend/src/pages/projects/projectView/board/project-view-board.tsx b/worklenz-frontend/src/pages/projects/projectView/board/project-view-board.tsx index 0ec238a8..2b1a7604 100644 --- a/worklenz-frontend/src/pages/projects/projectView/board/project-view-board.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/board/project-view-board.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useRef } from 'react'; +import { useEffect, useState, useRef, useMemo } from 'react'; import { useAppSelector } from '@/hooks/useAppSelector'; import TaskListFilters from '../taskList/task-list-filters/task-list-filters'; import { Flex, Skeleton } from 'antd'; @@ -35,7 +35,6 @@ import { evt_project_board_visit, evt_project_task_list_drag_and_move } from '@/ import { ITaskStatusCreateRequest } from '@/types/tasks/task-status-create-request'; import { statusApiService } from '@/api/taskAttributes/status/status.api.service'; import logger from '@/utils/errorLogger'; -import { tasksApiService } from '@/api/tasks/tasks.api.service'; import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status'; const ProjectViewBoard = () => { @@ -45,7 +44,10 @@ const ProjectViewBoard = () => { const authService = useAuthService(); const currentSession = authService.getCurrentSession(); const { trackMixpanelEvent } = useMixpanelTracking(); - const [ currentTaskIndex, setCurrentTaskIndex] = useState(-1); + const [currentTaskIndex, setCurrentTaskIndex] = useState(-1); + // Add local loading state to immediately show skeleton + const [isLoading, setIsLoading] = useState(true); + const { projectId } = useAppSelector(state => state.projectReducer); const { taskGroups, groupBy, loadingGroups, search, archived } = useAppSelector(state => state.boardReducer); const { statusCategories, loading: loadingStatusCategories } = useAppSelector( @@ -56,14 +58,34 @@ const ProjectViewBoard = () => { // Store the original source group ID when drag starts const originalSourceGroupIdRef = useRef(null); + // Update loading state based on all loading conditions useEffect(() => { - if (projectId && groupBy && projectView === 'kanban') { - if (!loadingGroups) { - dispatch(fetchBoardTaskGroups(projectId)); + setIsLoading(loadingGroups || loadingStatusCategories); + }, [loadingGroups, loadingStatusCategories]); + + // Load data efficiently with async/await and Promise.all + useEffect(() => { + const loadData = async () => { + if (projectId && groupBy && projectView === 'kanban') { + const promises = []; + + if (!loadingGroups) { + promises.push(dispatch(fetchBoardTaskGroups(projectId))); + } + + if (!statusCategories.length) { + promises.push(dispatch(fetchStatusesCategories())); + } + + // Wait for all data to load + await Promise.all(promises); } - } + }; + + loadData(); }, [dispatch, projectId, groupBy, projectView, search, archived]); + // Create sensors with memoization to prevent unnecessary re-renders const sensors = useSensors( useSensor(MouseSensor, { // Require the mouse to move by 10 pixels before activating @@ -394,18 +416,16 @@ const ProjectViewBoard = () => { }; }, [socket]); + // Track analytics event on component mount useEffect(() => { trackMixpanelEvent(evt_project_board_visit); - if (!statusCategories.length && projectId) { - dispatch(fetchStatusesCategories()); - } - }, [dispatch, projectId]); + }, []); return ( - + Date: Thu, 8 May 2025 13:59:31 +0530 Subject: [PATCH 19/70] Implement pagination for members reports and update UI components - Added a new `setPagination` action to manage pagination state in the members reports slice. - Updated the members reports page to display the total number of members in the header. - Enhanced the members reports table to handle pagination changes, ensuring data is fetched correctly based on the current page and page size. --- .../membersReports/membersReportsSlice.ts | 5 +++++ .../members-reports-table.tsx | 20 +++++++++++++++---- .../members-reports/members-reports.tsx | 6 ++---- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/worklenz-frontend/src/features/reporting/membersReports/membersReportsSlice.ts b/worklenz-frontend/src/features/reporting/membersReports/membersReportsSlice.ts index 157f8d7e..2dcd9fe1 100644 --- a/worklenz-frontend/src/features/reporting/membersReports/membersReportsSlice.ts +++ b/worklenz-frontend/src/features/reporting/membersReports/membersReportsSlice.ts @@ -107,6 +107,10 @@ const membersReportsSlice = createSlice({ setDateRange: (state, action) => { state.dateRange = action.payload; }, + setPagination: (state, action) => { + state.index = action.payload.index; + state.pageSize = action.payload.pageSize; + }, }, extraReducers: builder => { builder @@ -139,5 +143,6 @@ export const { setOrder, setDuration, setDateRange, + setPagination, } = membersReportsSlice.actions; export default membersReportsSlice.reducer; diff --git a/worklenz-frontend/src/pages/reporting/members-reports/members-reports-table/members-reports-table.tsx b/worklenz-frontend/src/pages/reporting/members-reports/members-reports-table/members-reports-table.tsx index e94e9818..26b26112 100644 --- a/worklenz-frontend/src/pages/reporting/members-reports/members-reports-table/members-reports-table.tsx +++ b/worklenz-frontend/src/pages/reporting/members-reports/members-reports-table/members-reports-table.tsx @@ -6,9 +6,14 @@ import { useAppDispatch } from '@/hooks/useAppDispatch'; import CustomTableTitle from '@/components/CustomTableTitle'; import TasksProgressCell from './tablesCells/tasksProgressCell/TasksProgressCell'; import MemberCell from './tablesCells/memberCell/MemberCell'; -import { fetchMembersData, toggleMembersReportsDrawer } from '@/features/reporting/membersReports/membersReportsSlice'; +import { + fetchMembersData, + setPagination, + toggleMembersReportsDrawer, +} from '@/features/reporting/membersReports/membersReportsSlice'; import { useAppSelector } from '@/hooks/useAppSelector'; import MembersReportsDrawer from '@/features/reporting/membersReports/membersReportsDrawer/members-reports-drawer'; +import { PaginationConfig } from 'antd/es/pagination'; const MembersReportsTable = () => { const { t } = useTranslation('reporting-members'); @@ -16,7 +21,9 @@ const MembersReportsTable = () => { const [selectedId, setSelectedId] = useState(null); const { duration, dateRange } = useAppSelector(state => state.reportingReducer); - const { membersList, isLoading, total, archived, searchQuery } = useAppSelector(state => state.membersReportsReducer); + const { membersList, isLoading, total, archived, searchQuery, index, pageSize } = useAppSelector( + state => state.membersReportsReducer + ); // function to handle drawer toggle const handleDrawerOpen = (id: string) => { @@ -24,6 +31,10 @@ const MembersReportsTable = () => { dispatch(toggleMembersReportsDrawer()); }; + const handleOnChange = (pagination: any, filters: any, sorter: any, extra: any) => { + dispatch(setPagination({ index: pagination.current, pageSize: pagination.pageSize })); + }; + const columns: TableColumnsType = [ { key: 'member', @@ -40,7 +51,7 @@ const MembersReportsTable = () => { title: , render: record => { const { todo, doing, done } = record.tasks_stat; - return (todo || doing || done) ? : '-'; + return todo || doing || done ? : '-'; }, }, { @@ -95,7 +106,7 @@ const MembersReportsTable = () => { useEffect(() => { if (!isLoading) dispatch(fetchMembersData({ duration, dateRange })); - }, [dispatch, archived, searchQuery, dateRange]); + }, [dispatch, archived, searchQuery, dateRange, index, pageSize]); return ( { dataSource={membersList} rowKey={record => record.id} pagination={{ showSizeChanger: true, defaultPageSize: 10, total: total }} + onChange={(pagination, filters, sorter, extra) => handleOnChange(pagination, filters, sorter, extra)} scroll={{ x: 'max-content' }} loading={isLoading} onRow={record => { diff --git a/worklenz-frontend/src/pages/reporting/members-reports/members-reports.tsx b/worklenz-frontend/src/pages/reporting/members-reports/members-reports.tsx index 3465b08d..9986fd59 100644 --- a/worklenz-frontend/src/pages/reporting/members-reports/members-reports.tsx +++ b/worklenz-frontend/src/pages/reporting/members-reports/members-reports.tsx @@ -25,9 +25,7 @@ const MembersReports = () => { useDocumentTitle('Reporting - Members'); const currentSession = useAuthService().getCurrentSession(); - const { archived, searchQuery } = useAppSelector( - state => state.membersReportsReducer, - ); + const { archived, searchQuery, total } = useAppSelector(state => state.membersReportsReducer); const { duration, dateRange } = useAppSelector(state => state.reportingReducer); @@ -44,7 +42,7 @@ const MembersReports = () => { return ( ) : ( )} From 62548e5c37f04df23424ebfc6cbc3d22cab90178 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 14 May 2025 15:41:09 +0530 Subject: [PATCH 23/70] feat(task-drawer): add recurring task configuration Add support for configuring recurring tasks in the task drawer. This includes adding a new `schedule_id` field to the task type, creating a new `TaskDrawerRecurringConfig` component, and updating localization files for English, Spanish, and Portuguese. The configuration allows setting repeat intervals, days of the week, and monthly recurrence options. --- .../en/task-drawer/task-drawer-info-tab.json | 3 +- .../task-drawer-recurring-config.json | 33 +++ .../es/task-drawer/task-drawer-info-tab.json | 3 +- .../task-drawer-recurring-config.json | 33 +++ .../pt/task-drawer/task-drawer-info-tab.json | 3 +- .../task-drawer-recurring-config.json | 33 +++ .../task-drawer-recurring-config.tsx | 245 ++++++++++++++++++ .../shared/info-tab/task-details-form.tsx | 5 + .../types/tasks/task-recurring-schedule.ts | 19 ++ .../src/types/tasks/task.types.ts | 1 + 10 files changed, 375 insertions(+), 3 deletions(-) create mode 100644 worklenz-frontend/public/locales/en/task-drawer/task-drawer-recurring-config.json create mode 100644 worklenz-frontend/public/locales/es/task-drawer/task-drawer-recurring-config.json create mode 100644 worklenz-frontend/public/locales/pt/task-drawer/task-drawer-recurring-config.json create mode 100644 worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx create mode 100644 worklenz-frontend/src/types/tasks/task-recurring-schedule.ts diff --git a/worklenz-frontend/public/locales/en/task-drawer/task-drawer-info-tab.json b/worklenz-frontend/public/locales/en/task-drawer/task-drawer-info-tab.json index 42ffdc83..b5caeb72 100644 --- a/worklenz-frontend/public/locales/en/task-drawer/task-drawer-info-tab.json +++ b/worklenz-frontend/public/locales/en/task-drawer/task-drawer-info-tab.json @@ -15,7 +15,8 @@ "hide-start-date": "Hide Start Date", "show-start-date": "Show Start Date", "hours": "Hours", - "minutes": "Minutes" + "minutes": "Minutes", + "recurring": "Recurring" }, "description": { "title": "Description", diff --git a/worklenz-frontend/public/locales/en/task-drawer/task-drawer-recurring-config.json b/worklenz-frontend/public/locales/en/task-drawer/task-drawer-recurring-config.json new file mode 100644 index 00000000..f1d0301d --- /dev/null +++ b/worklenz-frontend/public/locales/en/task-drawer/task-drawer-recurring-config.json @@ -0,0 +1,33 @@ +{ + "recurring": "Recurring", + "recurringTaskConfiguration": "Recurring task configuration", + "repeats": "Repeats", + "weekly": "Weekly", + "everyXDays": "Every X Days", + "everyXWeeks": "Every X Weeks", + "everyXMonths": "Every X Months", + "monthly": "Monthly", + "selectDaysOfWeek": "Select Days of the Week", + "mon": "Mon", + "tue": "Tue", + "wed": "Wed", + "thu": "Thu", + "fri": "Fri", + "sat": "Sat", + "sun": "Sun", + "monthlyRepeatType": "Monthly repeat type", + "onSpecificDate": "On a specific date", + "onSpecificDay": "On a specific day", + "dateOfMonth": "Date of the month", + "weekOfMonth": "Week of the month", + "dayOfWeek": "Day of the week", + "first": "First", + "second": "Second", + "third": "Third", + "fourth": "Fourth", + "last": "Last", + "intervalDays": "Interval (days)", + "intervalWeeks": "Interval (weeks)", + "intervalMonths": "Interval (months)", + "saveChanges": "Save Changes" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/es/task-drawer/task-drawer-info-tab.json b/worklenz-frontend/public/locales/es/task-drawer/task-drawer-info-tab.json index 58c5715e..cdafd81c 100644 --- a/worklenz-frontend/public/locales/es/task-drawer/task-drawer-info-tab.json +++ b/worklenz-frontend/public/locales/es/task-drawer/task-drawer-info-tab.json @@ -15,7 +15,8 @@ "hide-start-date": "Ocultar fecha de inicio", "show-start-date": "Mostrar fecha de inicio", "hours": "Horas", - "minutes": "Minutos" + "minutes": "Minutos", + "recurring": "Recurrente" }, "description": { "title": "Descripción", diff --git a/worklenz-frontend/public/locales/es/task-drawer/task-drawer-recurring-config.json b/worklenz-frontend/public/locales/es/task-drawer/task-drawer-recurring-config.json new file mode 100644 index 00000000..d9c711a5 --- /dev/null +++ b/worklenz-frontend/public/locales/es/task-drawer/task-drawer-recurring-config.json @@ -0,0 +1,33 @@ +{ + "recurring": "Recurrente", + "recurringTaskConfiguration": "Configuración de tarea recurrente", + "repeats": "Repeticiones", + "weekly": "Semanal", + "everyXDays": "Cada X días", + "everyXWeeks": "Cada X semanas", + "everyXMonths": "Cada X meses", + "monthly": "Mensual", + "selectDaysOfWeek": "Seleccionar días de la semana", + "mon": "Lun", + "tue": "Mar", + "wed": "Mié", + "thu": "Jue", + "fri": "Vie", + "sat": "Sáb", + "sun": "Dom", + "monthlyRepeatType": "Tipo de repetición mensual", + "onSpecificDate": "En una fecha específica", + "onSpecificDay": "En un día específico", + "dateOfMonth": "Fecha del mes", + "weekOfMonth": "Semana del mes", + "dayOfWeek": "Día de la semana", + "first": "Primero", + "second": "Segundo", + "third": "Tercero", + "fourth": "Cuarto", + "last": "Último", + "intervalDays": "Intervalo (días)", + "intervalWeeks": "Intervalo (semanas)", + "intervalMonths": "Intervalo (meses)", + "saveChanges": "Guardar cambios" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-info-tab.json b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-info-tab.json index 48922a52..fde2215a 100644 --- a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-info-tab.json +++ b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-info-tab.json @@ -15,7 +15,8 @@ "hide-start-date": "Ocultar data de início", "show-start-date": "Mostrar data de início", "hours": "Horas", - "minutes": "Minutos" + "minutes": "Minutos", + "recurring": "Recorrente" }, "description": { "title": "Descrição", diff --git a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-recurring-config.json b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-recurring-config.json new file mode 100644 index 00000000..5619884b --- /dev/null +++ b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-recurring-config.json @@ -0,0 +1,33 @@ +{ + "recurring": "Recorrente", + "recurringTaskConfiguration": "Configuração de tarefa recorrente", + "repeats": "Repete", + "weekly": "Semanal", + "everyXDays": "A cada X dias", + "everyXWeeks": "A cada X semanas", + "everyXMonths": "A cada X meses", + "monthly": "Mensal", + "selectDaysOfWeek": "Selecionar dias da semana", + "mon": "Seg", + "tue": "Ter", + "wed": "Qua", + "thu": "Qui", + "fri": "Sex", + "sat": "Sáb", + "sun": "Dom", + "monthlyRepeatType": "Tipo de repetição mensal", + "onSpecificDate": "Em uma data específica", + "onSpecificDay": "Em um dia específico", + "dateOfMonth": "Data do mês", + "weekOfMonth": "Semana do mês", + "dayOfWeek": "Dia da semana", + "first": "Primeira", + "second": "Segunda", + "third": "Terceira", + "fourth": "Quarta", + "last": "Última", + "intervalDays": "Intervalo (dias)", + "intervalWeeks": "Intervalo (semanas)", + "intervalMonths": "Intervalo (meses)", + "saveChanges": "Salvar alterações" +} \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx new file mode 100644 index 00000000..56daee49 --- /dev/null +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx @@ -0,0 +1,245 @@ +import React, { useState, useMemo, useEffect } from 'react'; +import { Form, Switch, Button, Popover, Select, Checkbox, Radio, InputNumber, Skeleton, Row, Col } from 'antd'; +import { SettingOutlined } from '@ant-design/icons'; +import { useSocket } from '@/socket/socketContext'; +import { SocketEvents } from '@/shared/socket-events'; +import { ITaskRecurringScheduleData } from '@/types/tasks/task-recurring-schedule'; +import { ITaskViewModel } from '@/types/tasks/task.types'; +import { useTranslation } from 'react-i18next'; + +// Dummy enums and types for demonstration; replace with actual imports/types +const ITaskRecurring = { + Weekly: 'weekly', + EveryXDays: 'every_x_days', + EveryXWeeks: 'every_x_weeks', + EveryXMonths: 'every_x_months', + Monthly: 'monthly', +}; + +const repeatOptions = [ + { label: 'Weekly', value: ITaskRecurring.Weekly }, + { label: 'Every X Days', value: ITaskRecurring.EveryXDays }, + { label: 'Every X Weeks', value: ITaskRecurring.EveryXWeeks }, + { label: 'Every X Months', value: ITaskRecurring.EveryXMonths }, + { label: 'Monthly', value: ITaskRecurring.Monthly }, +]; + +const daysOfWeek = [ + { label: 'Mon', value: 'mon' }, + { label: 'Tue', value: 'tue' }, + { label: 'Wed', value: 'wed' }, + { label: 'Thu', value: 'thu' }, + { label: 'Fri', value: 'fri' }, + { label: 'Sat', value: 'sat' }, + { label: 'Sun', value: 'sun' }, +]; + +const monthlyDateOptions = Array.from({ length: 31 }, (_, i) => i + 1); +const weekOptions = [ + { label: 'First', value: 'first' }, + { label: 'Second', value: 'second' }, + { label: 'Third', value: 'third' }, + { label: 'Fourth', value: 'fourth' }, + { label: 'Last', value: 'last' }, +]; +const dayOptions = daysOfWeek.map(d => ({ label: d.label, value: d.value })); + +const TaskDrawerRecurringConfig = ({ task }: {task: ITaskViewModel}) => { + const { socket, connected } = useSocket(); + const { t } = useTranslation('task-drawer/task-drawer-recurring-config'); + + const [recurring, setRecurring] = useState(false); + const [showConfig, setShowConfig] = useState(false); + const [repeatOption, setRepeatOption] = useState(repeatOptions[0]); + const [selectedDays, setSelectedDays] = useState([]); + const [monthlyOption, setMonthlyOption] = useState('date'); + const [selectedMonthlyDate, setSelectedMonthlyDate] = useState(1); + const [selectedMonthlyWeek, setSelectedMonthlyWeek] = useState(weekOptions[0].value); + const [selectedMonthlyDay, setSelectedMonthlyDay] = useState(dayOptions[0].value); + const [intervalDays, setIntervalDays] = useState(1); + const [intervalWeeks, setIntervalWeeks] = useState(1); + const [intervalMonths, setIntervalMonths] = useState(1); + const [loadingData, setLoadingData] = useState(false); + const [updatingData, setUpdatingData] = useState(false); + + const handleChange = (checked: boolean) => { + setRecurring(checked); + if (!checked) setShowConfig(false); + }; + + const configVisibleChange = (visible: boolean) => { + setShowConfig(visible); + }; + + const isMonthlySelected = useMemo(() => repeatOption.value === ITaskRecurring.Monthly, [repeatOption]); + + const handleDayCheckboxChange = (checkedValues: string[]) => { + setSelectedDays(checkedValues as unknown as string[]); + }; + + const handleSave = () => { + // Compose the schedule data and call the update handler + const data = { + recurring, + repeatOption, + selectedDays, + monthlyOption, + selectedMonthlyDate, + selectedMonthlyWeek, + selectedMonthlyDay, + intervalDays, + intervalWeeks, + intervalMonths, + }; + // if (onUpdateSchedule) onUpdateSchedule(data); + setShowConfig(false); + }; + + const getScheduleData = () => { + + }; + + const handleResponse = (response: ITaskRecurringScheduleData) => { + if (!task || !response.task_id) return; + } + + useEffect(() => { + if (task) setRecurring(!!task.schedule_id); + if (recurring) void getScheduleData(); + socket?.on(SocketEvents.TASK_RECURRING_CHANGE.toString(), handleResponse) + }, []) + + return ( +
+ +
+ +   + {recurring && ( + + + + ({ label: date.toString(), value: date }))} + style={{ width: 120 }} + /> + + )} + {monthlyOption === 'day' && ( + <> + + + + + )} + + )} + + {repeatOption.value === ITaskRecurring.EveryXDays && ( + + value && setIntervalDays(value)} /> + + )} + {repeatOption.value === ITaskRecurring.EveryXWeeks && ( + + value && setIntervalWeeks(value)} /> + + )} + {repeatOption.value === ITaskRecurring.EveryXMonths && ( + + value && setIntervalMonths(value)} /> + + )} + + + + + + } + overlayStyle={{ width: 510 }} + open={showConfig} + onOpenChange={configVisibleChange} + trigger="click" + > + + + )} +
+
+
+ ); +}; + +export default TaskDrawerRecurringConfig; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx index f9792485..a2dcaef1 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx @@ -29,6 +29,7 @@ import TaskDrawerBillable from './details/task-drawer-billable/task-drawer-billa import TaskDrawerProgress from './details/task-drawer-progress/task-drawer-progress'; import { useAppSelector } from '@/hooks/useAppSelector'; import logger from '@/utils/errorLogger'; +import TaskDrawerRecurringConfig from './details/task-drawer-recurring-config/task-drawer-recurring-config'; interface TaskDetailsFormProps { taskFormViewModel?: ITaskFormViewModel | null; @@ -175,6 +176,10 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) => + + + + diff --git a/worklenz-frontend/src/types/tasks/task-recurring-schedule.ts b/worklenz-frontend/src/types/tasks/task-recurring-schedule.ts new file mode 100644 index 00000000..8fc708d5 --- /dev/null +++ b/worklenz-frontend/src/types/tasks/task-recurring-schedule.ts @@ -0,0 +1,19 @@ +export interface ITaskRecurringSchedule { + type: 'daily' | 'weekly' | 'monthly' | 'interval'; + dayOfWeek?: number; // 0 = Sunday, 1 = Monday, ..., 6 = Saturday (for weekly tasks) + dayOfMonth?: number; // 1 - 31 (for monthly tasks) + weekOfMonth?: number; // 1 = 1st week, 2 = 2nd week, ..., 5 = Last week (for monthly tasks) + hour: number; // Time of the day in 24-hour format + minute: number; // Minute of the hour + interval?: { + days?: number; // Interval in days (for every x days) + weeks?: number; // Interval in weeks (for every x weeks) + months?: number; // Interval in months (for every x months) + }; +} + +export interface ITaskRecurringScheduleData { + task_id?: string, + id?: string, + schedule_type?: string +} \ No newline at end of file diff --git a/worklenz-frontend/src/types/tasks/task.types.ts b/worklenz-frontend/src/types/tasks/task.types.ts index 9c5da9bf..d155490c 100644 --- a/worklenz-frontend/src/types/tasks/task.types.ts +++ b/worklenz-frontend/src/types/tasks/task.types.ts @@ -64,6 +64,7 @@ export interface ITaskViewModel extends ITask { timer_start_time?: number; recurring?: boolean; task_level?: number; + schedule_id?: string | null; } export interface ITaskTeamMember extends ITeamMember { From fe2518d53c6456405e44be682a1d5b70c99e001e Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 14 May 2025 16:31:17 +0530 Subject: [PATCH 24/70] feat(service-worker): add unregister script and update index.html - Introduced a new script to unregister service workers, enhancing control over service worker lifecycle. - Updated index.html to include the unregister script, ensuring it is loaded for proper service worker management. --- worklenz-frontend/index.html | 2 ++ worklenz-frontend/public/unregister-sw.js | 7 +++++++ 2 files changed, 9 insertions(+) create mode 100644 worklenz-frontend/public/unregister-sw.js diff --git a/worklenz-frontend/index.html b/worklenz-frontend/index.html index 86abad6e..7f1e3d71 100644 --- a/worklenz-frontend/index.html +++ b/worklenz-frontend/index.html @@ -14,6 +14,8 @@ Worklenz + + diff --git a/worklenz-frontend/public/unregister-sw.js b/worklenz-frontend/public/unregister-sw.js new file mode 100644 index 00000000..62fb7ac4 --- /dev/null +++ b/worklenz-frontend/public/unregister-sw.js @@ -0,0 +1,7 @@ +if ('serviceWorker' in navigator) { + navigator.serviceWorker.getRegistrations().then(function(registrations) { + for(let registration of registrations) { + registration.unregister(); + } + }); +} \ No newline at end of file From 7ac35bfdbc7cad60d5334f31c1e96f7922d4d93b Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 14 May 2025 17:07:38 +0530 Subject: [PATCH 25/70] fix(service-worker): improve unregister logic for service workers - Updated the unregister script to first check for registered service workers and perform a hard reload if any are found. - If no service workers are registered, the script will now properly unregister any pending registrations, enhancing the service worker lifecycle management. --- worklenz-frontend/public/unregister-sw.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/worklenz-frontend/public/unregister-sw.js b/worklenz-frontend/public/unregister-sw.js index 62fb7ac4..82d2c57f 100644 --- a/worklenz-frontend/public/unregister-sw.js +++ b/worklenz-frontend/public/unregister-sw.js @@ -1,7 +1,13 @@ if ('serviceWorker' in navigator) { navigator.serviceWorker.getRegistrations().then(function(registrations) { - for(let registration of registrations) { - registration.unregister(); + if (registrations.length > 0) { + // If there are registered service workers, do a hard reload first + window.location.reload(true); + } else { + // If no service workers are registered, unregister any that might be pending + for(let registration of registrations) { + registration.unregister(); + } } }); } \ No newline at end of file From 0e1314d1833df55fb0bd2ba98936acc3b93a8064 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Wed, 14 May 2025 18:34:08 +0530 Subject: [PATCH 26/70] fix(service-worker): prevent multiple unregister attempts in session - Updated the unregister script to check if an attempt to unregister service workers has already been made in the current session, preventing unnecessary reloads and improving user experience. - If service workers are registered, the script will perform a hard reload; otherwise, it will unregister any pending registrations. --- worklenz-frontend/public/unregister-sw.js | 25 ++++++++++++++--------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/worklenz-frontend/public/unregister-sw.js b/worklenz-frontend/public/unregister-sw.js index 82d2c57f..b5978d50 100644 --- a/worklenz-frontend/public/unregister-sw.js +++ b/worklenz-frontend/public/unregister-sw.js @@ -1,13 +1,18 @@ if ('serviceWorker' in navigator) { - navigator.serviceWorker.getRegistrations().then(function(registrations) { - if (registrations.length > 0) { - // If there are registered service workers, do a hard reload first - window.location.reload(true); - } else { - // If no service workers are registered, unregister any that might be pending - for(let registration of registrations) { - registration.unregister(); + // Check if we've already attempted to unregister in this session + if (!sessionStorage.getItem('swUnregisterAttempted')) { + navigator.serviceWorker.getRegistrations().then(function(registrations) { + if (registrations.length > 0) { + // Mark that we've attempted to unregister + sessionStorage.setItem('swUnregisterAttempted', 'true'); + // If there are registered service workers, do a hard reload first + window.location.reload(true); + } else { + // If no service workers are registered, unregister any that might be pending + for(let registration of registrations) { + registration.unregister(); + } } - } - }); + }); + } } \ No newline at end of file From 407b3c5ba75e07c6c558191ddefe10921d19137a Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Wed, 14 May 2025 18:41:06 +0530 Subject: [PATCH 27/70] fix(service-worker): enhance unregister logic and update index.html - Updated the index.html to load the env-config.js script as a module for better compatibility. - Improved the unregister logic in both the unregister-sw.js and login-page.tsx to specifically target the ngsw-worker, ensuring it is unregistered correctly and the page reloads afterward. This prevents multiple unregister attempts and enhances user experience. --- worklenz-frontend/index.html | 2 +- worklenz-frontend/public/unregister-sw.js | 13 +++++++++---- worklenz-frontend/src/pages/auth/login-page.tsx | 12 ++++++++++++ 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/worklenz-frontend/index.html b/worklenz-frontend/index.html index 7f1e3d71..0435deeb 100644 --- a/worklenz-frontend/index.html +++ b/worklenz-frontend/index.html @@ -13,7 +13,7 @@ /> Worklenz - + diff --git a/worklenz-frontend/public/unregister-sw.js b/worklenz-frontend/public/unregister-sw.js index b5978d50..02c9bc86 100644 --- a/worklenz-frontend/public/unregister-sw.js +++ b/worklenz-frontend/public/unregister-sw.js @@ -2,13 +2,18 @@ if ('serviceWorker' in navigator) { // Check if we've already attempted to unregister in this session if (!sessionStorage.getItem('swUnregisterAttempted')) { navigator.serviceWorker.getRegistrations().then(function(registrations) { - if (registrations.length > 0) { + const ngswWorker = registrations.find(reg => reg.active?.scriptURL.includes('ngsw-worker')); + + if (ngswWorker) { // Mark that we've attempted to unregister sessionStorage.setItem('swUnregisterAttempted', 'true'); - // If there are registered service workers, do a hard reload first - window.location.reload(true); + // Unregister the ngsw-worker + ngswWorker.unregister().then(() => { + // Reload the page after unregistering + window.location.reload(true); + }); } else { - // If no service workers are registered, unregister any that might be pending + // If no ngsw-worker is found, unregister any other service workers for(let registration of registrations) { registration.unregister(); } diff --git a/worklenz-frontend/src/pages/auth/login-page.tsx b/worklenz-frontend/src/pages/auth/login-page.tsx index 097e4e65..7e16f1a5 100644 --- a/worklenz-frontend/src/pages/auth/login-page.tsx +++ b/worklenz-frontend/src/pages/auth/login-page.tsx @@ -77,6 +77,18 @@ const LoginPage: React.FC = () => { }; useEffect(() => { + // Check and unregister ngsw-worker if present + if ('serviceWorker' in navigator) { + navigator.serviceWorker.getRegistrations().then(function(registrations) { + const ngswWorker = registrations.find(reg => reg.active?.scriptURL.includes('ngsw-worker')); + if (ngswWorker) { + ngswWorker.unregister().then(() => { + window.location.reload(); + }); + } + }); + } + trackMixpanelEvent(evt_login_page_visit); if (currentSession && !currentSession?.setup_completed) { navigate('/worklenz/setup'); From c52b223c5967061ed869a3679b71a4ba86d86de7 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Wed, 14 May 2025 18:43:07 +0530 Subject: [PATCH 28/70] fix(index.html): change env-config.js script type for compatibility - Updated the script tag for env-config.js in index.html to remove the type="module" attribute, ensuring better compatibility with existing code. --- worklenz-frontend/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worklenz-frontend/index.html b/worklenz-frontend/index.html index 0435deeb..7f1e3d71 100644 --- a/worklenz-frontend/index.html +++ b/worklenz-frontend/index.html @@ -13,7 +13,7 @@ /> Worklenz - + From c4837e7e5c8ee3fd8339804d47bc9726f60f82f6 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Wed, 14 May 2025 19:17:39 +0530 Subject: [PATCH 29/70] fix(tasks-controller): update SQL queries to use template literals for projectId - Modified SQL queries in TasksControllerV2 to use template literals for the projectId variable, enhancing readability and consistency in the code. - Removed the parameterized query approach for projectId in the relevant sections of the code. --- worklenz-backend/src/controllers/tasks-controller-v2.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index 3284560f..c3231825 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -843,7 +843,7 @@ export default class TasksControllerV2 extends TasksControllerBase { -- First, reset manual_progress flag for all tasks that have subtasks within this project UPDATE tasks AS t SET manual_progress = FALSE - WHERE project_id = $1 + WHERE project_id = '${projectId}' AND EXISTS ( SELECT 1 FROM tasks @@ -860,7 +860,7 @@ export default class TasksControllerV2 extends TasksControllerBase { parent_task_id, 0 AS level FROM tasks - WHERE project_id = $1 + WHERE project_id = '${projectId}' AND NOT EXISTS ( SELECT 1 FROM tasks AS sub WHERE sub.parent_task_id = tasks.id @@ -888,12 +888,12 @@ export default class TasksControllerV2 extends TasksControllerBase { ORDER BY level ) AS ordered_tasks WHERE tasks.id = ordered_tasks.id - AND tasks.project_id = $1 + AND tasks.project_id = '${projectId}' AND (manual_progress IS FALSE OR manual_progress IS NULL); END $$; `; - const result = await db.query(query, [projectId]); + const result = await db.query(query); console.log(`Finished refreshing progress values for project ${projectId}`); } catch (error) { log_error('Error refreshing project task progress values', error); From 05ab135ed25cadf12ace147a97983e7f08f7efc5 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Thu, 15 May 2025 07:22:56 +0530 Subject: [PATCH 30/70] feat(assets): add light and dark mode logos for improved branding - Introduced new logo images for both light and dark modes, enhancing the visual consistency of the application. - Updated references in AuthPageHeader, NavbarLogo, AccountSetup, and ProjectViewInsights components to use the new logo images. --- .../src/assets/images/worklenz-dark-mode.png | Bin 0 -> 9571 bytes .../src/assets/images/worklenz-light-mode.png | Bin 0 -> 9283 bytes .../src/components/AuthPageHeader.tsx | 4 ++-- .../src/features/navbar/navbar-logo.tsx | 21 ++---------------- .../src/pages/account-setup/account-setup.tsx | 4 ++-- .../insights/project-view-insights.tsx | 2 +- 6 files changed, 7 insertions(+), 24 deletions(-) create mode 100644 worklenz-frontend/src/assets/images/worklenz-dark-mode.png create mode 100644 worklenz-frontend/src/assets/images/worklenz-light-mode.png diff --git a/worklenz-frontend/src/assets/images/worklenz-dark-mode.png b/worklenz-frontend/src/assets/images/worklenz-dark-mode.png new file mode 100644 index 0000000000000000000000000000000000000000..3a22e2380f58124fcbfdbb3b2f2e46fe2822555b GIT binary patch literal 9571 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 b2aL-^@#rgD%K_9kKWG{%I?7;0>&X8Be|jlU literal 0 HcmV?d00001 diff --git a/worklenz-frontend/src/components/AuthPageHeader.tsx b/worklenz-frontend/src/components/AuthPageHeader.tsx index a94d5fa5..e48b8ce8 100644 --- a/worklenz-frontend/src/components/AuthPageHeader.tsx +++ b/worklenz-frontend/src/components/AuthPageHeader.tsx @@ -1,6 +1,6 @@ import { Flex, Typography } from 'antd'; -import logo from '../assets/images/logo.png'; -import logoDark from '@/assets/images/logo-dark-mode.png'; +import logo from '@/assets/images/worklenz-light-mode.png'; +import logoDark from '@/assets/images/worklenz-dark-mode.png'; import { useAppSelector } from '@/hooks/useAppSelector'; type AuthPageHeaderProp = { diff --git a/worklenz-frontend/src/features/navbar/navbar-logo.tsx b/worklenz-frontend/src/features/navbar/navbar-logo.tsx index 273c3214..afa8b1c0 100644 --- a/worklenz-frontend/src/features/navbar/navbar-logo.tsx +++ b/worklenz-frontend/src/features/navbar/navbar-logo.tsx @@ -1,8 +1,8 @@ import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import logo from '@/assets/images/logo.png'; -import logoDark from '@/assets/images/logo-dark-mode.png'; +import logo from '@/assets/images/worklenz-light-mode.png'; +import logoDark from '@/assets/images/worklenz-dark-mode.png'; import { useAppSelector } from '@/hooks/useAppSelector'; import { useSelector } from 'react-redux'; @@ -20,23 +20,6 @@ const NavbarLogo = () => { alt={t('logoAlt')} style={{ width: '100%', maxWidth: 140 }} /> - - Beta - ); diff --git a/worklenz-frontend/src/pages/account-setup/account-setup.tsx b/worklenz-frontend/src/pages/account-setup/account-setup.tsx index 9e8bb20c..84496fe9 100644 --- a/worklenz-frontend/src/pages/account-setup/account-setup.tsx +++ b/worklenz-frontend/src/pages/account-setup/account-setup.tsx @@ -24,8 +24,8 @@ import { useDocumentTitle } from '@/hooks/useDoumentTItle'; import { getUserSession, setSession } from '@/utils/session-helper'; import { validateEmail } from '@/utils/validateEmail'; import { sanitizeInput } from '@/utils/sanitizeInput'; -import logo from '@/assets/images/logo.png'; -import logoDark from '@/assets/images/logo-dark-mode.png'; +import logo from '@/assets/images/worklenz-light-mode.png'; +import logoDark from '@/assets/images/worklenz-dark-mode.png'; import './account-setup.css'; import { IAccountSetupRequest } from '@/types/project-templates/project-templates.types'; diff --git a/worklenz-frontend/src/pages/projects/projectView/insights/project-view-insights.tsx b/worklenz-frontend/src/pages/projects/projectView/insights/project-view-insights.tsx index d986220d..55d9df48 100644 --- a/worklenz-frontend/src/pages/projects/projectView/insights/project-view-insights.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/insights/project-view-insights.tsx @@ -17,7 +17,7 @@ import { import { format } from 'date-fns'; import html2canvas from 'html2canvas'; import jsPDF from 'jspdf'; -import logo from '@/assets/images/logo.png'; +import logo from '@/assets/images/worklenz-light-mode.png'; import { evt_project_insights_members_visit, evt_project_insights_overview_visit, evt_project_insights_tasks_visit } from '@/shared/worklenz-analytics-events'; import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; From 33c15ac138febcdd95f3547a191a425370383dae Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Thu, 15 May 2025 07:42:21 +0530 Subject: [PATCH 31/70] feat(assets): add empty box placeholder image and update component reference - Introduced a new empty box placeholder image to enhance the visual representation of empty states in the application. - Updated the EmptyListPlaceholder component to reference the new image path, ensuring proper display in relevant contexts. --- .../src/assets/images/empty-box.webp | Bin 0 -> 19842 bytes .../src/components/EmptyListPlaceholder.tsx | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 worklenz-frontend/src/assets/images/empty-box.webp diff --git a/worklenz-frontend/src/assets/images/empty-box.webp b/worklenz-frontend/src/assets/images/empty-box.webp new file mode 100644 index 0000000000000000000000000000000000000000..a23c97bd2f8b9aec47f1218c7dc5112bb53c7d8a GIT binary patch literal 19842 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 { From 536c1c37b1de1e03b3a2cf8603a1b5364a625289 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Thu, 15 May 2025 07:56:15 +0530 Subject: [PATCH 32/70] feat(settings): add appearance settings with dark mode toggle and translations - Introduced new appearance settings page with a dark mode toggle feature. - Added localization support for English, Spanish, and Portuguese in appearance settings. - Removed the ThemeSelector component and updated PreferenceSelector accordingly. --- .../locales/en/settings/appearance.json | 5 +++ .../public/locales/en/settings/sidebar.json | 3 +- .../locales/es/settings/appearance.json | 5 +++ .../public/locales/es/settings/sidebar.json | 3 +- .../locales/pt/settings/appearance.json | 5 +++ .../public/locales/pt/settings/sidebar.json | 3 +- worklenz-frontend/src/App.tsx | 1 - .../src/components/PreferenceSelector.tsx | 6 +-- .../src/features/theme/ThemeSelector.tsx | 28 ------------ .../src/lib/settings/settings-constants.ts | 9 ++++ .../appearance/appearance-settings.tsx | 44 +++++++++++++++++++ 11 files changed, 77 insertions(+), 35 deletions(-) create mode 100644 worklenz-frontend/public/locales/en/settings/appearance.json create mode 100644 worklenz-frontend/public/locales/es/settings/appearance.json create mode 100644 worklenz-frontend/public/locales/pt/settings/appearance.json delete mode 100644 worklenz-frontend/src/features/theme/ThemeSelector.tsx create mode 100644 worklenz-frontend/src/pages/settings/appearance/appearance-settings.tsx diff --git a/worklenz-frontend/public/locales/en/settings/appearance.json b/worklenz-frontend/public/locales/en/settings/appearance.json new file mode 100644 index 00000000..76fb246f --- /dev/null +++ b/worklenz-frontend/public/locales/en/settings/appearance.json @@ -0,0 +1,5 @@ +{ + "title": "Appearance", + "darkMode": "Dark Mode", + "darkModeDescription": "Switch between light and dark mode to customize your viewing experience." +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/en/settings/sidebar.json b/worklenz-frontend/public/locales/en/settings/sidebar.json index 41bc3e0f..d0b64829 100644 --- a/worklenz-frontend/public/locales/en/settings/sidebar.json +++ b/worklenz-frontend/public/locales/en/settings/sidebar.json @@ -10,5 +10,6 @@ "team-members": "Team Members", "teams": "Teams", "change-password": "Change Password", - "language-and-region": "Language and Region" + "language-and-region": "Language and Region", + "appearance": "Appearance" } diff --git a/worklenz-frontend/public/locales/es/settings/appearance.json b/worklenz-frontend/public/locales/es/settings/appearance.json new file mode 100644 index 00000000..a4c168a4 --- /dev/null +++ b/worklenz-frontend/public/locales/es/settings/appearance.json @@ -0,0 +1,5 @@ +{ + "title": "Apariencia", + "darkMode": "Modo Oscuro", + "darkModeDescription": "Cambia entre el modo claro y oscuro para personalizar tu experiencia visual." +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/es/settings/sidebar.json b/worklenz-frontend/public/locales/es/settings/sidebar.json index 32d529ea..3793e77f 100644 --- a/worklenz-frontend/public/locales/es/settings/sidebar.json +++ b/worklenz-frontend/public/locales/es/settings/sidebar.json @@ -10,5 +10,6 @@ "team-members": "Miembros del equipo", "teams": "Equipos", "change-password": "Cambiar contraseña", - "language-and-region": "Idioma y región" + "language-and-region": "Idioma y región", + "appearance": "Apariencia" } diff --git a/worklenz-frontend/public/locales/pt/settings/appearance.json b/worklenz-frontend/public/locales/pt/settings/appearance.json new file mode 100644 index 00000000..eaffbb32 --- /dev/null +++ b/worklenz-frontend/public/locales/pt/settings/appearance.json @@ -0,0 +1,5 @@ +{ + "title": "Aparência", + "darkMode": "Modo Escuro", + "darkModeDescription": "Alterne entre o modo claro e escuro para personalizar sua experiência de visualização." +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/pt/settings/sidebar.json b/worklenz-frontend/public/locales/pt/settings/sidebar.json index b9047fae..67fac9dc 100644 --- a/worklenz-frontend/public/locales/pt/settings/sidebar.json +++ b/worklenz-frontend/public/locales/pt/settings/sidebar.json @@ -10,5 +10,6 @@ "team-members": "Membros da Equipe", "teams": "Equipes", "change-password": "Alterar Senha", - "language-and-region": "Idioma e Região" + "language-and-region": "Idioma e Região", + "appearance": "Aparência" } \ No newline at end of file diff --git a/worklenz-frontend/src/App.tsx b/worklenz-frontend/src/App.tsx index 13abc6f8..8b313508 100644 --- a/worklenz-frontend/src/App.tsx +++ b/worklenz-frontend/src/App.tsx @@ -39,7 +39,6 @@ const App: React.FC<{ children: React.ReactNode }> = ({ children }) => { }> - ); diff --git a/worklenz-frontend/src/components/PreferenceSelector.tsx b/worklenz-frontend/src/components/PreferenceSelector.tsx index 9bf3c324..b908c247 100644 --- a/worklenz-frontend/src/components/PreferenceSelector.tsx +++ b/worklenz-frontend/src/components/PreferenceSelector.tsx @@ -1,7 +1,7 @@ import { FloatButton, Space, Tooltip } from 'antd'; import { FormatPainterOutlined } from '@ant-design/icons'; -import LanguageSelector from '../features/i18n/language-selector'; -import ThemeSelector from '../features/theme/ThemeSelector'; +// import LanguageSelector from '../features/i18n/language-selector'; +// import ThemeSelector from '../features/theme/ThemeSelector'; const PreferenceSelector = () => { return ( @@ -17,7 +17,7 @@ const PreferenceSelector = () => { justifyContent: 'center', }} > - + {/* */} diff --git a/worklenz-frontend/src/features/theme/ThemeSelector.tsx b/worklenz-frontend/src/features/theme/ThemeSelector.tsx deleted file mode 100644 index ea26b286..00000000 --- a/worklenz-frontend/src/features/theme/ThemeSelector.tsx +++ /dev/null @@ -1,28 +0,0 @@ -// ThemeSelector.tsx -import { Button } from 'antd'; -import React from 'react'; -import { useAppDispatch } from '@/hooks/useAppDispatch'; -import { useAppSelector } from '@/hooks/useAppSelector'; -import { toggleTheme } from './themeSlice'; -import { MoonOutlined, SunOutlined } from '@ant-design/icons'; - -const ThemeSelector = () => { - const themeMode = useAppSelector(state => state.themeReducer.mode); - const dispatch = useAppDispatch(); - - const handleDarkModeToggle = () => { - dispatch(toggleTheme()); - }; - - return ( - - - -
- } - overlayStyle={{ width: 510 }} - open={showConfig} - onOpenChange={configVisibleChange} - trigger="click" - > - - - )} - - - + } ); + }; + + const configVisibleChange = (visible: boolean) => { + setShowConfig(visible); + }; + + const isMonthlySelected = useMemo( + () => repeatOption.value === ITaskRecurring.Monthly, + [repeatOption] + ); + + const handleDayCheckboxChange = (checkedValues: string[]) => { + setSelectedDays(checkedValues as unknown as string[]); + }; + + const handleSave = () => { + // Compose the schedule data and call the update handler + const data = { + recurring, + repeatOption, + selectedDays, + monthlyOption, + selectedMonthlyDate, + selectedMonthlyWeek, + selectedMonthlyDay, + intervalDays, + intervalWeeks, + intervalMonths, + }; + // if (onUpdateSchedule) onUpdateSchedule(data); + setShowConfig(false); + }; + + const getScheduleData = () => {}; + + const handleResponse = (response: ITaskRecurringScheduleData) => { + if (!task || !response.task_id) return; + }; + + useEffect(() => { + if (task) setRecurring(!!task.schedule_id); + if (recurring) void getScheduleData(); + socket?.on(SocketEvents.TASK_RECURRING_CHANGE.toString(), handleResponse); + }, []); + + return ( +
+ +
+ +   + {recurring && ( + +
+ + ({ + label: date.toString(), + value: date, + }))} + style={{ width: 120 }} + /> + + )} + {monthlyOption === 'day' && ( + <> + + + + + )} + + )} + + {repeatOption.value === ITaskRecurring.EveryXDays && ( + + value && setIntervalDays(value)} + /> + + )} + {repeatOption.value === ITaskRecurring.EveryXWeeks && ( + + value && setIntervalWeeks(value)} + /> + + )} + {repeatOption.value === ITaskRecurring.EveryXMonths && ( + + value && setIntervalMonths(value)} + /> + + )} + + + +
+ + } + overlayStyle={{ width: 510 }} + open={showConfig} + onOpenChange={configVisibleChange} + trigger="click" + > + +
+ )} +
+
+
+ ); }; -export default TaskDrawerRecurringConfig; \ No newline at end of file +export default TaskDrawerRecurringConfig; diff --git a/worklenz-frontend/src/features/tasks/tasks.slice.ts b/worklenz-frontend/src/features/tasks/tasks.slice.ts index cd443dbf..49c85e28 100644 --- a/worklenz-frontend/src/features/tasks/tasks.slice.ts +++ b/worklenz-frontend/src/features/tasks/tasks.slice.ts @@ -22,6 +22,7 @@ import { ITaskPhaseChangeResponse } from '@/types/tasks/task-phase-change-respon import { produce } from 'immer'; import { tasksCustomColumnsService } from '@/api/tasks/tasks-custom-columns.service'; import { SocketEvents } from '@/shared/socket-events'; +import { ITaskRecurringScheduleData } from '@/types/tasks/task-recurring-schedule'; export enum IGroupBy { STATUS = 'status', @@ -1006,6 +1007,15 @@ const taskSlice = createSlice({ column.pinned = isVisible; } }, + + updateRecurringChange: (state, action: PayloadAction) => { + const {id, schedule_type, task_id} = action.payload; + const taskInfo = findTaskInGroups(state.taskGroups, task_id as string); + if (!taskInfo) return; + + const { task } = taskInfo; + task.schedule_id = id; + } }, extraReducers: builder => { @@ -1165,6 +1175,7 @@ export const { updateSubTasks, updateCustomColumnValue, updateCustomColumnPinned, + updateRecurringChange } = taskSlice.actions; export default taskSlice.reducer; From 2a3f87cac1df1d8e0b3b84d44210e25c548ef688 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 16 May 2025 12:42:33 +0530 Subject: [PATCH 40/70] fix(index.html): update Google Analytics integration to load only in production --- worklenz-frontend/index.html | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/worklenz-frontend/index.html b/worklenz-frontend/index.html index 172938b5..57a2a1b0 100644 --- a/worklenz-frontend/index.html +++ b/worklenz-frontend/index.html @@ -26,13 +26,21 @@ })(window, document, "clarity", "script", "dx77073klh"); } - - - From 2e985bd05161bd337a7904820f3ad17706c3edd8 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 16 May 2025 14:32:45 +0530 Subject: [PATCH 41/70] feat(recurring-tasks): implement recurring task scheduling and API integration --- worklenz-backend/src/cron_jobs/index.ts | 4 +- .../src/cron_jobs/recurring-tasks.ts | 2 +- .../api/tasks/task-recurring.api.service.ts | 16 ++ .../task-drawer-recurring-config.tsx | 164 +++++++++++++----- .../features/task-drawer/task-drawer.slice.ts | 10 ++ .../types/tasks/task-recurring-schedule.ts | 48 +++-- 6 files changed, 185 insertions(+), 59 deletions(-) create mode 100644 worklenz-frontend/src/api/tasks/task-recurring.api.service.ts diff --git a/worklenz-backend/src/cron_jobs/index.ts b/worklenz-backend/src/cron_jobs/index.ts index f13ec2e8..20bd4f62 100644 --- a/worklenz-backend/src/cron_jobs/index.ts +++ b/worklenz-backend/src/cron_jobs/index.ts @@ -1,11 +1,11 @@ import {startDailyDigestJob} from "./daily-digest-job"; import {startNotificationsJob} from "./notifications-job"; import {startProjectDigestJob} from "./project-digest-job"; -import { startRecurringTasksJob } from "./recurring-tasks"; +import {startRecurringTasksJob} from "./recurring-tasks"; export function startCronJobs() { startNotificationsJob(); startDailyDigestJob(); startProjectDigestJob(); - // startRecurringTasksJob(); + startRecurringTasksJob(); } diff --git a/worklenz-backend/src/cron_jobs/recurring-tasks.ts b/worklenz-backend/src/cron_jobs/recurring-tasks.ts index a9ae7847..16854c7e 100644 --- a/worklenz-backend/src/cron_jobs/recurring-tasks.ts +++ b/worklenz-backend/src/cron_jobs/recurring-tasks.ts @@ -7,7 +7,7 @@ import TasksController from "../controllers/tasks-controller"; // At 11:00+00 (4.30pm+530) on every day-of-month if it's on every day-of-week from Monday through Friday. // const TIME = "0 11 */1 * 1-5"; -const TIME = "*/2 * * * *"; +const TIME = "*/2 * * * *"; // runs every 2 minutes - for testing purposes const TIME_FORMAT = "YYYY-MM-DD"; // const TIME = "0 0 * * *"; // Runs at midnight every day diff --git a/worklenz-frontend/src/api/tasks/task-recurring.api.service.ts b/worklenz-frontend/src/api/tasks/task-recurring.api.service.ts new file mode 100644 index 00000000..6e19d7cb --- /dev/null +++ b/worklenz-frontend/src/api/tasks/task-recurring.api.service.ts @@ -0,0 +1,16 @@ +import { API_BASE_URL } from "@/shared/constants"; +import { IServerResponse } from "@/types/common.types"; +import { ITaskRecurringSchedule } from "@/types/tasks/task-recurring-schedule"; +import apiClient from "../api-client"; + +const rootUrl = `${API_BASE_URL}/task-recurring`; + +export const taskRecurringApiService = { + getTaskRecurringData: async (schedule_id: string): Promise> => { + const response = await apiClient.get(`${rootUrl}/${schedule_id}`); + return response.data; + }, + updateTaskRecurringData: async (schedule_id: string, body: any): Promise> => { + return apiClient.put(`${rootUrl}/${schedule_id}`, body); + } +} \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx index 1e5af1d8..608fc321 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx @@ -15,21 +15,17 @@ import { import { SettingOutlined } from '@ant-design/icons'; import { useSocket } from '@/socket/socketContext'; import { SocketEvents } from '@/shared/socket-events'; -import { ITaskRecurringScheduleData } from '@/types/tasks/task-recurring-schedule'; +import { IRepeatOption, ITaskRecurring, ITaskRecurringSchedule, ITaskRecurringScheduleData } from '@/types/tasks/task-recurring-schedule'; import { ITaskViewModel } from '@/types/tasks/task.types'; import { useTranslation } from 'react-i18next'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { updateRecurringChange } from '@/features/tasks/tasks.slice'; +import { taskRecurringApiService } from '@/api/tasks/task-recurring.api.service'; +import logger from '@/utils/errorLogger'; +import { setTaskRecurringSchedule } from '@/features/task-drawer/task-drawer.slice'; -const ITaskRecurring = { - Weekly: 'weekly', - EveryXDays: 'every_x_days', - EveryXWeeks: 'every_x_weeks', - EveryXMonths: 'every_x_months', - Monthly: 'monthly', -}; - -const repeatOptions = [ +const repeatOptions: IRepeatOption[] = [ + { label: 'Daily', value: ITaskRecurring.Daily }, { label: 'Weekly', value: ITaskRecurring.Weekly }, { label: 'Every X Days', value: ITaskRecurring.EveryXDays }, { label: 'Every X Weeks', value: ITaskRecurring.EveryXWeeks }, @@ -38,22 +34,22 @@ const repeatOptions = [ ]; const daysOfWeek = [ - { label: 'Mon', value: 'mon' }, - { label: 'Tue', value: 'tue' }, - { label: 'Wed', value: 'wed' }, - { label: 'Thu', value: 'thu' }, - { label: 'Fri', value: 'fri' }, - { label: 'Sat', value: 'sat' }, - { label: 'Sun', value: 'sun' }, + { label: 'Sunday', value: 0, checked: false }, + { label: 'Monday', value: 1, checked: false }, + { label: 'Tuesday', value: 2, checked: false }, + { label: 'Wednesday', value: 3, checked: false }, + { label: 'Thursday', value: 4, checked: false }, + { label: 'Friday', value: 5, checked: false }, + { label: 'Saturday', value: 6, checked: false } ]; -const monthlyDateOptions = Array.from({ length: 31 }, (_, i) => i + 1); +const monthlyDateOptions = Array.from({ length: 28 }, (_, i) => i + 1); const weekOptions = [ - { label: 'First', value: 'first' }, - { label: 'Second', value: 'second' }, - { label: 'Third', value: 'third' }, - { label: 'Fourth', value: 'fourth' }, - { label: 'Last', value: 'last' }, + { label: 'First', value: 1 }, + { label: 'Second', value: 2 }, + { label: 'Third', value: 3 }, + { label: 'Fourth', value: 4 }, + { label: 'Last', value: 5 } ]; const dayOptions = daysOfWeek.map(d => ({ label: d.label, value: d.value })); @@ -64,7 +60,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => { const [recurring, setRecurring] = useState(false); const [showConfig, setShowConfig] = useState(false); - const [repeatOption, setRepeatOption] = useState(repeatOptions[0]); + const [repeatOption, setRepeatOption] = useState({}); const [selectedDays, setSelectedDays] = useState([]); const [monthlyOption, setMonthlyOption] = useState('date'); const [selectedMonthlyDate, setSelectedMonthlyDate] = useState(1); @@ -75,6 +71,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => { const [intervalMonths, setIntervalMonths] = useState(1); const [loadingData, setLoadingData] = useState(false); const [updatingData, setUpdatingData] = useState(false); + const [scheduleData, setScheduleData] = useState({}); const handleChange = (checked: boolean) => { if (!task.id) return; @@ -92,6 +89,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => { if (selected) setRepeatOption(selected); } dispatch(updateRecurringChange(schedule)); + dispatch(setTaskRecurringSchedule({ schedule_id: schedule.id as string, task_id: task.id })); setRecurring(checked); if (!checked) setShowConfig(false); @@ -112,35 +110,119 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => { setSelectedDays(checkedValues as unknown as string[]); }; - const handleSave = () => { - // Compose the schedule data and call the update handler - const data = { - recurring, - repeatOption, - selectedDays, - monthlyOption, - selectedMonthlyDate, - selectedMonthlyWeek, - selectedMonthlyDay, - intervalDays, - intervalWeeks, - intervalMonths, + const getSelectedDays = () => { + return daysOfWeek + .filter(day => day.checked) // Get only the checked days + .map(day => day.value); // Extract their numeric values + } + + const getUpdateBody = () => { + if (!task.id || !task.schedule_id || !repeatOption.value) return; + + const body: ITaskRecurringSchedule = { + id: task.id, + schedule_type: repeatOption.value }; - // if (onUpdateSchedule) onUpdateSchedule(data); - setShowConfig(false); + + switch (repeatOption.value) { + case ITaskRecurring.Weekly: + body.days_of_week = getSelectedDays(); + break; + + case ITaskRecurring.Monthly: + if (monthlyOption === 'date') { + body.date_of_month = selectedMonthlyDate; + setSelectedMonthlyDate(0); + setSelectedMonthlyDay(0); + } else { + body.week_of_month = selectedMonthlyWeek; + body.day_of_month = selectedMonthlyDay; + setSelectedMonthlyDate(0); + } + break; + + case ITaskRecurring.EveryXDays: + body.interval_days = intervalDays; + break; + + case ITaskRecurring.EveryXWeeks: + body.interval_weeks = intervalWeeks; + break; + + case ITaskRecurring.EveryXMonths: + body.interval_months = intervalMonths; + break; + } + return body; + } + + const handleSave = async () => { + if (!task.id || !task.schedule_id) return; + + try { + setUpdatingData(true); + const body = getUpdateBody(); + + const res = await taskRecurringApiService.updateTaskRecurringData(task.schedule_id, body); + if (res.done) { + setShowConfig(false); + } + } catch (e) { + logger.error("handleSave", e); + } finally { + setUpdatingData(false); + } }; - const getScheduleData = () => {}; + const updateDaysOfWeek = () => { + for (let i = 0; i < daysOfWeek.length; i++) { + daysOfWeek[i].checked = scheduleData.days_of_week?.includes(daysOfWeek[i].value) ?? false; + } + }; + + const getScheduleData = async () => { + if (!task.schedule_id) return; + setLoadingData(true); + try { + const res = await taskRecurringApiService.getTaskRecurringData(task.schedule_id); + if (res.done) { + setScheduleData(res.body); + if (!res.body) { + setRepeatOption(repeatOptions[0]); + } else { + const selected = repeatOptions.find(e => e.value == res.body.schedule_type); + if (selected) { + setRepeatOption(selected); + setSelectedMonthlyDate(scheduleData.date_of_month || 1); + setSelectedMonthlyDay(scheduleData.day_of_month || 0); + setSelectedMonthlyWeek(scheduleData.week_of_month || 0); + setIntervalDays(scheduleData.interval_days || 1); + setIntervalWeeks(scheduleData.interval_weeks || 1); + setIntervalMonths(scheduleData.interval_months || 1); + setMonthlyOption(selectedMonthlyDate ? 'date' : 'day'); + updateDaysOfWeek(); + } + } + }; + } catch (e) { + logger.error("getScheduleData", e); + } + finally { + setLoadingData(false); + } + } const handleResponse = (response: ITaskRecurringScheduleData) => { if (!task || !response.task_id) return; }; useEffect(() => { + if (!task) return; + if (task) setRecurring(!!task.schedule_id); if (recurring) void getScheduleData(); socket?.on(SocketEvents.TASK_RECURRING_CHANGE.toString(), handleResponse); - }, []); + }, [task]); return (
diff --git a/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts b/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts index 9654a2d0..74ba350c 100644 --- a/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts +++ b/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts @@ -105,6 +105,15 @@ const taskDrawerSlice = createSlice({ }>) => { state.timeLogEditing = action.payload; }, + setTaskRecurringSchedule: (state, action: PayloadAction<{ + schedule_id: string; + task_id: string; + }>) => { + const { schedule_id, task_id } = action.payload; + if (state.taskFormViewModel?.task && state.taskFormViewModel.task.id === task_id) { + state.taskFormViewModel.task.schedule_id = schedule_id; + } + }, }, extraReducers: builder => { builder.addCase(fetchTask.pending, state => { @@ -133,5 +142,6 @@ export const { setTaskLabels, setTaskSubscribers, setTimeLogEditing, + setTaskRecurringSchedule } = taskDrawerSlice.actions; export default taskDrawerSlice.reducer; diff --git a/worklenz-frontend/src/types/tasks/task-recurring-schedule.ts b/worklenz-frontend/src/types/tasks/task-recurring-schedule.ts index 8fc708d5..190b6e7f 100644 --- a/worklenz-frontend/src/types/tasks/task-recurring-schedule.ts +++ b/worklenz-frontend/src/types/tasks/task-recurring-schedule.ts @@ -1,19 +1,37 @@ +export enum ITaskRecurring { + Daily = 'daily', + Weekly = 'weekly', + Monthly = 'monthly', + EveryXDays = 'every_x_days', + EveryXWeeks = 'every_x_weeks', + EveryXMonths = 'every_x_months' +} + export interface ITaskRecurringSchedule { - type: 'daily' | 'weekly' | 'monthly' | 'interval'; - dayOfWeek?: number; // 0 = Sunday, 1 = Monday, ..., 6 = Saturday (for weekly tasks) - dayOfMonth?: number; // 1 - 31 (for monthly tasks) - weekOfMonth?: number; // 1 = 1st week, 2 = 2nd week, ..., 5 = Last week (for monthly tasks) - hour: number; // Time of the day in 24-hour format - minute: number; // Minute of the hour - interval?: { - days?: number; // Interval in days (for every x days) - weeks?: number; // Interval in weeks (for every x weeks) - months?: number; // Interval in months (for every x months) - }; + created_at?: string; + day_of_month?: number | null; + date_of_month?: number | null; + days_of_week?: number[] | null; + id?: string; // UUID v4 + interval_days?: number | null; + interval_months?: number | null; + interval_weeks?: number | null; + schedule_type?: ITaskRecurring; + week_of_month?: number | null; +} + +export interface IRepeatOption { + value?: ITaskRecurring + label?: string } export interface ITaskRecurringScheduleData { - task_id?: string, - id?: string, - schedule_type?: string -} \ No newline at end of file + task_id?: string, + id?: string, + schedule_type?: string +} + +export interface IRepeatOption { + value?: ITaskRecurring + label?: string +} From 49bdd00dacbb258c2692f87894656d6f8879a62c Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 16 May 2025 14:45:47 +0530 Subject: [PATCH 42/70] fix(todo-list): update empty list image source to use relative path --- worklenz-frontend/src/pages/home/todo-list/todo-list.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx b/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx index 9fe5c59c..a27d2ac0 100644 --- a/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx +++ b/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx @@ -147,7 +147,7 @@ const TodoList = () => {
{data?.body.length === 0 ? ( ) : ( From f3a7fd8be5659f378e60a2a8d4494ac6cda4ae30 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Sun, 18 May 2025 20:15:40 +0530 Subject: [PATCH 43/70] refactor(project-view): optimize component with useMemo and useCallback for performance improvements - Introduced useMemo and useCallback to memoize tab menu items and callback functions, enhancing performance. - Added resetProjectData function to clean up project state on component unmount. - Refactored the component to use React.memo for preventing unnecessary re-renders. --- .../projects/projectView/project-view.tsx | 76 ++++++++----------- 1 file changed, 31 insertions(+), 45 deletions(-) diff --git a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx index 91c1d636..d1ff8b9d 100644 --- a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useMemo, useCallback } from 'react'; import { PushpinFilled, PushpinOutlined, QuestionCircleOutlined } from '@ant-design/icons'; import { Badge, Button, ConfigProvider, Flex, Tabs, TabsProps, Tooltip } from 'antd'; import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'; @@ -43,6 +43,14 @@ const ProjectView = () => { const [pinnedTab, setPinnedTab] = useState(searchParams.get('pinned_tab') || ''); const [taskid, setTaskId] = useState(searchParams.get('task') || ''); + const resetProjectData = useCallback(() => { + dispatch(setProjectId(null)); + dispatch(resetStatuses()); + dispatch(deselectAll()); + dispatch(resetTaskListData()); + dispatch(resetBoardData()); + }, [dispatch]); + useEffect(() => { if (projectId) { dispatch(setProjectId(projectId)); @@ -59,9 +67,13 @@ const ProjectView = () => { dispatch(setSelectedTaskId(taskid || '')); dispatch(setShowTaskDrawer(true)); } - }, [dispatch, navigate, projectId, taskid]); - const pinToDefaultTab = async (itemKey: string) => { + return () => { + resetProjectData(); + }; + }, [dispatch, navigate, projectId, taskid, resetProjectData]); + + const pinToDefaultTab = useCallback(async (itemKey: string) => { if (!itemKey || !projectId) return; const defaultView = itemKey === 'tasks-list' ? 'TASK_LIST' : 'BOARD'; @@ -88,9 +100,9 @@ const ProjectView = () => { }).toString(), }); } - }; + }, [projectId, activeTab, navigate]); - const handleTabChange = (key: string) => { + const handleTabChange = useCallback((key: string) => { setActiveTab(key); dispatch(setProjectView(key === 'board' ? 'kanban' : 'list')); navigate({ @@ -100,9 +112,9 @@ const ProjectView = () => { pinned_tab: pinnedTab, }).toString(), }); - }; + }, [dispatch, location.pathname, navigate, pinnedTab]); - const tabMenuItems = tabItems.map(item => ({ + const tabMenuItems = useMemo(() => tabItems.map(item => ({ key: item.key, label: ( @@ -144,21 +156,17 @@ const ProjectView = () => { ), children: item.element, - })); + })), [pinnedTab, pinToDefaultTab]); - const resetProjectData = () => { - dispatch(setProjectId(null)); - dispatch(resetStatuses()); - dispatch(deselectAll()); - dispatch(resetTaskListData()); - dispatch(resetBoardData()); - }; - - useEffect(() => { - return () => { - resetProjectData(); - }; - }, []); + const portalElements = useMemo(() => ( + <> + {createPortal(, document.body, 'project-member-drawer')} + {createPortal(, document.body, 'phase-drawer')} + {createPortal(, document.body, 'status-drawer')} + {createPortal(, document.body, 'task-drawer')} + {createPortal(, document.body, 'delete-status-drawer')} + + ), []); return (
@@ -170,33 +178,11 @@ const ProjectView = () => { items={tabMenuItems} tabBarStyle={{ paddingInline: 0 }} destroyInactiveTabPane={true} - // tabBarExtraContent={ - //
- // - // - // - // - // - // - // - // - //
- // } /> - {createPortal(, document.body, 'project-member-drawer')} - {createPortal(, document.body, 'phase-drawer')} - {createPortal(, document.body, 'status-drawer')} - {createPortal(, document.body, 'task-drawer')} - {createPortal(, document.body, 'delete-status-drawer')} + {portalElements}
); }; -export default ProjectView; +export default React.memo(ProjectView); From f9858fbd4bcbe839aa257ba9232be7f0e1b02cce Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Sun, 18 May 2025 20:58:20 +0530 Subject: [PATCH 44/70] refactor(task-list): enhance performance with useMemo and useCallback - Introduced useMemo to optimize loading state and empty state calculations. - Added useMemo for socket event handler functions to prevent unnecessary re-renders. - Refactored data fetching logic to improve initial data load handling. - Improved drag-and-drop functionality with memoized handlers for better performance. --- .../taskList/project-view-task-list.tsx | 84 ++-- .../task-group-wrapper/task-group-wrapper.tsx | 395 +++++++++--------- 2 files changed, 250 insertions(+), 229 deletions(-) diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx index fcd4931a..410644fb 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo } from 'react'; import Flex from 'antd/es/flex'; import Skeleton from 'antd/es/skeleton'; import { useSearchParams } from 'react-router-dom'; @@ -17,8 +17,8 @@ const ProjectViewTaskList = () => { const dispatch = useAppDispatch(); const { projectView } = useTabSearchParam(); const [searchParams, setSearchParams] = useSearchParams(); - // Add local loading state to immediately show skeleton const [isLoading, setIsLoading] = useState(true); + const [initialLoadComplete, setInitialLoadComplete] = useState(false); const { projectId } = useAppSelector(state => state.projectReducer); const { taskGroups, loadingGroups, groupBy, archived, fields, search } = useAppSelector( @@ -30,47 +30,73 @@ const ProjectViewTaskList = () => { const { loadingPhases } = useAppSelector(state => state.phaseReducer); const { loadingColumns } = useAppSelector(state => state.taskReducer); + // Memoize the loading state calculation - ignoring task list filter loading + const isLoadingState = useMemo(() => + loadingGroups || loadingPhases || loadingStatusCategories, + [loadingGroups, loadingPhases, loadingStatusCategories] + ); + + // Memoize the empty state check + const isEmptyState = useMemo(() => + taskGroups && taskGroups.length === 0 && !isLoadingState, + [taskGroups, isLoadingState] + ); + + // Handle view type changes useEffect(() => { - // Set default view to list if projectView is not list or board if (projectView !== 'list' && projectView !== 'board') { - searchParams.set('tab', 'tasks-list'); - searchParams.set('pinned_tab', 'tasks-list'); - setSearchParams(searchParams); + const newParams = new URLSearchParams(searchParams); + newParams.set('tab', 'tasks-list'); + newParams.set('pinned_tab', 'tasks-list'); + setSearchParams(newParams); } - }, [projectView, searchParams, setSearchParams]); + }, [projectView, setSearchParams]); + // Update loading state useEffect(() => { - // Set loading state based on all loading conditions - setIsLoading(loadingGroups || loadingColumns || loadingPhases || loadingStatusCategories); - }, [loadingGroups, loadingColumns, loadingPhases, loadingStatusCategories]); + setIsLoading(isLoadingState); + }, [isLoadingState]); + // Fetch initial data only once useEffect(() => { - const loadData = async () => { - if (projectId && groupBy) { - const promises = []; - - if (!loadingColumns) promises.push(dispatch(fetchTaskListColumns(projectId))); - if (!loadingPhases) promises.push(dispatch(fetchPhasesByProjectId(projectId))); - if (!loadingGroups && projectView === 'list') { - promises.push(dispatch(fetchTaskGroups(projectId))); - } - if (!statusCategories.length) { - promises.push(dispatch(fetchStatusesCategories())); - } - - // Wait for all data to load - await Promise.all(promises); + const fetchInitialData = async () => { + if (!projectId || !groupBy || initialLoadComplete) return; + + try { + await Promise.all([ + dispatch(fetchTaskListColumns(projectId)), + dispatch(fetchPhasesByProjectId(projectId)), + dispatch(fetchStatusesCategories()) + ]); + setInitialLoadComplete(true); + } catch (error) { + console.error('Error fetching initial data:', error); } }; - - loadData(); - }, [dispatch, projectId, groupBy, fields, search, archived]); + + fetchInitialData(); + }, [projectId, groupBy, dispatch, initialLoadComplete]); + + // Fetch task groups + useEffect(() => { + const fetchTasks = async () => { + if (!projectId || !groupBy || projectView !== 'list' || !initialLoadComplete) return; + + try { + await dispatch(fetchTaskGroups(projectId)); + } catch (error) { + console.error('Error fetching task groups:', error); + } + }; + + fetchTasks(); + }, [projectId, groupBy, projectView, dispatch, fields, search, archived, initialLoadComplete]); return ( - {(taskGroups && taskGroups.length === 0 && !isLoading) ? ( + {isEmptyState ? ( ) : ( diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx index f619f20a..6a0e9374 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx @@ -2,7 +2,7 @@ import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useSocket } from '@/socket/socketContext'; import { useAuthService } from '@/hooks/useAuth'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { createPortal } from 'react-dom'; import Flex from 'antd/es/flex'; import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect'; @@ -87,16 +87,22 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { const loadingAssignees = useAppSelector(state => state.taskReducer.loadingAssignees); const { projectId } = useAppSelector(state => state.projectReducer); - const sensors = useSensors( - useSensor(PointerSensor, { + // Move useSensors to top level and memoize its configuration + const sensorConfig = useMemo( + () => ({ activationConstraint: { distance: 8 }, - }) + }), + [] ); + const pointerSensor = useSensor(PointerSensor, sensorConfig); + const sensors = useSensors(pointerSensor); + useEffect(() => { setGroups(taskGroups); }, [taskGroups]); + // Memoize resetTaskRowStyles to prevent unnecessary re-renders const resetTaskRowStyles = useCallback(() => { document.querySelectorAll('.task-row').forEach(row => { row.style.transition = 'transform 0.2s ease, opacity 0.2s ease'; @@ -106,21 +112,18 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { }); }, []); - // Socket handler for assignee updates - useEffect(() => { - if (!socket) return; - - const handleAssigneesUpdate = (data: ITaskAssigneesUpdateResponse) => { + // Memoize socket event handlers + const handleAssigneesUpdate = useCallback( + (data: ITaskAssigneesUpdateResponse) => { if (!data) return; - const updatedAssignees = data.assignees.map(assignee => ({ + const updatedAssignees = data.assignees?.map(assignee => ({ ...assignee, selected: true, - })); + })) || []; - // Find the group that contains the task or its subtasks - const groupId = groups.find(group => - group.tasks.some( + const groupId = groups?.find(group => + group.tasks?.some( task => task.id === data.id || (task.sub_tasks && task.sub_tasks.some(subtask => subtask.id === data.id)) @@ -136,47 +139,41 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { }) ); - dispatch(setTaskAssignee(data)); + dispatch( + setTaskAssignee({ + ...data, + manual_progress: false, + } as IProjectTask) + ); if (currentSession?.team_id && !loadingAssignees) { dispatch(fetchTaskAssignees(currentSession.team_id)); } } - }; + }, + [groups, dispatch, currentSession?.team_id, loadingAssignees] + ); - socket.on(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate); - return () => { - socket.off(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate); - }; - }, [socket, currentSession?.team_id, loadingAssignees, groups, dispatch]); - - // Socket handler for label updates - useEffect(() => { - if (!socket) return; - - const handleLabelsChange = async (labels: ILabelsChangeResponse) => { + // Memoize socket event handlers + const handleLabelsChange = useCallback( + async (labels: ILabelsChangeResponse) => { + if (!labels) return; + await Promise.all([ dispatch(updateTaskLabel(labels)), dispatch(setTaskLabels(labels)), dispatch(fetchLabels()), projectId && dispatch(fetchLabelsByProject(projectId)), ]); - }; + }, + [dispatch, projectId] + ); - socket.on(SocketEvents.TASK_LABELS_CHANGE.toString(), handleLabelsChange); - socket.on(SocketEvents.CREATE_LABEL.toString(), handleLabelsChange); + // Memoize socket event handlers + const handleTaskStatusChange = useCallback( + (response: ITaskListStatusChangeResponse) => { + if (!response) return; - return () => { - socket.off(SocketEvents.TASK_LABELS_CHANGE.toString(), handleLabelsChange); - socket.off(SocketEvents.CREATE_LABEL.toString(), handleLabelsChange); - }; - }, [socket, dispatch, projectId]); - - // Socket handler for status updates - useEffect(() => { - if (!socket) return; - - const handleTaskStatusChange = (response: ITaskListStatusChangeResponse) => { if (response.completed_deps === false) { alertService.error( 'Task is not completed', @@ -186,11 +183,14 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { } dispatch(updateTaskStatus(response)); - // dispatch(setTaskStatus(response)); dispatch(deselectAll()); - }; + }, + [dispatch] + ); - const handleTaskProgress = (data: { + // Memoize socket event handlers + const handleTaskProgress = useCallback( + (data: { id: string; status: string; complete_ratio: number; @@ -198,6 +198,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { total_tasks_count: number; parent_task: string; }) => { + if (!data) return; + dispatch( updateTaskProgress({ taskId: data.parent_task || data.id, @@ -206,190 +208,150 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { completedCount: data.completed_count, }) ); - }; + }, + [dispatch] + ); - socket.on(SocketEvents.TASK_STATUS_CHANGE.toString(), handleTaskStatusChange); - socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress); + // Memoize socket event handlers + const handlePriorityChange = useCallback( + (response: ITaskListPriorityChangeResponse) => { + if (!response) return; - return () => { - socket.off(SocketEvents.TASK_STATUS_CHANGE.toString(), handleTaskStatusChange); - socket.off(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress); - }; - }, [socket, dispatch]); - - // Socket handler for priority updates - useEffect(() => { - if (!socket) return; - - const handlePriorityChange = (response: ITaskListPriorityChangeResponse) => { dispatch(updateTaskPriority(response)); dispatch(setTaskPriority(response)); dispatch(deselectAll()); - }; + }, + [dispatch] + ); - socket.on(SocketEvents.TASK_PRIORITY_CHANGE.toString(), handlePriorityChange); - - return () => { - socket.off(SocketEvents.TASK_PRIORITY_CHANGE.toString(), handlePriorityChange); - }; - }, [socket, dispatch]); - - // Socket handler for due date updates - useEffect(() => { - if (!socket) return; - - const handleEndDateChange = (task: { + // Memoize socket event handlers + const handleEndDateChange = useCallback( + (task: { id: string; parent_task: string | null; end_date: string; }) => { - dispatch(updateTaskEndDate({ task })); - dispatch(setTaskEndDate(task)); - }; + if (!task) return; - socket.on(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange); + const taskWithProgress = { + ...task, + manual_progress: false, + } as IProjectTask; - return () => { - socket.off(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange); - }; - }, [socket, dispatch]); + dispatch(updateTaskEndDate({ task: taskWithProgress })); + dispatch(setTaskEndDate(taskWithProgress)); + }, + [dispatch] + ); - // Socket handler for task name updates - useEffect(() => { - if (!socket) return; + // Memoize socket event handlers + const handleTaskNameChange = useCallback( + (data: { id: string; parent_task: string; name: string }) => { + if (!data) return; - const handleTaskNameChange = (data: { id: string; parent_task: string; name: string }) => { dispatch(updateTaskName(data)); - }; + }, + [dispatch] + ); - socket.on(SocketEvents.TASK_NAME_CHANGE.toString(), handleTaskNameChange); + // Memoize socket event handlers + const handlePhaseChange = useCallback( + (data: ITaskPhaseChangeResponse) => { + if (!data) return; - return () => { - socket.off(SocketEvents.TASK_NAME_CHANGE.toString(), handleTaskNameChange); - }; - }, [socket, dispatch]); - - // Socket handler for phase updates - useEffect(() => { - if (!socket) return; - - const handlePhaseChange = (data: ITaskPhaseChangeResponse) => { dispatch(updateTaskPhase(data)); dispatch(deselectAll()); - }; + }, + [dispatch] + ); - socket.on(SocketEvents.TASK_PHASE_CHANGE.toString(), handlePhaseChange); - - return () => { - socket.off(SocketEvents.TASK_PHASE_CHANGE.toString(), handlePhaseChange); - }; - }, [socket, dispatch]); - - // Socket handler for start date updates - useEffect(() => { - if (!socket) return; - - const handleStartDateChange = (task: { + // Memoize socket event handlers + const handleStartDateChange = useCallback( + (task: { id: string; parent_task: string | null; start_date: string; }) => { - dispatch(updateTaskStartDate({ task })); - dispatch(setStartDate(task)); - }; + if (!task) return; - socket.on(SocketEvents.TASK_START_DATE_CHANGE.toString(), handleStartDateChange); + const taskWithProgress = { + ...task, + manual_progress: false, + } as IProjectTask; - return () => { - socket.off(SocketEvents.TASK_START_DATE_CHANGE.toString(), handleStartDateChange); - }; - }, [socket, dispatch]); + dispatch(updateTaskStartDate({ task: taskWithProgress })); + dispatch(setStartDate(taskWithProgress)); + }, + [dispatch] + ); - // Socket handler for task subscribers updates - useEffect(() => { - if (!socket) return; + // Memoize socket event handlers + const handleTaskSubscribersChange = useCallback( + (data: InlineMember[]) => { + if (!data) return; - const handleTaskSubscribersChange = (data: InlineMember[]) => { dispatch(setTaskSubscribers(data)); - }; + }, + [dispatch] + ); - socket.on(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handleTaskSubscribersChange); - - return () => { - socket.off(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handleTaskSubscribersChange); - }; - }, [socket, dispatch]); - - // Socket handler for task estimation updates - useEffect(() => { - if (!socket) return; - - const handleEstimationChange = (task: { + // Memoize socket event handlers + const handleEstimationChange = useCallback( + (task: { id: string; parent_task: string | null; estimation: number; }) => { - dispatch(updateTaskEstimation({ task })); - }; + if (!task) return; - socket.on(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handleEstimationChange); + const taskWithProgress = { + ...task, + manual_progress: false, + } as IProjectTask; - return () => { - socket.off(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handleEstimationChange); - }; - }, [socket, dispatch]); + dispatch(updateTaskEstimation({ task: taskWithProgress })); + }, + [dispatch] + ); - // Socket handler for task description updates - useEffect(() => { - if (!socket) return; - - const handleTaskDescriptionChange = (data: { + // Memoize socket event handlers + const handleTaskDescriptionChange = useCallback( + (data: { id: string; parent_task: string; description: string; }) => { + if (!data) return; + dispatch(updateTaskDescription(data)); - }; + }, + [dispatch] + ); - socket.on(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handleTaskDescriptionChange); - - return () => { - socket.off(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handleTaskDescriptionChange); - }; - }, [socket, dispatch]); - - // Socket handler for new task creation - useEffect(() => { - if (!socket) return; - - const handleNewTaskReceived = (data: IProjectTask) => { + // Memoize socket event handlers + const handleNewTaskReceived = useCallback( + (data: IProjectTask) => { if (!data) return; if (data.parent_task_id) { dispatch(updateSubTasks(data)); } - }; + }, + [dispatch] + ); - socket.on(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived); - - return () => { - socket.off(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived); - }; - }, [socket, dispatch]); - - // Socket handler for task progress updates - useEffect(() => { - if (!socket) return; - - const handleTaskProgressUpdated = (data: { + // Memoize socket event handlers + const handleTaskProgressUpdated = useCallback( + (data: { task_id: string; progress_value?: number; weight?: number; }) => { + if (!data || !taskGroups) return; + if (data.progress_value !== undefined) { - // Find the task in the task groups and update its progress for (const group of taskGroups) { - const task = group.tasks.find(task => task.id === data.task_id); + const task = group.tasks?.find(task => task.id === data.task_id); if (task) { dispatch( updateTaskProgress({ @@ -403,25 +365,76 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { } } } + }, + [dispatch, taskGroups] + ); + + // Set up socket event listeners + useEffect(() => { + if (!socket) return; + + const eventHandlers = { + [SocketEvents.QUICK_ASSIGNEES_UPDATE.toString()]: handleAssigneesUpdate, + [SocketEvents.TASK_LABELS_CHANGE.toString()]: handleLabelsChange, + [SocketEvents.CREATE_LABEL.toString()]: handleLabelsChange, + [SocketEvents.TASK_STATUS_CHANGE.toString()]: handleTaskStatusChange, + [SocketEvents.GET_TASK_PROGRESS.toString()]: handleTaskProgress, + [SocketEvents.TASK_PRIORITY_CHANGE.toString()]: handlePriorityChange, + [SocketEvents.TASK_END_DATE_CHANGE.toString()]: handleEndDateChange, + [SocketEvents.TASK_NAME_CHANGE.toString()]: handleTaskNameChange, + [SocketEvents.TASK_PHASE_CHANGE.toString()]: handlePhaseChange, + [SocketEvents.TASK_START_DATE_CHANGE.toString()]: handleStartDateChange, + [SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString()]: handleTaskSubscribersChange, + [SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString()]: handleEstimationChange, + [SocketEvents.TASK_DESCRIPTION_CHANGE.toString()]: handleTaskDescriptionChange, + [SocketEvents.QUICK_TASK.toString()]: handleNewTaskReceived, + [SocketEvents.TASK_PROGRESS_UPDATED.toString()]: handleTaskProgressUpdated, }; - socket.on(SocketEvents.TASK_PROGRESS_UPDATED.toString(), handleTaskProgressUpdated); + // Register all event handlers + Object.entries(eventHandlers).forEach(([event, handler]) => { + if (handler) { + socket.on(event, handler); + } + }); + // Cleanup function return () => { - socket.off(SocketEvents.TASK_PROGRESS_UPDATED.toString(), handleTaskProgressUpdated); + Object.entries(eventHandlers).forEach(([event, handler]) => { + if (handler) { + socket.off(event, handler); + } + }); }; - }, [socket, dispatch, taskGroups]); + }, [ + socket, + handleAssigneesUpdate, + handleLabelsChange, + handleTaskStatusChange, + handleTaskProgress, + handlePriorityChange, + handleEndDateChange, + handleTaskNameChange, + handlePhaseChange, + handleStartDateChange, + handleTaskSubscribersChange, + handleEstimationChange, + handleTaskDescriptionChange, + handleNewTaskReceived, + handleTaskProgressUpdated, + ]); + // Memoize drag handlers const handleDragStart = useCallback(({ active }: DragStartEvent) => { setActiveId(active.id as string); - // Add smooth transition to the dragged item const draggedElement = document.querySelector(`[data-id="${active.id}"]`); if (draggedElement) { (draggedElement as HTMLElement).style.transition = 'transform 0.2s ease'; } }, []); + // Memoize drag handlers const handleDragEnd = useCallback( async ({ active, over }: DragEndEvent) => { setActiveId(null); @@ -440,10 +453,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { const fromIndex = sourceGroup.tasks.findIndex(t => t.id === activeTaskId); if (fromIndex === -1) return; - // Create a deep clone of the task to avoid reference issues const task = JSON.parse(JSON.stringify(sourceGroup.tasks[fromIndex])); - // Check if task dependencies allow the move if (activeGroupId !== overGroupId) { const canContinue = await checkTaskDependencyStatus(task.id, overGroupId); if (!canContinue) { @@ -455,7 +466,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { return; } - // Update task properties based on target group switch (groupBy) { case IGroupBy.STATUS: task.status = overGroupId; @@ -468,35 +478,29 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { task.priority_color_dark = targetGroup.color_code_dark; break; case IGroupBy.PHASE: - // Check if ALPHA_CHANNEL is already added const baseColor = targetGroup.color_code.endsWith(ALPHA_CHANNEL) - ? targetGroup.color_code.slice(0, -ALPHA_CHANNEL.length) // Remove ALPHA_CHANNEL - : targetGroup.color_code; // Use as is if not present + ? targetGroup.color_code.slice(0, -ALPHA_CHANNEL.length) + : targetGroup.color_code; task.phase_id = overGroupId; - task.phase_color = baseColor; // Set the cleaned color + task.phase_color = baseColor; break; } } const isTargetGroupEmpty = targetGroup.tasks.length === 0; - - // Calculate toIndex - for empty groups, always add at index 0 const toIndex = isTargetGroupEmpty ? 0 : overTaskId ? targetGroup.tasks.findIndex(t => t.id === overTaskId) : targetGroup.tasks.length; - // Calculate toPos similar to Angular implementation const toPos = isTargetGroupEmpty ? -1 : targetGroup.tasks[toIndex]?.sort_order || targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order || -1; - // Update Redux state if (activeGroupId === overGroupId) { - // Same group - move within array const updatedTasks = [...sourceGroup.tasks]; updatedTasks.splice(fromIndex, 1); updatedTasks.splice(toIndex, 0, task); @@ -514,7 +518,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { }, }); } else { - // Different groups - transfer between arrays const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex); const updatedTargetTasks = [...targetGroup.tasks]; @@ -540,7 +543,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { }); } - // Emit socket event socket?.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { project_id: projectId, from_index: sourceGroup.tasks[fromIndex].sort_order, @@ -549,13 +551,11 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { from_group: sourceGroup.id, to_group: targetGroup.id, group_by: groupBy, - task: sourceGroup.tasks[fromIndex], // Send original task to maintain references + task: sourceGroup.tasks[fromIndex], team_id: currentSession?.team_id, }); - // Reset styles setTimeout(resetTaskRowStyles, 0); - trackMixpanelEvent(evt_project_task_list_drag_and_move); }, [ @@ -570,6 +570,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { ] ); + // Memoize drag handlers const handleDragOver = useCallback( ({ active, over }: DragEndEvent) => { if (!over) return; @@ -589,12 +590,9 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { if (fromIndex === -1 || toIndex === -1) return; - // Create a deep clone of the task to avoid reference issues const task = JSON.parse(JSON.stringify(sourceGroup.tasks[fromIndex])); - // Update Redux state if (activeGroupId === overGroupId) { - // Same group - move within array const updatedTasks = [...sourceGroup.tasks]; updatedTasks.splice(fromIndex, 1); updatedTasks.splice(toIndex, 0, task); @@ -612,10 +610,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { }, }); } else { - // Different groups - transfer between arrays const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex); const updatedTargetTasks = [...targetGroup.tasks]; - updatedTargetTasks.splice(toIndex, 0, task); dispatch({ @@ -663,7 +659,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { // Handle animation cleanup after drag ends useIsomorphicLayoutEffect(() => { if (activeId === null) { - // Final cleanup after React updates DOM const timeoutId = setTimeout(resetTaskRowStyles, 50); return () => clearTimeout(timeoutId); } From 69b910f2a43b64d9c71000ce170c771d81dc4cae Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Mon, 19 May 2025 06:28:03 +0530 Subject: [PATCH 45/70] refactor(sql-functions): enhance SQL functions with COALESCE for better null handling - Updated various SQL queries to use COALESCE, ensuring that null values are replaced with defaults for improved data integrity. - Modified the handling of schedule_id for recurring tasks to return a JSON object or 'null' as appropriate. - Improved the return structure of task-related JSON objects to prevent null values in the response. --- worklenz-backend/database/sql/4_functions.sql | 46 +++++++++++-------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/worklenz-backend/database/sql/4_functions.sql b/worklenz-backend/database/sql/4_functions.sql index 189f8ac7..56bae8ba 100644 --- a/worklenz-backend/database/sql/4_functions.sql +++ b/worklenz-backend/database/sql/4_functions.sql @@ -3351,15 +3351,15 @@ BEGIN SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) FROM (SELECT team_member_id, project_member_id, - (SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id), - (SELECT email_notifications_enabled + COALESCE((SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id), '') as name, + COALESCE((SELECT email_notifications_enabled FROM notification_settings WHERE team_id = tm.team_id - AND notification_settings.user_id = u.id) AS email_notifications_enabled, - u.avatar_url, + AND notification_settings.user_id = u.id), false) AS email_notifications_enabled, + COALESCE(u.avatar_url, '') as avatar_url, u.id AS user_id, - u.email, - u.socket_id AS socket_id, + COALESCE(u.email, '') as email, + COALESCE(u.socket_id, '') as socket_id, tm.team_id AS team_id FROM tasks_assignees INNER JOIN team_members tm ON tm.id = tasks_assignees.team_member_id @@ -4066,14 +4066,14 @@ DECLARE _schedule_id JSON; _task_completed_at TIMESTAMPTZ; BEGIN - SELECT name FROM tasks WHERE id = _task_id INTO _task_name; + SELECT COALESCE(name, '') FROM tasks WHERE id = _task_id INTO _task_name; - SELECT name + SELECT COALESCE(name, '') FROM task_statuses WHERE id = (SELECT status_id FROM tasks WHERE id = _task_id) INTO _previous_status_name; - SELECT name FROM task_statuses WHERE id = _status_id INTO _new_status_name; + SELECT COALESCE(name, '') FROM task_statuses WHERE id = _status_id INTO _new_status_name; IF (_previous_status_name != _new_status_name) THEN @@ -4081,14 +4081,22 @@ BEGIN SELECT get_task_complete_info(_task_id, _status_id) INTO _task_info; - SELECT name FROM users WHERE id = _user_id INTO _updater_name; + SELECT COALESCE(name, '') FROM users WHERE id = _user_id INTO _updater_name; _message = CONCAT(_updater_name, ' transitioned "', _task_name, '" from ', _previous_status_name, ' ⟶ ', _new_status_name); END IF; SELECT completed_at FROM tasks WHERE id = _task_id INTO _task_completed_at; - SELECT schedule_id FROM tasks WHERE id = _task_id INTO _schedule_id; + + -- Handle schedule_id properly for recurring tasks + SELECT CASE + WHEN schedule_id IS NULL THEN 'null'::json + ELSE json_build_object('id', schedule_id) + END + FROM tasks + WHERE id = _task_id + INTO _schedule_id; SELECT COALESCE(ROW_TO_JSON(r), '{}'::JSON) FROM (SELECT is_done, is_doing, is_todo @@ -4097,7 +4105,7 @@ BEGIN INTO _status_category; RETURN JSON_BUILD_OBJECT( - 'message', _message, + 'message', COALESCE(_message, ''), 'project_id', (SELECT project_id FROM tasks WHERE id = _task_id), 'parent_done', (CASE WHEN EXISTS(SELECT 1 @@ -4105,14 +4113,14 @@ BEGIN WHERE tasks_with_status_view.task_id = _task_id AND is_done IS TRUE) THEN 1 ELSE 0 END), - 'color_code', (_task_info ->> 'color_code')::TEXT, - 'color_code_dark', (_task_info ->> 'color_code_dark')::TEXT, - 'total_tasks', (_task_info ->> 'total_tasks')::INT, - 'total_completed', (_task_info ->> 'total_completed')::INT, - 'members', (_task_info ->> 'members')::JSON, + 'color_code', COALESCE((_task_info ->> 'color_code')::TEXT, ''), + 'color_code_dark', COALESCE((_task_info ->> 'color_code_dark')::TEXT, ''), + 'total_tasks', COALESCE((_task_info ->> 'total_tasks')::INT, 0), + 'total_completed', COALESCE((_task_info ->> 'total_completed')::INT, 0), + 'members', COALESCE((_task_info ->> 'members')::JSON, '[]'::JSON), 'completed_at', _task_completed_at, - 'status_category', _status_category, - 'schedule_id', _schedule_id + 'status_category', COALESCE(_status_category, '{}'::JSON), + 'schedule_id', COALESCE(_schedule_id, 'null'::JSON) ); END $$; From 82155cab8dc7dc753a3b560ee1b47d5874f85490 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 19 May 2025 10:57:43 +0530 Subject: [PATCH 46/70] docs: add user guide and cron job documentation for recurring tasks Add detailed documentation for recurring tasks, including a user guide explaining how to set up and manage recurring tasks, and a technical guide for the recurring tasks cron job. The user guide covers the purpose, setup process, and schedule options, while the technical guide explains the cron job's logic, database interactions, and configuration options. Additionally, include a migration script to fix ENUM type and casting issues for progress_mode_type. --- docs/recurring-tasks-user-guide.md | 39 +++++ docs/recurring-tasks.md | 56 ++++++ .../20250427000000-fix-progress-mode-type.sql | 160 ++++++++++++++++++ 3 files changed, 255 insertions(+) create mode 100644 docs/recurring-tasks-user-guide.md create mode 100644 docs/recurring-tasks.md create mode 100644 worklenz-backend/database/migrations/20250427000000-fix-progress-mode-type.sql diff --git a/docs/recurring-tasks-user-guide.md b/docs/recurring-tasks-user-guide.md new file mode 100644 index 00000000..98476b64 --- /dev/null +++ b/docs/recurring-tasks-user-guide.md @@ -0,0 +1,39 @@ +# Recurring Tasks: User Guide + +## What Are Recurring Tasks? +Recurring tasks are tasks that repeat automatically on a schedule you choose. This helps you save time and ensures important work is never forgotten. For example, you can set up a recurring task for weekly team meetings, monthly reports, or daily check-ins. + +## Why Use Recurring Tasks? +- **Save time:** No need to create the same task over and over. +- **Stay organized:** Tasks appear automatically when needed. +- **Never miss a deadline:** Tasks are created on time, every time. + +## How to Set Up a Recurring Task +1. Go to the tasks section in your workspace. +2. Choose to create a new task and look for the option to make it recurring. +3. Fill in the task details (name, description, assignees, etc.). +4. Select your preferred schedule (see options below). +5. Save the task. It will now be created automatically based on your chosen schedule. + +## Schedule Options +You can choose how often your task repeats. Here are the most common options: + +- **Daily:** The task is created every day. +- **Weekly:** The task is created once a week. You can pick the day (e.g., every Monday). +- **Monthly:** The task is created once a month. You can pick the date (e.g., the 1st of every month). +- **Weekdays:** The task is created every Monday to Friday. +- **Custom:** Set your own schedule, such as every 2 days, every 3 weeks, or only on certain days. + +### Examples +- "Send team update" every Friday (weekly) +- "Submit expense report" on the 1st of each month (monthly) +- "Check backups" every day (daily) +- "Review project status" every Monday and Thursday (custom) + +## Tips +- You can edit or stop a recurring task at any time. +- Assign team members and labels to recurring tasks for better organization. +- Check your task list regularly to see newly created recurring tasks. + +## Need Help? +If you have questions or need help setting up recurring tasks, contact your workspace admin or support team. \ No newline at end of file diff --git a/docs/recurring-tasks.md b/docs/recurring-tasks.md new file mode 100644 index 00000000..71fd51cc --- /dev/null +++ b/docs/recurring-tasks.md @@ -0,0 +1,56 @@ +# Recurring Tasks Cron Job Documentation + +## Overview +The recurring tasks cron job automates the creation of tasks based on predefined templates and schedules. It ensures that tasks are generated at the correct intervals without manual intervention, supporting efficient project management and timely task assignment. + +## Purpose +- Automatically create tasks according to recurring schedules defined in the database. +- Prevent duplicate task creation for the same schedule and date. +- Assign team members and labels to newly created tasks as specified in the template. + +## Scheduling Logic +- The cron job is scheduled using the [cron](https://www.npmjs.com/package/cron) package. +- The schedule is defined by a cron expression (e.g., `*/2 * * * *` for every 2 minutes, or `0 11 */1 * 1-5` for 11:00 UTC on weekdays). +- On each tick, the job: + 1. Fetches all recurring task templates and their schedules. + 2. Determines the next occurrence for each template using `calculateNextEndDate`. + 3. Checks if a task for the next occurrence already exists. + 4. Creates a new task if it does not exist and the next occurrence is within the allowed future window. + +## Database Interactions +- **Templates and Schedules:** + - Templates are stored in `task_recurring_templates`. + - Schedules are stored in `task_recurring_schedules`. + - The job joins these tables to get all necessary data for task creation. +- **Task Creation:** + - Uses a stored procedure `create_quick_task` to insert new tasks. + - Assigns team members and labels by calling appropriate functions/controllers. +- **State Tracking:** + - Updates `last_checked_at` and `last_created_task_end_date` in the schedule after processing. + +## Task Creation Process +1. **Fetch Templates:** Retrieve all templates and their associated schedules. +2. **Determine Next Occurrence:** Use the last task's end date or the schedule's creation date to calculate the next due date. +3. **Check for Existing Task:** Ensure no duplicate task is created for the same schedule and date. +4. **Create Task:** + - Insert the new task using the template's data. + - Assign team members and labels as specified. +5. **Update Schedule:** Record the last checked and created dates for accurate future runs. + +## Configuration & Extension Points +- **Cron Expression:** Modify the `TIME` constant in the code to change the schedule. +- **Task Template Structure:** Extend the template or schedule interfaces to support additional fields. +- **Task Creation Logic:** Customize the task creation process or add new assignment/labeling logic as needed. + +## Error Handling +- Errors are logged using the `log_error` utility. +- The job continues processing other templates even if one fails. + +## References +- Source: `src/cron_jobs/recurring-tasks.ts` +- Utilities: `src/shared/utils.ts` +- Database: `src/config/db.ts` +- Controllers: `src/controllers/tasks-controller.ts` + +--- +For further customization or troubleshooting, refer to the source code and update the documentation as needed. \ No newline at end of file diff --git a/worklenz-backend/database/migrations/20250427000000-fix-progress-mode-type.sql b/worklenz-backend/database/migrations/20250427000000-fix-progress-mode-type.sql new file mode 100644 index 00000000..557b1bc5 --- /dev/null +++ b/worklenz-backend/database/migrations/20250427000000-fix-progress-mode-type.sql @@ -0,0 +1,160 @@ +-- Migration: Fix progress_mode_type ENUM and casting issues +-- Date: 2025-04-27 +-- Version: 1.0.0 + +BEGIN; + +-- First, let's ensure the ENUM type exists with the correct values +DO $$ +BEGIN + -- Check if the type exists + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'progress_mode_type') THEN + CREATE TYPE progress_mode_type AS ENUM ('manual', 'weighted', 'time', 'default'); + ELSE + -- Add any missing values to the existing ENUM + BEGIN + ALTER TYPE progress_mode_type ADD VALUE IF NOT EXISTS 'manual'; + ALTER TYPE progress_mode_type ADD VALUE IF NOT EXISTS 'weighted'; + ALTER TYPE progress_mode_type ADD VALUE IF NOT EXISTS 'time'; + ALTER TYPE progress_mode_type ADD VALUE IF NOT EXISTS 'default'; + EXCEPTION + WHEN duplicate_object THEN + -- Ignore if values already exist + NULL; + END; + END IF; +END $$; + +-- Update functions to use proper type casting +CREATE OR REPLACE FUNCTION on_update_task_progress(_body json) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _task_id UUID; + _progress_value INTEGER; + _parent_task_id UUID; + _project_id UUID; + _current_mode progress_mode_type; +BEGIN + _task_id = (_body ->> 'task_id')::UUID; + _progress_value = (_body ->> 'progress_value')::INTEGER; + _parent_task_id = (_body ->> 'parent_task_id')::UUID; + + -- Get the project ID and determine the current progress mode + SELECT project_id INTO _project_id FROM tasks WHERE id = _task_id; + + IF _project_id IS NOT NULL THEN + SELECT + CASE + WHEN use_manual_progress IS TRUE THEN 'manual'::progress_mode_type + WHEN use_weighted_progress IS TRUE THEN 'weighted'::progress_mode_type + WHEN use_time_progress IS TRUE THEN 'time'::progress_mode_type + ELSE 'default'::progress_mode_type + END + INTO _current_mode + FROM projects + WHERE id = _project_id; + ELSE + _current_mode := 'default'::progress_mode_type; + END IF; + + -- Update the task with progress value and set the progress mode + UPDATE tasks + SET progress_value = _progress_value, + manual_progress = TRUE, + progress_mode = _current_mode, + updated_at = CURRENT_TIMESTAMP + WHERE id = _task_id; + + -- Return the updated task info + RETURN JSON_BUILD_OBJECT( + 'task_id', _task_id, + 'progress_value', _progress_value, + 'progress_mode', _current_mode + ); +END; +$$; + +-- Update the on_update_task_weight function to use proper type casting +CREATE OR REPLACE FUNCTION on_update_task_weight(_body json) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _task_id UUID; + _weight INTEGER; + _parent_task_id UUID; + _project_id UUID; +BEGIN + _task_id = (_body ->> 'task_id')::UUID; + _weight = (_body ->> 'weight')::INTEGER; + _parent_task_id = (_body ->> 'parent_task_id')::UUID; + + -- Get the project ID + SELECT project_id INTO _project_id FROM tasks WHERE id = _task_id; + + -- Update the task with weight value and set progress_mode to 'weighted' + UPDATE tasks + SET weight = _weight, + progress_mode = 'weighted'::progress_mode_type, + updated_at = CURRENT_TIMESTAMP + WHERE id = _task_id; + + -- Return the updated task info + RETURN JSON_BUILD_OBJECT( + 'task_id', _task_id, + 'weight', _weight + ); +END; +$$; + +-- Update the reset_project_progress_values function to use proper type casting +CREATE OR REPLACE FUNCTION reset_project_progress_values() RETURNS TRIGGER + LANGUAGE plpgsql +AS +$$ +DECLARE + _old_mode progress_mode_type; + _new_mode progress_mode_type; + _project_id UUID; +BEGIN + _project_id := NEW.id; + + -- Determine old and new modes with proper type casting + _old_mode := + CASE + WHEN OLD.use_manual_progress IS TRUE THEN 'manual'::progress_mode_type + WHEN OLD.use_weighted_progress IS TRUE THEN 'weighted'::progress_mode_type + WHEN OLD.use_time_progress IS TRUE THEN 'time'::progress_mode_type + ELSE 'default'::progress_mode_type + END; + + _new_mode := + CASE + WHEN NEW.use_manual_progress IS TRUE THEN 'manual'::progress_mode_type + WHEN NEW.use_weighted_progress IS TRUE THEN 'weighted'::progress_mode_type + WHEN NEW.use_time_progress IS TRUE THEN 'time'::progress_mode_type + ELSE 'default'::progress_mode_type + END; + + -- If mode has changed, reset progress values for tasks with the old mode + IF _old_mode <> _new_mode THEN + -- Reset progress values for tasks that were set in the old mode + UPDATE tasks + SET progress_value = NULL, + progress_mode = NULL + WHERE project_id = _project_id + AND progress_mode = _old_mode; + END IF; + + RETURN NEW; +END; +$$; + +-- Update the tasks table to ensure proper type casting for existing values +UPDATE tasks +SET progress_mode = progress_mode::text::progress_mode_type +WHERE progress_mode IS NOT NULL; + +COMMIT; \ No newline at end of file From c19e06d902f650c572ec7fe83842cb83431bc001 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 19 May 2025 12:11:32 +0530 Subject: [PATCH 47/70] refactor: enhance task completion ratio calculation and reporting - Updated the `get_task_complete_ratio` function to improve handling of manual, weighted, and time-based progress calculations. - Added logic to ensure accurate task completion ratios, including checks for subtasks and project settings. - Enhanced error logging in the `refreshProjectTaskProgressValues` method for better debugging. - Introduced new fields in the reporting allocation controller to calculate and display total working hours and utilization metrics for team members. - Updated the frontend time sheet component to display utilization and over/under utilized hours in tooltips for better user insights. --- .../consolidated-progress-migrations.sql | 328 +++++++++++++----- .../reporting-allocation-controller.ts | 58 ++++ .../src/controllers/tasks-controller-v2.ts | 8 +- .../members-time-sheet/members-time-sheet.tsx | 16 + .../src/types/reporting/reporting.types.ts | 3 + 5 files changed, 329 insertions(+), 84 deletions(-) diff --git a/worklenz-backend/database/migrations/consolidated-progress-migrations.sql b/worklenz-backend/database/migrations/consolidated-progress-migrations.sql index c1007d24..ef89a923 100644 --- a/worklenz-backend/database/migrations/consolidated-progress-migrations.sql +++ b/worklenz-backend/database/migrations/consolidated-progress-migrations.sql @@ -23,33 +23,40 @@ ALTER TABLE projects ADD COLUMN IF NOT EXISTS use_time_progress BOOLEAN DEFAULT FALSE; -- Update function to consider manual progress -CREATE OR REPLACE FUNCTION get_task_complete_ratio(_task_id UUID) RETURNS JSON +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; + _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; + _use_time_progress BOOLEAN = FALSE; + _task_complete BOOLEAN = FALSE; + _progress_mode VARCHAR(20) = NULL; BEGIN - -- Check if manual progress is set - SELECT manual_progress, progress_value, project_id + -- Check if manual progress is set for this task + SELECT manual_progress, progress_value, project_id, progress_mode, + EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = tasks.id + AND is_done IS TRUE + ) AS is_complete FROM tasks WHERE id = _task_id - INTO _is_manual, _manual_value, _project_id; + INTO _is_manual, _manual_value, _project_id, _progress_mode, _task_complete; -- Check if the project uses manual progress - IF _project_id IS NOT NULL - THEN + IF _project_id IS NOT NULL THEN SELECT COALESCE(use_manual_progress, FALSE), COALESCE(use_weighted_progress, FALSE), COALESCE(use_time_progress, FALSE) @@ -58,49 +65,212 @@ BEGIN INTO _use_manual_progress, _use_weighted_progress, _use_time_progress; END IF; - -- If manual progress is enabled and has a value, use it directly - IF _is_manual IS TRUE AND _manual_value IS NOT NULL - THEN + -- Get all subtasks + SELECT COUNT(*) + FROM tasks + WHERE parent_task_id = _task_id AND archived IS FALSE + INTO _sub_tasks_count; + + -- If task is complete, always return 100% + IF _task_complete IS TRUE THEN RETURN JSON_BUILD_OBJECT( + 'ratio', 100, + 'total_completed', 1, + 'total_tasks', 1, + 'is_manual', FALSE + ); + END IF; + + -- Determine current active mode + DECLARE + _current_mode VARCHAR(20) = CASE + WHEN _use_manual_progress IS TRUE THEN 'manual' + WHEN _use_weighted_progress IS TRUE THEN 'weighted' + WHEN _use_time_progress IS TRUE THEN 'time' + ELSE 'default' + END; + BEGIN + -- Only use manual progress value if it was set in the current active mode + -- and time progress is not enabled + IF _use_time_progress IS FALSE AND + ((_is_manual IS TRUE AND _manual_value IS NOT NULL AND + (_progress_mode IS NULL OR _progress_mode = _current_mode)) OR + (_use_manual_progress IS TRUE AND _manual_value IS NOT NULL AND + (_progress_mode IS NULL OR _progress_mode = 'manual'))) THEN + RETURN JSON_BUILD_OBJECT( 'ratio', _manual_value, 'total_completed', 0, 'total_tasks', 0, 'is_manual', TRUE - ); + ); + END IF; + END; + + -- If there are no subtasks, calculate based on the task itself + IF _sub_tasks_count = 0 THEN + -- Use time-based estimation if enabled + IF _use_time_progress IS TRUE THEN + -- Calculate progress based on logged time vs estimated time + WITH task_time_info AS ( + SELECT + COALESCE(t.total_minutes, 0) as estimated_minutes, + COALESCE(( + SELECT SUM(time_spent) + FROM task_work_log + WHERE task_id = t.id + ), 0) as logged_minutes + FROM tasks t + WHERE t.id = _task_id + ) + SELECT + CASE + WHEN _task_complete IS TRUE THEN 100 + WHEN estimated_minutes > 0 THEN + LEAST((logged_minutes / estimated_minutes) * 100, 100) + ELSE 0 + END + INTO _ratio + FROM task_time_info; + ELSE + -- Traditional calculation for non-time-based tasks + SELECT (CASE WHEN _task_complete IS TRUE THEN 1 ELSE 0 END) + INTO _parent_task_done; + + _ratio = _parent_task_done * 100; + END IF; + ELSE + -- If project uses manual progress, calculate based on subtask manual progress values + IF _use_manual_progress IS TRUE AND _use_time_progress IS FALSE THEN + WITH subtask_progress AS ( + SELECT + t.id, + t.manual_progress, + t.progress_value, + t.progress_mode, + EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = t.id + AND is_done IS TRUE + ) AS is_complete + FROM tasks t + WHERE t.parent_task_id = _task_id + AND t.archived IS FALSE + ), + subtask_with_values AS ( + SELECT + CASE + WHEN is_complete IS TRUE THEN 100 + WHEN progress_value IS NOT NULL AND (progress_mode = 'manual' OR progress_mode IS NULL) THEN progress_value + ELSE 0 + END AS progress_value + FROM subtask_progress + ) + SELECT COALESCE(AVG(progress_value), 0) + FROM subtask_with_values + INTO _ratio; + -- If project uses weighted progress, calculate based on subtask weights + ELSIF _use_weighted_progress IS TRUE AND _use_time_progress IS FALSE THEN + WITH subtask_progress AS ( + SELECT + t.id, + t.manual_progress, + t.progress_value, + t.progress_mode, + EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = t.id + AND is_done IS TRUE + ) AS is_complete, + COALESCE(t.weight, 100) AS weight + FROM tasks t + WHERE t.parent_task_id = _task_id + AND t.archived IS FALSE + ), + subtask_with_values AS ( + SELECT + CASE + WHEN is_complete IS TRUE THEN 100 + WHEN progress_value IS NOT NULL AND (progress_mode = 'weighted' OR progress_mode IS NULL) THEN progress_value + ELSE 0 + END AS progress_value, + weight + FROM subtask_progress + ) + SELECT COALESCE( + SUM(progress_value * weight) / NULLIF(SUM(weight), 0), + 0 + ) + FROM subtask_with_values + INTO _ratio; + -- If project uses time-based progress, calculate based on actual logged time + ELSIF _use_time_progress IS TRUE THEN + WITH task_time_info AS ( + SELECT + t.id, + COALESCE(t.total_minutes, 0) as estimated_minutes, + COALESCE(( + SELECT SUM(time_spent) + FROM task_work_log + WHERE task_id = t.id + ), 0) as logged_minutes, + EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = t.id + AND is_done IS TRUE + ) AS is_complete + FROM tasks t + WHERE t.parent_task_id = _task_id + AND t.archived IS FALSE + ) + SELECT COALESCE( + SUM( + CASE + WHEN is_complete IS TRUE THEN estimated_minutes + ELSE LEAST(logged_minutes, estimated_minutes) + END + ) / NULLIF(SUM(estimated_minutes), 0) * 100, + 0 + ) + FROM task_time_info + INTO _ratio; + ELSE + -- Traditional calculation based on completion status + SELECT (CASE WHEN _task_complete 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; - -- 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; + -- 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', FALSE - ); + 'ratio', _ratio, + 'total_completed', _total_completed, + 'total_tasks', _total_tasks, + 'is_manual', _is_manual + ); END $$; @@ -615,38 +785,38 @@ BEGIN ) FROM subtask_with_values INTO _ratio; - -- If project uses time-based progress, calculate based on estimated time + -- If project uses time-based progress, calculate based on actual logged time ELSIF _use_time_progress IS TRUE THEN - WITH subtask_progress AS (SELECT t.id, - t.manual_progress, - t.progress_value, - t.progress_mode, - EXISTS(SELECT 1 - FROM tasks_with_status_view - WHERE tasks_with_status_view.task_id = t.id - AND is_done IS TRUE) AS is_complete, - COALESCE(t.total_minutes, 0) AS estimated_minutes - FROM tasks t - WHERE t.parent_task_id = _task_id - AND t.archived IS FALSE), - subtask_with_values AS (SELECT CASE - -- For completed tasks, always use 100% - WHEN is_complete IS TRUE THEN 100 - -- For tasks with progress value set in the correct mode, use it - WHEN progress_value IS NOT NULL AND - (progress_mode = 'time' OR progress_mode IS NULL) - THEN progress_value - -- Default to 0 for incomplete tasks with no progress value or wrong mode - ELSE 0 - END AS progress_value, - estimated_minutes - FROM subtask_progress) + WITH task_time_info AS ( + SELECT + t.id, + COALESCE(t.total_minutes, 0) as estimated_minutes, + COALESCE(( + SELECT SUM(time_spent) + FROM task_work_log + WHERE task_id = t.id + ), 0) as logged_minutes, + EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = t.id + AND is_done IS TRUE + ) AS is_complete + 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_with_values + SUM( + CASE + WHEN is_complete IS TRUE THEN estimated_minutes + ELSE LEAST(logged_minutes, estimated_minutes) + END + ) / NULLIF(SUM(estimated_minutes), 0) * 100, + 0 + ) + FROM task_time_info INTO _ratio; ELSE -- Traditional calculation based on completion status diff --git a/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts b/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts index be79c4b8..aee82dcd 100644 --- a/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts +++ b/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts @@ -408,6 +408,58 @@ export default class ReportingAllocationController extends ReportingControllerBa const { duration, date_range } = req.body; + // Calculate the date range (start and end) + let startDate: moment.Moment; + let endDate: moment.Moment; + if (date_range && date_range.length === 2) { + startDate = moment(date_range[0]); + endDate = moment(date_range[1]); + } else { + switch (duration) { + case DATE_RANGES.YESTERDAY: + startDate = moment().subtract(1, "day"); + endDate = moment().subtract(1, "day"); + break; + case DATE_RANGES.LAST_WEEK: + startDate = moment().subtract(1, "week").startOf("isoWeek"); + endDate = moment().subtract(1, "week").endOf("isoWeek"); + break; + case DATE_RANGES.LAST_MONTH: + startDate = moment().subtract(1, "month").startOf("month"); + endDate = moment().subtract(1, "month").endOf("month"); + break; + case DATE_RANGES.LAST_QUARTER: + startDate = moment().subtract(3, "months").startOf("quarter"); + endDate = moment().subtract(1, "quarter").endOf("quarter"); + break; + default: + startDate = moment().startOf("day"); + endDate = moment().endOf("day"); + } + } + + // Count only weekdays (Mon-Fri) in the period + let workingDays = 0; + let current = startDate.clone(); + while (current.isSameOrBefore(endDate, 'day')) { + const day = current.isoWeekday(); + if (day >= 1 && day <= 5) workingDays++; + current.add(1, 'day'); + } + + // Get hours_per_day for all selected projects + const projectHoursQuery = `SELECT id, hours_per_day FROM projects WHERE id IN (${projectIds})`; + const projectHoursResult = await db.query(projectHoursQuery, []); + const projectHoursMap: Record = {}; + for (const row of projectHoursResult.rows) { + projectHoursMap[row.id] = row.hours_per_day || 8; + } + // Sum total working hours for all selected projects + let totalWorkingHours = 0; + for (const pid of Object.keys(projectHoursMap)) { + totalWorkingHours += workingDays * projectHoursMap[pid]; + } + const durationClause = this.getDateRangeClause(duration || DATE_RANGES.LAST_WEEK, date_range); const archivedClause = archived ? "" @@ -430,6 +482,12 @@ export default class ReportingAllocationController extends ReportingControllerBa for (const member of result.rows) { member.value = member.logged_time ? parseFloat(moment.duration(member.logged_time, "seconds").asHours().toFixed(2)) : 0; member.color_code = getColor(member.name); + member.total_working_hours = totalWorkingHours; + member.utilization_percent = (totalWorkingHours > 0 && member.logged_time) ? ((parseFloat(member.logged_time) / (totalWorkingHours * 3600)) * 100).toFixed(2) : '0.00'; + member.utilized_hours = member.logged_time ? (parseFloat(member.logged_time) / 3600).toFixed(2) : '0.00'; + // Over/under utilized hours: utilized_hours - total_working_hours + const overUnder = member.utilized_hours && member.total_working_hours ? (parseFloat(member.utilized_hours) - member.total_working_hours) : 0; + member.over_under_utilized_hours = overUnder.toFixed(2); } return res.status(200).send(new ServerResponse(true, result.rows)); diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index c3231825..d6efa1bb 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -833,9 +833,7 @@ export default class TasksControllerV2 extends TasksControllerBase { } public static async refreshProjectTaskProgressValues(projectId: string): Promise { - try { - console.log(`Refreshing progress values for project ${projectId}`); - + try { // Run the recalculate_all_task_progress function only for tasks in this project const query = ` DO $$ @@ -893,10 +891,10 @@ export default class TasksControllerV2 extends TasksControllerBase { END $$; `; - const result = await db.query(query); + await db.query(query); console.log(`Finished refreshing progress values for project ${projectId}`); } catch (error) { - log_error('Error refreshing project task progress values', error); + log_error("Error refreshing project task progress values", error); } } diff --git a/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx b/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx index f4c1bf89..7283a40a 100644 --- a/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx +++ b/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx @@ -81,6 +81,22 @@ const MembersTimeSheet = forwardRef((_, ref) => { display: false, position: 'top' as const, }, + tooltip: { + callbacks: { + label: function(context: any) { + const idx = context.dataIndex; + const member = jsonData[idx]; + const hours = member?.utilized_hours || '0.00'; + const percent = member?.utilization_percent || '0.00'; + const overUnder = member?.over_under_utilized_hours || '0.00'; + return [ + `${context.dataset.label}: ${hours} h`, + `Utilization: ${percent}%`, + `Over/Under Utilized: ${overUnder} h` + ]; + } + } + } }, backgroundColor: 'black', indexAxis: 'y' as const, diff --git a/worklenz-frontend/src/types/reporting/reporting.types.ts b/worklenz-frontend/src/types/reporting/reporting.types.ts index 91ad7392..aa36069c 100644 --- a/worklenz-frontend/src/types/reporting/reporting.types.ts +++ b/worklenz-frontend/src/types/reporting/reporting.types.ts @@ -406,6 +406,9 @@ export interface IRPTTimeMember { value?: number; color_code: string; logged_time?: string; + utilized_hours?: string; + utilization_percent?: string; + over_under_utilized_hours?: string; } export interface IMemberTaskStatGroupResonse { From fc30c1854e796f2e93b9e264f595c8b65b3519e6 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 19 May 2025 12:35:32 +0530 Subject: [PATCH 48/70] feat(reporting): add support for 'all time' date range in reporting allocation - Implemented logic to fetch the earliest start date from selected projects when the 'all time' duration is specified. - Updated the start date to default to January 1, 2000 if no valid date is found, ensuring robust date handling in reports. --- .../reporting/reporting-allocation-controller.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts b/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts index aee82dcd..4db8e3d5 100644 --- a/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts +++ b/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts @@ -414,6 +414,13 @@ export default class ReportingAllocationController extends ReportingControllerBa if (date_range && date_range.length === 2) { startDate = moment(date_range[0]); endDate = moment(date_range[1]); + } else if (duration === DATE_RANGES.ALL_TIME) { + // Fetch the earliest start_date (or created_at if null) from selected projects + const minDateQuery = `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE id IN (${projectIds})`; + const minDateResult = await db.query(minDateQuery, []); + const minDate = minDateResult.rows[0]?.min_date; + startDate = minDate ? moment(minDate) : moment('2000-01-01'); + endDate = moment(); } else { switch (duration) { case DATE_RANGES.YESTERDAY: From 84c7428fed2646425d8a2f4cb8216ecc96646bc6 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Wed, 21 May 2025 08:07:59 +0530 Subject: [PATCH 49/70] feat(recurring-tasks): enhance recurring task functionality and documentation - Expanded schedule options for recurring tasks, including new intervals for every X days, weeks, and months. - Added future task creation logic to ensure tasks are created within defined limits based on their schedule type. - Updated user guide to reflect new scheduling options and future task creation details. - Improved backend logic for recurring task creation, including batch processing and future limit calculations. - Added environment configuration for enabling recurring jobs. - Enhanced frontend localization for recurring task configuration labels. --- docs/recurring-tasks-user-guide.md | 35 +++- docs/recurring-tasks.md | 48 ++++++ worklenz-backend/.env.template | 6 +- .../src/controllers/tasks-controller-base.ts | 33 ++-- .../src/controllers/tasks-controller-v2.ts | 1 - .../src/cron_jobs/recurring-tasks.ts | 159 ++++++++++++------ .../task-drawer-recurring-config.json | 1 + .../task-drawer-recurring-config.json | 1 + .../task-drawer-recurring-config.json | 1 + .../task-drawer-recurring-config.tsx | 99 ++++++----- 10 files changed, 255 insertions(+), 129 deletions(-) diff --git a/docs/recurring-tasks-user-guide.md b/docs/recurring-tasks-user-guide.md index 98476b64..3d91572a 100644 --- a/docs/recurring-tasks-user-guide.md +++ b/docs/recurring-tasks-user-guide.md @@ -16,24 +16,45 @@ Recurring tasks are tasks that repeat automatically on a schedule you choose. Th 5. Save the task. It will now be created automatically based on your chosen schedule. ## Schedule Options -You can choose how often your task repeats. Here are the most common options: +You can choose how often your task repeats. Here are the available options: - **Daily:** The task is created every day. -- **Weekly:** The task is created once a week. You can pick the day (e.g., every Monday). -- **Monthly:** The task is created once a month. You can pick the date (e.g., the 1st of every month). -- **Weekdays:** The task is created every Monday to Friday. -- **Custom:** Set your own schedule, such as every 2 days, every 3 weeks, or only on certain days. +- **Weekly:** The task is created once a week. You can pick one or more days (e.g., every Monday and Thursday). +- **Monthly:** The task is created once a month. You have two options: + - **On a specific date:** Choose a date from 1 to 28 (limited to 28 to ensure consistency across all months) + - **On a specific day:** Choose a week (first, second, third, fourth, or last) and a day of the week +- **Every X Days:** The task is created every specified number of days (e.g., every 3 days) +- **Every X Weeks:** The task is created every specified number of weeks (e.g., every 2 weeks) +- **Every X Months:** The task is created every specified number of months (e.g., every 3 months) ### Examples - "Send team update" every Friday (weekly) -- "Submit expense report" on the 1st of each month (monthly) +- "Submit expense report" on the 15th of each month (monthly, specific date) +- "Monthly team meeting" on the first Monday of each month (monthly, specific day) - "Check backups" every day (daily) -- "Review project status" every Monday and Thursday (custom) +- "Review project status" every Monday and Thursday (weekly, multiple days) +- "Quarterly report" every 3 months (every X months) + +## Future Task Creation +The system automatically creates tasks up to a certain point in the future to ensure timely scheduling: + +- **Daily Tasks:** Created up to 7 days in advance +- **Weekly Tasks:** Created up to 2 weeks in advance +- **Monthly Tasks:** Created up to 2 months in advance +- **Every X Days/Weeks/Months:** Created up to 2 intervals in advance + +This ensures that: +- You always have upcoming tasks visible in your schedule +- Tasks are created at appropriate intervals +- The system maintains a reasonable number of future tasks ## Tips - You can edit or stop a recurring task at any time. - Assign team members and labels to recurring tasks for better organization. - Check your task list regularly to see newly created recurring tasks. +- For monthly tasks, dates are limited to 1-28 to ensure the task occurs on the same date every month. +- Tasks are created automatically within the future limit window - you don't need to manually create them. +- If you need to see tasks further in the future, they will be created automatically as the current tasks are completed. ## Need Help? If you have questions or need help setting up recurring tasks, contact your workspace admin or support team. \ No newline at end of file diff --git a/docs/recurring-tasks.md b/docs/recurring-tasks.md index 71fd51cc..71448719 100644 --- a/docs/recurring-tasks.md +++ b/docs/recurring-tasks.md @@ -17,6 +17,51 @@ The recurring tasks cron job automates the creation of tasks based on predefined 3. Checks if a task for the next occurrence already exists. 4. Creates a new task if it does not exist and the next occurrence is within the allowed future window. +## Future Limit Logic +The system implements different future limits based on the schedule type to maintain an appropriate number of future tasks: + +```typescript +const FUTURE_LIMITS = { + daily: moment.duration(7, 'days'), + weekly: moment.duration(2, 'weeks'), + monthly: moment.duration(2, 'months'), + every_x_days: (interval: number) => moment.duration(interval * 2, 'days'), + every_x_weeks: (interval: number) => moment.duration(interval * 2, 'weeks'), + every_x_months: (interval: number) => moment.duration(interval * 2, 'months') +}; +``` + +### Implementation Details +- **Base Calculation:** + ```typescript + const futureLimit = moment(template.last_checked_at || template.created_at) + .add(getFutureLimit(schedule.schedule_type, schedule.interval), 'days'); + ``` + +- **Task Creation Rules:** + 1. Only create tasks if the next occurrence is before the future limit + 2. Skip creation if a task already exists for that date + 3. Update `last_checked_at` after processing + +- **Benefits:** + - Prevents excessive task creation + - Maintains system performance + - Ensures timely task visibility + - Allows for schedule modifications + +## Date Handling +- **Monthly Tasks:** + - Dates are limited to 1-28 to ensure consistency across all months + - This prevents issues with months having different numbers of days + - No special handling needed for February or months with 30/31 days +- **Weekly Tasks:** + - Supports multiple days of the week (0-6, where 0 is Sunday) + - Tasks are created for each selected day +- **Interval-based Tasks:** + - Every X days/weeks/months from the last task's end date + - Minimum interval is 1 day/week/month + - No maximum limit, but tasks are only created up to the future limit + ## Database Interactions - **Templates and Schedules:** - Templates are stored in `task_recurring_templates`. @@ -27,6 +72,7 @@ The recurring tasks cron job automates the creation of tasks based on predefined - Assigns team members and labels by calling appropriate functions/controllers. - **State Tracking:** - Updates `last_checked_at` and `last_created_task_end_date` in the schedule after processing. + - Maintains future limits based on schedule type. ## Task Creation Process 1. **Fetch Templates:** Retrieve all templates and their associated schedules. @@ -41,10 +87,12 @@ The recurring tasks cron job automates the creation of tasks based on predefined - **Cron Expression:** Modify the `TIME` constant in the code to change the schedule. - **Task Template Structure:** Extend the template or schedule interfaces to support additional fields. - **Task Creation Logic:** Customize the task creation process or add new assignment/labeling logic as needed. +- **Future Window:** Adjust the future limits by modifying the `FUTURE_LIMITS` configuration. ## Error Handling - Errors are logged using the `log_error` utility. - The job continues processing other templates even if one fails. +- Failed task creations are not retried automatically. ## References - Source: `src/cron_jobs/recurring-tasks.ts` diff --git a/worklenz-backend/.env.template b/worklenz-backend/.env.template index e0bea264..fdd8fe44 100644 --- a/worklenz-backend/.env.template +++ b/worklenz-backend/.env.template @@ -78,4 +78,8 @@ GOOGLE_CAPTCHA_SECRET_KEY=your_captcha_secret_key GOOGLE_CAPTCHA_PASS_SCORE=0.8 # Email Cronjobs -ENABLE_EMAIL_CRONJOBS=true \ No newline at end of file +ENABLE_EMAIL_CRONJOBS=true + +# RECURRING_JOBS +ENABLE_RECURRING_JOBS=true +RECURRING_JOBS_INTERVAL="0 11 */1 * 1-5" \ No newline at end of file diff --git a/worklenz-backend/src/controllers/tasks-controller-base.ts b/worklenz-backend/src/controllers/tasks-controller-base.ts index 1fe89210..d2524bad 100644 --- a/worklenz-backend/src/controllers/tasks-controller-base.ts +++ b/worklenz-backend/src/controllers/tasks-controller-base.ts @@ -1,6 +1,6 @@ import WorklenzControllerBase from "./worklenz-controller-base"; -import {getColor} from "../shared/utils"; -import {PriorityColorCodes, TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA} from "../shared/constants"; +import { getColor } from "../shared/utils"; +import { PriorityColorCodes, TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA } from "../shared/constants"; import moment from "moment/moment"; export const GroupBy = { @@ -32,23 +32,14 @@ export default class TasksControllerBase extends WorklenzControllerBase { } public static updateTaskViewModel(task: any) { - 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; } @@ -58,28 +49,24 @@ export default class TasksControllerBase extends WorklenzControllerBase { // 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) + 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}; + task.time_spent = { hours: ~~(task.total_minutes_spent / 60), minutes: task.total_minutes_spent % 60 }; task.comments_count = Number(task.comments_count) ? +task.comments_count : 0; task.attachments_count = Number(task.attachments_count) ? +task.attachments_count : 0; diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index d6efa1bb..6e01c686 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -97,7 +97,6 @@ export default class TasksControllerV2 extends TasksControllerBase { try { const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [taskId]); const [data] = result.rows; - console.log("data", data); if (data && data.info && data.info.ratio !== undefined) { data.info.ratio = +((data.info.ratio || 0).toFixed()); return data.info; diff --git a/worklenz-backend/src/cron_jobs/recurring-tasks.ts b/worklenz-backend/src/cron_jobs/recurring-tasks.ts index 16854c7e..2780edd5 100644 --- a/worklenz-backend/src/cron_jobs/recurring-tasks.ts +++ b/worklenz-backend/src/cron_jobs/recurring-tasks.ts @@ -7,12 +7,90 @@ import TasksController from "../controllers/tasks-controller"; // At 11:00+00 (4.30pm+530) on every day-of-month if it's on every day-of-week from Monday through Friday. // const TIME = "0 11 */1 * 1-5"; -const TIME = "*/2 * * * *"; // runs every 2 minutes - for testing purposes +const TIME = process.env.RECURRING_JOBS_INTERVAL || "0 11 */1 * 1-5"; const TIME_FORMAT = "YYYY-MM-DD"; // const TIME = "0 0 * * *"; // Runs at midnight every day const log = (value: any) => console.log("recurring-task-cron-job:", value); +// Define future limits for different schedule types +// More conservative limits to prevent task list clutter +const FUTURE_LIMITS = { + daily: moment.duration(3, "days"), + weekly: moment.duration(1, "week"), + monthly: moment.duration(1, "month"), + every_x_days: (interval: number) => moment.duration(interval, "days"), + every_x_weeks: (interval: number) => moment.duration(interval, "weeks"), + every_x_months: (interval: number) => moment.duration(interval, "months") +}; + +// Helper function to get the future limit based on schedule type +function getFutureLimit(scheduleType: string, interval?: number): moment.Duration { + switch (scheduleType) { + case "daily": + return FUTURE_LIMITS.daily; + case "weekly": + return FUTURE_LIMITS.weekly; + case "monthly": + return FUTURE_LIMITS.monthly; + case "every_x_days": + return FUTURE_LIMITS.every_x_days(interval || 1); + case "every_x_weeks": + return FUTURE_LIMITS.every_x_weeks(interval || 1); + case "every_x_months": + return FUTURE_LIMITS.every_x_months(interval || 1); + default: + return moment.duration(3, "days"); // Default to 3 days + } +} + +// Helper function to batch create tasks +async function createBatchTasks(template: ITaskTemplate & IRecurringSchedule, endDates: moment.Moment[]) { + const createdTasks = []; + + for (const nextEndDate of endDates) { + const existingTaskQuery = ` + SELECT id FROM tasks + WHERE schedule_id = $1 AND end_date::DATE = $2::DATE; + `; + const existingTaskResult = await db.query(existingTaskQuery, [template.schedule_id, nextEndDate.format(TIME_FORMAT)]); + + if (existingTaskResult.rows.length === 0) { + const createTaskQuery = `SELECT create_quick_task($1::json) as task;`; + const taskData = { + name: template.name, + priority_id: template.priority_id, + project_id: template.project_id, + reporter_id: template.reporter_id, + status_id: template.status_id || null, + end_date: nextEndDate.format(TIME_FORMAT), + schedule_id: template.schedule_id + }; + const createTaskResult = await db.query(createTaskQuery, [JSON.stringify(taskData)]); + const createdTask = createTaskResult.rows[0].task; + + if (createdTask) { + createdTasks.push(createdTask); + + for (const assignee of template.assignees) { + await TasksController.createTaskBulkAssignees(assignee.team_member_id, template.project_id, createdTask.id, assignee.assigned_by); + } + + for (const label of template.labels) { + const q = `SELECT add_or_remove_task_label($1, $2) AS labels;`; + await db.query(q, [createdTask.id, label.label_id]); + } + + console.log(`Created task for template ${template.name} with end date ${nextEndDate.format(TIME_FORMAT)}`); + } + } else { + console.log(`Skipped creating task for template ${template.name} with end date ${nextEndDate.format(TIME_FORMAT)} - task already exists`); + } + } + + return createdTasks; +} + async function onRecurringTaskJobTick() { try { log("(cron) Recurring tasks job started."); @@ -33,65 +111,44 @@ async function onRecurringTaskJobTick() { ? moment(template.last_task_end_date) : moment(template.created_at); - const futureLimit = moment(template.last_checked_at || template.created_at).add(1, "week"); + // Calculate future limit based on schedule type + const futureLimit = moment(template.last_checked_at || template.created_at) + .add(getFutureLimit( + template.schedule_type, + template.interval_days || template.interval_weeks || template.interval_months || 1 + )); let nextEndDate = calculateNextEndDate(template, lastTaskEndDate); + const endDatesToCreate: moment.Moment[] = []; - // Find the next future occurrence - while (nextEndDate.isSameOrBefore(now)) { + // Find all future occurrences within the limit + while (nextEndDate.isSameOrBefore(futureLimit)) { + if (nextEndDate.isAfter(now)) { + endDatesToCreate.push(moment(nextEndDate)); + } nextEndDate = calculateNextEndDate(template, nextEndDate); } - // Only create a task if it's within the future limit - if (nextEndDate.isSameOrBefore(futureLimit)) { - const existingTaskQuery = ` - SELECT id FROM tasks - WHERE schedule_id = $1 AND end_date::DATE = $2::DATE; + // Batch create tasks for all future dates + if (endDatesToCreate.length > 0) { + const createdTasks = await createBatchTasks(template, endDatesToCreate); + createdTaskCount += createdTasks.length; + + // Update the last_checked_at in the schedule + const updateScheduleQuery = ` + UPDATE task_recurring_schedules + SET last_checked_at = $1::DATE, + last_created_task_end_date = $2 + WHERE id = $3; `; - const existingTaskResult = await db.query(existingTaskQuery, [template.schedule_id, nextEndDate.format(TIME_FORMAT)]); - - if (existingTaskResult.rows.length === 0) { - const createTaskQuery = `SELECT create_quick_task($1::json) as task;`; - const taskData = { - name: template.name, - priority_id: template.priority_id, - project_id: template.project_id, - reporter_id: template.reporter_id, - status_id: template.status_id || null, - end_date: nextEndDate.format(TIME_FORMAT), - schedule_id: template.schedule_id - }; - const createTaskResult = await db.query(createTaskQuery, [JSON.stringify(taskData)]); - const createdTask = createTaskResult.rows[0].task; - - if (createdTask) { - createdTaskCount++; - - for (const assignee of template.assignees) { - await TasksController.createTaskBulkAssignees(assignee.team_member_id, template.project_id, createdTask.id, assignee.assigned_by); - } - - for (const label of template.labels) { - const q = `SELECT add_or_remove_task_label($1, $2) AS labels;`; - await db.query(q, [createdTask.id, label.label_id]); - } - - console.log(`Created task for template ${template.name} with end date ${nextEndDate.format(TIME_FORMAT)}`); - } - } else { - console.log(`Skipped creating task for template ${template.name} with end date ${nextEndDate.format(TIME_FORMAT)} - task already exists`); - } + await db.query(updateScheduleQuery, [ + moment().format(TIME_FORMAT), + endDatesToCreate[endDatesToCreate.length - 1].format(TIME_FORMAT), + template.schedule_id + ]); } else { - console.log(`No task created for template ${template.name} - next occurrence is beyond the future limit`); + console.log(`No tasks created for template ${template.name} - next occurrence is beyond the future limit`); } - - // Update the last_checked_at in the schedule - const updateScheduleQuery = ` - UPDATE task_recurring_schedules - SET last_checked_at = $1::DATE, last_created_task_end_date = $2 - WHERE id = $3; - `; - await db.query(updateScheduleQuery, [moment(template.last_checked_at || template.created_at).add(1, "day").format(TIME_FORMAT), nextEndDate.format(TIME_FORMAT), template.schedule_id]); } log(`(cron) Recurring tasks job ended with ${createdTaskCount} new tasks created.`); diff --git a/worklenz-frontend/public/locales/en/task-drawer/task-drawer-recurring-config.json b/worklenz-frontend/public/locales/en/task-drawer/task-drawer-recurring-config.json index f1d0301d..10a9db71 100644 --- a/worklenz-frontend/public/locales/en/task-drawer/task-drawer-recurring-config.json +++ b/worklenz-frontend/public/locales/en/task-drawer/task-drawer-recurring-config.json @@ -2,6 +2,7 @@ "recurring": "Recurring", "recurringTaskConfiguration": "Recurring task configuration", "repeats": "Repeats", + "daily": "Daily", "weekly": "Weekly", "everyXDays": "Every X Days", "everyXWeeks": "Every X Weeks", diff --git a/worklenz-frontend/public/locales/es/task-drawer/task-drawer-recurring-config.json b/worklenz-frontend/public/locales/es/task-drawer/task-drawer-recurring-config.json index d9c711a5..ecc48c5f 100644 --- a/worklenz-frontend/public/locales/es/task-drawer/task-drawer-recurring-config.json +++ b/worklenz-frontend/public/locales/es/task-drawer/task-drawer-recurring-config.json @@ -2,6 +2,7 @@ "recurring": "Recurrente", "recurringTaskConfiguration": "Configuración de tarea recurrente", "repeats": "Repeticiones", + "daily": "Diario", "weekly": "Semanal", "everyXDays": "Cada X días", "everyXWeeks": "Cada X semanas", diff --git a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-recurring-config.json b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-recurring-config.json index 5619884b..d693f277 100644 --- a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-recurring-config.json +++ b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-recurring-config.json @@ -2,6 +2,7 @@ "recurring": "Recorrente", "recurringTaskConfiguration": "Configuração de tarefa recorrente", "repeats": "Repete", + "daily": "Diário", "weekly": "Semanal", "everyXDays": "A cada X dias", "everyXWeeks": "A cada X semanas", diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx index 608fc321..1ff8b315 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx @@ -24,44 +24,46 @@ import { taskRecurringApiService } from '@/api/tasks/task-recurring.api.service' import logger from '@/utils/errorLogger'; import { setTaskRecurringSchedule } from '@/features/task-drawer/task-drawer.slice'; -const repeatOptions: IRepeatOption[] = [ - { label: 'Daily', value: ITaskRecurring.Daily }, - { label: 'Weekly', value: ITaskRecurring.Weekly }, - { label: 'Every X Days', value: ITaskRecurring.EveryXDays }, - { label: 'Every X Weeks', value: ITaskRecurring.EveryXWeeks }, - { label: 'Every X Months', value: ITaskRecurring.EveryXMonths }, - { label: 'Monthly', value: ITaskRecurring.Monthly }, -]; - -const daysOfWeek = [ - { label: 'Sunday', value: 0, checked: false }, - { label: 'Monday', value: 1, checked: false }, - { label: 'Tuesday', value: 2, checked: false }, - { label: 'Wednesday', value: 3, checked: false }, - { label: 'Thursday', value: 4, checked: false }, - { label: 'Friday', value: 5, checked: false }, - { label: 'Saturday', value: 6, checked: false } -]; - const monthlyDateOptions = Array.from({ length: 28 }, (_, i) => i + 1); -const weekOptions = [ - { label: 'First', value: 1 }, - { label: 'Second', value: 2 }, - { label: 'Third', value: 3 }, - { label: 'Fourth', value: 4 }, - { label: 'Last', value: 5 } -]; -const dayOptions = daysOfWeek.map(d => ({ label: d.label, value: d.value })); const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => { const { socket, connected } = useSocket(); const dispatch = useAppDispatch(); const { t } = useTranslation('task-drawer/task-drawer-recurring-config'); + const repeatOptions: IRepeatOption[] = [ + { label: t('daily'), value: ITaskRecurring.Daily }, + { label: t('weekly'), value: ITaskRecurring.Weekly }, + { label: t('everyXDays'), value: ITaskRecurring.EveryXDays }, + { label: t('everyXWeeks'), value: ITaskRecurring.EveryXWeeks }, + { label: t('everyXMonths'), value: ITaskRecurring.EveryXMonths }, + { label: t('monthly'), value: ITaskRecurring.Monthly }, + ]; + + const daysOfWeek = [ + { label: t('sun'), value: 0, checked: false }, + { label: t('mon'), value: 1, checked: false }, + { label: t('tue'), value: 2, checked: false }, + { label: t('wed'), value: 3, checked: false }, + { label: t('thu'), value: 4, checked: false }, + { label: t('fri'), value: 5, checked: false }, + { label: t('sat'), value: 6, checked: false } + ]; + + const weekOptions = [ + { label: t('first'), value: 1 }, + { label: t('second'), value: 2 }, + { label: t('third'), value: 3 }, + { label: t('fourth'), value: 4 }, + { label: t('last'), value: 5 } + ]; + + const dayOptions = daysOfWeek.map(d => ({ label: d.label, value: d.value })); + const [recurring, setRecurring] = useState(false); const [showConfig, setShowConfig] = useState(false); const [repeatOption, setRepeatOption] = useState({}); - const [selectedDays, setSelectedDays] = useState([]); + const [selectedDays, setSelectedDays] = useState([]); const [monthlyOption, setMonthlyOption] = useState('date'); const [selectedMonthlyDate, setSelectedMonthlyDate] = useState(1); const [selectedMonthlyWeek, setSelectedMonthlyWeek] = useState(weekOptions[0].value); @@ -106,8 +108,8 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => { [repeatOption] ); - const handleDayCheckboxChange = (checkedValues: string[]) => { - setSelectedDays(checkedValues as unknown as string[]); + const handleDayCheckboxChange = (checkedValues: number[]) => { + setSelectedDays(checkedValues); }; const getSelectedDays = () => { @@ -165,7 +167,9 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => { const res = await taskRecurringApiService.updateTaskRecurringData(task.schedule_id, body); if (res.done) { + setRecurring(true); setShowConfig(false); + configVisibleChange(false); } } catch (e) { logger.error("handleSave", e); @@ -220,9 +224,9 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => { if (!task) return; if (task) setRecurring(!!task.schedule_id); - if (recurring) void getScheduleData(); + if (task.schedule_id) void getScheduleData(); socket?.on(SocketEvents.TASK_RECURRING_CHANGE.toString(), handleResponse); - }, [task]); + }, [task?.schedule_id]); return (
@@ -232,11 +236,11 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {   {recurring && (
- + { )} {monthlyOption === 'day' && ( <> - + { )} {repeatOption.value === ITaskRecurring.EveryXDays && ( - + { )} {repeatOption.value === ITaskRecurring.EveryXWeeks && ( - + { )} {repeatOption.value === ITaskRecurring.EveryXMonths && ( - + { loading={updatingData} onClick={handleSave} > - Save Changes + {t('saveChanges')} From 0cb0efe43e50c06380fb13ad0242e86d41ecaa94 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Wed, 21 May 2025 11:28:10 +0530 Subject: [PATCH 50/70] feat(cron-jobs): conditionally enable recurring tasks based on environment variable - Updated the cron job initialization to start recurring tasks only if the ENABLE_RECURRING_JOBS environment variable is set to "true". This allows for more flexible job management based on deployment configurations. --- worklenz-backend/src/cron_jobs/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worklenz-backend/src/cron_jobs/index.ts b/worklenz-backend/src/cron_jobs/index.ts index 20bd4f62..108a76f2 100644 --- a/worklenz-backend/src/cron_jobs/index.ts +++ b/worklenz-backend/src/cron_jobs/index.ts @@ -7,5 +7,5 @@ export function startCronJobs() { startNotificationsJob(); startDailyDigestJob(); startProjectDigestJob(); - startRecurringTasksJob(); + if (process.env.ENABLE_RECURRING_JOBS === "true") startRecurringTasksJob(); } From 2bdae400acee00ffbe851e270ccfb4e67ab8928c Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Wed, 21 May 2025 11:42:42 +0530 Subject: [PATCH 51/70] feat(hubspot-integration): add HubSpot script component for production environment - Introduced a new HubSpot component that dynamically loads the HubSpot tracking script when in production. - Updated MainLayout to replace TawkTo with HubSpot for improved customer engagement tracking. --- worklenz-frontend/src/components/HubSpot.tsx | 24 ++++++++++++++++++++ worklenz-frontend/src/layouts/MainLayout.tsx | 6 ++--- 2 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 worklenz-frontend/src/components/HubSpot.tsx diff --git a/worklenz-frontend/src/components/HubSpot.tsx b/worklenz-frontend/src/components/HubSpot.tsx new file mode 100644 index 00000000..072ca433 --- /dev/null +++ b/worklenz-frontend/src/components/HubSpot.tsx @@ -0,0 +1,24 @@ +import { useEffect } from 'react'; + +const HubSpot = () => { + useEffect(() => { + const script = document.createElement('script'); + script.type = 'text/javascript'; + script.id = 'hs-script-loader'; + script.async = true; + script.defer = true; + script.src = '//js.hs-scripts.com/22348300.js'; + document.body.appendChild(script); + + return () => { + const existingScript = document.getElementById('hs-script-loader'); + if (existingScript) { + existingScript.remove(); + } + }; + }, []); + + return null; +}; + +export default HubSpot; \ No newline at end of file diff --git a/worklenz-frontend/src/layouts/MainLayout.tsx b/worklenz-frontend/src/layouts/MainLayout.tsx index bbfd302b..d82073a1 100644 --- a/worklenz-frontend/src/layouts/MainLayout.tsx +++ b/worklenz-frontend/src/layouts/MainLayout.tsx @@ -7,7 +7,7 @@ import { colors } from '../styles/colors'; import { verifyAuthentication } from '@/features/auth/authSlice'; import { useEffect } from 'react'; import { useAppDispatch } from '@/hooks/useAppDispatch'; -import TawkTo from '@/components/TawkTo'; +import HubSpot from '@/components/HubSpot'; const MainLayout = () => { const themeMode = useAppSelector(state => state.themeReducer.mode); @@ -68,9 +68,7 @@ const MainLayout = () => { - {import.meta.env.VITE_APP_ENV === 'production' && ( - - )} + {import.meta.env.VITE_APP_ENV === 'production' && } ); From 4687478704da4dbb5762682a412776cf3eb3b11d Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Wed, 21 May 2025 15:09:42 +0530 Subject: [PATCH 52/70] fix: update empty list image source to use S3 URL for consistency across components --- worklenz-frontend/src/components/EmptyListPlaceholder.tsx | 2 +- .../recent-and-favourite-project-list.tsx | 2 +- worklenz-frontend/src/pages/home/task-list/tasks-list.tsx | 2 +- worklenz-frontend/src/pages/home/todo-list/todo-list.tsx | 2 +- .../pages/projects/projectView/members/project-view-members.tsx | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/worklenz-frontend/src/components/EmptyListPlaceholder.tsx b/worklenz-frontend/src/components/EmptyListPlaceholder.tsx index 372cd845..dfe1aa76 100644 --- a/worklenz-frontend/src/components/EmptyListPlaceholder.tsx +++ b/worklenz-frontend/src/components/EmptyListPlaceholder.tsx @@ -8,7 +8,7 @@ type EmptyListPlaceholderProps = { }; const EmptyListPlaceholder = ({ - imageSrc = '/src/assets/images/empty-box.webp', + imageSrc = 'https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp', imageHeight = 60, text, }: EmptyListPlaceholderProps) => { diff --git a/worklenz-frontend/src/pages/home/recent-and-favourite-project-list/recent-and-favourite-project-list.tsx b/worklenz-frontend/src/pages/home/recent-and-favourite-project-list/recent-and-favourite-project-list.tsx index 27822e12..2102da02 100644 --- a/worklenz-frontend/src/pages/home/recent-and-favourite-project-list/recent-and-favourite-project-list.tsx +++ b/worklenz-frontend/src/pages/home/recent-and-favourite-project-list/recent-and-favourite-project-list.tsx @@ -132,7 +132,7 @@ const RecentAndFavouriteProjectList = () => {
{projectsData?.body?.length === 0 ? ( { ) : data?.body.total === 0 ? ( ) : ( diff --git a/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx b/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx index a27d2ac0..f8715808 100644 --- a/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx +++ b/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx @@ -147,7 +147,7 @@ const TodoList = () => {
{data?.body.length === 0 ? ( ) : ( diff --git a/worklenz-frontend/src/pages/projects/projectView/members/project-view-members.tsx b/worklenz-frontend/src/pages/projects/projectView/members/project-view-members.tsx index 8b1b862f..2d669f73 100644 --- a/worklenz-frontend/src/pages/projects/projectView/members/project-view-members.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/members/project-view-members.tsx @@ -263,7 +263,7 @@ const ProjectViewMembers = () => { > {members?.total === 0 ? ( From 8704b6a8c84014d45c4cc476a34ef816a4ae3bbd Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Wed, 21 May 2025 15:45:50 +0530 Subject: [PATCH 53/70] style: adjust font-family formatting and add styles for HubSpot chat widget - Reformatted the font-family declaration for improved readability. - Added specific styles to prevent global styles from affecting the HubSpot chat widget, ensuring consistent appearance. --- worklenz-frontend/src/index.css | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/worklenz-frontend/src/index.css b/worklenz-frontend/src/index.css index bb0a0781..55f4c2f3 100644 --- a/worklenz-frontend/src/index.css +++ b/worklenz-frontend/src/index.css @@ -58,9 +58,9 @@ html.light body { margin: 0; padding: 0; box-sizing: border-box; - font-family: -apple-system, BlinkMacSystemFont, "Inter", Roboto, "Helvetica Neue", Arial, - "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", - "Noto Color Emoji" !important; + font-family: + -apple-system, BlinkMacSystemFont, "Inter", Roboto, "Helvetica Neue", Arial, "Noto Sans", + sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" !important; } /* helper classes */ @@ -145,3 +145,11 @@ Not supports in Firefox and IE */ tr:hover .action-buttons { opacity: 1; } + +/* Prevent global styles from affecting HubSpot chat widget */ +#hubspot-messages-iframe-container, +#hubspot-messages-iframe-container * { + background: none !important; + border-radius: 50% !important; + box-shadow: none !important; +} From d7ca1d8bd2454d591d17f80c58ab80597ae1f4a1 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Wed, 21 May 2025 18:39:28 +0530 Subject: [PATCH 54/70] style: remove HubSpot chat widget styles from global CSS - Deleted specific styles that prevented global styles from affecting the HubSpot chat widget, streamlining the CSS file. --- worklenz-frontend/src/index.css | 7 ------- 1 file changed, 7 deletions(-) diff --git a/worklenz-frontend/src/index.css b/worklenz-frontend/src/index.css index 55f4c2f3..3c1af53d 100644 --- a/worklenz-frontend/src/index.css +++ b/worklenz-frontend/src/index.css @@ -146,10 +146,3 @@ tr:hover .action-buttons { opacity: 1; } -/* Prevent global styles from affecting HubSpot chat widget */ -#hubspot-messages-iframe-container, -#hubspot-messages-iframe-container * { - background: none !important; - border-radius: 50% !important; - box-shadow: none !important; -} From f716971654bb99b9331bc82ba9e9abdbee022fec Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Wed, 21 May 2025 18:50:18 +0530 Subject: [PATCH 55/70] feat(hubspot-integration): dynamically load HubSpot script in production environment - Added a script to conditionally load the HubSpot tracking script in the index.html file when the hostname matches 'app.worklenz.com'. - Removed the HubSpot component from MainLayout to streamline the integration process. --- worklenz-frontend/index.html | 11 +++++++++++ worklenz-frontend/src/layouts/MainLayout.tsx | 1 - 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/worklenz-frontend/index.html b/worklenz-frontend/index.html index 57a2a1b0..ba93ca2c 100644 --- a/worklenz-frontend/index.html +++ b/worklenz-frontend/index.html @@ -48,6 +48,17 @@
+ \ No newline at end of file diff --git a/worklenz-frontend/src/layouts/MainLayout.tsx b/worklenz-frontend/src/layouts/MainLayout.tsx index d82073a1..83a4f4c4 100644 --- a/worklenz-frontend/src/layouts/MainLayout.tsx +++ b/worklenz-frontend/src/layouts/MainLayout.tsx @@ -68,7 +68,6 @@ const MainLayout = () => { - {import.meta.env.VITE_APP_ENV === 'production' && } ); From c1e923c703736517f2a69d2ee1ba81ee6352e7ff Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Wed, 21 May 2025 21:42:16 +0530 Subject: [PATCH 56/70] feat(ownership-transfer): implement transfer_team_ownership function for team ownership management - Added a new PostgreSQL function to handle the transfer of team ownership between users --- worklenz-backend/database/sql/4_functions.sql | 216 ++++++++++ .../password-changed-notification.html | 286 +++++++------ .../reset-password.html | 344 ++++++++------- .../team-invitation.html | 343 ++++++++------- ...gistered-team-invitation-notification.html | 398 ++++++++++-------- .../worklenz-email-templates/welcome.html | 396 +++++++++-------- 6 files changed, 1129 insertions(+), 854 deletions(-) diff --git a/worklenz-backend/database/sql/4_functions.sql b/worklenz-backend/database/sql/4_functions.sql index 56bae8ba..9c9cc820 100644 --- a/worklenz-backend/database/sql/4_functions.sql +++ b/worklenz-backend/database/sql/4_functions.sql @@ -6156,3 +6156,219 @@ BEGIN RETURN v_new_id; END; $$; + +CREATE OR REPLACE FUNCTION transfer_team_ownership(_team_id UUID, _new_owner_id UUID) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _old_owner_id UUID; + _owner_role_id UUID; + _admin_role_id UUID; + _old_org_id UUID; + _new_org_id UUID; + _has_license BOOLEAN; + _old_owner_role_id UUID; + _new_owner_role_id UUID; + _has_active_coupon BOOLEAN; + _other_teams_count INTEGER; + _new_owner_org_id UUID; + _license_type_id UUID; + _has_valid_license BOOLEAN; +BEGIN + -- Get the current owner's ID and organization + SELECT t.user_id, t.organization_id + INTO _old_owner_id, _old_org_id + FROM teams t + WHERE t.id = _team_id; + + IF _old_owner_id IS NULL THEN + RAISE EXCEPTION 'Team not found'; + END IF; + + -- Get the new owner's organization + SELECT organization_id INTO _new_owner_org_id + FROM organizations + WHERE user_id = _new_owner_id; + + -- Get the old organization + SELECT id INTO _old_org_id + FROM organizations + WHERE id = _old_org_id; + + IF _old_org_id IS NULL THEN + RAISE EXCEPTION 'Organization not found'; + END IF; + + -- Check if new owner has any valid license type + SELECT EXISTS ( + SELECT 1 + FROM ( + -- Check regular subscriptions + SELECT lus.user_id, lus.status, lus.active + FROM licensing_user_subscriptions lus + WHERE lus.user_id = _new_owner_id + AND lus.active = TRUE + AND lus.status IN ('active', 'trialing') + + UNION ALL + + -- Check custom subscriptions + SELECT lcs.user_id, lcs.subscription_status as status, TRUE as active + FROM licensing_custom_subs lcs + WHERE lcs.user_id = _new_owner_id + AND lcs.end_date > CURRENT_DATE + + UNION ALL + + -- Check trial status in organizations + SELECT o.user_id, o.subscription_status as status, TRUE as active + FROM organizations o + WHERE o.user_id = _new_owner_id + AND o.trial_in_progress = TRUE + AND o.trial_expire_date > CURRENT_DATE + ) valid_licenses + ) INTO _has_valid_license; + + IF NOT _has_valid_license THEN + RAISE EXCEPTION 'New owner does not have a valid license (subscription, custom subscription, or trial)'; + END IF; + + -- Check if new owner has any active coupon codes + SELECT EXISTS ( + SELECT 1 + FROM licensing_coupon_codes lcc + WHERE lcc.redeemed_by = _new_owner_id + AND lcc.is_redeemed = TRUE + AND lcc.is_refunded = FALSE + ) INTO _has_active_coupon; + + IF _has_active_coupon THEN + RAISE EXCEPTION 'New owner has active coupon codes that need to be handled before transfer'; + END IF; + + -- Count other teams in the organization for information purposes + SELECT COUNT(*) INTO _other_teams_count + FROM teams + WHERE organization_id = _old_org_id + AND id != _team_id; + + -- If new owner has their own organization, move the team to their organization + IF _new_owner_org_id IS NOT NULL THEN + -- Update the team to use the new owner's organization + UPDATE teams + SET user_id = _new_owner_id, + organization_id = _new_owner_org_id + WHERE id = _team_id; + + -- Create notification about organization change + PERFORM create_notification( + _old_owner_id, + _team_id, + NULL, + NULL, + CONCAT('Team ', (SELECT name FROM teams WHERE id = _team_id), ' has been moved to a different organization') + ); + + PERFORM create_notification( + _new_owner_id, + _team_id, + NULL, + NULL, + CONCAT('Team ', (SELECT name FROM teams WHERE id = _team_id), ' has been moved to your organization') + ); + ELSE + -- If new owner doesn't have an organization, transfer the old organization to them + UPDATE organizations + SET user_id = _new_owner_id + WHERE id = _old_org_id; + + -- Update the team to use the same organization + UPDATE teams + SET user_id = _new_owner_id, + organization_id = _old_org_id + WHERE id = _team_id; + + -- Notify both users about organization ownership transfer + PERFORM create_notification( + _old_owner_id, + NULL, + NULL, + NULL, + CONCAT('You are no longer the owner of organization ', (SELECT organization_name FROM organizations WHERE id = _old_org_id), '') + ); + + PERFORM create_notification( + _new_owner_id, + NULL, + NULL, + NULL, + CONCAT('You are now the owner of organization ', (SELECT organization_name FROM organizations WHERE id = _old_org_id), '') + ); + END IF; + + -- Get the owner and admin role IDs + SELECT id INTO _owner_role_id FROM roles WHERE team_id = _team_id AND owner = TRUE; + SELECT id INTO _admin_role_id FROM roles WHERE team_id = _team_id AND admin_role = TRUE; + + -- Get current role IDs for both users + SELECT role_id INTO _old_owner_role_id + FROM team_members + WHERE team_id = _team_id AND user_id = _old_owner_id; + + SELECT role_id INTO _new_owner_role_id + FROM team_members + WHERE team_id = _team_id AND user_id = _new_owner_id; + + -- Update the old owner's role to admin if they want to stay in the team + IF _old_owner_role_id IS NOT NULL THEN + UPDATE team_members + SET role_id = _admin_role_id + WHERE team_id = _team_id AND user_id = _old_owner_id; + END IF; + + -- Update the new owner's role to owner + IF _new_owner_role_id IS NOT NULL THEN + UPDATE team_members + SET role_id = _owner_role_id + WHERE team_id = _team_id AND user_id = _new_owner_id; + ELSE + -- If new owner is not a team member yet, add them + INSERT INTO team_members (user_id, team_id, role_id) + VALUES (_new_owner_id, _team_id, _owner_role_id); + END IF; + + -- Create notification for both users about team ownership + PERFORM create_notification( + _old_owner_id, + _team_id, + NULL, + NULL, + CONCAT('You are no longer the owner of team ', (SELECT name FROM teams WHERE id = _team_id), '') + ); + + PERFORM create_notification( + _new_owner_id, + _team_id, + NULL, + NULL, + CONCAT('You are now the owner of team ', (SELECT name FROM teams WHERE id = _team_id), '') + ); + + RETURN json_build_object( + 'success', TRUE, + 'old_owner_id', _old_owner_id, + 'new_owner_id', _new_owner_id, + 'team_id', _team_id, + 'old_org_id', _old_org_id, + 'new_org_id', COALESCE(_new_owner_org_id, _old_org_id), + 'old_role_id', _old_owner_role_id, + 'new_role_id', _new_owner_role_id, + 'has_valid_license', _has_valid_license, + 'has_active_coupon', _has_active_coupon, + 'other_teams_count', _other_teams_count, + 'org_ownership_transferred', _new_owner_org_id IS NULL, + 'team_moved_to_new_org', _new_owner_org_id IS NOT NULL + ); +END; +$$; diff --git a/worklenz-backend/worklenz-email-templates/password-changed-notification.html b/worklenz-backend/worklenz-email-templates/password-changed-notification.html index f734d8a8..2c8e2d3a 100644 --- a/worklenz-backend/worklenz-email-templates/password-changed-notification.html +++ b/worklenz-backend/worklenz-email-templates/password-changed-notification.html @@ -2,31 +2,30 @@ - + Password Changed | Worklenz + - - - - + + + + + + + +
+
diff --git a/worklenz-backend/worklenz-email-templates/reset-password.html b/worklenz-backend/worklenz-email-templates/reset-password.html index d6f7e4d7..9c5f2c24 100644 --- a/worklenz-backend/worklenz-email-templates/reset-password.html +++ b/worklenz-backend/worklenz-email-templates/reset-password.html @@ -2,31 +2,30 @@ - + Reset Your Password | Worklenz + - - - - - - - - + + + +
diff --git a/worklenz-backend/worklenz-email-templates/team-invitation.html b/worklenz-backend/worklenz-email-templates/team-invitation.html index 921e845d..f0d17e33 100644 --- a/worklenz-backend/worklenz-email-templates/team-invitation.html +++ b/worklenz-backend/worklenz-email-templates/team-invitation.html @@ -2,31 +2,30 @@ - + Join Your Team on Worklenz + - - - - - - - - + + + +
diff --git a/worklenz-backend/worklenz-email-templates/unregistered-team-invitation-notification.html b/worklenz-backend/worklenz-email-templates/unregistered-team-invitation-notification.html index a231f9ad..2db5cfc2 100644 --- a/worklenz-backend/worklenz-email-templates/unregistered-team-invitation-notification.html +++ b/worklenz-backend/worklenz-email-templates/unregistered-team-invitation-notification.html @@ -2,31 +2,30 @@ - + Join Your Team on Worklenz + - - - - - + + + + + + + + + + + + + + + +
diff --git a/worklenz-backend/worklenz-email-templates/welcome.html b/worklenz-backend/worklenz-email-templates/welcome.html index bc258a6d..7bb62821 100644 --- a/worklenz-backend/worklenz-email-templates/welcome.html +++ b/worklenz-backend/worklenz-email-templates/welcome.html @@ -2,31 +2,30 @@ - + Welcome to Worklenz + - - - - - + + + + + + + + + + + + + + + +
From c18889a127d1965845ac3edc36fede3bef97ff3f Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Thu, 22 May 2025 09:39:53 +0530 Subject: [PATCH 57/70] refactor(task-drawer): remove unused imports and add edit mode for task name Removed unused `useEffect` import in `task-drawer-status-dropdown.tsx` and unused `connected` variable. Added edit mode for task name in `task-drawer-header.tsx` to improve user interaction by allowing inline editing of the task name. --- .../task-drawer-header/task-drawer-header.tsx | 50 +++++++++++++------ .../task-drawer-status-dropdown.tsx | 4 +- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx b/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx index 6a20f0b9..0bc322f3 100644 --- a/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx +++ b/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx @@ -27,6 +27,7 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => { const { socket, connected } = useSocket(); const { clearTaskFromUrl } = useTaskDrawerUrlSync(); const isDeleting = useRef(false); + const [isEditing, setIsEditing] = useState(false); const { taskFormViewModel, selectedTaskId } = useAppSelector(state => state.taskDrawerReducer); const [taskName, setTaskName] = useState(taskFormViewModel?.task?.name ?? ''); @@ -88,6 +89,7 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => { }; const handleInputBlur = () => { + setIsEditing(false); if ( !selectedTaskId || !connected || @@ -113,21 +115,39 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => { return ( - onTaskNameChange(e)} - onBlur={handleInputBlur} - placeholder={t('taskHeader.taskNamePlaceholder')} - className="task-name-input" - style={{ - width: '100%', - border: 'none', - }} - showCount={false} - maxLength={250} - /> + {isEditing ? ( + onTaskNameChange(e)} + onBlur={handleInputBlur} + placeholder={t('taskHeader.taskNamePlaceholder')} + className="task-name-input" + style={{ + width: '100%', + border: 'none', + }} + showCount={true} + maxLength={250} + autoFocus + /> + ) : ( +

setIsEditing(true)} + style={{ + margin: 0, + padding: '4px 11px', + fontSize: '16px', + cursor: 'pointer', + wordWrap: 'break-word', + overflowWrap: 'break-word', + width: '100%' + }} + > + {taskName || t('taskHeader.taskNamePlaceholder')} +

+ )}
{ - const { socket, connected } = useSocket(); + const { socket } = useSocket(); const dispatch = useAppDispatch(); const themeMode = useAppSelector(state => state.themeReducer.mode); const { tab } = useTabSearchParam(); From 312c6b5be82dcca07eb0bbe33aa0373a7a57ddcf Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Tue, 27 May 2025 17:13:04 +0530 Subject: [PATCH 58/70] feat(settings): add project templates settings to the configuration - Restored the project templates settings in the settings constants file, making it accessible for admin users. --- .../src/lib/settings/settings-constants.ts | 16 ++++++++-------- .../project-templates-settings.tsx | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/worklenz-frontend/src/lib/settings/settings-constants.ts b/worklenz-frontend/src/lib/settings/settings-constants.ts index 9855b008..7f9beb2a 100644 --- a/worklenz-frontend/src/lib/settings/settings-constants.ts +++ b/worklenz-frontend/src/lib/settings/settings-constants.ts @@ -108,14 +108,14 @@ export const settingsItems: SettingMenuItems[] = [ element: React.createElement(CategoriesSettings), adminOnly: true, }, - // { - // key: 'project-templates', - // name: 'project-templates', - // endpoint: 'project-templates', - // icon: React.createElement(FileZipOutlined), - // element: React.createElement(ProjectTemplatesSettings), - // adminOnly: true, - // }, + { + key: 'project-templates', + name: 'project-templates', + endpoint: 'project-templates', + icon: React.createElement(FileZipOutlined), + element: React.createElement(ProjectTemplatesSettings), + adminOnly: true, + }, { key: 'task-templates', name: 'task-templates', diff --git a/worklenz-frontend/src/pages/settings/project-templates/project-templates-settings.tsx b/worklenz-frontend/src/pages/settings/project-templates/project-templates-settings.tsx index 714947fc..7da55d59 100644 --- a/worklenz-frontend/src/pages/settings/project-templates/project-templates-settings.tsx +++ b/worklenz-frontend/src/pages/settings/project-templates/project-templates-settings.tsx @@ -51,7 +51,7 @@ const ProjectTemplatesSettings = () => { style={{ display: 'flex', gap: '10px', justifyContent: 'right' }} className="button-visibilty" > - + {/* - + */} Date: Tue, 27 May 2025 22:40:19 -0400 Subject: [PATCH 59/70] Generate random passwords in update-docker-env.sh --- update-docker-env.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/update-docker-env.sh b/update-docker-env.sh index 77ab1beb..12044bd1 100755 --- a/update-docker-env.sh +++ b/update-docker-env.sh @@ -73,8 +73,8 @@ cat > worklenz-backend/.env << EOL NODE_ENV=production PORT=3000 SESSION_NAME=worklenz.sid -SESSION_SECRET=change_me_in_production -COOKIE_SECRET=change_me_in_production +SESSION_SECRET=$(openssl rand -base64 48) +COOKIE_SECRET=$(openssl rand -base64 48) # CORS SOCKET_IO_CORS=${FRONTEND_URL} @@ -92,7 +92,7 @@ LOGIN_SUCCESS_REDIRECT="${FRONTEND_URL}/auth/authenticating" DB_HOST=db DB_PORT=5432 DB_USER=postgres -DB_PASSWORD=password +DB_PASSWORD=$(openssl rand -base64 48) DB_NAME=worklenz_db DB_MAX_CLIENTS=50 USE_PG_NATIVE=true @@ -123,7 +123,7 @@ SLACK_WEBHOOK= COMMIT_BUILD_IMMEDIATELY=true # JWT Secret -JWT_SECRET=change_me_in_production +JWT_SECRET=$(openssl rand -base64 48) EOL echo "Environment configuration updated for ${HOSTNAME} with" $([ "$USE_SSL" = "true" ] && echo "HTTPS/WSS" || echo "HTTP/WS") @@ -138,4 +138,4 @@ echo "Frontend URL: ${FRONTEND_URL}" echo "API URL: ${HTTP_PREFIX}${HOSTNAME}:3000" echo "Socket URL: ${WS_PREFIX}${HOSTNAME}:3000" echo "MinIO Dashboard URL: ${MINIO_DASHBOARD_URL}" -echo "CORS is configured to allow requests from: ${FRONTEND_URL}" \ No newline at end of file +echo "CORS is configured to allow requests from: ${FRONTEND_URL}" From 65af5f659eac0c9a4e5acc308eb666ba4162338e Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 30 May 2025 10:56:19 +0530 Subject: [PATCH 60/70] refactor(build): remove Gruntfile and transition to npm scripts for build process - Deleted Gruntfile.js to streamline the build process. - Updated package.json to include new npm scripts for build, clean, and watch tasks. - Added dependencies for concurrent execution and CSRF token management. - Integrated csrf-sync for improved CSRF protection in the application. - Refactored app and API client to utilize the new CSRF token management approach. --- worklenz-backend/Gruntfile.js | 131 - worklenz-backend/package-lock.json | 2443 ++++++----------- worklenz-backend/package.json | 43 +- worklenz-backend/scripts/compress.js | 53 + worklenz-backend/src/app.ts | 53 +- worklenz-frontend/src/App.tsx | 8 + worklenz-frontend/src/api/api-client.ts | 45 +- .../api/home-page/home-page.api.service.ts | 15 +- .../api/projects/projects.v1.api.service.ts | 15 +- 9 files changed, 982 insertions(+), 1824 deletions(-) delete mode 100644 worklenz-backend/Gruntfile.js create mode 100644 worklenz-backend/scripts/compress.js diff --git a/worklenz-backend/Gruntfile.js b/worklenz-backend/Gruntfile.js deleted file mode 100644 index b621cbc0..00000000 --- a/worklenz-backend/Gruntfile.js +++ /dev/null @@ -1,131 +0,0 @@ -module.exports = function (grunt) { - - // Project configuration. - grunt.initConfig({ - pkg: grunt.file.readJSON("package.json"), - clean: { - dist: "build" - }, - compress: require("./grunt/grunt-compress"), - copy: { - main: { - files: [ - {expand: true, cwd: "src", src: ["public/**"], dest: "build"}, - {expand: true, cwd: "src", src: ["views/**"], dest: "build"}, - {expand: true, cwd: "landing-page-assets", src: ["**"], dest: "build/public/assets"}, - {expand: true, cwd: "src", src: ["shared/sample-data.json"], dest: "build", filter: "isFile"}, - {expand: true, cwd: "src", src: ["shared/templates/**"], dest: "build", filter: "isFile"}, - {expand: true, cwd: "src", src: ["shared/postgresql-error-codes.json"], dest: "build", filter: "isFile"}, - ] - }, - packages: { - files: [ - {expand: true, cwd: "", src: [".env"], dest: "build", filter: "isFile"}, - {expand: true, cwd: "", src: [".gitignore"], dest: "build", filter: "isFile"}, - {expand: true, cwd: "", src: ["release"], dest: "build", filter: "isFile"}, - {expand: true, cwd: "", src: ["jest.config.js"], dest: "build", filter: "isFile"}, - {expand: true, cwd: "", src: ["package.json"], dest: "build", filter: "isFile"}, - {expand: true, cwd: "", src: ["package-lock.json"], dest: "build", filter: "isFile"}, - {expand: true, cwd: "", src: ["common_modules/**"], dest: "build"} - ] - } - }, - sync: { - main: { - files: [ - {cwd: "src", src: ["views/**", "public/**"], dest: "build/"}, // makes all src relative to cwd - ], - verbose: true, - failOnError: true, - compareUsing: "md5" - } - }, - uglify: { - all: { - files: [{ - expand: true, - cwd: "build", - src: "**/*.js", - dest: "build" - }] - }, - controllers: { - files: [{ - expand: true, - cwd: "build", - src: "controllers/*.js", - dest: "build" - }] - }, - routes: { - files: [{ - expand: true, - cwd: "build", - src: "routes/**/*.js", - dest: "build" - }] - }, - assets: { - files: [{ - expand: true, - cwd: "build", - src: "public/assets/**/*.js", - dest: "build" - }] - } - }, - shell: { - tsc: { - command: "tsc --build tsconfig.prod.json" - }, - esbuild: { - // command: "esbuild `find src -type f -name '*.ts'` --platform=node --minify=false --target=esnext --format=cjs --tsconfig=tsconfig.prod.json --outdir=build" - command: "node esbuild && node cli/esbuild-patch" - }, - tsc_dev: { - command: "tsc --build tsconfig.json" - }, - swagger: { - command: "node ./cli/swagger" - }, - inline_queries: { - command: "node ./cli/inline-queries" - } - }, - watch: { - scripts: { - files: ["src/**/*.ts"], - tasks: ["shell:tsc_dev"], - options: { - debounceDelay: 250, - spawn: false, - } - }, - other: { - files: ["src/**/*.pug", "landing-page-assets/**"], - tasks: ["sync"] - } - } - }); - - grunt.registerTask("clean", ["clean"]); - grunt.registerTask("copy", ["copy:main"]); - grunt.registerTask("swagger", ["shell:swagger"]); - grunt.registerTask("build:tsc", ["shell:tsc"]); - grunt.registerTask("build", ["clean", "shell:tsc", "copy:main", "compress"]); - grunt.registerTask("build:es", ["clean", "shell:esbuild", "copy:main", "uglify:assets", "compress"]); - grunt.registerTask("build:strict", ["clean", "shell:tsc", "copy:packages", "uglify:all", "copy:main", "compress"]); - grunt.registerTask("dev", ["clean", "copy:main", "shell:tsc_dev", "shell:inline_queries", "watch"]); - - // Load the plugin that provides the "uglify" task. - grunt.loadNpmTasks("grunt-contrib-watch"); - grunt.loadNpmTasks("grunt-contrib-clean"); - grunt.loadNpmTasks("grunt-contrib-copy"); - grunt.loadNpmTasks("grunt-contrib-uglify"); - grunt.loadNpmTasks("grunt-contrib-compress"); - grunt.loadNpmTasks("grunt-shell"); - grunt.loadNpmTasks("grunt-sync"); - - // Default task(s). - grunt.registerTask("default", []); -}; diff --git a/worklenz-backend/package-lock.json b/worklenz-backend/package-lock.json index 2953defa..138d01ff 100644 --- a/worklenz-backend/package-lock.json +++ b/worklenz-backend/package-lock.json @@ -24,6 +24,7 @@ "cors": "^2.8.5", "cron": "^2.4.0", "crypto-js": "^4.1.1", + "csrf-sync": "^4.2.1", "csurf": "^1.11.0", "debug": "^4.3.4", "dotenv": "^16.3.1", @@ -99,26 +100,22 @@ "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", "chokidar": "^3.5.3", + "concurrently": "^9.1.2", + "cpx2": "^8.0.0", "esbuild": "^0.17.19", "esbuild-envfile-plugin": "^1.0.5", "esbuild-node-externals": "^1.8.0", "eslint": "^8.45.0", "eslint-plugin-security": "^1.7.1", "fs-extra": "^10.1.0", - "grunt": "^1.6.1", - "grunt-contrib-clean": "^2.0.1", - "grunt-contrib-compress": "^2.0.0", - "grunt-contrib-copy": "^1.0.0", - "grunt-contrib-uglify": "^5.2.2", - "grunt-contrib-watch": "^1.1.0", - "grunt-shell": "^4.0.0", - "grunt-sync": "^0.8.2", "highcharts": "^11.1.0", "jest": "^28.1.3", "jest-sonar-reporter": "^2.0.0", "ncp": "^2.0.0", "nodeman": "^1.1.2", + "rimraf": "^6.0.1", "swagger-jsdoc": "^6.2.8", + "terser": "^5.40.0", "ts-jest": "^28.0.8", "ts-node": "^10.9.1", "tslint": "^6.1.3", @@ -3527,6 +3524,109 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -3834,6 +3934,23 @@ "node": ">=8" } }, + "node_modules/@jest/core/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@jest/core/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4271,14 +4388,15 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", "dev": true, + "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.0.1", + "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/trace-mapping": "^0.3.24" }, "engines": { "node": ">=6.0.0" @@ -4294,14 +4412,26 @@ } }, "node_modules/@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.0.0" } }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", + "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.15", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", @@ -4309,21 +4439,16 @@ "dev": true }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.18", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.18.tgz", - "integrity": "sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA==", + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "dev": true, + "license": "MIT", "dependencies": { - "@jridgewell/resolve-uri": "3.1.0", - "@jridgewell/sourcemap-codec": "1.4.14" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@jridgewell/trace-mapping/node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.14", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", - "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", - "dev": true - }, "node_modules/@jsdevtools/ono": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", @@ -4360,6 +4485,22 @@ "node": ">=10" } }, + "node_modules/@mapbox/node-pre-gyp/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@mapbox/node-pre-gyp/node_modules/semver": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", @@ -5940,10 +6081,11 @@ } }, "node_modules/acorn": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", - "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "dev": true, + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -5969,15 +6111,6 @@ "node": ">=0.4.0" } }, - "node_modules/adm-zip": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.10.tgz", - "integrity": "sha512-x0HvcHqVJNTPk/Bw8JbLWlWoo6Wwnsug0fnYYro1HBrjxZ3G7/AZk7Ahv8JwDe1uIcz8eBqvu86FuF1POiG7vQ==", - "dev": true, - "engines": { - "node": ">=6.0" - } - }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -6141,29 +6274,11 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, - "node_modules/array-each": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/array-each/-/array-each-1.0.1.tgz", - "integrity": "sha512-zHjL5SZa68hkKHBFBK6DJCTtr9sfTCPCaph/L7tMSLcTFgy+zX7E+6q5UArbtOtMBCtxdICpfTCspRse+ywyXA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, - "node_modules/array-slice": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/array-slice/-/array-slice-1.1.0.tgz", - "integrity": "sha512-B1qMD3RBP7O8o0H2KbrXDyB0IccejMF15+87Lvlor12ONPRHP6gTjXMNkt/d3ZuOGbAe66hFmaCfECI24Ufp6w==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", @@ -6539,18 +6654,6 @@ "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" }, - "node_modules/body": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/body/-/body-5.1.0.tgz", - "integrity": "sha512-chUsBxGRtuElD6fmw1gHLpvnKdVLK302peeFa9ZqAEk8TyzZ3fygLyUEDDPTJvL9+Bor0dIwn6ePOsRM2y0zQQ==", - "dev": true, - "dependencies": { - "continuable-cache": "^0.3.1", - "error": "^7.0.0", - "raw-body": "~1.1.0", - "safe-json-parse": "~1.0.1" - } - }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -6599,31 +6702,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/body/node_modules/bytes": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-1.0.0.tgz", - "integrity": "sha512-/x68VkHLeTl3/Ll8IvxdwzhrT+IyKc52e/oyHhA2RwqPqswSnjVbSddfPRwAsJtbilMAPSRWwAlpxdYsSWOTKQ==", - "dev": true - }, - "node_modules/body/node_modules/raw-body": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-1.1.7.tgz", - "integrity": "sha512-WmJJU2e9Y6M5UzTOkHaM7xJGAPQD8PNzx3bAd2+uhZAim6wDk6dAZxPVYLF67XhbR4hmKGh33Lpmh4XWrCH5Mg==", - "dev": true, - "dependencies": { - "bytes": "1", - "string_decoder": "0.10" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/body/node_modules/string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", - "dev": true - }, "node_modules/bowser": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", @@ -7174,6 +7252,124 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, + "node_modules/concurrently": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.1.2.tgz", + "integrity": "sha512-H9MWcoPsYddwbOGM6difjVwVZHl63nwMEwDJG/L7VGtuaJhb12h2caPG2tVPWs7emuYix252iGfqOyrz1GczTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "lodash": "^4.17.21", + "rxjs": "^7.8.1", + "shell-quote": "^1.8.1", + "supports-color": "^8.1.1", + "tree-kill": "^1.2.2", + "yargs": "^17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/concurrently/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/concurrently/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/concurrently/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/connect-flash": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/connect-flash/-/connect-flash-0.1.1.tgz", @@ -7247,12 +7443,6 @@ "node": ">= 0.6" } }, - "node_modules/continuable-cache": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/continuable-cache/-/continuable-cache-0.3.1.tgz", - "integrity": "sha512-TF30kpKhTH8AGCG3dut0rdd/19B7Z+qCnrMoBLpyQu/2drZdNrrpcjPEoJeSVsQM+8KmWG5O56oPDjSSUsuTyA==", - "dev": true - }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", @@ -7316,6 +7506,141 @@ "node": ">= 0.10" } }, + "node_modules/cpx2": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/cpx2/-/cpx2-8.0.0.tgz", + "integrity": "sha512-RxD9jrSVNSOmfcbiPlr3XnKbUKH9K1w2HCv0skczUKhsZTueiDBecxuaSAKQkYSLQaGVA4ZQJZlTj5hVNNEvKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debounce": "^2.0.0", + "debug": "^4.1.1", + "duplexer": "^0.1.1", + "fs-extra": "^11.1.0", + "glob": "^11.0.0", + "glob2base": "0.0.12", + "ignore": "^6.0.2", + "minimatch": "^10.0.1", + "p-map": "^7.0.0", + "resolve": "^1.12.0", + "safe-buffer": "^5.2.0", + "shell-quote": "^1.8.0", + "subarg": "^1.0.0" + }, + "bin": { + "cpx": "bin/index.js" + }, + "engines": { + "node": "^20.0.0 || >=22.0.0", + "npm": ">=10" + } + }, + "node_modules/cpx2/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/cpx2/node_modules/fs-extra": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", + "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/cpx2/node_modules/glob": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.2.tgz", + "integrity": "sha512-YT7U7Vye+t5fZ/QMkBFrTJ7ZQxInIUjwyAjVj84CYXqgBdv30MFUPGnBR6sQaVq6Is15wYJUsnzTuWaGRBhBAQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^4.0.1", + "minimatch": "^10.0.0", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cpx2/node_modules/ignore": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-6.0.2.tgz", + "integrity": "sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/cpx2/node_modules/minimatch": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", + "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cpx2/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/cpx2/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", @@ -7387,6 +7712,15 @@ "node": ">= 0.8" } }, + "node_modules/csrf-sync": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/csrf-sync/-/csrf-sync-4.2.1.tgz", + "integrity": "sha512-+q9tlUSCi/kbwr1NYwn5+MeuNhwxz3wSv1yl42BgIWfIuErZ3HajRwzvZTkfiyIqt1PZT8lQSlffhSYjCneN7g==", + "license": "ISC", + "dependencies": { + "http-errors": "^2.0.0" + } + }, "node_modules/csurf": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/csurf/-/csurf-1.11.0.tgz", @@ -7454,20 +7788,24 @@ "node": ">=0.6" } }, - "node_modules/dateformat": { - "version": "4.6.3", - "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", - "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", - "dev": true, - "engines": { - "node": "*" - } - }, "node_modules/dayjs": { "version": "1.11.9", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.9.tgz", "integrity": "sha512-QvzAURSbQ0pKdIye2txOzNaHmxtUBXerpY0FJsFXUMKbIZeFm5ht1LS/jFsrncjnmtv8HsG0W2g6c0zUjZWmpA==" }, + "node_modules/debounce": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-2.2.0.tgz", + "integrity": "sha512-Xks6RUDLZFdz8LIdR6q0MTH44k7FikOmnh5xkSjMig6ch45afc8sjTjRQf3P6ax8dMgcQrYO/AR2RGWURrruqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -7557,15 +7895,6 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/detect-file": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz", - "integrity": "sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/detect-libc": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", @@ -7743,6 +8072,13 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -7842,15 +8178,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/error": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/error/-/error-7.2.1.tgz", - "integrity": "sha512-fo9HBvWnx3NGUKMvMwB/CBCMMrfEJgbDTVDEkPygA3Bdd3lM1OyCd+rbQ8BwnpF6GdVeOLDNmyL4N5Bg80ZvdA==", - "dev": true, - "dependencies": { - "string-template": "~0.2.1" - } - }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -8318,12 +8645,6 @@ "node": ">= 0.6" } }, - "node_modules/eventemitter2": { - "version": "0.4.14", - "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-0.4.14.tgz", - "integrity": "sha512-K7J4xq5xAD5jHsGM5ReWXRTFa3JRGofHiMcVgQ8PRwgWxzjHpMWCIzsmyf60+mh8KLsqYPcjUMa0AC4hd6lPyQ==", - "dev": true - }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -8404,18 +8725,6 @@ "node": ">=6" } }, - "node_modules/expand-tilde": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", - "integrity": "sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==", - "dev": true, - "dependencies": { - "homedir-polyfill": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/expect": { "version": "28.1.3", "resolved": "https://registry.npmjs.org/expect/-/expect-28.1.3.tgz", @@ -8599,12 +8908,6 @@ } ] }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true - }, "node_modules/fast-csv": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", @@ -8686,18 +8989,6 @@ "reusify": "^1.0.4" } }, - "node_modules/faye-websocket": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.10.0.tgz", - "integrity": "sha512-Xhj93RXbMSq8urNCUq4p9l0P6hnySJ/7YNRhYNug0bLOuii7pKO7xQFb5mx9xZXWCar88pLPb805PvUkwrLZpQ==", - "dev": true, - "dependencies": { - "websocket-driver": ">=0.5.1" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -8712,21 +9003,6 @@ "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==" }, - "node_modules/figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "dev": true, - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -8739,12 +9015,6 @@ "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/file-sync-cmp": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/file-sync-cmp/-/file-sync-cmp-0.1.1.tgz", - "integrity": "sha512-0k45oWBokCqh2MOexeYKpyqmGKG+8mQ2Wd8iawx+uWd/weWJQAZ6SoPybagdCI4xFisag8iAR77WPm4h3pTfxA==", - "dev": true - }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -8796,6 +9066,13 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/find-index": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/find-index/-/find-index-0.1.1.tgz", + "integrity": "sha512-uJ5vWrfBKMcE6y2Z8834dwEZj9mNGxYa3t3I53OwFeuZ8D9oc2E5zcsrkuhX6h4iYrjhiv0T3szQmxlAV9uxDg==", + "dev": true, + "license": "MIT" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -8812,46 +9089,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/findup-sync": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-5.0.0.tgz", - "integrity": "sha512-MzwXju70AuyflbgeOhzvQWAvvQdo1XL0A9bVvlXsYcFEBM87WR4OakL4OfZq+QRmr+duJubio+UtNQCPsVESzQ==", - "dev": true, - "dependencies": { - "detect-file": "^1.0.0", - "is-glob": "^4.0.3", - "micromatch": "^4.0.4", - "resolve-dir": "^1.0.1" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/fined": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fined/-/fined-1.2.0.tgz", - "integrity": "sha512-ZYDqPLGxDkDhDZBjZBb+oD1+j0rA4E0pXY50eplAAOPg2N/gUBSSk5IM1/QhPfyVo19lJ+CvXpqfvk+b2p/8Ng==", - "dev": true, - "dependencies": { - "expand-tilde": "^2.0.2", - "is-plain-object": "^2.0.3", - "object.defaults": "^1.1.0", - "object.pick": "^1.2.0", - "parse-filepath": "^1.0.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/flagged-respawn": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-1.0.1.tgz", - "integrity": "sha512-lNaHNVymajmk0OJMBn8fVUAU1BtDeKIqKoVhk4xAALB57aALg6b4W0MfJ/cUE0g9YBXy5XhSlPIpYIJ7HaY/3Q==", - "dev": true, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/flat-cache": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", @@ -8865,6 +9102,23 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/flat-cache/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/flatted": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", @@ -8896,25 +9150,34 @@ } } }, - "node_modules/for-in": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", - "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/for-own": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/for-own/-/for-own-1.0.0.tgz", - "integrity": "sha512-0OABksIGrxKK8K4kynWkQ7y1zounQxP+CWnyclVwj81KW3vlLlGUx57DKGcP/LH216GzqnstnPocF16Nxs0Ycg==", + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "dev": true, + "license": "ISC", "dependencies": { - "for-in": "^1.0.1" + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" }, "engines": { - "node": ">=0.10.0" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/form-data": { @@ -9076,18 +9339,6 @@ "node": ">=10" } }, - "node_modules/gaze": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/gaze/-/gaze-1.1.3.tgz", - "integrity": "sha512-BRdNm8hbWzFzWHERTrejLqwHDfS4GibPoq5wjTPIoJHoBtKGPg3xAFfxmM+9ztbXelxcf2hwQcaz1PtmFeue8g==", - "dev": true, - "dependencies": { - "globule": "^1.0.0" - }, - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/generic-pool": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", @@ -9159,15 +9410,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/getobject": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/getobject/-/getobject-1.0.2.tgz", - "integrity": "sha512-2zblDBaFcb3rB4rF77XVnuINOE2h2k/OnqXAiy0IrTxUfV1iFp3la33oAQVY9pCpWU268WFYVt2t71hlMuLsOg==", - "dev": true, - "engines": { - "node": ">=10" - } - }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -9204,46 +9446,16 @@ "node": ">= 6" } }, - "node_modules/global-modules": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz", - "integrity": "sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==", + "node_modules/glob2base": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/glob2base/-/glob2base-0.0.12.tgz", + "integrity": "sha512-ZyqlgowMbfj2NPjxaZZ/EtsXlOch28FRXgMd64vqZWk1bT9+wvSRLYD1om9M7QfQru51zJPAT17qXm4/zd+9QA==", "dev": true, "dependencies": { - "global-prefix": "^1.0.1", - "is-windows": "^1.0.1", - "resolve-dir": "^1.0.0" + "find-index": "^0.1.1" }, "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/global-prefix": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-1.0.2.tgz", - "integrity": "sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==", - "dev": true, - "dependencies": { - "expand-tilde": "^2.0.2", - "homedir-polyfill": "^1.0.1", - "ini": "^1.3.4", - "is-windows": "^1.0.1", - "which": "^1.2.14" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/global-prefix/node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "which": "bin/which" + "node": ">= 0.10" } }, "node_modules/globals": { @@ -9275,52 +9487,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globule": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/globule/-/globule-1.3.4.tgz", - "integrity": "sha512-OPTIfhMBh7JbBYDpa5b+Q5ptmMWKwcNcFSR/0c6t8V4f3ZAVBEsKNY37QdVqmLRYSMhOUGYrY0QhSoEpzGr/Eg==", - "dev": true, - "dependencies": { - "glob": "~7.1.1", - "lodash": "^4.17.21", - "minimatch": "~3.0.2" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/globule/node_modules/glob": { - "version": "7.1.7", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", - "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/globule/node_modules/minimatch": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz", - "integrity": "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -9344,709 +9510,6 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, - "node_modules/grunt": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/grunt/-/grunt-1.6.1.tgz", - "integrity": "sha512-/ABUy3gYWu5iBmrUSRBP97JLpQUm0GgVveDCp6t3yRNIoltIYw7rEj3g5y1o2PGPR2vfTRGa7WC/LZHLTXnEzA==", - "dev": true, - "dependencies": { - "dateformat": "~4.6.2", - "eventemitter2": "~0.4.13", - "exit": "~0.1.2", - "findup-sync": "~5.0.0", - "glob": "~7.1.6", - "grunt-cli": "~1.4.3", - "grunt-known-options": "~2.0.0", - "grunt-legacy-log": "~3.0.0", - "grunt-legacy-util": "~2.0.1", - "iconv-lite": "~0.6.3", - "js-yaml": "~3.14.0", - "minimatch": "~3.0.4", - "nopt": "~3.0.6" - }, - "bin": { - "grunt": "bin/grunt" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/grunt-cli": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/grunt-cli/-/grunt-cli-1.4.3.tgz", - "integrity": "sha512-9Dtx/AhVeB4LYzsViCjUQkd0Kw0McN2gYpdmGYKtE2a5Yt7v1Q+HYZVWhqXc/kGnxlMtqKDxSwotiGeFmkrCoQ==", - "dev": true, - "dependencies": { - "grunt-known-options": "~2.0.0", - "interpret": "~1.1.0", - "liftup": "~3.0.1", - "nopt": "~4.0.1", - "v8flags": "~3.2.0" - }, - "bin": { - "grunt": "bin/grunt" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/grunt-cli/node_modules/nopt": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.3.tgz", - "integrity": "sha512-CvaGwVMztSMJLOeXPrez7fyfObdZqNUK1cPAEzLHrTybIua9pMdmmPR5YwtfNftIOMv3DPUhFaxsZMNTQO20Kg==", - "dev": true, - "dependencies": { - "abbrev": "1", - "osenv": "^0.1.4" - }, - "bin": { - "nopt": "bin/nopt.js" - } - }, - "node_modules/grunt-contrib-clean": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/grunt-contrib-clean/-/grunt-contrib-clean-2.0.1.tgz", - "integrity": "sha512-uRvnXfhiZt8akb/ZRDHJpQQtkkVkqc/opWO4Po/9ehC2hPxgptB9S6JHDC/Nxswo4CJSM0iFPT/Iym3cEMWzKA==", - "dev": true, - "dependencies": { - "async": "^3.2.3", - "rimraf": "^2.6.2" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "grunt": ">=0.4.5" - } - }, - "node_modules/grunt-contrib-clean/node_modules/rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - } - }, - "node_modules/grunt-contrib-compress": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/grunt-contrib-compress/-/grunt-contrib-compress-2.0.0.tgz", - "integrity": "sha512-r/dAGx4qG+rmBFF4lb/hTktW2huGMGxkSLf9msh3PPtq0+cdQRQerZJ30UKevX3BLQsohwLzO0p1z/LrH6aKXQ==", - "dev": true, - "dependencies": { - "adm-zip": "^0.5.1", - "archiver": "^5.1.0", - "chalk": "^4.1.0", - "lodash": "^4.17.20", - "pretty-bytes": "^5.4.1", - "stream-buffers": "^3.0.2" - }, - "engines": { - "node": ">=10.16" - } - }, - "node_modules/grunt-contrib-compress/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/grunt-contrib-compress/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/grunt-contrib-compress/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/grunt-contrib-compress/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/grunt-contrib-compress/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/grunt-contrib-compress/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/grunt-contrib-copy": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/grunt-contrib-copy/-/grunt-contrib-copy-1.0.0.tgz", - "integrity": "sha512-gFRFUB0ZbLcjKb67Magz1yOHGBkyU6uL29hiEW1tdQ9gQt72NuMKIy/kS6dsCbV0cZ0maNCb0s6y+uT1FKU7jA==", - "dev": true, - "dependencies": { - "chalk": "^1.1.1", - "file-sync-cmp": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/grunt-contrib-copy/node_modules/ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/grunt-contrib-copy/node_modules/ansi-styles": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", - "integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/grunt-contrib-copy/node_modules/chalk": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", - "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==", - "dev": true, - "dependencies": { - "ansi-styles": "^2.2.1", - "escape-string-regexp": "^1.0.2", - "has-ansi": "^2.0.0", - "strip-ansi": "^3.0.0", - "supports-color": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/grunt-contrib-copy/node_modules/strip-ansi": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", - "dev": true, - "dependencies": { - "ansi-regex": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/grunt-contrib-copy/node_modules/supports-color": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", - "integrity": "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/grunt-contrib-uglify": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/grunt-contrib-uglify/-/grunt-contrib-uglify-5.2.2.tgz", - "integrity": "sha512-ITxiWxrjjP+RZu/aJ5GLvdele+sxlznh+6fK9Qckio5ma8f7Iv8woZjRkGfafvpuygxNefOJNc+hfjjBayRn2Q==", - "dev": true, - "dependencies": { - "chalk": "^4.1.2", - "maxmin": "^3.0.0", - "uglify-js": "^3.16.1", - "uri-path": "^1.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/grunt-contrib-uglify/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/grunt-contrib-uglify/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/grunt-contrib-uglify/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/grunt-contrib-uglify/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/grunt-contrib-uglify/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/grunt-contrib-uglify/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/grunt-contrib-watch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/grunt-contrib-watch/-/grunt-contrib-watch-1.1.0.tgz", - "integrity": "sha512-yGweN+0DW5yM+oo58fRu/XIRrPcn3r4tQx+nL7eMRwjpvk+rQY6R8o94BPK0i2UhTg9FN21hS+m8vR8v9vXfeg==", - "dev": true, - "dependencies": { - "async": "^2.6.0", - "gaze": "^1.1.0", - "lodash": "^4.17.10", - "tiny-lr": "^1.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/grunt-contrib-watch/node_modules/async": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", - "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", - "dev": true, - "dependencies": { - "lodash": "^4.17.14" - } - }, - "node_modules/grunt-known-options": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/grunt-known-options/-/grunt-known-options-2.0.0.tgz", - "integrity": "sha512-GD7cTz0I4SAede1/+pAbmJRG44zFLPipVtdL9o3vqx9IEyb7b4/Y3s7r6ofI3CchR5GvYJ+8buCSioDv5dQLiA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/grunt-legacy-log": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/grunt-legacy-log/-/grunt-legacy-log-3.0.0.tgz", - "integrity": "sha512-GHZQzZmhyq0u3hr7aHW4qUH0xDzwp2YXldLPZTCjlOeGscAOWWPftZG3XioW8MasGp+OBRIu39LFx14SLjXRcA==", - "dev": true, - "dependencies": { - "colors": "~1.1.2", - "grunt-legacy-log-utils": "~2.1.0", - "hooker": "~0.2.3", - "lodash": "~4.17.19" - }, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/grunt-legacy-log-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/grunt-legacy-log-utils/-/grunt-legacy-log-utils-2.1.0.tgz", - "integrity": "sha512-lwquaPXJtKQk0rUM1IQAop5noEpwFqOXasVoedLeNzaibf/OPWjKYvvdqnEHNmU+0T0CaReAXIbGo747ZD+Aaw==", - "dev": true, - "dependencies": { - "chalk": "~4.1.0", - "lodash": "~4.17.19" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/grunt-legacy-log-utils/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/grunt-legacy-log-utils/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/grunt-legacy-log-utils/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/grunt-legacy-log-utils/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/grunt-legacy-log-utils/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/grunt-legacy-log-utils/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/grunt-legacy-util": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/grunt-legacy-util/-/grunt-legacy-util-2.0.1.tgz", - "integrity": "sha512-2bQiD4fzXqX8rhNdXkAywCadeqiPiay0oQny77wA2F3WF4grPJXCvAcyoWUJV+po/b15glGkxuSiQCK299UC2w==", - "dev": true, - "dependencies": { - "async": "~3.2.0", - "exit": "~0.1.2", - "getobject": "~1.0.0", - "hooker": "~0.2.3", - "lodash": "~4.17.21", - "underscore.string": "~3.3.5", - "which": "~2.0.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/grunt-shell": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/grunt-shell/-/grunt-shell-4.0.0.tgz", - "integrity": "sha512-dHFy8VZDfWGYLTeNvIHze4PKXGvIlDWuN0UE7hUZstTQeiEyv1VmW1MaDYQ3X5tE3bCi3bEia1gGKH8z/f1czQ==", - "dev": true, - "dependencies": { - "chalk": "^3.0.0", - "npm-run-path": "^2.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - }, - "peerDependencies": { - "grunt": ">=1" - } - }, - "node_modules/grunt-shell/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/grunt-shell/node_modules/chalk": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", - "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/grunt-shell/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/grunt-shell/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/grunt-shell/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/grunt-shell/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/grunt-sync": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/grunt-sync/-/grunt-sync-0.8.2.tgz", - "integrity": "sha512-PB+xKI9YPyZn3NZQXpKHfZVlxHdf1L8GMl+Wi0mLhYypWuOdZPW2EzTmSuhhFbXjkb0aIOxvII1zdZZEl9zqGg==", - "dev": true, - "dependencies": { - "fs-extra": "^6.0.1", - "glob": "^7.0.5", - "md5-file": "^2.0.3" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/grunt-sync/node_modules/fs-extra": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-6.0.1.tgz", - "integrity": "sha512-GnyIkKhhzXZUWFCaJzvyDLEEgDkPfb4/TPvJCJVuS8MWZgoSsErf++QpiAlDnKFcqhRlm+tIOcencCjyJE6ZCA==", - "dev": true, - "dependencies": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - } - }, - "node_modules/grunt-sync/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/grunt-sync/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/grunt/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/grunt/node_modules/glob": { - "version": "7.1.7", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", - "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/grunt/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/grunt/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/grunt/node_modules/minimatch": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz", - "integrity": "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/grunt/node_modules/nopt": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", - "integrity": "sha512-4GUt3kSEYmk4ITxzB/b9vaIDfUVWN/Ml1Fwl11IlnIG2iaJ9O6WXZ9SrYM9NLI8OCBieN2Y8SWC2oJV0RQ7qYg==", - "dev": true, - "dependencies": { - "abbrev": "1" - }, - "bin": { - "nopt": "bin/nopt.js" - } - }, - "node_modules/grunt/node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true - }, - "node_modules/gzip-size": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-5.1.1.tgz", - "integrity": "sha512-FNHi6mmoHvs1mxZAds4PpdCS6QG8B4C1krxJsMutgxl5t3+GlRTzzI3NEkifXx2pVsOvJdOGSmIgDhQ55FwdPA==", - "dev": true, - "dependencies": { - "duplexer": "^0.1.1", - "pify": "^4.0.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -10058,27 +9521,6 @@ "node": ">= 0.4.0" } }, - "node_modules/has-ansi": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", - "integrity": "sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==", - "dev": true, - "dependencies": { - "ansi-regex": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/has-ansi/node_modules/ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -10146,27 +9588,6 @@ "dev": true, "license": "https://www.highcharts.com/license" }, - "node_modules/homedir-polyfill": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", - "integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==", - "dev": true, - "dependencies": { - "parse-passwd": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/hooker": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/hooker/-/hooker-0.2.3.tgz", - "integrity": "sha512-t+UerCsQviSymAInD01Pw+Dn/usmz1sRO+3Zk1+lx8eg+WKpD2ulcwWqHHL0+aseRBr+3+vIhiG1K1JTwaIcTA==", - "dev": true, - "engines": { - "node": "*" - } - }, "node_modules/hpp": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/hpp/-/hpp-0.2.3.tgz", @@ -10218,12 +9639,6 @@ "node": ">= 0.8" } }, - "node_modules/http-parser-js": { - "version": "0.5.8", - "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz", - "integrity": "sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==", - "dev": true - }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -10375,12 +9790,6 @@ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" }, - "node_modules/interpret": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.1.0.tgz", - "integrity": "sha512-CLM8SNMDu7C5psFCn6Wg/tgpj/bKAg7hc2gWqcuR9OD5Ft9PhBpIu8PLicPeis+xDd6YX2ncI8MCA64I9tftIA==", - "dev": true - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -10389,19 +9798,6 @@ "node": ">= 0.10" } }, - "node_modules/is-absolute": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", - "integrity": "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==", - "dev": true, - "dependencies": { - "is-relative": "^1.0.0", - "is-windows": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -10508,18 +9904,6 @@ "node": ">=8" } }, - "node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-promise": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", @@ -10540,18 +9924,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-relative": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", - "integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==", - "dev": true, - "dependencies": { - "is-unc-path": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -10563,27 +9935,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-unc-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz", - "integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==", - "dev": true, - "dependencies": { - "unc-path-regex": "^0.1.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-windows": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", - "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -10595,15 +9946,6 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, - "node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/istanbul-lib-coverage": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", @@ -10739,6 +10081,22 @@ "node": ">=8" } }, + "node_modules/jackspeak": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/javascript-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/javascript-stringify/-/javascript-stringify-2.1.0.tgz", @@ -12525,15 +11883,6 @@ "safe-buffer": "^5.0.1" } }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -12622,40 +11971,6 @@ "immediate": "~3.0.5" } }, - "node_modules/liftup": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/liftup/-/liftup-3.0.1.tgz", - "integrity": "sha512-yRHaiQDizWSzoXk3APcA71eOI/UuhEkNN9DiW2Tt44mhYzX4joFoCZlxsSOF7RyeLlfqzFLQI1ngFq3ggMPhOw==", - "dev": true, - "dependencies": { - "extend": "^3.0.2", - "findup-sync": "^4.0.0", - "fined": "^1.2.0", - "flagged-respawn": "^1.0.1", - "is-plain-object": "^2.0.4", - "object.map": "^1.0.1", - "rechoir": "^0.7.0", - "resolve": "^1.19.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/liftup/node_modules/findup-sync": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-4.0.0.tgz", - "integrity": "sha512-6jvvn/12IC4quLBL1KNokxC7wWTvYncaVUYSoxWw7YykPLuRrnv4qdHcSOywOI5RpkOVGeQRtWM8/q+G6W6qfQ==", - "dev": true, - "dependencies": { - "detect-file": "^1.0.0", - "is-glob": "^4.0.0", - "micromatch": "^4.0.2", - "resolve-dir": "^1.0.1" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -12667,12 +11982,6 @@ "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==" }, - "node_modules/livereload-js": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/livereload-js/-/livereload-js-2.4.0.tgz", - "integrity": "sha512-XPQH8Z2GDP/Hwz2PCDrh2mth4yFejwA1OZ/81Ti3LgKyhDcEjsSsqFWZojHG0va/duGd+WyosY7eXLDoOyqcPw==", - "dev": true - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -12838,18 +12147,6 @@ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "dev": true }, - "node_modules/make-iterator": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/make-iterator/-/make-iterator-1.0.1.tgz", - "integrity": "sha512-pxiuXh0iVEq7VM7KMIhs5gxsfxCux2URptUQaXo4iZZJxBAzTPOLE2BumO5dbfVYq/hBJFBR/a1mFDmOx5AGmw==", - "dev": true, - "dependencies": { - "kind-of": "^6.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -12859,15 +12156,6 @@ "tmpl": "1.0.5" } }, - "node_modules/map-cache": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", - "integrity": "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -12877,100 +12165,6 @@ "node": ">= 0.4" } }, - "node_modules/maxmin": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/maxmin/-/maxmin-3.0.0.tgz", - "integrity": "sha512-wcahMInmGtg/7c6a75fr21Ch/Ks1Tb+Jtoan5Ft4bAI0ZvJqyOw8kkM7e7p8hDSzY805vmxwHT50KcjGwKyJ0g==", - "dev": true, - "dependencies": { - "chalk": "^4.1.0", - "figures": "^3.2.0", - "gzip-size": "^5.1.1", - "pretty-bytes": "^5.3.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/maxmin/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/maxmin/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/maxmin/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/maxmin/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/maxmin/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/maxmin/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/md5-file": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/md5-file/-/md5-file-2.0.7.tgz", - "integrity": "sha512-kWAICpJv8fIY0Ka/90iOX9nCJ407Fgj82ceWwcxi2HvVkKGHRMS/Y4caqBaju5skNYXiQohGUjwGZ7rVgzUhRw==", - "dev": true - }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -13383,27 +12577,6 @@ "node": ">=0.10.0" } }, - "node_modules/npm-run-path": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", - "integrity": "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==", - "dev": true, - "dependencies": { - "path-key": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", - "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/npmlog": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", @@ -13440,46 +12613,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object.defaults": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/object.defaults/-/object.defaults-1.1.0.tgz", - "integrity": "sha512-c/K0mw/F11k4dEUBMW8naXUuBuhxRCfG7W+yFy8EcijU/rSmazOUd1XAEEe6bC0OuXY4HUKjTJv7xbxIMqdxrA==", - "dev": true, - "dependencies": { - "array-each": "^1.0.1", - "array-slice": "^1.0.0", - "for-own": "^1.0.0", - "isobject": "^3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object.map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/object.map/-/object.map-1.0.1.tgz", - "integrity": "sha512-3+mAJu2PLfnSVGHwIWubpOFLscJANBKuB/6A4CxBstc4aqwQY0FWcsppuy4jU5GSB95yES5JHSI+33AWuS4k6w==", - "dev": true, - "dependencies": { - "for-own": "^1.0.0", - "make-iterator": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object.pick": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", - "integrity": "sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==", - "dev": true, - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/obuf": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", @@ -13560,34 +12693,6 @@ "node": ">= 0.8.0" } }, - "node_modules/os-homedir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", - "integrity": "sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/osenv": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz", - "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", - "dev": true, - "dependencies": { - "os-homedir": "^1.0.0", - "os-tmpdir": "^1.0.0" - } - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -13618,6 +12723,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz", + "integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -13627,6 +12745,13 @@ "node": ">=6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -13644,20 +12769,6 @@ "node": ">=6" } }, - "node_modules/parse-filepath": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz", - "integrity": "sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q==", - "dev": true, - "dependencies": { - "is-absolute": "^1.0.0", - "map-cache": "^0.2.0", - "path-root": "^0.1.1" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -13676,15 +12787,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse-passwd": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz", - "integrity": "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/parse-srcset": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", @@ -13813,25 +12915,41 @@ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, - "node_modules/path-root": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/path-root/-/path-root-0.1.1.tgz", - "integrity": "sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==", + "node_modules/path-scurry": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", + "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "path-root-regex": "^0.1.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=0.10.0" + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/path-root-regex": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/path-root-regex/-/path-root-regex-0.1.2.tgz", - "integrity": "sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ==", + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", + "integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==", "dev": true, + "license": "ISC", "engines": { - "node": ">=0.10.0" + "node": "20 || >=22" + } + }, + "node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" } }, "node_modules/path-to-regexp": { @@ -14081,15 +13199,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/pirates": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", @@ -14264,18 +13373,6 @@ "node": ">= 0.8.0" } }, - "node_modules/pretty-bytes": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", - "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", - "dev": true, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/pretty-format": { "version": "28.1.3", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-28.1.3.tgz", @@ -14650,18 +13747,6 @@ "node": ">=8.10.0" } }, - "node_modules/rechoir": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.1.tgz", - "integrity": "sha512-/njmZ8s1wVeR6pjTZ+0nCnv8SpZNRMT2D1RLOJQESlYFDBvwpTA4KWJpZ+sBJ4+vhjILRcK7JIFdGCdxEAAitg==", - "dev": true, - "dependencies": { - "resolve": "^1.9.0" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/redis": { "version": "4.6.7", "resolved": "https://registry.npmjs.org/redis/-/redis-4.6.7.tgz", @@ -14801,19 +13886,6 @@ "node": ">=8" } }, - "node_modules/resolve-dir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz", - "integrity": "sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==", - "dev": true, - "dependencies": { - "expand-tilde": "^2.0.0", - "global-modules": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -14843,19 +13915,85 @@ } }, "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz", + "integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==", + "dev": true, + "license": "ISC", "dependencies": { - "glob": "^7.1.3" + "glob": "^11.0.0", + "package-json-from-dist": "^1.0.0" }, "bin": { - "rimraf": "bin.js" + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.2.tgz", + "integrity": "sha512-YT7U7Vye+t5fZ/QMkBFrTJ7ZQxInIUjwyAjVj84CYXqgBdv30MFUPGnBR6sQaVq6Is15wYJUsnzTuWaGRBhBAQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^4.0.1", + "minimatch": "^10.0.0", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", + "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/rndm": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/rndm/-/rndm-1.2.0.tgz", @@ -14884,17 +14022,21 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, - "node_modules/safe-json-parse": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/safe-json-parse/-/safe-json-parse-1.0.1.tgz", - "integrity": "sha512-o0JmTu17WGUaUOHa1l0FPGXKBfijbxK6qoHzlkihsDXxzBHvJcA7zgviKR92Xs841rX9pK16unfphLq0/KqX7A==", - "dev": true - }, "node_modules/safe-regex": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz", @@ -15161,6 +14303,19 @@ "node": ">=8" } }, + "node_modules/shell-quote": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz", + "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -15392,12 +14547,6 @@ "node": ">= 10.x" } }, - "node_modules/sprintf-js": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz", - "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==", - "dev": true - }, "node_modules/stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", @@ -15435,15 +14584,6 @@ "node": ">= 0.8" } }, - "node_modules/stream-buffers": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-3.0.2.tgz", - "integrity": "sha512-DQi1h8VEBA/lURbSwFtEHnSTb9s2/pwLEaFuNhXwy1Dx3Sa0lOuYT2yNUr4/j2fs8oCAMANtrZ5OrPZtyVs3MQ==", - "dev": true, - "engines": { - "node": ">= 0.10.0" - } - }, "node_modules/streamx": { "version": "2.15.5", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.15.5.tgz", @@ -15493,12 +14633,6 @@ "node": ">=10" } }, - "node_modules/string-template": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/string-template/-/string-template-0.2.1.tgz", - "integrity": "sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw==", - "dev": true - }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -15512,6 +14646,22 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -15523,6 +14673,20 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", @@ -15558,6 +14722,16 @@ "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==" }, + "node_modules/subarg": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/subarg/-/subarg-1.0.0.tgz", + "integrity": "sha512-RIrIdRY0X1xojthNcVtgT9sjpOGagEUKpZdgBUi054OEPFo282yg+zE+t1Rj3+RqKq2xStL7uUHhY+AjbC4BXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.1.0" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -15736,6 +14910,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/terser": { + "version": "5.40.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.40.0.tgz", + "integrity": "sha512-cfeKl/jjwSR5ar7d0FGmave9hFGJT8obyo0z+CrQOylLDbk7X81nPU6vq9VORa5jU30SkDnT2FXjLbR8HLP+xA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.14.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/terser/node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -15761,29 +14972,6 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, - "node_modules/tiny-lr": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tiny-lr/-/tiny-lr-1.1.1.tgz", - "integrity": "sha512-44yhA3tsaRoMOjQQ+5v5mVdqef+kH6Qze9jTpqtVufgYjYt08zyZAwNwwVBj3i1rJMnR52IxOW0LK0vBzgAkuA==", - "dev": true, - "dependencies": { - "body": "^5.1.0", - "debug": "^3.1.0", - "faye-websocket": "~0.10.0", - "livereload-js": "^2.3.0", - "object-assign": "^4.1.0", - "qs": "^6.4.0" - } - }, - "node_modules/tiny-lr/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.1" - } - }, "node_modules/tmp": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", @@ -15795,6 +14983,22 @@ "node": ">=8.17.0" } }, + "node_modules/tmp/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -15848,6 +15052,16 @@ "node": "*" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/triple-beam": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", @@ -16210,28 +15424,6 @@ "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==" }, - "node_modules/unc-path-regex": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", - "integrity": "sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/underscore.string": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/underscore.string/-/underscore.string-3.3.6.tgz", - "integrity": "sha512-VoC83HWXmCrF6rgkyxS9GHv8W9Q5nhMKho+OadDJGzL2oDYbYEppBaCMH6pFlwLeqj2QS+hhkw2kpXkSdD1JxQ==", - "dev": true, - "dependencies": { - "sprintf-js": "^1.1.1", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": "*" - } - }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", @@ -16373,15 +15565,6 @@ "punycode": "^2.1.0" } }, - "node_modules/uri-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/uri-path/-/uri-path-1.0.0.tgz", - "integrity": "sha512-8pMuAn4KacYdGMkFaoQARicp4HSw24/DHOVKWqVRJ8LhhAwPPFpdGvdL9184JVmUwe7vz7Z9n6IqI6t5n2ELdg==", - "dev": true, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/util": { "version": "0.10.4", "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", @@ -16436,18 +15619,6 @@ "node": ">=10.12.0" } }, - "node_modules/v8flags": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-3.2.0.tgz", - "integrity": "sha512-mH8etigqMfiGWdeXpaaqGfs6BndypxusHHcv2qSHyZkGEznCd/qAXCWWRzeowtL54147cktFOC4P5y+kl8d8Jg==", - "dev": true, - "dependencies": { - "homedir-polyfill": "^1.0.1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/validator": { "version": "13.9.0", "resolved": "https://registry.npmjs.org/validator/-/validator-13.9.0.tgz", @@ -16487,29 +15658,6 @@ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, - "node_modules/websocket-driver": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", - "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", - "dev": true, - "dependencies": { - "http-parser-js": ">=0.5.1", - "safe-buffer": ">=5.1.0", - "websocket-extensions": ">=0.1.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/websocket-extensions": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", - "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -16608,6 +15756,61 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/wrap-ansi/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", diff --git a/worklenz-backend/package.json b/worklenz-backend/package.json index f3faaaec..cffa800b 100644 --- a/worklenz-backend/package.json +++ b/worklenz-backend/package.json @@ -11,16 +11,30 @@ "repository": "GITHUB_REPO_HERE", "author": "worklenz.com", "scripts": { - "start": "node ./build/bin/www", - "tcs": "grunt build:tsc", - "build": "grunt build", - "watch": "grunt watch", - "dev": "grunt dev", - "es": "esbuild `find src -type f -name '*.ts'` --platform=node --minify=true --watch=true --target=esnext --format=cjs --tsconfig=tsconfig.prod.json --outdir=dist", - "copy": "grunt copy", + "test": "jest", + "start": "node build/bin/www.js", + "dev": "npm run build:dev && npm run watch", + "build": "npm run clean && npm run compile && npm run copy && npm run compress", + "build:dev": "npm run clean && npm run compile:dev && npm run copy", + "build:prod": "npm run clean && npm run compile:prod && npm run copy && npm run minify && npm run compress", + "clean": "rimraf build", + "compile": "tsc --build tsconfig.prod.json", + "compile:dev": "tsc --build tsconfig.json", + "compile:prod": "tsc --build tsconfig.prod.json", + "copy": "npm run copy:assets && npm run copy:views && npm run copy:config && npm run copy:shared", + "copy:assets": "npx cpx2 \"src/public/**\" build/public", + "copy:views": "npx cpx2 \"src/views/**\" build/views", + "copy:config": "npx cpx2 \".env\" build && npx cpx2 \"package.json\" build", + "copy:shared": "npx cpx2 \"src/shared/postgresql-error-codes.json\" build/shared && npx cpx2 \"src/shared/sample-data.json\" build/shared && npx cpx2 \"src/shared/templates/**\" build/shared/templates", + "watch": "concurrently \"npm run watch:ts\" \"npm run watch:assets\"", + "watch:ts": "tsc --build tsconfig.json --watch", + "watch:assets": "npx cpx2 \"src/{public,views}/**\" build --watch", + "minify": "terser build/**/*.js --compress --mangle --output-dir build", + "compress": "node scripts/compress.js", + "swagger": "node ./cli/swagger", + "inline-queries": "node ./cli/inline-queries", "sonar": "sonar-scanner -Dproject.settings=sonar-project-dev.properties", "tsc": "tsc", - "test": "jest --setupFiles dotenv/config", "test:watch": "jest --watch --setupFiles dotenv/config" }, "jestSonar": { @@ -45,6 +59,7 @@ "cors": "^2.8.5", "cron": "^2.4.0", "crypto-js": "^4.1.1", + "csrf-sync": "^4.2.1", "csurf": "^1.11.0", "debug": "^4.3.4", "dotenv": "^16.3.1", @@ -120,26 +135,22 @@ "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", "chokidar": "^3.5.3", + "concurrently": "^9.1.2", + "cpx2": "^8.0.0", "esbuild": "^0.17.19", "esbuild-envfile-plugin": "^1.0.5", "esbuild-node-externals": "^1.8.0", "eslint": "^8.45.0", "eslint-plugin-security": "^1.7.1", "fs-extra": "^10.1.0", - "grunt": "^1.6.1", - "grunt-contrib-clean": "^2.0.1", - "grunt-contrib-compress": "^2.0.0", - "grunt-contrib-copy": "^1.0.0", - "grunt-contrib-uglify": "^5.2.2", - "grunt-contrib-watch": "^1.1.0", - "grunt-shell": "^4.0.0", - "grunt-sync": "^0.8.2", "highcharts": "^11.1.0", "jest": "^28.1.3", "jest-sonar-reporter": "^2.0.0", "ncp": "^2.0.0", "nodeman": "^1.1.2", + "rimraf": "^6.0.1", "swagger-jsdoc": "^6.2.8", + "terser": "^5.40.0", "ts-jest": "^28.0.8", "ts-node": "^10.9.1", "tslint": "^6.1.3", diff --git a/worklenz-backend/scripts/compress.js b/worklenz-backend/scripts/compress.js new file mode 100644 index 00000000..6a946163 --- /dev/null +++ b/worklenz-backend/scripts/compress.js @@ -0,0 +1,53 @@ +const fs = require('fs'); +const path = require('path'); +const { createGzip } = require('zlib'); +const { pipeline } = require('stream'); + +async function compressFile(inputPath, outputPath) { + return new Promise((resolve, reject) => { + const gzip = createGzip(); + const source = fs.createReadStream(inputPath); + const destination = fs.createWriteStream(outputPath); + + pipeline(source, gzip, destination, (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); +} + +async function compressDirectory(dir) { + const files = fs.readdirSync(dir, { withFileTypes: true }); + + for (const file of files) { + const fullPath = path.join(dir, file.name); + + if (file.isDirectory()) { + await compressDirectory(fullPath); + } else if (file.name.endsWith('.js') || file.name.endsWith('.css')) { + const gzPath = fullPath + '.gz'; + await compressFile(fullPath, gzPath); + console.log(`Compressed: ${fullPath} -> ${gzPath}`); + } + } +} + +async function main() { + try { + const buildDir = path.join(__dirname, '../build'); + if (fs.existsSync(buildDir)) { + await compressDirectory(buildDir); + console.log('Compression complete!'); + } else { + console.log('Build directory not found. Run build first.'); + } + } catch (error) { + console.error('Compression failed:', error); + process.exit(1); + } +} + +main(); \ No newline at end of file diff --git a/worklenz-backend/src/app.ts b/worklenz-backend/src/app.ts index 4da00a0e..68f18af3 100644 --- a/worklenz-backend/src/app.ts +++ b/worklenz-backend/src/app.ts @@ -6,7 +6,7 @@ import logger from "morgan"; import helmet from "helmet"; import compression from "compression"; import passport from "passport"; -import csurf from "csurf"; +import { csrfSync } from "csrf-sync"; import rateLimit from "express-rate-limit"; import cors from "cors"; import flash from "connect-flash"; @@ -112,17 +112,13 @@ function isLoggedIn(req: Request, _res: Response, next: NextFunction) { return req.user ? next() : next(createError(401)); } -// CSRF configuration -const csrfProtection = csurf({ - cookie: { - key: "XSRF-TOKEN", - path: "/", - httpOnly: false, - secure: isProduction(), // Only secure in production - sameSite: isProduction() ? "none" : "lax", // Different settings for dev vs prod - domain: isProduction() ? ".worklenz.com" : undefined // Only set domain in production - }, - ignoreMethods: ["HEAD", "OPTIONS"] +// CSRF configuration using csrf-sync for session-based authentication +const { + invalidCsrfTokenError, + generateToken, + csrfSynchronisedProtection, +} = csrfSync({ + getTokenFromRequest: (req: Request) => req.headers["x-csrf-token"] as string || (req.body && req.body["_csrf"]) }); // Apply CSRF selectively (exclude webhooks and public routes) @@ -135,38 +131,25 @@ app.use((req, res, next) => { ) { next(); } else { - csrfProtection(req, res, next); + csrfSynchronisedProtection(req, res, next); } }); -// Set CSRF token cookie +// Set CSRF token method on request object for compatibility app.use((req: Request, res: Response, next: NextFunction) => { - if (req.csrfToken) { - const token = req.csrfToken(); - res.cookie("XSRF-TOKEN", token, { - httpOnly: false, - secure: isProduction(), - sameSite: isProduction() ? "none" : "lax", - domain: isProduction() ? ".worklenz.com" : undefined, - path: "/" - }); + // Add csrfToken method to request object for compatibility + if (!req.csrfToken && generateToken) { + req.csrfToken = (overwrite?: boolean) => generateToken(req, overwrite); } next(); }); // CSRF token refresh endpoint app.get("/csrf-token", (req: Request, res: Response) => { - if (req.csrfToken) { - const token = req.csrfToken(); - res.cookie("XSRF-TOKEN", token, { - httpOnly: false, - secure: isProduction(), - sameSite: isProduction() ? "none" : "lax", - domain: isProduction() ? ".worklenz.com" : undefined, - path: "/" - }); - res.status(200).json({ done: true, message: "CSRF token refreshed" }); - } else { + try { + const token = generateToken(req); + res.status(200).json({ done: true, message: "CSRF token refreshed", token }); + } catch (error) { res.status(500).json({ done: false, message: "Failed to generate CSRF token" }); } }); @@ -219,7 +202,7 @@ if (isInternalServer()) { // CSRF error handler app.use((err: any, req: Request, res: Response, next: NextFunction) => { - if (err.code === "EBADCSRFTOKEN") { + if (err === invalidCsrfTokenError) { return res.status(403).json({ done: false, message: "Invalid CSRF token", diff --git a/worklenz-frontend/src/App.tsx b/worklenz-frontend/src/App.tsx index 8b313508..3181a25e 100644 --- a/worklenz-frontend/src/App.tsx +++ b/worklenz-frontend/src/App.tsx @@ -13,6 +13,7 @@ import router from './app/routes'; // Hooks & Utils import { useAppSelector } from './hooks/useAppSelector'; import { initMixpanel } from './utils/mixpanelInit'; +import { initializeCsrfToken } from './api/api-client'; // Types & Constants import { Language } from './features/i18n/localesSlice'; @@ -35,6 +36,13 @@ const App: React.FC<{ children: React.ReactNode }> = ({ children }) => { }); }, [language]); + // Initialize CSRF token on app startup + useEffect(() => { + initializeCsrfToken().catch(error => { + logger.error('Failed to initialize CSRF token:', error); + }); + }, []); + return ( }> diff --git a/worklenz-frontend/src/api/api-client.ts b/worklenz-frontend/src/api/api-client.ts index ec43f7a5..721a5274 100644 --- a/worklenz-frontend/src/api/api-client.ts +++ b/worklenz-frontend/src/api/api-client.ts @@ -4,27 +4,36 @@ import alertService from '@/services/alerts/alertService'; import logger from '@/utils/errorLogger'; import config from '@/config/env'; -export const getCsrfToken = (): string | null => { - const match = document.cookie.split('; ').find(cookie => cookie.startsWith('XSRF-TOKEN=')); +// Store CSRF token in memory (since csrf-sync uses session-based tokens) +let csrfToken: string | null = null; - if (!match) { - return null; - } - return decodeURIComponent(match.split('=')[1]); +export const getCsrfToken = (): string | null => { + return csrfToken; }; -// Function to refresh CSRF token if needed +// Function to refresh CSRF token from server export const refreshCsrfToken = async (): Promise => { try { // Make a GET request to the server to get a fresh CSRF token - await axios.get(`${config.apiUrl}/csrf-token`, { withCredentials: true }); - return getCsrfToken(); + const response = await axios.get(`${config.apiUrl}/csrf-token`, { withCredentials: true }); + if (response.data && response.data.token) { + csrfToken = response.data.token; + return csrfToken; + } + return null; } catch (error) { console.error('Failed to refresh CSRF token:', error); return null; } }; +// Initialize CSRF token on app load +export const initializeCsrfToken = async (): Promise => { + if (!csrfToken) { + await refreshCsrfToken(); + } +}; + const apiClient = axios.create({ baseURL: config.apiUrl, withCredentials: true, @@ -36,12 +45,16 @@ const apiClient = axios.create({ // Request interceptor apiClient.interceptors.request.use( - config => { - const token = getCsrfToken(); - if (token) { - config.headers['X-CSRF-Token'] = token; + async config => { + // Ensure we have a CSRF token before making requests + if (!csrfToken) { + await refreshCsrfToken(); + } + + if (csrfToken) { + config.headers['X-CSRF-Token'] = csrfToken; } else { - console.warn('No CSRF token found'); + console.warn('No CSRF token available'); } return config; }, @@ -84,7 +97,7 @@ apiClient.interceptors.response.use( (typeof errorResponse.data === 'object' && errorResponse.data !== null && 'message' in errorResponse.data && - errorResponse.data.message === 'Invalid CSRF token' || + (errorResponse.data.message === 'invalid csrf token' || errorResponse.data.message === 'Invalid CSRF token') || (error as any).code === 'EBADCSRFTOKEN')) { alertService.error('Security Error', 'Invalid security token. Refreshing your session...'); @@ -94,7 +107,7 @@ apiClient.interceptors.response.use( // Update the token in the failed request error.config.headers['X-CSRF-Token'] = newToken; // Retry the original request with the new token - return axios(error.config); + return apiClient(error.config); } else { // If token refresh failed, redirect to login window.location.href = '/auth/login'; diff --git a/worklenz-frontend/src/api/home-page/home-page.api.service.ts b/worklenz-frontend/src/api/home-page/home-page.api.service.ts index 74f5615a..b71e03a3 100644 --- a/worklenz-frontend/src/api/home-page/home-page.api.service.ts +++ b/worklenz-frontend/src/api/home-page/home-page.api.service.ts @@ -5,7 +5,7 @@ import { toQueryString } from '@/utils/toQueryString'; import { IHomeTasksModel, IHomeTasksConfig } from '@/types/home/home-page.types'; import { IMyTask } from '@/types/home/my-tasks.types'; import { IProject } from '@/types/project/project.types'; -import { getCsrfToken } from '../api-client'; +import { getCsrfToken, refreshCsrfToken } from '../api-client'; import config from '@/config/env'; const rootUrl = '/home'; @@ -14,9 +14,18 @@ const api = createApi({ reducerPath: 'homePageApi', baseQuery: fetchBaseQuery({ baseUrl: `${config.apiUrl}${API_BASE_URL}`, - prepareHeaders: headers => { - headers.set('X-CSRF-Token', getCsrfToken() || ''); + prepareHeaders: async headers => { + // Get CSRF token, refresh if needed + let token = getCsrfToken(); + if (!token) { + token = await refreshCsrfToken(); + } + + if (token) { + headers.set('X-CSRF-Token', token); + } headers.set('Content-Type', 'application/json'); + return headers; }, credentials: 'include', }), diff --git a/worklenz-frontend/src/api/projects/projects.v1.api.service.ts b/worklenz-frontend/src/api/projects/projects.v1.api.service.ts index 1fe279d5..1ad45b8b 100644 --- a/worklenz-frontend/src/api/projects/projects.v1.api.service.ts +++ b/worklenz-frontend/src/api/projects/projects.v1.api.service.ts @@ -5,7 +5,7 @@ import { IProjectCategory } from '@/types/project/projectCategory.types'; import { IProjectsViewModel } from '@/types/project/projectsViewModel.types'; import { IServerResponse } from '@/types/common.types'; import { IProjectMembersViewModel } from '@/types/projectMember.types'; -import { getCsrfToken } from '../api-client'; +import { getCsrfToken, refreshCsrfToken } from '../api-client'; import config from '@/config/env'; const rootUrl = '/projects'; @@ -14,9 +14,18 @@ export const projectsApi = createApi({ reducerPath: 'projectsApi', baseQuery: fetchBaseQuery({ baseUrl: `${config.apiUrl}${API_BASE_URL}`, - prepareHeaders: headers => { - headers.set('X-CSRF-Token', getCsrfToken() || ''); + prepareHeaders: async headers => { + // Get CSRF token, refresh if needed + let token = getCsrfToken(); + if (!token) { + token = await refreshCsrfToken(); + } + + if (token) { + headers.set('X-CSRF-Token', token); + } headers.set('Content-Type', 'application/json'); + return headers; }, credentials: 'include', }), From 6ffdbc64d07cd0ca56662f89542a64986a6351ff Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 30 May 2025 11:10:11 +0530 Subject: [PATCH 61/70] refactor(navbar): comment out license expiry alert for future implementation - Commented out the conditional rendering of the license expiry alert in the Navbar component for future adjustments. --- worklenz-frontend/src/features/navbar/navbar.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/worklenz-frontend/src/features/navbar/navbar.tsx b/worklenz-frontend/src/features/navbar/navbar.tsx index 295a8a17..41d7c0e7 100644 --- a/worklenz-frontend/src/features/navbar/navbar.tsx +++ b/worklenz-frontend/src/features/navbar/navbar.tsx @@ -101,14 +101,6 @@ const Navbar = () => { justifyContent: 'space-between', }} > - {daysUntilExpiry !== null && ((daysUntilExpiry <= 3 && daysUntilExpiry > 0) || (daysUntilExpiry >= -7 && daysUntilExpiry < 0)) && ( - 0 ? `Your license will expire in ${daysUntilExpiry} days` : `Your license has expired ${Math.abs(daysUntilExpiry)} days ago`} - type="warning" - showIcon - style={{ width: '100%', marginTop: 12 }} - /> - )} Date: Fri, 30 May 2025 11:40:27 +0530 Subject: [PATCH 62/70] feat(task-list): implement optimized task group handling and filter data loading - Introduced `useFilterDataLoader` hook to manage asynchronous loading of filter data without blocking the main UI. - Created `TaskGroupWrapperOptimized` for improved rendering of task groups with drag-and-drop functionality. - Refactored `ProjectViewTaskList` to utilize the new optimized components and enhance loading state management. - Added `TaskGroup` component for better organization and interaction with task groups. - Updated `TaskListFilters` to leverage the new filter data loading mechanism, ensuring a smoother user experience. --- .../src/hooks/useFilterDataLoader.ts | 69 ++++ .../src/hooks/useTaskDragAndDrop.ts | 146 ++++++++ .../src/hooks/useTaskSocketHandlers.ts | 343 ++++++++++++++++++ .../components/task-group/task-group.tsx | 241 ++++++++++++ .../taskList/project-view-task-list.tsx | 82 +++-- .../taskList/task-group-wrapper-optimized.tsx | 112 ++++++ .../task-list-filters/task-list-filters.tsx | 43 ++- 7 files changed, 1000 insertions(+), 36 deletions(-) create mode 100644 worklenz-frontend/src/hooks/useFilterDataLoader.ts create mode 100644 worklenz-frontend/src/hooks/useTaskDragAndDrop.ts create mode 100644 worklenz-frontend/src/hooks/useTaskSocketHandlers.ts create mode 100644 worklenz-frontend/src/pages/projects/projectView/taskList/components/task-group/task-group.tsx create mode 100644 worklenz-frontend/src/pages/projects/projectView/taskList/task-group-wrapper-optimized.tsx diff --git a/worklenz-frontend/src/hooks/useFilterDataLoader.ts b/worklenz-frontend/src/hooks/useFilterDataLoader.ts new file mode 100644 index 00000000..e3aa4f41 --- /dev/null +++ b/worklenz-frontend/src/hooks/useFilterDataLoader.ts @@ -0,0 +1,69 @@ +import { useEffect, useCallback } from 'react'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice'; +import { + fetchLabelsByProject, + fetchTaskAssignees, +} from '@/features/tasks/tasks.slice'; +import { getTeamMembers } from '@/features/team-members/team-members.slice'; + +/** + * Hook to manage filter data loading independently of main task list loading + * This ensures filter data loading doesn't block the main UI skeleton + */ +export const useFilterDataLoader = () => { + const dispatch = useAppDispatch(); + + const { priorities } = useAppSelector(state => ({ + priorities: state.priorityReducer.priorities, + })); + + const { projectId } = useAppSelector(state => ({ + projectId: state.projectReducer.projectId, + })); + + // Load filter data asynchronously + const loadFilterData = useCallback(async () => { + try { + // Load priorities if not already loaded (usually fast/cached) + if (!priorities.length) { + dispatch(fetchPriorities()); + } + + // Load project-specific data in parallel without blocking + if (projectId) { + // These dispatch calls are fire-and-forget + // They will update the UI when ready, but won't block initial render + dispatch(fetchLabelsByProject(projectId)); + dispatch(fetchTaskAssignees(projectId)); + } + + // Load team members for member filters + dispatch(getTeamMembers({ + index: 0, + size: 100, + field: null, + order: null, + search: null, + all: true + })); + } catch (error) { + console.error('Error loading filter data:', error); + // Don't throw - filter loading errors shouldn't break the main UI + } + }, [dispatch, priorities.length, projectId]); + + // Load filter data on mount and when dependencies change + useEffect(() => { + // Use setTimeout to ensure this runs after the main component render + // This prevents filter loading from blocking the initial render + const timeoutId = setTimeout(loadFilterData, 0); + + return () => clearTimeout(timeoutId); + }, [loadFilterData]); + + return { + loadFilterData, + }; +}; \ No newline at end of file diff --git a/worklenz-frontend/src/hooks/useTaskDragAndDrop.ts b/worklenz-frontend/src/hooks/useTaskDragAndDrop.ts new file mode 100644 index 00000000..cab0a361 --- /dev/null +++ b/worklenz-frontend/src/hooks/useTaskDragAndDrop.ts @@ -0,0 +1,146 @@ +import { useMemo, useCallback } from 'react'; +import { + DndContext, + DragEndEvent, + DragOverEvent, + DragStartEvent, + PointerSensor, + useSensor, + useSensors, + KeyboardSensor, + TouchSensor, +} from '@dnd-kit/core'; +import { sortableKeyboardCoordinates } from '@dnd-kit/sortable'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { updateTaskStatus } from '@/features/tasks/tasks.slice'; +import { ITaskListGroup } from '@/types/tasks/taskList.types'; +import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; + +export const useTaskDragAndDrop = () => { + const dispatch = useAppDispatch(); + const { taskGroups, groupBy } = useAppSelector(state => ({ + taskGroups: state.taskReducer.taskGroups, + groupBy: state.taskReducer.groupBy, + })); + + // Memoize sensors configuration for better performance + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + useSensor(TouchSensor, { + activationConstraint: { + delay: 250, + tolerance: 5, + }, + }) + ); + + const handleDragStart = useCallback((event: DragStartEvent) => { + // Add visual feedback for drag start + const { active } = event; + if (active) { + document.body.style.cursor = 'grabbing'; + } + }, []); + + const handleDragOver = useCallback((event: DragOverEvent) => { + // Handle drag over logic if needed + // This can be used for visual feedback during drag + }, []); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + // Reset cursor + document.body.style.cursor = ''; + + const { active, over } = event; + + if (!active || !over || !taskGroups) { + return; + } + + try { + const activeId = active.id as string; + const overId = over.id as string; + + // Find the task being dragged + let draggedTask: IProjectTask | null = null; + let sourceGroupId: string | null = null; + + for (const group of taskGroups) { + const task = group.tasks?.find((t: IProjectTask) => t.id === activeId); + if (task) { + draggedTask = task; + sourceGroupId = group.id; + break; + } + } + + if (!draggedTask || !sourceGroupId) { + console.warn('Could not find dragged task'); + return; + } + + // Determine target group + let targetGroupId: string | null = null; + + // Check if dropped on a group container + const targetGroup = taskGroups.find((group: ITaskListGroup) => group.id === overId); + if (targetGroup) { + targetGroupId = targetGroup.id; + } else { + // Check if dropped on another task + for (const group of taskGroups) { + const targetTask = group.tasks?.find((t: IProjectTask) => t.id === overId); + if (targetTask) { + targetGroupId = group.id; + break; + } + } + } + + if (!targetGroupId || targetGroupId === sourceGroupId) { + return; // No change needed + } + + // Update task status based on group change + const targetGroupData = taskGroups.find((group: ITaskListGroup) => group.id === targetGroupId); + if (targetGroupData && groupBy === 'status') { + const updatePayload: any = { + task_id: draggedTask.id, + status_id: targetGroupData.id, + }; + + if (draggedTask.parent_task_id) { + updatePayload.parent_task = draggedTask.parent_task_id; + } + + dispatch(updateTaskStatus(updatePayload)); + } + } catch (error) { + console.error('Error handling drag end:', error); + } + }, + [taskGroups, groupBy, dispatch] + ); + + // Memoize the drag and drop configuration + const dragAndDropConfig = useMemo( + () => ({ + sensors, + onDragStart: handleDragStart, + onDragOver: handleDragOver, + onDragEnd: handleDragEnd, + }), + [sensors, handleDragStart, handleDragOver, handleDragEnd] + ); + + return dragAndDropConfig; +}; \ No newline at end of file diff --git a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts new file mode 100644 index 00000000..7c85ead6 --- /dev/null +++ b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts @@ -0,0 +1,343 @@ +import { useCallback, useEffect } from 'react'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { useSocket } from '@/socket/socketContext'; +import { useAuthService } from '@/hooks/useAuth'; +import { SocketEvents } from '@/shared/socket-events'; +import logger from '@/utils/errorLogger'; +import alertService from '@/services/alerts/alertService'; + +import { ITaskAssigneesUpdateResponse } from '@/types/tasks/task-assignee-update-response'; +import { ILabelsChangeResponse } from '@/types/tasks/taskList.types'; +import { ITaskListStatusChangeResponse } from '@/types/tasks/task-list-status.types'; +import { ITaskListPriorityChangeResponse } from '@/types/tasks/task-list-priority.types'; +import { ITaskPhaseChangeResponse } from '@/types/tasks/task-phase-change-response'; +import { InlineMember } from '@/types/teamMembers/inlineMember.types'; +import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; +import { ITaskListGroup } from '@/types/tasks/taskList.types'; + +import { + fetchTaskAssignees, + updateTaskAssignees, + fetchLabelsByProject, + updateTaskLabel, + updateTaskStatus, + updateTaskPriority, + updateTaskEndDate, + updateTaskEstimation, + updateTaskName, + updateTaskPhase, + updateTaskStartDate, + updateTaskDescription, + updateSubTasks, + updateTaskProgress, +} from '@/features/tasks/tasks.slice'; +import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice'; +import { + setStartDate, + setTaskAssignee, + setTaskEndDate, + setTaskLabels, + setTaskPriority, + setTaskStatus, + setTaskSubscribers, +} from '@/features/task-drawer/task-drawer.slice'; +import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice'; + +export const useTaskSocketHandlers = () => { + const dispatch = useAppDispatch(); + const { socket } = useSocket(); + const currentSession = useAuthService().getCurrentSession(); + + const { loadingAssignees, taskGroups } = useAppSelector((state: any) => state.taskReducer); + const { projectId } = useAppSelector((state: any) => state.projectReducer); + + // Memoize socket event handlers + const handleAssigneesUpdate = useCallback( + (data: ITaskAssigneesUpdateResponse) => { + if (!data) return; + + const updatedAssignees = data.assignees?.map(assignee => ({ + ...assignee, + selected: true, + })) || []; + + const groupId = taskGroups?.find((group: ITaskListGroup) => + group.tasks?.some( + (task: IProjectTask) => + task.id === data.id || + (task.sub_tasks && task.sub_tasks.some((subtask: IProjectTask) => subtask.id === data.id)) + ) + )?.id; + + if (groupId) { + dispatch( + updateTaskAssignees({ + groupId, + taskId: data.id, + assignees: updatedAssignees, + }) + ); + + dispatch( + setTaskAssignee({ + ...data, + manual_progress: false, + } as IProjectTask) + ); + + if (currentSession?.team_id && !loadingAssignees) { + dispatch(fetchTaskAssignees(currentSession.team_id)); + } + } + }, + [taskGroups, dispatch, currentSession?.team_id, loadingAssignees] + ); + + const handleLabelsChange = useCallback( + async (labels: ILabelsChangeResponse) => { + if (!labels) return; + + await Promise.all([ + dispatch(updateTaskLabel(labels)), + dispatch(setTaskLabels(labels)), + dispatch(fetchLabels()), + projectId && dispatch(fetchLabelsByProject(projectId)), + ]); + }, + [dispatch, projectId] + ); + + const handleTaskStatusChange = useCallback( + (response: ITaskListStatusChangeResponse) => { + if (!response) return; + + if (response.completed_deps === false) { + alertService.error( + 'Task is not completed', + 'Please complete the task dependencies before proceeding' + ); + return; + } + + dispatch(updateTaskStatus(response)); + dispatch(deselectAll()); + }, + [dispatch] + ); + + const handleTaskProgress = useCallback( + (data: { + id: string; + status: string; + complete_ratio: number; + completed_count: number; + total_tasks_count: number; + parent_task: string; + }) => { + if (!data) return; + + dispatch( + updateTaskProgress({ + taskId: data.parent_task || data.id, + progress: data.complete_ratio, + totalTasksCount: data.total_tasks_count, + completedCount: data.completed_count, + }) + ); + }, + [dispatch] + ); + + const handlePriorityChange = useCallback( + (response: ITaskListPriorityChangeResponse) => { + if (!response) return; + + dispatch(updateTaskPriority(response)); + dispatch(setTaskPriority(response)); + dispatch(deselectAll()); + }, + [dispatch] + ); + + const handleEndDateChange = useCallback( + (task: { + id: string; + parent_task: string | null; + end_date: string; + }) => { + if (!task) return; + + const taskWithProgress = { + ...task, + manual_progress: false, + } as IProjectTask; + + dispatch(updateTaskEndDate({ task: taskWithProgress })); + dispatch(setTaskEndDate(taskWithProgress)); + }, + [dispatch] + ); + + const handleTaskNameChange = useCallback( + (data: { id: string; parent_task: string; name: string }) => { + if (!data) return; + dispatch(updateTaskName(data)); + }, + [dispatch] + ); + + const handlePhaseChange = useCallback( + (data: ITaskPhaseChangeResponse) => { + if (!data) return; + dispatch(updateTaskPhase(data)); + dispatch(deselectAll()); + }, + [dispatch] + ); + + const handleStartDateChange = useCallback( + (task: { + id: string; + parent_task: string | null; + start_date: string; + }) => { + if (!task) return; + + const taskWithProgress = { + ...task, + manual_progress: false, + } as IProjectTask; + + dispatch(updateTaskStartDate({ task: taskWithProgress })); + dispatch(setStartDate(taskWithProgress)); + }, + [dispatch] + ); + + const handleTaskSubscribersChange = useCallback( + (data: InlineMember[]) => { + if (!data) return; + dispatch(setTaskSubscribers(data)); + }, + [dispatch] + ); + + const handleEstimationChange = useCallback( + (task: { + id: string; + parent_task: string | null; + estimation: number; + }) => { + if (!task) return; + + const taskWithProgress = { + ...task, + manual_progress: false, + } as IProjectTask; + + dispatch(updateTaskEstimation({ task: taskWithProgress })); + }, + [dispatch] + ); + + const handleTaskDescriptionChange = useCallback( + (data: { + id: string; + parent_task: string; + description: string; + }) => { + if (!data) return; + dispatch(updateTaskDescription(data)); + }, + [dispatch] + ); + + const handleNewTaskReceived = useCallback( + (data: IProjectTask) => { + if (!data) return; + if (data.parent_task_id) { + dispatch(updateSubTasks(data)); + } + }, + [dispatch] + ); + + const handleTaskProgressUpdated = useCallback( + (data: { + task_id: string; + progress_value?: number; + weight?: number; + }) => { + if (!data || !taskGroups) return; + + if (data.progress_value !== undefined) { + for (const group of taskGroups) { + const task = group.tasks?.find((task: IProjectTask) => task.id === data.task_id); + if (task) { + dispatch( + updateTaskProgress({ + taskId: data.task_id, + progress: data.progress_value, + totalTasksCount: task.total_tasks_count || 0, + completedCount: task.completed_count || 0, + }) + ); + break; + } + } + } + }, + [dispatch, taskGroups] + ); + + // Register socket event listeners + useEffect(() => { + if (!socket) return; + + const eventHandlers = [ + { event: SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handler: handleAssigneesUpdate }, + { event: SocketEvents.TASK_LABELS_CHANGE.toString(), handler: handleLabelsChange }, + { event: SocketEvents.TASK_STATUS_CHANGE.toString(), handler: handleTaskStatusChange }, + { event: SocketEvents.TASK_PROGRESS_UPDATED.toString(), handler: handleTaskProgress }, + { event: SocketEvents.TASK_PRIORITY_CHANGE.toString(), handler: handlePriorityChange }, + { event: SocketEvents.TASK_END_DATE_CHANGE.toString(), handler: handleEndDateChange }, + { event: SocketEvents.TASK_NAME_CHANGE.toString(), handler: handleTaskNameChange }, + { event: SocketEvents.TASK_PHASE_CHANGE.toString(), handler: handlePhaseChange }, + { event: SocketEvents.TASK_START_DATE_CHANGE.toString(), handler: handleStartDateChange }, + { event: SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handler: handleTaskSubscribersChange }, + { event: SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handler: handleEstimationChange }, + { event: SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handler: handleTaskDescriptionChange }, + { event: SocketEvents.QUICK_TASK.toString(), handler: handleNewTaskReceived }, + { event: SocketEvents.TASK_PROGRESS_UPDATED.toString(), handler: handleTaskProgressUpdated }, + ]; + + // Register all event listeners + eventHandlers.forEach(({ event, handler }) => { + socket.on(event, handler); + }); + + // Cleanup function + return () => { + eventHandlers.forEach(({ event, handler }) => { + socket.off(event, handler); + }); + }; + }, [ + socket, + handleAssigneesUpdate, + handleLabelsChange, + handleTaskStatusChange, + handleTaskProgress, + handlePriorityChange, + handleEndDateChange, + handleTaskNameChange, + handlePhaseChange, + handleStartDateChange, + handleTaskSubscribersChange, + handleEstimationChange, + handleTaskDescriptionChange, + handleNewTaskReceived, + handleTaskProgressUpdated, + ]); +}; \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/components/task-group/task-group.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/components/task-group/task-group.tsx new file mode 100644 index 00000000..e5800fe4 --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/components/task-group/task-group.tsx @@ -0,0 +1,241 @@ +import React, { useState, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDroppable } from '@dnd-kit/core'; +import Flex from 'antd/es/flex'; +import Badge from 'antd/es/badge'; +import Button from 'antd/es/button'; +import Dropdown from 'antd/es/dropdown'; +import Input from 'antd/es/input'; +import Typography from 'antd/es/typography'; +import { MenuProps } from 'antd/es/menu'; +import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@ant-design/icons'; + +import { colors } from '@/styles/colors'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; +import { ITaskListGroup } from '@/types/tasks/taskList.types'; +import Collapsible from '@/components/collapsible/collapsible'; +import TaskListTable from '../../task-list-table/task-list-table'; +import { IGroupBy, updateTaskGroupColor } from '@/features/tasks/tasks.slice'; +import { useAuthService } from '@/hooks/useAuth'; +import { statusApiService } from '@/api/taskAttributes/status/status.api.service'; +import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service'; +import { ITaskPhase } from '@/types/tasks/taskPhase.types'; +import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice'; +import { fetchStatuses } from '@/features/taskAttributes/taskStatusSlice'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; +import { evt_project_board_column_setting_click } from '@/shared/worklenz-analytics-events'; +import { ALPHA_CHANNEL } from '@/shared/constants'; +import useIsProjectManager from '@/hooks/useIsProjectManager'; +import logger from '@/utils/errorLogger'; + +interface TaskGroupProps { + taskGroup: ITaskListGroup; + groupBy: string; + color: string; + activeId?: string | null; +} + +const TaskGroup: React.FC = ({ + taskGroup, + groupBy, + color, + activeId +}) => { + const { t } = useTranslation('task-list-table'); + const dispatch = useAppDispatch(); + const { trackMixpanelEvent } = useMixpanelTracking(); + const isProjectManager = useIsProjectManager(); + const currentSession = useAuthService().getCurrentSession(); + + const [isExpanded, setIsExpanded] = useState(true); + const [isRenaming, setIsRenaming] = useState(false); + const [groupName, setGroupName] = useState(taskGroup.name || ''); + + const { projectId } = useAppSelector((state: any) => state.projectReducer); + const themeMode = useAppSelector((state: any) => state.themeReducer.mode); + + // Memoize droppable configuration + const { setNodeRef } = useDroppable({ + id: taskGroup.id, + data: { + type: 'group', + groupId: taskGroup.id, + }, + }); + + // Memoize task count + const taskCount = useMemo(() => taskGroup.tasks?.length || 0, [taskGroup.tasks]); + + // Memoize dropdown items + const dropdownItems: MenuProps['items'] = useMemo(() => { + if (groupBy !== IGroupBy.STATUS || !isProjectManager) return []; + + return [ + { + key: 'rename', + label: t('renameText'), + icon: , + onClick: () => setIsRenaming(true), + }, + { + key: 'change-category', + label: t('changeCategoryText'), + icon: , + children: [ + { + key: 'todo', + label: t('todoText'), + onClick: () => handleStatusCategoryChange('0'), + }, + { + key: 'doing', + label: t('doingText'), + onClick: () => handleStatusCategoryChange('1'), + }, + { + key: 'done', + label: t('doneText'), + onClick: () => handleStatusCategoryChange('2'), + }, + ], + }, + ]; + }, [groupBy, isProjectManager, t]); + + const handleStatusCategoryChange = async (category: string) => { + if (!projectId || !taskGroup.id) return; + + try { + await statusApiService.updateStatus({ + id: taskGroup.id, + category_id: category, + project_id: projectId, + }); + + dispatch(fetchStatuses()); + trackMixpanelEvent(evt_project_board_column_setting_click, { + column_id: taskGroup.id, + action: 'change_category', + category, + }); + } catch (error) { + logger.error('Error updating status category:', error); + } + }; + + const handleRename = async () => { + if (!projectId || !taskGroup.id || !groupName.trim()) return; + + try { + if (groupBy === IGroupBy.STATUS) { + await statusApiService.updateStatus({ + id: taskGroup.id, + name: groupName.trim(), + project_id: projectId, + }); + dispatch(fetchStatuses()); + } else if (groupBy === IGroupBy.PHASE) { + const phaseData: ITaskPhase = { + id: taskGroup.id, + name: groupName.trim(), + project_id: projectId, + color_code: taskGroup.color_code, + }; + await phasesApiService.updatePhase(phaseData); + dispatch(fetchPhasesByProjectId(projectId)); + } + + setIsRenaming(false); + } catch (error) { + logger.error('Error renaming group:', error); + } + }; + + const handleColorChange = async (newColor: string) => { + if (!projectId || !taskGroup.id) return; + + try { + const baseColor = newColor.endsWith(ALPHA_CHANNEL) + ? newColor.slice(0, -ALPHA_CHANNEL.length) + : newColor; + + if (groupBy === IGroupBy.PHASE) { + const phaseData: ITaskPhase = { + id: taskGroup.id, + name: taskGroup.name || '', + project_id: projectId, + color_code: baseColor, + }; + await phasesApiService.updatePhase(phaseData); + dispatch(fetchPhasesByProjectId(projectId)); + } + + dispatch(updateTaskGroupColor({ + groupId: taskGroup.id, + color: baseColor, + })); + } catch (error) { + logger.error('Error updating group color:', error); + } + }; + + return ( +
+ + {/* Group Header */} + + + + {dropdownItems.length > 0 && !isRenaming && ( + +
+ ); +}; + +export default React.memo(TaskGroup); \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx index 410644fb..29914771 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx @@ -4,7 +4,7 @@ import Skeleton from 'antd/es/skeleton'; import { useSearchParams } from 'react-router-dom'; import TaskListFilters from './task-list-filters/task-list-filters'; -import TaskGroupWrapper from './task-list-table/task-group-wrapper/task-group-wrapper'; +import TaskGroupWrapperOptimized from './task-group-wrapper-optimized'; import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { fetchTaskGroups, fetchTaskListColumns } from '@/features/tasks/tasks.slice'; @@ -17,29 +17,50 @@ const ProjectViewTaskList = () => { const dispatch = useAppDispatch(); const { projectView } = useTabSearchParam(); const [searchParams, setSearchParams] = useSearchParams(); - const [isLoading, setIsLoading] = useState(true); const [initialLoadComplete, setInitialLoadComplete] = useState(false); - const { projectId } = useAppSelector(state => state.projectReducer); - const { taskGroups, loadingGroups, groupBy, archived, fields, search } = useAppSelector( - state => state.taskReducer - ); - const { statusCategories, loading: loadingStatusCategories } = useAppSelector( - state => state.taskStatusReducer - ); - const { loadingPhases } = useAppSelector(state => state.phaseReducer); - const { loadingColumns } = useAppSelector(state => state.taskReducer); + // Combine related selectors to reduce subscriptions + const { + projectId, + taskGroups, + loadingGroups, + groupBy, + archived, + fields, + search, + } = useAppSelector(state => ({ + projectId: state.projectReducer.projectId, + taskGroups: state.taskReducer.taskGroups, + loadingGroups: state.taskReducer.loadingGroups, + groupBy: state.taskReducer.groupBy, + archived: state.taskReducer.archived, + fields: state.taskReducer.fields, + search: state.taskReducer.search, + })); - // Memoize the loading state calculation - ignoring task list filter loading - const isLoadingState = useMemo(() => - loadingGroups || loadingPhases || loadingStatusCategories, - [loadingGroups, loadingPhases, loadingStatusCategories] + const { + statusCategories, + loading: loadingStatusCategories, + } = useAppSelector(state => ({ + statusCategories: state.taskStatusReducer.statusCategories, + loading: state.taskStatusReducer.loading, + })); + + const { loadingPhases } = useAppSelector(state => ({ + loadingPhases: state.phaseReducer.loadingPhases, + })); + + // Single source of truth for loading state - EXCLUDE labels loading from skeleton + // Labels loading should not block the main task list display + const isLoading = useMemo(() => + loadingGroups || loadingPhases || loadingStatusCategories || !initialLoadComplete, + [loadingGroups, loadingPhases, loadingStatusCategories, initialLoadComplete] ); // Memoize the empty state check const isEmptyState = useMemo(() => - taskGroups && taskGroups.length === 0 && !isLoadingState, - [taskGroups, isLoadingState] + taskGroups && taskGroups.length === 0 && !isLoading, + [taskGroups, isLoading] ); // Handle view type changes @@ -50,34 +71,32 @@ const ProjectViewTaskList = () => { newParams.set('pinned_tab', 'tasks-list'); setSearchParams(newParams); } - }, [projectView, setSearchParams]); + }, [projectView, setSearchParams, searchParams]); - // Update loading state - useEffect(() => { - setIsLoading(isLoadingState); - }, [isLoadingState]); - - // Fetch initial data only once + // Batch initial data fetching - core data only useEffect(() => { const fetchInitialData = async () => { if (!projectId || !groupBy || initialLoadComplete) return; try { - await Promise.all([ + // Batch only essential API calls for initial load + // Filter data (labels, assignees, etc.) will load separately and not block the UI + await Promise.allSettled([ dispatch(fetchTaskListColumns(projectId)), dispatch(fetchPhasesByProjectId(projectId)), - dispatch(fetchStatusesCategories()) + dispatch(fetchStatusesCategories()), ]); setInitialLoadComplete(true); } catch (error) { console.error('Error fetching initial data:', error); + setInitialLoadComplete(true); // Still mark as complete to prevent infinite loading } }; fetchInitialData(); }, [projectId, groupBy, dispatch, initialLoadComplete]); - // Fetch task groups + // Fetch task groups with dependency on initial load completion useEffect(() => { const fetchTasks = async () => { if (!projectId || !groupBy || projectView !== 'list' || !initialLoadComplete) return; @@ -92,15 +111,22 @@ const ProjectViewTaskList = () => { fetchTasks(); }, [projectId, groupBy, projectView, dispatch, fields, search, archived, initialLoadComplete]); + // Memoize the task groups to prevent unnecessary re-renders + const memoizedTaskGroups = useMemo(() => taskGroups || [], [taskGroups]); + return ( + {/* Filters load independently and don't block the main content */} {isEmptyState ? ( ) : ( - + )} diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-group-wrapper-optimized.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-group-wrapper-optimized.tsx new file mode 100644 index 00000000..71257305 --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-group-wrapper-optimized.tsx @@ -0,0 +1,112 @@ +import React, { useEffect, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import Flex from 'antd/es/flex'; +import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect'; + +import { + DndContext, + pointerWithin, +} from '@dnd-kit/core'; + +import { ITaskListGroup } from '@/types/tasks/taskList.types'; +import { useAppSelector } from '@/hooks/useAppSelector'; + +import TaskListTableWrapper from './task-list-table/task-list-table-wrapper/task-list-table-wrapper'; +import TaskListBulkActionsBar from '@/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar'; +import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer'; + +import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; +import { useTaskDragAndDrop } from '@/hooks/useTaskDragAndDrop'; + +interface TaskGroupWrapperOptimizedProps { + taskGroups: ITaskListGroup[]; + groupBy: string; +} + +const TaskGroupWrapperOptimized = ({ taskGroups, groupBy }: TaskGroupWrapperOptimizedProps) => { + const themeMode = useAppSelector((state: any) => state.themeReducer.mode); + + // Use extracted hooks + useTaskSocketHandlers(); + const { + activeId, + sensors, + handleDragStart, + handleDragEnd, + handleDragOver, + resetTaskRowStyles, + } = useTaskDragAndDrop({ taskGroups, groupBy }); + + // Memoize task groups with colors + const taskGroupsWithColors = useMemo(() => + taskGroups?.map(taskGroup => ({ + ...taskGroup, + displayColor: themeMode === 'dark' ? taskGroup.color_code_dark : taskGroup.color_code, + })) || [], + [taskGroups, themeMode] + ); + + // Add drag styles + useEffect(() => { + const style = document.createElement('style'); + style.textContent = ` + .task-row[data-is-dragging="true"] { + opacity: 0.5 !important; + transform: rotate(5deg) !important; + z-index: 1000 !important; + position: relative !important; + } + .task-row { + transition: transform 0.2s ease, opacity 0.2s ease; + } + `; + document.head.appendChild(style); + + return () => { + document.head.removeChild(style); + }; + }, []); + + // Handle animation cleanup after drag ends + useIsomorphicLayoutEffect(() => { + if (activeId === null) { + const timeoutId = setTimeout(resetTaskRowStyles, 50); + return () => clearTimeout(timeoutId); + } + }, [activeId, resetTaskRowStyles]); + + return ( + + + {taskGroupsWithColors.map(taskGroup => ( + + ))} + + {createPortal(, document.body, 'bulk-action-container')} + + {createPortal( + {}} />, + document.body, + 'task-template-drawer' + )} + + + ); +}; + +export default React.memo(TaskGroupWrapperOptimized); \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-filters/task-list-filters.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-filters/task-list-filters.tsx index c32153b8..fcf866f1 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-filters/task-list-filters.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-filters/task-list-filters.tsx @@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next'; import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice'; import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useFilterDataLoader } from '@/hooks/useFilterDataLoader'; import { fetchLabelsByProject, fetchTaskAssignees, @@ -33,23 +34,49 @@ const TaskListFilters: React.FC = ({ position }) => { const { projectView } = useTabSearchParam(); const priorities = useAppSelector(state => state.priorityReducer.priorities); - const projectId = useAppSelector(state => state.projectReducer.projectId); const archived = useAppSelector(state => state.taskReducer.archived); const handleShowArchivedChange = () => dispatch(toggleArchived()); + // Load filter data asynchronously and non-blocking + // This runs independently of the main task list loading useEffect(() => { - const fetchInitialData = async () => { - if (!priorities.length) await dispatch(fetchPriorities()); - if (projectId) { - await dispatch(fetchLabelsByProject(projectId)); - await dispatch(fetchTaskAssignees(projectId)); + const loadFilterData = async () => { + try { + // Load priorities first (usually cached/fast) + if (!priorities.length) { + dispatch(fetchPriorities()); + } + + // Load project-specific filter data in parallel, but don't await + // This allows the main task list to load while filters are still loading + if (projectId) { + // Fire and forget - these will update the UI when ready + dispatch(fetchLabelsByProject(projectId)); + dispatch(fetchTaskAssignees(projectId)); + } + + // Load team members (usually needed for member filters) + dispatch(getTeamMembers({ + index: 0, + size: 100, + field: null, + order: null, + search: null, + all: true + })); + } catch (error) { + console.error('Error loading filter data:', error); + // Don't throw - filter loading errors shouldn't break the main UI } - dispatch(getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true })); }; - fetchInitialData(); + // Use setTimeout to ensure this runs after the main component render + // This prevents filter loading from blocking the initial render + const timeoutId = setTimeout(loadFilterData, 0); + + return () => clearTimeout(timeoutId); }, [dispatch, priorities.length, projectId]); return ( From 5e4d78c6f5d434bbe6c0f144776556bc432f4bd1 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 2 Jun 2025 09:19:58 +0530 Subject: [PATCH 63/70] refactor(task-details-form): enhance progress input handling and improve assignee rendering - Added `InlineMember` type import for better type management. - Enhanced the `Avatars` component to handle multiple sources for assignee names, improving flexibility in data handling. --- .../shared/info-tab/task-details-form.tsx | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx index a2dcaef1..23dac128 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx @@ -30,6 +30,7 @@ import TaskDrawerProgress from './details/task-drawer-progress/task-drawer-progr import { useAppSelector } from '@/hooks/useAppSelector'; import logger from '@/utils/errorLogger'; import TaskDrawerRecurringConfig from './details/task-drawer-recurring-config/task-drawer-recurring-config'; +import { InlineMember } from '@/types/teamMembers/inlineMember.types'; interface TaskDetailsFormProps { taskFormViewModel?: ITaskFormViewModel | null; @@ -45,29 +46,32 @@ const ConditionalProgressInput = ({ task, form }: ConditionalProgressInputProps) const { project } = useAppSelector(state => state.projectReducer); const hasSubTasks = task?.sub_tasks_count > 0; const isSubTask = !!task?.parent_task_id; - - // Add more aggressive logging and checks - logger.debug(`Task ${task.id} status: hasSubTasks=${hasSubTasks}, isSubTask=${isSubTask}, modes: time=${project?.use_time_progress}, manual=${project?.use_manual_progress}, weighted=${project?.use_weighted_progress}`); - + // STRICT RULE: Never show progress input for parent tasks with subtasks // This is the most important check and must be done first if (hasSubTasks) { logger.debug(`Task ${task.id} has ${task.sub_tasks_count} subtasks. Hiding progress input.`); return null; } - + // Only for tasks without subtasks, determine which input to show based on project mode if (project?.use_time_progress) { // In time-based mode, show progress input ONLY for tasks without subtasks - return ; + return ( + + ); } else if (project?.use_manual_progress) { // In manual mode, show progress input ONLY for tasks without subtasks - return ; + return ( + + ); } else if (project?.use_weighted_progress && isSubTask) { // In weighted mode, show weight input for subtasks - return ; + return ( + + ); } - + return null; }; @@ -148,7 +152,13 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) => - + @@ -160,10 +170,7 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) => {taskFormViewModel?.task && ( - + )} From 24fa837a39ba4fc5e95d22724b4404c9290138ef Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 2 Jun 2025 13:07:50 +0530 Subject: [PATCH 64/70] feat(auth): enhance login and verification processes with detailed debug logging - Added comprehensive debug logging to the login strategy and verification endpoint to track authentication flow and errors. - Improved title determination logic for login and signup success/failure messages based on authentication status. - Implemented middleware for logging request details on the login route to aid in debugging. --- .../src/controllers/auth-controller.ts | 28 +++++++++++++-- .../passport-local-login.ts | 36 +++++++++++++++---- worklenz-backend/src/routes/auth/index.ts | 14 +++++++- 3 files changed, 68 insertions(+), 10 deletions(-) diff --git a/worklenz-backend/src/controllers/auth-controller.ts b/worklenz-backend/src/controllers/auth-controller.ts index 8364d59c..b2d24c16 100644 --- a/worklenz-backend/src/controllers/auth-controller.ts +++ b/worklenz-backend/src/controllers/auth-controller.ts @@ -35,8 +35,32 @@ export default class AuthController extends WorklenzControllerBase { const auth_error = errors.length > 0 ? errors[0] : null; const message = messages.length > 0 ? messages[0] : null; - const midTitle = req.query.strategy === "login" ? "Login Failed!" : "Signup Failed!"; - const title = req.query.strategy ? midTitle : null; + // Debug logging + console.log("=== VERIFY ENDPOINT HIT ==="); + console.log("Verify endpoint - Strategy:", req.query.strategy); + console.log("Verify endpoint - Authenticated:", req.isAuthenticated()); + console.log("Verify endpoint - User:", !!req.user); + console.log("Verify endpoint - User ID:", req.user?.id); + console.log("Verify endpoint - Auth error:", auth_error); + console.log("Verify endpoint - Success message:", message); + console.log("Verify endpoint - Flash errors:", errors); + console.log("Verify endpoint - Flash messages:", messages); + console.log("Verify endpoint - Session ID:", req.sessionID); + console.log("Verify endpoint - Session passport:", (req.session as any).passport); + console.log("Verify endpoint - Session flash:", (req.session as any).flash); + + // Determine title based on authentication status and strategy + let title = null; + if (req.query.strategy) { + if (auth_error) { + // Show failure title only when there's an actual error + title = req.query.strategy === "login" ? "Login Failed!" : "Signup Failed!"; + } else if (req.isAuthenticated() && message) { + // Show success title when authenticated and there's a success message + title = req.query.strategy === "login" ? "Login Successful!" : "Signup Successful!"; + } + // If no error and not authenticated, don't show any title (this might be a redirect without completion) + } if (req.user) req.user.build_v = FileConstants.getRelease(); diff --git a/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts b/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts index 7d29fae8..f399b326 100644 --- a/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts +++ b/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts @@ -3,13 +3,23 @@ import { Strategy as LocalStrategy } from "passport-local"; import { log_error } from "../../shared/utils"; import db from "../../config/db"; import { Request } from "express"; +import { ERROR_KEY, SUCCESS_KEY } from "./passport-constants"; async function handleLogin(req: Request, email: string, password: string, done: any) { + console.log("=== LOGIN STRATEGY STARTED ==="); console.log("Login attempt for:", email); + console.log("Password provided:", !!password); + console.log("Request body:", req.body); + + // Clear any existing flash messages + (req.session as any).flash = {}; if (!email || !password) { - console.log("Missing credentials"); - return done(null, false, { message: "Please enter both email and password" }); + console.log("Missing credentials - email:", !!email, "password:", !!password); + const errorMsg = "Please enter both email and password"; + console.log("Setting error flash message:", errorMsg); + req.flash(ERROR_KEY, errorMsg); + return done(null, false); } try { @@ -24,18 +34,30 @@ async function handleLogin(req: Request, email: string, password: string, done: const [data] = result.rows; if (!data?.password) { - console.log("No account found"); - return done(null, false, { message: "No account found with this email" }); + console.log("No account found for email:", email); + const errorMsg = "No account found with this email"; + console.log("Setting error flash message:", errorMsg); + req.flash(ERROR_KEY, errorMsg); + return done(null, false); } const passwordMatch = bcrypt.compareSync(password, data.password); - console.log("Password match:", passwordMatch); + console.log("Password match result:", passwordMatch); if (passwordMatch && email === data.email) { delete data.password; - return done(null, data, {message: "User successfully logged in"}); + console.log("Login successful for user:", data.id); + const successMsg = "User successfully logged in"; + console.log("Setting success flash message:", successMsg); + req.flash(SUCCESS_KEY, successMsg); + return done(null, data); } - return done(null, false, { message: "Incorrect email or password" }); + + console.log("Password mismatch or email mismatch"); + const errorMsg = "Incorrect email or password"; + console.log("Setting error flash message:", errorMsg); + req.flash(ERROR_KEY, errorMsg); + return done(null, false); } catch (error) { console.error("Login error:", error); log_error(error, req.body); diff --git a/worklenz-backend/src/routes/auth/index.ts b/worklenz-backend/src/routes/auth/index.ts index 1d34fb27..5c57d314 100644 --- a/worklenz-backend/src/routes/auth/index.ts +++ b/worklenz-backend/src/routes/auth/index.ts @@ -17,7 +17,19 @@ const options = (key: string): passport.AuthenticateOptions => ({ successRedirect: `/secure/verify?strategy=${key}` }); -authRouter.post("/login", passport.authenticate("local-login", options("login"))); +// Debug middleware for login +const loginDebugMiddleware = (req: express.Request, res: express.Response, next: express.NextFunction) => { + console.log("=== LOGIN ROUTE HIT ==="); + console.log("Request method:", req.method); + console.log("Request URL:", req.url); + console.log("Request body:", req.body); + console.log("Content-Type:", req.headers["content-type"]); + console.log("Session ID:", req.sessionID); + console.log("Is authenticated before:", req.isAuthenticated()); + next(); +}; + +authRouter.post("/login", loginDebugMiddleware, passport.authenticate("local-login", options("login"))); authRouter.post("/signup", signUpValidator, passwordValidator, passport.authenticate("local-signup", options("signup"))); authRouter.post("/signup/check", signUpValidator, passwordValidator, safeControllerFunction(AuthController.status_check)); authRouter.get("/verify", AuthController.verify); From 69f50095795d054702cfa02175b58cc3584b8fdb Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 2 Jun 2025 13:20:40 +0530 Subject: [PATCH 65/70] refactor(auth): remove debug logging and enhance session middleware - Eliminated extensive debug logging from the login strategy and verification endpoint to streamline the authentication process. - Updated session middleware to improve cookie handling, enabling proxy support and adjusting session creation behavior. - Ensured secure cookie settings for cross-origin requests in production environments. --- .../src/controllers/auth-controller.ts | 14 -------------- .../src/middlewares/session-middleware.ts | 17 ++++++++++------- .../passport-strategies/passport-local-login.ts | 15 --------------- worklenz-backend/src/routes/auth/index.ts | 14 +------------- 4 files changed, 11 insertions(+), 49 deletions(-) diff --git a/worklenz-backend/src/controllers/auth-controller.ts b/worklenz-backend/src/controllers/auth-controller.ts index b2d24c16..4fea4f59 100644 --- a/worklenz-backend/src/controllers/auth-controller.ts +++ b/worklenz-backend/src/controllers/auth-controller.ts @@ -35,20 +35,6 @@ export default class AuthController extends WorklenzControllerBase { const auth_error = errors.length > 0 ? errors[0] : null; const message = messages.length > 0 ? messages[0] : null; - // Debug logging - console.log("=== VERIFY ENDPOINT HIT ==="); - console.log("Verify endpoint - Strategy:", req.query.strategy); - console.log("Verify endpoint - Authenticated:", req.isAuthenticated()); - console.log("Verify endpoint - User:", !!req.user); - console.log("Verify endpoint - User ID:", req.user?.id); - console.log("Verify endpoint - Auth error:", auth_error); - console.log("Verify endpoint - Success message:", message); - console.log("Verify endpoint - Flash errors:", errors); - console.log("Verify endpoint - Flash messages:", messages); - console.log("Verify endpoint - Session ID:", req.sessionID); - console.log("Verify endpoint - Session passport:", (req.session as any).passport); - console.log("Verify endpoint - Session flash:", (req.session as any).flash); - // Determine title based on authentication status and strategy let title = null; if (req.query.strategy) { diff --git a/worklenz-backend/src/middlewares/session-middleware.ts b/worklenz-backend/src/middlewares/session-middleware.ts index cb6cd624..a0452bee 100644 --- a/worklenz-backend/src/middlewares/session-middleware.ts +++ b/worklenz-backend/src/middlewares/session-middleware.ts @@ -5,12 +5,15 @@ import { isProduction } from "../shared/utils"; // eslint-disable-next-line @typescript-eslint/no-var-requires const pgSession = require("connect-pg-simple")(session); +// For cross-origin requests, we need special cookie settings +const isHttps = process.env.NODE_ENV === "production" || process.env.FORCE_HTTPS === "true"; + export default session({ - name: process.env.SESSION_NAME, + name: process.env.SESSION_NAME || "worklenz.sid", secret: process.env.SESSION_SECRET || "development-secret-key", - proxy: false, + proxy: true, // Enable proxy support for proper session handling resave: false, - saveUninitialized: true, + saveUninitialized: false, // Changed to false to prevent unnecessary session creation rolling: true, store: new pgSession({ pool: db.pool, @@ -18,10 +21,10 @@ export default session({ }), cookie: { path: "/", - // secure: isProduction(), - // httpOnly: isProduction(), - // sameSite: "none", - // domain: isProduction() ? ".worklenz.com" : undefined, + secure: isHttps, // Only secure in production with HTTPS + httpOnly: true, // Enable httpOnly for security + sameSite: isHttps ? "none" : false, // Use "none" for HTTPS cross-origin, disable for HTTP + domain: undefined, // Don't set domain for cross-origin requests maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days } }); \ No newline at end of file diff --git a/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts b/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts index f399b326..d71c4a36 100644 --- a/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts +++ b/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts @@ -6,18 +6,11 @@ import { Request } from "express"; import { ERROR_KEY, SUCCESS_KEY } from "./passport-constants"; async function handleLogin(req: Request, email: string, password: string, done: any) { - console.log("=== LOGIN STRATEGY STARTED ==="); - console.log("Login attempt for:", email); - console.log("Password provided:", !!password); - console.log("Request body:", req.body); - // Clear any existing flash messages (req.session as any).flash = {}; if (!email || !password) { - console.log("Missing credentials - email:", !!email, "password:", !!password); const errorMsg = "Please enter both email and password"; - console.log("Setting error flash message:", errorMsg); req.flash(ERROR_KEY, errorMsg); return done(null, false); } @@ -29,33 +22,25 @@ async function handleLogin(req: Request, email: string, password: string, done: AND google_id IS NULL AND is_deleted IS FALSE;`; const result = await db.query(q, [email]); - console.log("User query result count:", result.rowCount); const [data] = result.rows; if (!data?.password) { - console.log("No account found for email:", email); const errorMsg = "No account found with this email"; - console.log("Setting error flash message:", errorMsg); req.flash(ERROR_KEY, errorMsg); return done(null, false); } const passwordMatch = bcrypt.compareSync(password, data.password); - console.log("Password match result:", passwordMatch); if (passwordMatch && email === data.email) { delete data.password; - console.log("Login successful for user:", data.id); const successMsg = "User successfully logged in"; - console.log("Setting success flash message:", successMsg); req.flash(SUCCESS_KEY, successMsg); return done(null, data); } - console.log("Password mismatch or email mismatch"); const errorMsg = "Incorrect email or password"; - console.log("Setting error flash message:", errorMsg); req.flash(ERROR_KEY, errorMsg); return done(null, false); } catch (error) { diff --git a/worklenz-backend/src/routes/auth/index.ts b/worklenz-backend/src/routes/auth/index.ts index 5c57d314..1d34fb27 100644 --- a/worklenz-backend/src/routes/auth/index.ts +++ b/worklenz-backend/src/routes/auth/index.ts @@ -17,19 +17,7 @@ const options = (key: string): passport.AuthenticateOptions => ({ successRedirect: `/secure/verify?strategy=${key}` }); -// Debug middleware for login -const loginDebugMiddleware = (req: express.Request, res: express.Response, next: express.NextFunction) => { - console.log("=== LOGIN ROUTE HIT ==="); - console.log("Request method:", req.method); - console.log("Request URL:", req.url); - console.log("Request body:", req.body); - console.log("Content-Type:", req.headers["content-type"]); - console.log("Session ID:", req.sessionID); - console.log("Is authenticated before:", req.isAuthenticated()); - next(); -}; - -authRouter.post("/login", loginDebugMiddleware, passport.authenticate("local-login", options("login"))); +authRouter.post("/login", passport.authenticate("local-login", options("login"))); authRouter.post("/signup", signUpValidator, passwordValidator, passport.authenticate("local-signup", options("signup"))); authRouter.post("/signup/check", signUpValidator, passwordValidator, safeControllerFunction(AuthController.status_check)); authRouter.get("/verify", AuthController.verify); From cfa0af24aeb99cfaee0028b1250cb0d91f3bd397 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 2 Jun 2025 13:29:05 +0530 Subject: [PATCH 66/70] refactor(session-middleware): improve cookie handling and security settings - Updated session middleware to use secure cookies in production environments. - Adjusted sameSite attribute to "lax" for standard handling of same-origin requests. - Removed unnecessary comments and streamlined cookie settings for clarity. --- .../src/middlewares/session-middleware.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/worklenz-backend/src/middlewares/session-middleware.ts b/worklenz-backend/src/middlewares/session-middleware.ts index a0452bee..fea60018 100644 --- a/worklenz-backend/src/middlewares/session-middleware.ts +++ b/worklenz-backend/src/middlewares/session-middleware.ts @@ -5,15 +5,12 @@ import { isProduction } from "../shared/utils"; // eslint-disable-next-line @typescript-eslint/no-var-requires const pgSession = require("connect-pg-simple")(session); -// For cross-origin requests, we need special cookie settings -const isHttps = process.env.NODE_ENV === "production" || process.env.FORCE_HTTPS === "true"; - export default session({ name: process.env.SESSION_NAME || "worklenz.sid", secret: process.env.SESSION_SECRET || "development-secret-key", - proxy: true, // Enable proxy support for proper session handling + proxy: true, resave: false, - saveUninitialized: false, // Changed to false to prevent unnecessary session creation + saveUninitialized: false, rolling: true, store: new pgSession({ pool: db.pool, @@ -21,10 +18,9 @@ export default session({ }), cookie: { path: "/", - secure: isHttps, // Only secure in production with HTTPS - httpOnly: true, // Enable httpOnly for security - sameSite: isHttps ? "none" : false, // Use "none" for HTTPS cross-origin, disable for HTTP - domain: undefined, // Don't set domain for cross-origin requests + secure: isProduction(), // Use secure cookies in production + httpOnly: true, + sameSite: "lax", // Standard setting for same-origin requests maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days } }); \ No newline at end of file From 09f44a5685e1b258efe8da9427be75e9d745e3db Mon Sep 17 00:00:00 2001 From: kithmina1999 Date: Thu, 5 Jun 2025 10:40:06 +0530 Subject: [PATCH 67/70] fix: change DB_PASSWORD to static value for development Using a static password simplifies development environment setup. The previous random password generation caused issues during local testing and debugging. --- update-docker-env.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/update-docker-env.sh b/update-docker-env.sh index 12044bd1..7852e86f 100755 --- a/update-docker-env.sh +++ b/update-docker-env.sh @@ -92,7 +92,7 @@ LOGIN_SUCCESS_REDIRECT="${FRONTEND_URL}/auth/authenticating" DB_HOST=db DB_PORT=5432 DB_USER=postgres -DB_PASSWORD=$(openssl rand -base64 48) +DB_PASSWORD=password DB_NAME=worklenz_db DB_MAX_CLIENTS=50 USE_PG_NATIVE=true From bd7773393503e72f1617d8b3489fb9d76001f276 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Thu, 5 Jun 2025 11:11:16 +0530 Subject: [PATCH 68/70] feat(timers): add running timers feature to the navbar - Introduced a new `TimerButton` component to display and manage running timers. - Implemented API service method `getRunningTimers` to fetch active timers. - Updated the navbar to replace the HelpButton with the TimerButton for better functionality. - Enhanced timer display with real-time updates and socket event handling for timer start/stop actions. --- .../api/tasks/task-time-logs.api.service.ts | 15 + .../src/features/navbar/navbar.tsx | 5 +- .../features/navbar/timers/timer-button.tsx | 275 ++++++++++++++++++ 3 files changed, 293 insertions(+), 2 deletions(-) create mode 100644 worklenz-frontend/src/features/navbar/timers/timer-button.tsx diff --git a/worklenz-frontend/src/api/tasks/task-time-logs.api.service.ts b/worklenz-frontend/src/api/tasks/task-time-logs.api.service.ts index f4565837..37673590 100644 --- a/worklenz-frontend/src/api/tasks/task-time-logs.api.service.ts +++ b/worklenz-frontend/src/api/tasks/task-time-logs.api.service.ts @@ -5,6 +5,16 @@ import { ITaskLogViewModel } from "@/types/tasks/task-log-view.types"; const rootUrl = `${API_BASE_URL}/task-time-log`; +export interface IRunningTimer { + task_id: string; + start_time: string; + task_name: string; + project_id: string; + project_name: string; + parent_task_id?: string; + parent_task_name?: string; +} + export const taskTimeLogsApiService = { getByTask: async (id: string) : Promise> => { const response = await apiClient.get(`${rootUrl}/task/${id}`); @@ -26,6 +36,11 @@ export const taskTimeLogsApiService = { return response.data; }, + getRunningTimers: async (): Promise> => { + const response = await apiClient.get(`${rootUrl}/running-timers`); + return response.data; + }, + exportToExcel(taskId: string) { window.location.href = `${import.meta.env.VITE_API_URL}${API_BASE_URL}/task-time-log/export/${taskId}`; }, diff --git a/worklenz-frontend/src/features/navbar/navbar.tsx b/worklenz-frontend/src/features/navbar/navbar.tsx index 41d7c0e7..430318d3 100644 --- a/worklenz-frontend/src/features/navbar/navbar.tsx +++ b/worklenz-frontend/src/features/navbar/navbar.tsx @@ -5,7 +5,6 @@ import { Col, ConfigProvider, Flex, Menu, MenuProps, Alert } from 'antd'; import { createPortal } from 'react-dom'; import InviteTeamMembers from '../../components/common/invite-team-members/invite-team-members'; -import HelpButton from './help/HelpButton'; import InviteButton from './invite/InviteButton'; import MobileMenuButton from './mobileMenu/MobileMenuButton'; import NavbarLogo from './navbar-logo'; @@ -22,6 +21,7 @@ import { useAuthService } from '@/hooks/useAuth'; import { authApiService } from '@/api/auth/auth.api.service'; import { ISUBSCRIPTION_TYPE } from '@/shared/constants'; import logger from '@/utils/errorLogger'; +import TimerButton from './timers/timer-button'; const Navbar = () => { const [current, setCurrent] = useState('home'); @@ -90,6 +90,7 @@ const Navbar = () => { }, [location]); return ( + { - + diff --git a/worklenz-frontend/src/features/navbar/timers/timer-button.tsx b/worklenz-frontend/src/features/navbar/timers/timer-button.tsx new file mode 100644 index 00000000..c4a229e8 --- /dev/null +++ b/worklenz-frontend/src/features/navbar/timers/timer-button.tsx @@ -0,0 +1,275 @@ +import { ClockCircleOutlined, StopOutlined } from '@ant-design/icons'; +import { Badge, Button, Dropdown, List, Tooltip, Typography, Space, Divider, theme } from 'antd'; +import React, { useEffect, useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { taskTimeLogsApiService, IRunningTimer } from '@/api/tasks/task-time-logs.api.service'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useSocket } from '@/socket/socketContext'; +import { SocketEvents } from '@/shared/socket-events'; +import { updateTaskTimeTracking } from '@/features/tasks/tasks.slice'; +import moment from 'moment'; + +const { Text } = Typography; +const { useToken } = theme; + +const TimerButton = () => { + const [runningTimers, setRunningTimers] = useState([]); + const [loading, setLoading] = useState(false); + const [currentTimes, setCurrentTimes] = useState>({}); + const [dropdownOpen, setDropdownOpen] = useState(false); + const { t } = useTranslation('navbar'); + const { token } = useToken(); + const dispatch = useAppDispatch(); + const { socket } = useSocket(); + + const fetchRunningTimers = useCallback(async () => { + try { + setLoading(true); + const response = await taskTimeLogsApiService.getRunningTimers(); + if (response.done) { + setRunningTimers(response.body || []); + } + } catch (error) { + console.error('Error fetching running timers:', error); + } finally { + setLoading(false); + } + }, []); + + const updateCurrentTimes = () => { + const newTimes: Record = {}; + runningTimers.forEach(timer => { + const startTime = moment(timer.start_time); + const now = moment(); + const duration = moment.duration(now.diff(startTime)); + const hours = Math.floor(duration.asHours()); + const minutes = duration.minutes(); + const seconds = duration.seconds(); + newTimes[timer.task_id] = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + }); + setCurrentTimes(newTimes); + }; + + useEffect(() => { + fetchRunningTimers(); + + // Set up polling to refresh timers every 30 seconds + const pollInterval = setInterval(() => { + fetchRunningTimers(); + }, 30000); + + return () => clearInterval(pollInterval); + }, [fetchRunningTimers]); + + useEffect(() => { + if (runningTimers.length > 0) { + updateCurrentTimes(); + const interval = setInterval(updateCurrentTimes, 1000); + return () => clearInterval(interval); + } + }, [runningTimers]); + + // Listen for timer start/stop events and project updates to refresh the count + useEffect(() => { + if (!socket) return; + + const handleTimerStart = (data: string) => { + try { + const { id } = typeof data === 'string' ? JSON.parse(data) : data; + if (id) { + // Refresh the running timers list when a new timer is started + fetchRunningTimers(); + } + } catch (error) { + console.error('Error parsing timer start event:', error); + } + }; + + const handleTimerStop = (data: string) => { + try { + const { id } = typeof data === 'string' ? JSON.parse(data) : data; + if (id) { + // Refresh the running timers list when a timer is stopped + fetchRunningTimers(); + } + } catch (error) { + console.error('Error parsing timer stop event:', error); + } + }; + + const handleProjectUpdates = () => { + // Refresh timers when project updates are available + fetchRunningTimers(); + }; + + socket.on(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart); + socket.on(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop); + socket.on(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates); + + return () => { + socket.off(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart); + socket.off(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop); + socket.off(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates); + }; + }, [socket, fetchRunningTimers]); + + const hasRunningTimers = () => { + return runningTimers.length > 0; + }; + + const timerCount = () => { + return runningTimers.length; + }; + + const handleStopTimer = (taskId: string) => { + if (!socket) return; + + socket.emit(SocketEvents.TASK_TIMER_STOP.toString(), JSON.stringify({ task_id: taskId })); + dispatch(updateTaskTimeTracking({ taskId, timeTracking: null })); + }; + + const dropdownContent = ( +
+ {runningTimers.length === 0 ? ( +
+ No running timers +
+ ) : ( + ( + +
+ + + {timer.task_name} + +
+ {timer.project_name} +
+ {timer.parent_task_name && ( + + Parent: {timer.parent_task_name} + + )} +
+
+
+ + Started: {moment(timer.start_time).format('HH:mm')} + + + {currentTimes[timer.task_id] || '00:00:00'} + +
+
+ +
+
+
+
+ )} + /> + )} + {runningTimers.length > 0 && ( + <> + +
+ + {runningTimers.length} timer{runningTimers.length !== 1 ? 's' : ''} running + +
+ + )} +
+ ); + + return ( + dropdownContent} + trigger={['click']} + placement="bottomRight" + open={dropdownOpen} + onOpenChange={(open) => { + setDropdownOpen(open); + if (open) { + fetchRunningTimers(); + } + }} + > + +