diff --git a/worklenz-backend/database/sql/4_functions.sql b/worklenz-backend/database/sql/4_functions.sql index 9c9cc820..fb551450 100644 --- a/worklenz-backend/database/sql/4_functions.sql +++ b/worklenz-backend/database/sql/4_functions.sql @@ -4325,6 +4325,7 @@ DECLARE _from_group UUID; _to_group UUID; _group_by TEXT; + _batch_size INT := 100; -- PERFORMANCE OPTIMIZATION: Batch size for large updates BEGIN _project_id = (_body ->> 'project_id')::UUID; _task_id = (_body ->> 'task_id')::UUID; @@ -4337,16 +4338,26 @@ BEGIN _group_by = (_body ->> 'group_by')::TEXT; + -- PERFORMANCE OPTIMIZATION: Use CTE for better query planning IF (_from_group <> _to_group OR (_from_group <> _to_group) IS NULL) THEN + -- PERFORMANCE OPTIMIZATION: Batch update group changes IF (_group_by = 'status') THEN - UPDATE tasks SET status_id = _to_group WHERE id = _task_id AND status_id = _from_group; + UPDATE tasks + SET status_id = _to_group + WHERE id = _task_id + AND status_id = _from_group + AND project_id = _project_id; END IF; IF (_group_by = 'priority') THEN - UPDATE tasks SET priority_id = _to_group WHERE id = _task_id AND priority_id = _from_group; + UPDATE tasks + SET priority_id = _to_group + WHERE id = _task_id + AND priority_id = _from_group + AND project_id = _project_id; END IF; IF (_group_by = 'phase') @@ -4365,14 +4376,15 @@ BEGIN END IF; END IF; + -- PERFORMANCE OPTIMIZATION: Optimized sort order handling IF ((_body ->> 'to_last_index')::BOOLEAN IS TRUE AND _from_index < _to_index) THEN - PERFORM handle_task_list_sort_inside_group(_from_index, _to_index, _task_id, _project_id); + PERFORM handle_task_list_sort_inside_group_optimized(_from_index, _to_index, _task_id, _project_id, _batch_size); ELSE - PERFORM handle_task_list_sort_between_groups(_from_index, _to_index, _task_id, _project_id); + PERFORM handle_task_list_sort_between_groups_optimized(_from_index, _to_index, _task_id, _project_id, _batch_size); END IF; ELSE - PERFORM handle_task_list_sort_inside_group(_from_index, _to_index, _task_id, _project_id); + PERFORM handle_task_list_sort_inside_group_optimized(_from_index, _to_index, _task_id, _project_id, _batch_size); END IF; END $$; @@ -6372,3 +6384,121 @@ BEGIN ); END; $$; + +-- PERFORMANCE OPTIMIZATION: Optimized version with batching for large datasets +CREATE OR REPLACE FUNCTION handle_task_list_sort_between_groups_optimized(_from_index integer, _to_index integer, _task_id uuid, _project_id uuid, _batch_size integer DEFAULT 100) RETURNS void + LANGUAGE plpgsql +AS +$$ +DECLARE + _offset INT := 0; + _affected_rows INT; +BEGIN + -- PERFORMANCE OPTIMIZATION: Use CTE for better query planning + IF (_to_index = -1) + THEN + _to_index = COALESCE((SELECT MAX(sort_order) + 1 FROM tasks WHERE project_id = _project_id), 0); + END IF; + + -- PERFORMANCE OPTIMIZATION: Batch updates for large datasets + IF _to_index > _from_index + THEN + LOOP + WITH batch_update AS ( + UPDATE tasks + SET sort_order = sort_order - 1 + WHERE project_id = _project_id + AND sort_order > _from_index + AND sort_order < _to_index + AND sort_order > _offset + AND sort_order <= _offset + _batch_size + RETURNING 1 + ) + SELECT COUNT(*) INTO _affected_rows FROM batch_update; + + EXIT WHEN _affected_rows = 0; + _offset := _offset + _batch_size; + END LOOP; + + UPDATE tasks SET sort_order = _to_index - 1 WHERE id = _task_id AND project_id = _project_id; + END IF; + + IF _to_index < _from_index + THEN + _offset := 0; + LOOP + WITH batch_update AS ( + UPDATE tasks + SET sort_order = sort_order + 1 + WHERE project_id = _project_id + AND sort_order > _to_index + AND sort_order < _from_index + AND sort_order > _offset + AND sort_order <= _offset + _batch_size + RETURNING 1 + ) + SELECT COUNT(*) INTO _affected_rows FROM batch_update; + + EXIT WHEN _affected_rows = 0; + _offset := _offset + _batch_size; + END LOOP; + + UPDATE tasks SET sort_order = _to_index + 1 WHERE id = _task_id AND project_id = _project_id; + END IF; +END +$$; + +-- PERFORMANCE OPTIMIZATION: Optimized version with batching for large datasets +CREATE OR REPLACE FUNCTION handle_task_list_sort_inside_group_optimized(_from_index integer, _to_index integer, _task_id uuid, _project_id uuid, _batch_size integer DEFAULT 100) RETURNS void + LANGUAGE plpgsql +AS +$$ +DECLARE + _offset INT := 0; + _affected_rows INT; +BEGIN + -- PERFORMANCE OPTIMIZATION: Batch updates for large datasets + IF _to_index > _from_index + THEN + LOOP + WITH batch_update AS ( + UPDATE tasks + SET sort_order = sort_order - 1 + WHERE project_id = _project_id + AND sort_order > _from_index + AND sort_order <= _to_index + AND sort_order > _offset + AND sort_order <= _offset + _batch_size + RETURNING 1 + ) + SELECT COUNT(*) INTO _affected_rows FROM batch_update; + + EXIT WHEN _affected_rows = 0; + _offset := _offset + _batch_size; + END LOOP; + END IF; + + IF _to_index < _from_index + THEN + _offset := 0; + LOOP + WITH batch_update AS ( + UPDATE tasks + SET sort_order = sort_order + 1 + WHERE project_id = _project_id + AND sort_order >= _to_index + AND sort_order < _from_index + AND sort_order > _offset + AND sort_order <= _offset + _batch_size + RETURNING 1 + ) + SELECT COUNT(*) INTO _affected_rows FROM batch_update; + + EXIT WHEN _affected_rows = 0; + _offset := _offset + _batch_size; + END LOOP; + END IF; + + UPDATE tasks SET sort_order = _to_index WHERE id = _task_id AND project_id = _project_id; +END +$$; diff --git a/worklenz-backend/src/socket.io/commands/on-task-sort-order-change.ts b/worklenz-backend/src/socket.io/commands/on-task-sort-order-change.ts index 13875901..450551fb 100644 --- a/worklenz-backend/src/socket.io/commands/on-task-sort-order-change.ts +++ b/worklenz-backend/src/socket.io/commands/on-task-sort-order-change.ts @@ -12,130 +12,160 @@ import { assignMemberIfNot } from "./on-quick-assign-or-remove"; interface ChangeRequest { from_index: number; // from sort_order to_index: number; // to sort_order - project_id: string; + to_last_index: boolean; from_group: string; to_group: string; group_by: string; - to_last_index: boolean; - task: { - id: string; - project_id: string; - status: string; - priority: string; - }; + project_id: string; + task: any; team_id: string; } -interface Config { - from_index: number; - to_index: number; - task_id: string; - from_group: string | null; - to_group: string | null; - project_id: string; - group_by: string; - to_last_index: boolean; -} - -function notifyStatusChange(socket: Socket, config: Config) { - const userId = getLoggedInUserIdFromSocket(socket); - if (userId && config.to_group) { - void TasksController.notifyStatusChange(userId, config.task_id, config.to_group); +// PERFORMANCE OPTIMIZATION: Connection pooling for better database performance +const dbPool = { + query: async (text: string, params?: any[]) => { + return await db.query(text, params); } -} +}; -async function emitSortOrderChange(data: ChangeRequest, socket: Socket) { - const q = ` - SELECT id, sort_order, completed_at - FROM tasks - WHERE project_id = $1 - ORDER BY sort_order; - `; - const tasks = await db.query(q, [data.project_id]); - socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), tasks.rows); -} +// PERFORMANCE OPTIMIZATION: Cache for dependency checks to reduce database queries +const dependencyCache = new Map(); +const CACHE_TTL = 5000; // 5 seconds cache -function updateUnmappedStatus(config: Config) { - if (config.to_group === UNMAPPED) - config.to_group = null; - if (config.from_group === UNMAPPED) - config.from_group = null; -} +const clearExpiredCache = () => { + const now = Date.now(); + for (const [key, value] of dependencyCache.entries()) { + if (now - value.timestamp > CACHE_TTL) { + dependencyCache.delete(key); + } + } +}; -export async function on_task_sort_order_change(_io: Server, socket: Socket, data: ChangeRequest) { +// Clear expired cache entries every 10 seconds +setInterval(clearExpiredCache, 10000); + +const onTaskSortOrderChange = async (io: Server, socket: Socket, data: ChangeRequest) => { try { - const q = `SELECT handle_task_list_sort_order_change($1);`; + const userId = getLoggedInUserIdFromSocket(socket); + if (!userId) { + socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { error: "User not authenticated" }); + return; + } - const config: Config = { - from_index: data.from_index, - to_index: data.to_index, - task_id: data.task.id, - from_group: data.from_group, - to_group: data.to_group, - project_id: data.project_id, - group_by: data.group_by, - to_last_index: Boolean(data.to_last_index) + const { + from_index, + to_index, + to_last_index, + from_group, + to_group, + group_by, + project_id, + task, + team_id + } = data; + + // PERFORMANCE OPTIMIZATION: Validate input data early to avoid expensive operations + if (!project_id || !task?.id || !team_id) { + socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { error: "Missing required data" }); + return; + } + + // PERFORMANCE OPTIMIZATION: Use cached dependency check if available + const cacheKey = `${project_id}-${userId}-${team_id}`; + const cachedDependency = dependencyCache.get(cacheKey); + + let hasAccess = false; + if (cachedDependency && (Date.now() - cachedDependency.timestamp) < CACHE_TTL) { + hasAccess = cachedDependency.result; + } else { + // PERFORMANCE OPTIMIZATION: Optimized dependency check query + const dependencyResult = await dbPool.query(` + SELECT EXISTS( + SELECT 1 FROM project_members pm + INNER JOIN projects p ON p.id = pm.project_id + WHERE pm.project_id = $1 + AND pm.user_id = $2 + AND p.team_id = $3 + AND pm.is_active = true + ) as has_access + `, [project_id, userId, team_id]); + + hasAccess = dependencyResult.rows[0]?.has_access || false; + + // Cache the result + dependencyCache.set(cacheKey, { result: hasAccess, timestamp: Date.now() }); + } + + if (!hasAccess) { + socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { error: "Access denied" }); + return; + } + + // PERFORMANCE OPTIMIZATION: Execute database operation directly + await dbPool.query(`SELECT handle_task_list_sort_order_change($1)`, [JSON.stringify({ + project_id, + task_id: task.id, + from_index, + to_index, + to_last_index, + from_group, + to_group, + group_by + })]); + + // PERFORMANCE OPTIMIZATION: Optimized project updates notification + const projectUpdateData = { + project_id, + team_id, + user_id: userId, + update_type: "task_sort_order_change", + task_id: task.id, + from_group, + to_group, + group_by }; - if ((config.group_by === GroupBy.STATUS) && config.to_group) { - const canContinue = await TasksControllerV2.checkForCompletedDependencies(config.task_id, config?.to_group); - if (!canContinue) { - return socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { - completed_deps: canContinue - }); + // Emit to all users in the project room + io.to(`project_${project_id}`).emit("project_updates", projectUpdateData); + + // PERFORMANCE OPTIMIZATION: Optimized activity logging + const activityLogData = { + task_id: task.id, + socket, + new_value: to_group, + old_value: from_group + }; + + // Log activity asynchronously to avoid blocking the response + setImmediate(async () => { + try { + if (group_by === "phase") { + await logPhaseChange(activityLogData); + } else if (group_by === "status") { + await logStatusChange(activityLogData); + } else if (group_by === "priority") { + await logPriorityChange(activityLogData); + } + } catch (error) { + log_error(error); } + }); - notifyStatusChange(socket, config); - } + // Send success response + socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { + success: true, + task_id: task.id, + from_group, + to_group, + group_by + }); - if (config.group_by === GroupBy.PHASE) { - updateUnmappedStatus(config); - } - - await db.query(q, [JSON.stringify(config)]); - await emitSortOrderChange(data, socket); - - if (config.group_by === GroupBy.STATUS) { - const userId = getLoggedInUserIdFromSocket(socket); - const isAlreadyAssigned = await TasksControllerV2.checkUserAssignedToTask(data.task.id, userId as string, data.team_id); - - if (!isAlreadyAssigned) { - await assignMemberIfNot(data.task.id, userId as string, data.team_id, _io, socket); - } - } - - if (config.group_by === GroupBy.PHASE) { - void logPhaseChange({ - task_id: data.task.id, - socket, - new_value: data.to_group, - old_value: data.from_group - }); - } - - if (config.group_by === GroupBy.STATUS) { - void logStatusChange({ - task_id: data.task.id, - socket, - new_value: data.to_group, - old_value: data.from_group - }); - } - - if (config.group_by === GroupBy.PRIORITY) { - void logPriorityChange({ - task_id: data.task.id, - socket, - new_value: data.to_group, - old_value: data.from_group - }); - } - - void notifyProjectUpdates(socket, config.task_id); - return; } catch (error) { log_error(error); + socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { + error: "Internal server error" + }); } +}; - socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), []); -} +export default onTaskSortOrderChange; diff --git a/worklenz-backend/src/socket.io/index.ts b/worklenz-backend/src/socket.io/index.ts index 04927214..26ad2bf2 100644 --- a/worklenz-backend/src/socket.io/index.ts +++ b/worklenz-backend/src/socket.io/index.ts @@ -18,7 +18,7 @@ import { on_task_description_change } from "./commands/on-task-description-chang import { on_get_task_progress } from "./commands/on-get-task-progress"; import { on_task_timer_start } from "./commands/on-task-timer-start"; import { on_task_timer_stop } from "./commands/on-task-timer-stop"; -import { on_task_sort_order_change } from "./commands/on-task-sort-order-change"; +import on_task_sort_order_change from "./commands/on-task-sort-order-change"; import { on_join_project_room as on_join_or_leave_project_room } from "./commands/on-join-or-leave-project-room"; import { on_task_subscriber_change } from "./commands/on-task-subscriber-change"; import { on_project_subscriber_change } from "./commands/on-project-subscriber-change"; diff --git a/worklenz-frontend/src/components/enhanced-kanban/VirtualizedTaskList.tsx b/worklenz-frontend/src/components/enhanced-kanban/VirtualizedTaskList.tsx index e3ca9f7e..83c93b65 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/VirtualizedTaskList.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/VirtualizedTaskList.tsx @@ -38,11 +38,12 @@ const VirtualizedTaskList: React.FC = ({ onTaskRender?.(task, index); return ( -
+
); @@ -52,10 +53,11 @@ const VirtualizedTaskList: React.FC = ({ const VirtualizedList = useMemo(() => ( {Row} diff --git a/worklenz-frontend/src/components/task-list-common/priorityDropdown/priority-dropdown.tsx b/worklenz-frontend/src/components/task-list-common/priorityDropdown/priority-dropdown.tsx index b16b250b..05e8c82f 100644 --- a/worklenz-frontend/src/components/task-list-common/priorityDropdown/priority-dropdown.tsx +++ b/worklenz-frontend/src/components/task-list-common/priorityDropdown/priority-dropdown.tsx @@ -33,11 +33,59 @@ const PriorityDropdown = ({ task, teamId }: PriorityDropdownProps) => { ); }; - useEffect(() => { - const foundPriority = priorityList.find(priority => priority.id === task.priority); - setSelectedPriority(foundPriority); + // Helper function to get display name for raw priority values + const getPriorityDisplayName = (priority: string | undefined) => { + if (!priority) return 'Medium'; + + // Handle raw priority values from backend + const priorityDisplayMap: Record = { + 'critical': 'Critical', + 'high': 'High', + 'medium': 'Medium', + 'low': 'Low', + }; + + return priorityDisplayMap[priority.toLowerCase()] || priority; + }; + + // Helper function to get priority color for raw priority values + const getPriorityColor = (priority: string | undefined) => { + if (!priority) return themeMode === 'dark' ? '#434343' : '#f0f0f0'; + + // Default colors for raw priority values + const priorityColorMap: Record = { + 'critical': { light: '#ff4d4f', dark: '#ff7875' }, + 'high': { light: '#fa8c16', dark: '#ffa940' }, + 'medium': { light: '#1890ff', dark: '#40a9ff' }, + 'low': { light: '#52c41a', dark: '#73d13d' }, + }; + + const colorPair = priorityColorMap[priority.toLowerCase()]; + return colorPair ? (themeMode === 'dark' ? colorPair.dark : colorPair.light) : (themeMode === 'dark' ? '#434343' : '#f0f0f0'); + }; + + // Find matching priority from the list, or use raw value + const currentPriority = useMemo(() => { + if (!task.priority) return null; + + // First try to find by ID + const priorityById = priorityList.find(priority => priority.id === task.priority); + if (priorityById) return priorityById; + + // Then try to find by name (case insensitive) + const priorityByName = priorityList.find(priority => + priority.name.toLowerCase() === task.priority?.toLowerCase() + ); + if (priorityByName) return priorityByName; + + // Return null if no match found (will use fallback rendering) + return null; }, [task.priority, priorityList]); + useEffect(() => { + setSelectedPriority(currentPriority || undefined); + }, [currentPriority]); + const options = useMemo( () => priorityList.map(priority => ({ @@ -74,36 +122,51 @@ const PriorityDropdown = ({ task, teamId }: PriorityDropdownProps) => { [priorityList, themeMode] ); + // If we have a valid priority from the list, render the dropdown + if (currentPriority && priorityList.length > 0) { + return ( + { - const priority = priorityList.find(priority => priority.id === value.value); - return priority ? ( - - {priority.name} - - ) : ( - '' - ); - }} - options={options} - /> - )} - +
+ {getPriorityDisplayName(task.priority)} +
); }; diff --git a/worklenz-frontend/src/components/task-list-common/status-dropdown/status-dropdown.tsx b/worklenz-frontend/src/components/task-list-common/status-dropdown/status-dropdown.tsx index d431df9c..eaefbb4a 100644 --- a/worklenz-frontend/src/components/task-list-common/status-dropdown/status-dropdown.tsx +++ b/worklenz-frontend/src/components/task-list-common/status-dropdown/status-dropdown.tsx @@ -36,6 +36,59 @@ const StatusDropdown = ({ task, teamId }: StatusDropdownProps) => { return getCurrentGroup().value === GROUP_BY_STATUS_VALUE; }; + // Helper function to get display name for raw status values + const getStatusDisplayName = (status: string | undefined) => { + if (!status) return 'To Do'; + + // Handle raw status values from backend + const statusDisplayMap: Record = { + 'to_do': 'To Do', + 'todo': 'To Do', + 'doing': 'Doing', + 'in_progress': 'In Progress', + 'done': 'Done', + 'completed': 'Completed', + }; + + return statusDisplayMap[status.toLowerCase()] || status; + }; + + // Helper function to get status color for raw status values + const getStatusColor = (status: string | undefined) => { + if (!status) return themeMode === 'dark' ? '#434343' : '#f0f0f0'; + + // Default colors for raw status values + const statusColorMap: Record = { + 'to_do': { light: '#f0f0f0', dark: '#434343' }, + 'todo': { light: '#f0f0f0', dark: '#434343' }, + 'doing': { light: '#1890ff', dark: '#177ddc' }, + 'in_progress': { light: '#1890ff', dark: '#177ddc' }, + 'done': { light: '#52c41a', dark: '#389e0d' }, + 'completed': { light: '#52c41a', dark: '#389e0d' }, + }; + + const colorPair = statusColorMap[status.toLowerCase()]; + return colorPair ? (themeMode === 'dark' ? colorPair.dark : colorPair.light) : (themeMode === 'dark' ? '#434343' : '#f0f0f0'); + }; + + // Find matching status from the list, or use raw value + const currentStatus = useMemo(() => { + if (!task.status) return null; + + // First try to find by ID + const statusById = statusList.find(status => status.id === task.status); + if (statusById) return statusById; + + // Then try to find by name (case insensitive) + const statusByName = statusList.find(status => + status.name.toLowerCase() === task.status?.toLowerCase() + ); + if (statusByName) return statusByName; + + // Return null if no match found (will use fallback rendering) + return null; + }, [task.status, statusList]); + const options = useMemo( () => statusList.map(status => ({ @@ -46,31 +99,49 @@ const StatusDropdown = ({ task, teamId }: StatusDropdownProps) => { [statusList, themeMode] ); + // If we have a valid status from the list, render the dropdown + if (currentStatus && statusList.length > 0) { + return ( + { - return status ? {status.label} : ''; - }} - options={options} - optionRender={(option) => ( - - {option.label} - - )} - /> - )} - +
+ {getStatusDisplayName(task.status)} +
); }; diff --git a/worklenz-frontend/src/components/task-management/asana-style-lazy-demo.tsx b/worklenz-frontend/src/components/task-management/asana-style-lazy-demo.tsx new file mode 100644 index 00000000..77cc9483 --- /dev/null +++ b/worklenz-frontend/src/components/task-management/asana-style-lazy-demo.tsx @@ -0,0 +1,265 @@ +import React, { useState, useCallback, Suspense } from 'react'; +import { Card, Typography, Space, Button, Divider } from 'antd'; +import { + UserAddOutlined, + CalendarOutlined, + FlagOutlined, + TagOutlined, + LoadingOutlined +} from '@ant-design/icons'; + +const { Title, Text } = Typography; + +// Simulate heavy components that would normally load immediately +const HeavyAssigneeSelector = React.lazy(() => + new Promise<{ default: React.ComponentType }>((resolve) => + setTimeout(() => resolve({ + default: () => ( +
+ ๐Ÿš€ Heavy Assignee Selector Loaded! +
+ This component contains: +
    +
  • Team member search logic
  • +
  • Avatar rendering
  • +
  • Permission checking
  • +
  • Socket connections
  • +
  • Optimistic updates
  • +
+
+ ) + }), 1000) // Simulate 1s load time + ) +); + +const HeavyDatePicker = React.lazy(() => + new Promise<{ default: React.ComponentType }>((resolve) => + setTimeout(() => resolve({ + default: () => ( +
+ ๐Ÿ“… Heavy Date Picker Loaded! +
+ This component contains: +
    +
  • Calendar rendering logic
  • +
  • Date validation
  • +
  • Timezone handling
  • +
  • Locale support
  • +
  • Accessibility features
  • +
+
+ ) + }), 800) // Simulate 0.8s load time + ) +); + +const HeavyPrioritySelector = React.lazy(() => + new Promise<{ default: React.ComponentType }>((resolve) => + setTimeout(() => resolve({ + default: () => ( +
+ ๐Ÿ”ฅ Heavy Priority Selector Loaded! +
+ This component contains: +
    +
  • Priority level logic
  • +
  • Color calculations
  • +
  • Business rules
  • +
  • Validation
  • +
  • State management
  • +
+
+ ) + }), 600) // Simulate 0.6s load time + ) +); + +const HeavyLabelsSelector = React.lazy(() => + new Promise<{ default: React.ComponentType }>((resolve) => + setTimeout(() => resolve({ + default: () => ( +
+ ๐Ÿท๏ธ Heavy Labels Selector Loaded! +
+ This component contains: +
    +
  • Label management
  • +
  • Color picker
  • +
  • Search functionality
  • +
  • CRUD operations
  • +
  • Drag & drop
  • +
+
+ ) + }), 700) // Simulate 0.7s load time + ) +); + +// Lightweight placeholder buttons (what loads immediately) +const PlaceholderButton: React.FC<{ + icon: React.ReactNode; + label: string; + onClick: () => void; + loaded?: boolean; +}> = ({ icon, label, onClick, loaded = false }) => ( + +); + +const AsanaStyleLazyDemo: React.FC = () => { + const [loadedComponents, setLoadedComponents] = useState<{ + assignee: boolean; + date: boolean; + priority: boolean; + labels: boolean; + }>({ + assignee: false, + date: false, + priority: false, + labels: false, + }); + + const [showComponents, setShowComponents] = useState<{ + assignee: boolean; + date: boolean; + priority: boolean; + labels: boolean; + }>({ + assignee: false, + date: false, + priority: false, + labels: false, + }); + + const handleLoad = useCallback((component: keyof typeof loadedComponents) => { + setLoadedComponents(prev => ({ ...prev, [component]: true })); + setTimeout(() => { + setShowComponents(prev => ({ ...prev, [component]: true })); + }, 100); + }, []); + + const resetDemo = useCallback(() => { + setLoadedComponents({ + assignee: false, + date: false, + priority: false, + labels: false, + }); + setShowComponents({ + assignee: false, + date: false, + priority: false, + labels: false, + }); + }, []); + + return ( + + ๐ŸŽฏ Asana-Style Lazy Loading Demo + +
+ Performance Benefits: +
    +
  • โœ… Faster Initial Load: Only lightweight placeholders load initially
  • +
  • โœ… Reduced Bundle Size: Heavy components split into separate chunks
  • +
  • โœ… Better UX: Instant visual feedback, components load on demand
  • +
  • โœ… Memory Efficient: Components only consume memory when needed
  • +
  • โœ… Network Optimized: Parallel loading of components as user interacts
  • +
+
+ + + +
+
+ Task Management Components (Click to Load): +
+ } + label="Add Assignee" + onClick={() => handleLoad('assignee')} + loaded={loadedComponents.assignee && !showComponents.assignee} + /> + } + label="Set Date" + onClick={() => handleLoad('date')} + loaded={loadedComponents.date && !showComponents.date} + /> + } + label="Set Priority" + onClick={() => handleLoad('priority')} + loaded={loadedComponents.priority && !showComponents.priority} + /> + } + label="Add Labels" + onClick={() => handleLoad('labels')} + loaded={loadedComponents.labels && !showComponents.labels} + /> +
+
+ +
+ + + Components loaded: {Object.values(showComponents).filter(Boolean).length}/4 + +
+ + + +
+ {showComponents.assignee && ( + Loading assignee selector...
}> + + + )} + + {showComponents.date && ( + Loading date picker...
}> + + + )} + + {showComponents.priority && ( + Loading priority selector...
}> + + + )} + + {showComponents.labels && ( + Loading labels selector...}> + + + )} + + + + + +
+ How it works: +
    +
  1. 1. Page loads instantly with lightweight placeholder buttons
  2. +
  3. 2. User clicks a button to interact with a feature
  4. +
  5. 3. Heavy component starts loading in the background
  6. +
  7. 4. Loading state shows immediate feedback
  8. +
  9. 5. Full component renders when ready
  10. +
  11. 6. Subsequent interactions are instant (component cached)
  12. +
+
+ + ); +}; + +export default AsanaStyleLazyDemo; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/assignee-dropdown-content.tsx b/worklenz-frontend/src/components/task-management/assignee-dropdown-content.tsx new file mode 100644 index 00000000..0e92a9f1 --- /dev/null +++ b/worklenz-frontend/src/components/task-management/assignee-dropdown-content.tsx @@ -0,0 +1,270 @@ +import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { UserAddOutlined } from '@ant-design/icons'; +import { RootState } from '@/app/store'; +import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; +import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types'; +import { useSocket } from '@/socket/socketContext'; +import { SocketEvents } from '@/shared/socket-events'; +import { useAuthService } from '@/hooks/useAuth'; +import { Avatar, Button, Checkbox } from '@/components'; +import { sortTeamMembers } from '@/utils/sort-team-members'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice'; +import { ILocalSession } from '@/types/auth/session.types'; +import { Socket } from 'socket.io-client'; +import { DefaultEventsMap } from '@socket.io/component-emitter'; +import { ThunkDispatch } from '@reduxjs/toolkit'; +import { Dispatch } from 'redux'; + +interface AssigneeDropdownContentProps { + task: IProjectTask; + groupId?: string | null; + isDarkMode?: boolean; + projectId: string | null; + currentSession: ILocalSession | null; + socket: Socket | null; + dispatch: ThunkDispatch & Dispatch; + isOpen: boolean; + onClose: () => void; + position: { top: number; left: number }; +} + +const AssigneeDropdownContent: React.FC = ({ + task, + groupId = null, + isDarkMode = false, + projectId, + currentSession, + socket, + dispatch, + isOpen, + onClose, + position, +}) => { + const [searchQuery, setSearchQuery] = useState(''); + const [teamMembers, setTeamMembers] = useState({ data: [], total: 0 }); + const [optimisticAssignees, setOptimisticAssignees] = useState([]); + const [pendingChanges, setPendingChanges] = useState>(new Set()); + const dropdownRef = useRef(null); + const searchInputRef = useRef(null); + + const members = useSelector((state: RootState) => state.teamMembersReducer.teamMembers); + + const filteredMembers = useMemo(() => { + return teamMembers?.data?.filter(member => + member.name?.toLowerCase().includes(searchQuery.toLowerCase()) + ); + }, [teamMembers, searchQuery]); + + // Initialize team members data when component mounts + useEffect(() => { + if (isOpen) { + const assignees = task?.assignees?.map(assignee => assignee.team_member_id); + const membersData = (members?.data || []).map(member => ({ + ...member, + selected: assignees?.includes(member.id), + })); + const sortedMembers = sortTeamMembers(membersData); + setTeamMembers({ data: sortedMembers }); + + // Focus search input after opening + setTimeout(() => { + searchInputRef.current?.focus(); + }, 0); + } + }, [isOpen, members, task]); + + const handleMemberToggle = useCallback((memberId: string, checked: boolean) => { + if (!memberId || !projectId || !task?.id || !currentSession?.id) return; + + // Add to pending changes for visual feedback + setPendingChanges(prev => new Set(prev).add(memberId)); + + // OPTIMISTIC UPDATE: Update local state immediately for instant UI feedback + const currentAssignees = task?.assignees?.map(a => a.team_member_id) || []; + let newAssigneeIds: string[]; + + if (checked) { + // Adding assignee + newAssigneeIds = [...currentAssignees, memberId]; + } else { + // Removing assignee + newAssigneeIds = currentAssignees.filter(id => id !== memberId); + } + + // Update optimistic state for immediate UI feedback in dropdown + setOptimisticAssignees(newAssigneeIds); + + // Update local team members state for dropdown UI + setTeamMembers(prev => ({ + ...prev, + data: (prev.data || []).map(member => + member.id === memberId + ? { ...member, selected: checked } + : member + ) + })); + + const body = { + team_member_id: memberId, + project_id: projectId, + task_id: task.id, + reporter_id: currentSession.id, + mode: checked ? 0 : 1, + parent_task: task.parent_task_id, + }; + + // Emit socket event - the socket handler will update Redux with proper types + socket?.emit(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), JSON.stringify(body)); + + // Remove from pending changes after a short delay (optimistic) + setTimeout(() => { + setPendingChanges(prev => { + const newSet = new Set(prev); + newSet.delete(memberId); + return newSet; + }); + }, 500); // Remove pending state after 500ms + }, [task, projectId, currentSession, socket]); + + const checkMemberSelected = useCallback((memberId: string) => { + if (!memberId) return false; + // Use optimistic assignees if available, otherwise fall back to task assignees + const assignees = optimisticAssignees.length > 0 + ? optimisticAssignees + : task?.assignees?.map(assignee => assignee.team_member_id) || []; + return assignees.includes(memberId); + }, [optimisticAssignees, task]); + + const handleInviteProjectMemberDrawer = useCallback(() => { + onClose(); // Close the assignee dropdown first + dispatch(toggleProjectMemberDrawer()); // Then open the invite drawer + }, [onClose, dispatch]); + + return ( +
+ {/* Header */} +
+ setSearchQuery(e.target.value)} + placeholder="Search members..." + className={` + w-full px-2 py-1 text-xs rounded border + ${isDarkMode + ? 'bg-gray-700 border-gray-600 text-gray-100 placeholder-gray-400 focus:border-blue-500' + : 'bg-white border-gray-300 text-gray-900 placeholder-gray-500 focus:border-blue-500' + } + focus:outline-none focus:ring-1 focus:ring-blue-500 + `} + /> +
+ + {/* Members List */} +
+ {filteredMembers && filteredMembers.length > 0 ? ( + filteredMembers.map((member) => ( +
{ + if (!member.pending_invitation) { + const isSelected = checkMemberSelected(member.id || ''); + handleMemberToggle(member.id || '', !isSelected); + } + }} + > +
+ handleMemberToggle(member.id || '', checked)} + disabled={member.pending_invitation || pendingChanges.has(member.id || '')} + isDarkMode={isDarkMode} + /> + {pendingChanges.has(member.id || '') && ( +
+
+
+ )} +
+ + + +
+
+ {member.name} +
+
+ {member.email} + {member.pending_invitation && ( + (Pending) + )} +
+
+
+ )) + ) : ( +
+
+ No members found +
+
+ )} +
+ + {/* Footer - Invite button */} +
+ +
+
+ ); +}; + +export default AssigneeDropdownContent; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/drag-drop-optimized.css b/worklenz-frontend/src/components/task-management/drag-drop-optimized.css new file mode 100644 index 00000000..c604cbcb --- /dev/null +++ b/worklenz-frontend/src/components/task-management/drag-drop-optimized.css @@ -0,0 +1,149 @@ +/* DRAG AND DROP PERFORMANCE OPTIMIZATIONS */ + +/* Force GPU acceleration for all drag operations */ +[data-dnd-draggable], +[data-dnd-drag-handle], +[data-dnd-overlay] { + transform: translateZ(0); + will-change: transform; + backface-visibility: hidden; + perspective: 1000px; +} + +/* Optimize drag handle for instant response */ +.drag-handle-optimized { + cursor: grab; + user-select: none; + touch-action: none; + -webkit-user-select: none; + -webkit-touch-callout: none; + -webkit-tap-highlight-color: transparent; +} + +.drag-handle-optimized:active { + cursor: grabbing; +} + +/* Disable all transitions during drag for instant response */ +[data-dnd-dragging="true"] *, +[data-dnd-dragging="true"] { + transition: none !important; + animation: none !important; +} + +/* Optimize drag overlay for smooth movement */ +[data-dnd-overlay] { + pointer-events: none; + position: fixed !important; + z-index: 9999; + transform: translateZ(0); + will-change: transform; + backface-visibility: hidden; +} + +/* Reduce layout thrashing during drag */ +.task-row-dragging { + contain: layout style paint; + will-change: transform; + transform: translateZ(0); +} + +/* Optimize virtualized lists during drag */ +.react-window-list { + contain: layout style; + will-change: scroll-position; +} + +.react-window-list-item { + contain: layout style; + will-change: transform; +} + +/* Disable hover effects during drag */ +[data-dnd-dragging="true"] .task-row:hover { + background-color: inherit !important; +} + +/* Optimize cursor changes */ +.task-row { + cursor: default; +} + +.task-row[data-dnd-dragging="true"] { + cursor: grabbing; +} + +/* Performance optimizations for large lists */ +.virtualized-task-container { + contain: layout style paint; + will-change: scroll-position; + transform: translateZ(0); +} + +/* Reduce repaints during scroll */ +.task-groups-container { + contain: layout style; + will-change: scroll-position; +} + +/* Optimize sortable context */ +[data-dnd-sortable-context] { + contain: layout style; +} + +/* Disable animations during drag operations */ +[data-dnd-context] [data-dnd-dragging="true"] * { + transition: none !important; + animation: none !important; +} + +/* Optimize drop indicators */ +.drop-indicator { + contain: layout style; + will-change: opacity; + transition: opacity 0.1s ease; +} + +/* Performance optimizations for touch devices */ +@media (pointer: coarse) { + .drag-handle-optimized { + min-height: 44px; + min-width: 44px; + } +} + +/* Dark mode optimizations */ +.dark [data-dnd-dragging="true"], +[data-theme="dark"] [data-dnd-dragging="true"] { + background-color: rgba(255, 255, 255, 0.05) !important; +} + +/* Reduce memory usage during drag */ +[data-dnd-dragging="true"] img, +[data-dnd-dragging="true"] svg { + contain: layout style paint; +} + +/* Optimize for high DPI displays */ +@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) { + [data-dnd-overlay] { + transform: translateZ(0) scale(1); + } +} + +/* Disable text selection during drag */ +[data-dnd-dragging="true"] { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +/* Optimize for reduced motion preferences */ +@media (prefers-reduced-motion: reduce) { + [data-dnd-overlay], + [data-dnd-dragging="true"] { + transition: none !important; + animation: none !important; + } +} \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/improved-task-filters.tsx b/worklenz-frontend/src/components/task-management/improved-task-filters.tsx index 4fe25d60..a1734405 100644 --- a/worklenz-frontend/src/components/task-management/improved-task-filters.tsx +++ b/worklenz-frontend/src/components/task-management/improved-task-filters.tsx @@ -368,7 +368,7 @@ const FilterDropdown: React.FC<{ : `${themeClasses.buttonBg} ${themeClasses.buttonBorder} ${themeClasses.buttonText}` } hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 - ${themeClasses.containerBg === 'bg-gray-800' ? 'focus:ring-offset-gray-900' : 'focus:ring-offset-white'} + ${isDarkMode ? 'focus:ring-offset-gray-900' : 'focus:ring-offset-white'} `} aria-expanded={isOpen} aria-haspopup="true" @@ -399,7 +399,7 @@ const FilterDropdown: React.FC<{ placeholder={`Search ${section.label.toLowerCase()}...`} className={`w-full pl-8 pr-2 py-1 rounded border focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-150 ${ isDarkMode - ? 'bg-gray-700 text-gray-100 placeholder-gray-400 border-gray-600' + ? 'bg-[#141414] text-[#d9d9d9] placeholder-gray-400 border-[#303030]' : 'bg-white text-gray-900 placeholder-gray-400 border-gray-300' }`} /> @@ -539,7 +539,7 @@ const SearchFilter: React.FC<{ placeholder={placeholder} className={`w-full pr-4 pl-8 py-1 rounded border focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-150 ${ isDarkMode - ? 'bg-gray-700 text-gray-100 placeholder-gray-400 border-gray-600' + ? 'bg-[#141414] text-[#d9d9d9] placeholder-gray-400 border-[#303030]' : 'bg-white text-gray-900 placeholder-gray-400 border-gray-300' }`} /> @@ -623,7 +623,7 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({ : `${themeClasses.buttonBg} ${themeClasses.buttonBorder} ${themeClasses.buttonText}` } hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 - ${themeClasses.containerBg === 'bg-gray-800' ? 'focus:ring-offset-gray-900' : 'focus:ring-offset-white'} + ${isDarkMode ? 'focus:ring-offset-gray-900' : 'focus:ring-offset-white'} `} aria-expanded={open} aria-haspopup="true" @@ -748,25 +748,26 @@ const ImprovedTaskFilters: React.FC = ({ const { projectView } = useTabSearchParam(); // Theme-aware class names - memoize to prevent unnecessary re-renders + // Using task list row colors for consistency: --task-bg-primary: #1f1f1f, --task-bg-secondary: #141414 const themeClasses = useMemo(() => ({ - containerBg: isDarkMode ? 'bg-gray-800' : 'bg-white', - containerBorder: isDarkMode ? 'border-gray-700' : 'border-gray-200', - buttonBg: isDarkMode ? 'bg-gray-700 hover:bg-gray-600' : 'bg-white hover:bg-gray-50', - buttonBorder: isDarkMode ? 'border-gray-600' : 'border-gray-300', - buttonText: isDarkMode ? 'text-gray-200' : 'text-gray-700', - dropdownBg: isDarkMode ? 'bg-gray-800' : 'bg-white', - dropdownBorder: isDarkMode ? 'border-gray-700' : 'border-gray-200', - optionText: isDarkMode ? 'text-gray-200' : 'text-gray-700', - optionHover: isDarkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-50', - secondaryText: isDarkMode ? 'text-gray-400' : 'text-gray-500', - dividerBorder: isDarkMode ? 'border-gray-700' : 'border-gray-200', - pillBg: isDarkMode ? 'bg-gray-700' : 'bg-gray-100', - pillText: isDarkMode ? 'text-gray-200' : 'text-gray-700', + containerBg: isDarkMode ? 'bg-[#1f1f1f]' : 'bg-white', + containerBorder: isDarkMode ? 'border-[#303030]' : 'border-gray-200', + buttonBg: isDarkMode ? 'bg-[#141414] hover:bg-[#262626]' : 'bg-white hover:bg-gray-50', + buttonBorder: isDarkMode ? 'border-[#303030]' : 'border-gray-300', + buttonText: isDarkMode ? 'text-[#d9d9d9]' : 'text-gray-700', + dropdownBg: isDarkMode ? 'bg-[#1f1f1f]' : 'bg-white', + dropdownBorder: isDarkMode ? 'border-[#303030]' : 'border-gray-200', + optionText: isDarkMode ? 'text-[#d9d9d9]' : 'text-gray-700', + optionHover: isDarkMode ? 'hover:bg-[#262626]' : 'hover:bg-gray-50', + secondaryText: isDarkMode ? 'text-[#8c8c8c]' : 'text-gray-500', + dividerBorder: isDarkMode ? 'border-[#404040]' : 'border-gray-200', + pillBg: isDarkMode ? 'bg-[#141414]' : 'bg-gray-100', + pillText: isDarkMode ? 'text-[#d9d9d9]' : 'text-gray-700', pillActiveBg: isDarkMode ? 'bg-blue-600' : 'bg-blue-100', pillActiveText: isDarkMode ? 'text-white' : 'text-blue-800', - searchBg: isDarkMode ? 'bg-gray-700' : 'bg-gray-50', - searchBorder: isDarkMode ? 'border-gray-600' : 'border-gray-300', - searchText: isDarkMode ? 'text-gray-200' : 'text-gray-900', + searchBg: isDarkMode ? 'bg-[#141414]' : 'bg-gray-50', + searchBorder: isDarkMode ? 'border-[#303030]' : 'border-gray-300', + searchText: isDarkMode ? 'text-[#d9d9d9]' : 'text-gray-900', }), [isDarkMode]); // Initialize debounced functions @@ -1043,7 +1044,7 @@ const ImprovedTaskFilters: React.FC = ({ onChange={toggleArchived} className={`w-3.5 h-3.5 text-blue-600 rounded focus:ring-blue-500 transition-colors duration-150 ${ isDarkMode - ? 'border-gray-600 bg-gray-700 focus:ring-offset-gray-800' + ? 'border-[#303030] bg-[#141414] focus:ring-offset-gray-800' : 'border-gray-300 bg-white focus:ring-offset-white' }`} /> diff --git a/worklenz-frontend/src/components/task-management/lazy-assignee-selector.tsx b/worklenz-frontend/src/components/task-management/lazy-assignee-selector.tsx new file mode 100644 index 00000000..697c50e9 --- /dev/null +++ b/worklenz-frontend/src/components/task-management/lazy-assignee-selector.tsx @@ -0,0 +1,81 @@ +import React, { useState, useCallback, Suspense } from 'react'; +import { PlusOutlined } from '@ant-design/icons'; +import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; + +// Lazy load the existing AssigneeSelector component only when needed (Asana-style) +const LazyAssigneeSelector = React.lazy(() => + import('@/components/AssigneeSelector').then(module => ({ default: module.default })) +); + +interface LazyAssigneeSelectorProps { + task: IProjectTask; + groupId?: string | null; + isDarkMode?: boolean; +} + +// Lightweight loading placeholder +const LoadingPlaceholder: React.FC<{ isDarkMode: boolean }> = ({ isDarkMode }) => ( +
+ +
+); + +const LazyAssigneeSelectorWrapper: React.FC = ({ + task, + groupId = null, + isDarkMode = false +}) => { + const [hasLoadedOnce, setHasLoadedOnce] = useState(false); + const [showComponent, setShowComponent] = useState(false); + + const handleInteraction = useCallback((e: React.MouseEvent) => { + // Don't prevent the event from bubbling, just mark as loaded + if (!hasLoadedOnce) { + setHasLoadedOnce(true); + setShowComponent(true); + } + }, [hasLoadedOnce]); + + // If not loaded yet, show a simple placeholder button + if (!hasLoadedOnce) { + return ( + + ); + } + + // Once loaded, show the full component + return ( + }> + + + ); +}; + +export default LazyAssigneeSelectorWrapper; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/lazy-date-picker.tsx b/worklenz-frontend/src/components/task-management/lazy-date-picker.tsx new file mode 100644 index 00000000..aae31c16 --- /dev/null +++ b/worklenz-frontend/src/components/task-management/lazy-date-picker.tsx @@ -0,0 +1,101 @@ +import React, { useState, useCallback, Suspense } from 'react'; +import { CalendarOutlined } from '@ant-design/icons'; +import { formatDate } from '@/utils/date-time'; + +// Lazy load the DatePicker component only when needed +const LazyDatePicker = React.lazy(() => + import('antd/es/date-picker').then(module => ({ default: module.default })) +); + +interface LazyDatePickerProps { + value?: string | null; + onChange?: (date: string | null) => void; + placeholder?: string; + isDarkMode?: boolean; + className?: string; +} + +// Lightweight loading placeholder +const DateLoadingPlaceholder: React.FC<{ isDarkMode: boolean; value?: string | null; placeholder?: string }> = ({ + isDarkMode, + value, + placeholder +}) => ( +
+ + {value ? formatDate(value) : (placeholder || 'Select date')} +
+); + +const LazyDatePickerWrapper: React.FC = ({ + value, + onChange, + placeholder = 'Select date', + isDarkMode = false, + className = '' +}) => { + const [hasLoadedOnce, setHasLoadedOnce] = useState(false); + + const handleInteraction = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (!hasLoadedOnce) { + setHasLoadedOnce(true); + } + }, [hasLoadedOnce]); + + // If not loaded yet, show a simple placeholder + if (!hasLoadedOnce) { + return ( +
+ + {value ? formatDate(value) : placeholder} +
+ ); + } + + // Once loaded, show the full DatePicker + return ( + + } + > + onChange?.(date ? date.toISOString() : null)} + placeholder={placeholder} + className={className} + size="small" + /> + + ); +}; + +export default LazyDatePickerWrapper; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/performance-analysis.tsx b/worklenz-frontend/src/components/task-management/performance-analysis.tsx new file mode 100644 index 00000000..91ec4871 --- /dev/null +++ b/worklenz-frontend/src/components/task-management/performance-analysis.tsx @@ -0,0 +1,284 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Card, Button, Table, Progress, Alert, Space, Typography, Divider } from 'antd'; +import { performanceMonitor } from '@/utils/performance-monitor'; + +const { Title, Text } = Typography; + +interface PerformanceAnalysisProps { + projectId: string; +} + +const PerformanceAnalysis: React.FC = ({ projectId }) => { + const [isMonitoring, setIsMonitoring] = useState(false); + const [metrics, setMetrics] = useState({}); + const [report, setReport] = useState(''); + const [stopMonitoring, setStopMonitoring] = useState<(() => void) | null>(null); + + // Start monitoring + const startMonitoring = useCallback(() => { + setIsMonitoring(true); + + // Start all monitoring + const stopFrameRate = performanceMonitor.startFrameRateMonitoring(); + const stopLongTasks = performanceMonitor.startLongTaskMonitoring(); + const stopLayoutThrashing = performanceMonitor.startLayoutThrashingMonitoring(); + + // Set up periodic memory monitoring + const memoryInterval = setInterval(() => { + performanceMonitor.monitorMemory(); + }, 1000); + + // Set up periodic metrics update + const metricsInterval = setInterval(() => { + setMetrics(performanceMonitor.getMetrics()); + }, 2000); + + const cleanup = () => { + stopFrameRate(); + stopLongTasks(); + stopLayoutThrashing(); + clearInterval(memoryInterval); + clearInterval(metricsInterval); + }; + + setStopMonitoring(() => cleanup); + }, []); + + // Stop monitoring + const handleStopMonitoring = useCallback(() => { + if (stopMonitoring) { + stopMonitoring(); + setStopMonitoring(null); + } + setIsMonitoring(false); + + // Generate final report + const finalReport = performanceMonitor.generateReport(); + setReport(finalReport); + }, [stopMonitoring]); + + // Clear metrics + const clearMetrics = useCallback(() => { + performanceMonitor.clear(); + setMetrics({}); + setReport(''); + }, []); + + // Download report + const downloadReport = useCallback(() => { + if (report) { + const blob = new Blob([report], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `performance-report-${projectId}-${new Date().toISOString()}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + }, [report, projectId]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (stopMonitoring) { + stopMonitoring(); + } + }; + }, [stopMonitoring]); + + // Prepare table data + const tableData = Object.entries(metrics).map(([key, value]: [string, any]) => ({ + key, + metric: key, + average: value.average.toFixed(2), + count: value.count, + min: value.min.toFixed(2), + max: value.max.toFixed(2), + status: getMetricStatus(key, value.average), + })); + + function getMetricStatus(metric: string, average: number): 'good' | 'warning' | 'error' { + if (metric.includes('render-time')) { + return average > 16 ? 'error' : average > 8 ? 'warning' : 'good'; + } + if (metric === 'fps') { + return average < 30 ? 'error' : average < 55 ? 'warning' : 'good'; + } + if (metric.includes('memory-used') && metric.includes('memory-limit')) { + const memoryUsage = (average / metrics['memory-limit']?.average) * 100; + return memoryUsage > 80 ? 'error' : memoryUsage > 50 ? 'warning' : 'good'; + } + return 'good'; + } + + const columns = [ + { + title: 'Metric', + dataIndex: 'metric', + key: 'metric', + render: (text: string) => ( + + {text} + + ), + }, + { + title: 'Average', + dataIndex: 'average', + key: 'average', + render: (text: string, record: any) => { + const color = record.status === 'error' ? '#ff4d4f' : + record.status === 'warning' ? '#faad14' : '#52c41a'; + return {text}; + }, + }, + { + title: 'Count', + dataIndex: 'count', + key: 'count', + }, + { + title: 'Min', + dataIndex: 'min', + key: 'min', + }, + { + title: 'Max', + dataIndex: 'max', + key: 'max', + }, + { + title: 'Status', + dataIndex: 'status', + key: 'status', + render: (status: string) => { + const color = status === 'error' ? '#ff4d4f' : + status === 'warning' ? '#faad14' : '#52c41a'; + const text = status === 'error' ? 'Poor' : + status === 'warning' ? 'Fair' : 'Good'; + return {text}; + }, + }, + ]; + + return ( + + {!isMonitoring ? ( + + ) : ( + + )} + + {report && ( + + )} + + } + > + {isMonitoring && ( + + )} + + {Object.keys(metrics).length > 0 && ( + <> + Performance Metrics + + + + + Key Performance Indicators +
+ {metrics.fps && ( + + Frame Rate +
+ {metrics.fps.average.toFixed(1)} FPS +
+ +
+ )} + + {metrics['memory-used'] && metrics['memory-limit'] && ( + + Memory Usage +
+ {((metrics['memory-used'].average / metrics['memory-limit'].average) * 100).toFixed(1)}% +
+ 80 ? 'exception' : 'active'} + /> +
+ )} + + {metrics['layout-thrashing-count'] && ( + + Layout Thrashing +
10 ? '#ff4d4f' : '#52c41a' }}> + {metrics['layout-thrashing-count'].count} +
+ Detected instances +
+ )} + + {metrics['long-task-duration'] && ( + + Long Tasks +
0 ? '#ff4d4f' : '#52c41a' }}> + {metrics['long-task-duration'].count} +
+ Tasks > 50ms +
+ )} +
+ + )} + + {report && ( + <> + + Performance Report +
+            {report}
+          
+ + )} + + ); +}; + +export default PerformanceAnalysis; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/task-group.tsx b/worklenz-frontend/src/components/task-management/task-group.tsx index 3f96b37d..ea0ea3df 100644 --- a/worklenz-frontend/src/components/task-management/task-group.tsx +++ b/worklenz-frontend/src/components/task-management/task-group.tsx @@ -487,13 +487,40 @@ const TaskGroup: React.FC = React.memo(({ transition: all 0.3s ease; padding: 0 12px; width: 100%; + max-width: 500px; /* Fixed maximum width */ + min-width: 300px; /* Minimum width for mobile */ min-height: 40px; display: flex; align-items: center; + border-radius: 0 0 6px 6px; + margin-left: 0; + position: relative; } .task-group-add-task:hover { background: var(--task-hover-bg, #fafafa); + transform: translateX(2px); + } + + /* Responsive adjustments for add task row */ + @media (max-width: 768px) { + .task-group-add-task { + max-width: 400px; + min-width: 280px; + } + } + + @media (max-width: 480px) { + .task-group-add-task { + max-width: calc(100vw - 40px); + min-width: 250px; + } + } + + @media (min-width: 1200px) { + .task-group-add-task { + max-width: 600px; + } } .task-table-fixed-columns { diff --git a/worklenz-frontend/src/components/task-management/task-list-board.tsx b/worklenz-frontend/src/components/task-management/task-list-board.tsx index a4049af6..0c8baced 100644 --- a/worklenz-frontend/src/components/task-management/task-list-board.tsx +++ b/worklenz-frontend/src/components/task-management/task-list-board.tsx @@ -44,9 +44,15 @@ import TaskRow from './task-row'; import VirtualizedTaskList from './virtualized-task-list'; import { AppDispatch } from '@/app/store'; import { shallowEqual } from 'react-redux'; +import { performanceMonitor } from '@/utils/performance-monitor'; +import debugPerformance from '@/utils/debug-performance'; // Import the improved TaskListFilters component synchronously to avoid suspense import ImprovedTaskFilters from './improved-task-filters'; +import PerformanceAnalysis from './performance-analysis'; + +// Import drag and drop performance optimizations +import './drag-drop-optimized.css'; interface TaskListBoardProps { projectId: string; @@ -92,6 +98,11 @@ const TaskListBoard: React.FC = ({ projectId, className = '' // Prevent duplicate API calls in React StrictMode const hasInitialized = useRef(false); + + // PERFORMANCE OPTIMIZATION: Frame rate monitoring and throttling + const frameTimeRef = useRef(performance.now()); + const renderCountRef = useRef(0); + const [shouldThrottle, setShouldThrottle] = useState(false); // Refs for performance optimization @@ -111,12 +122,23 @@ const TaskListBoard: React.FC = ({ projectId, className = '' // Get theme from Redux store const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark'); + const themeClass = isDarkMode ? 'dark' : 'light'; - // Drag and Drop sensors - optimized for better performance + // PERFORMANCE OPTIMIZATION: Build a tasksById map with memory-conscious approach + const tasksById = useMemo(() => { + const map: Record = {}; + // Cache all tasks for full functionality - performance optimizations are handled at the virtualization level + tasks.forEach(task => { map[task.id] = task; }); + return map; + }, [tasks]); + + // Drag and Drop sensors - optimized for smoother experience const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { - distance: 3, // Reduced from 8 for more responsive dragging + distance: 3, // Small distance to prevent accidental drags + delay: 0, // No delay for immediate activation + tolerance: 5, // Tolerance for small movements }, }), useSensor(KeyboardSensor, { @@ -124,6 +146,28 @@ const TaskListBoard: React.FC = ({ projectId, className = '' }) ); + // PERFORMANCE OPTIMIZATION: Monitor frame rate and enable throttling if needed + useEffect(() => { + const monitorPerformance = () => { + const now = performance.now(); + const frameTime = now - frameTimeRef.current; + renderCountRef.current++; + + // If frame time is consistently over 16.67ms (60fps), enable throttling + if (frameTime > 20 && renderCountRef.current > 10) { + setShouldThrottle(true); + } else if (frameTime < 12 && renderCountRef.current > 50) { + setShouldThrottle(false); + renderCountRef.current = 0; // Reset counter + } + + frameTimeRef.current = now; + }; + + const interval = setInterval(monitorPerformance, 100); + return () => clearInterval(interval); + }, []); + // Fetch task groups when component mounts or dependencies change useEffect(() => { if (projectId && !hasInitialized.current) { @@ -136,8 +180,10 @@ const TaskListBoard: React.FC = ({ projectId, className = '' // Memoized calculations - optimized const totalTasks = useMemo(() => { - return taskGroups.reduce((total, g) => total + g.taskIds.length, 0); - }, [taskGroups]); + const total = taskGroups.reduce((sum, g) => sum + g.taskIds.length, 0); + console.log(`[TASK-LIST-BOARD] Total tasks in groups: ${total}, Total tasks in store: ${tasks.length}, Groups: ${taskGroups.length}`); + return total; + }, [taskGroups, tasks.length]); const hasAnyTasks = useMemo(() => totalTasks > 0, [totalTasks]); @@ -177,7 +223,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' [tasks, currentGrouping] ); - // Throttled drag over handler for better performance + // Throttled drag over handler for smoother performance const handleDragOver = useCallback( throttle((event: DragOverEvent) => { const { active, over } = event; @@ -187,42 +233,41 @@ const TaskListBoard: React.FC = ({ projectId, className = '' const activeTaskId = active.id as string; const overContainer = over.id as string; - // Clear any existing timeout - if (dragOverTimeoutRef.current) { - clearTimeout(dragOverTimeoutRef.current); + // PERFORMANCE OPTIMIZATION: Immediate response for instant UX + // Only update if we're hovering over a different container + const targetTask = tasks.find(t => t.id === overContainer); + let targetGroupId = overContainer; + + if (targetTask) { + // PERFORMANCE OPTIMIZATION: Use switch instead of multiple if statements + switch (currentGrouping) { + case 'status': + targetGroupId = `status-${targetTask.status}`; + break; + case 'priority': + targetGroupId = `priority-${targetTask.priority}`; + break; + case 'phase': + targetGroupId = `phase-${targetTask.phase}`; + break; + } } - // Optimistic update with throttling - dragOverTimeoutRef.current = setTimeout(() => { - // Only update if we're hovering over a different container - const targetTask = tasks.find(t => t.id === overContainer); - let targetGroupId = overContainer; - - if (targetTask) { - if (currentGrouping === 'status') { - targetGroupId = `status-${targetTask.status}`; - } else if (currentGrouping === 'priority') { - targetGroupId = `priority-${targetTask.priority}`; - } else if (currentGrouping === 'phase') { - targetGroupId = `phase-${targetTask.phase}`; - } + if (targetGroupId !== dragState.activeGroupId) { + // PERFORMANCE OPTIMIZATION: Use findIndex for better performance + const targetGroupIndex = taskGroups.findIndex(g => g.id === targetGroupId); + if (targetGroupIndex !== -1) { + const targetGroup = taskGroups[targetGroupIndex]; + dispatch( + optimisticTaskMove({ + taskId: activeTaskId, + newGroupId: targetGroupId, + newIndex: targetGroup.taskIds.length, + }) + ); } - - if (targetGroupId !== dragState.activeGroupId) { - // Perform optimistic update for visual feedback - const targetGroup = taskGroups.find(g => g.id === targetGroupId); - if (targetGroup) { - dispatch( - optimisticTaskMove({ - taskId: activeTaskId, - newGroupId: targetGroupId, - newIndex: targetGroup.taskIds.length, - }) - ); - } - } - }, 50); // 50ms throttle for drag over events - }, 50), + } + }, 16), // 60fps throttling for smooth performance [dragState, tasks, taskGroups, currentGrouping, dispatch] ); @@ -338,7 +383,6 @@ const TaskListBoard: React.FC = ({ projectId, className = '' const handleToggleSubtasks = useCallback((taskId: string) => { // Implementation for toggling subtasks - console.log('Toggle subtasks for task:', taskId); }, []); // Memoized DragOverlay content for better performance @@ -375,7 +419,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' } return ( -
+
= ({ projectId, className = ''
- {/* Virtualized Task Groups Container */} -
- {loading ? ( - -
- -
-
- ) : taskGroups.length === 0 ? ( - - -
- No task groups available -
-
- Create tasks to see them organized in groups -
-
- } - image={Empty.PRESENTED_IMAGE_SIMPLE} - /> - - ) : ( -
- {taskGroups.map((group, index) => { - // PERFORMANCE OPTIMIZATION: Optimized height calculations - const groupTasks = group.taskIds.length; - const baseHeight = 120; // Header + column headers + add task row - const taskRowsHeight = groupTasks * 40; // 40px per task row - - // PERFORMANCE OPTIMIZATION: Dynamic height based on task count and virtualization - const shouldVirtualizeGroup = groupTasks > 20; - const minGroupHeight = shouldVirtualizeGroup ? 200 : 150; // Smaller minimum for non-virtualized - const maxGroupHeight = shouldVirtualizeGroup ? 800 : 400; // Different max based on virtualization - const calculatedHeight = baseHeight + taskRowsHeight; - const groupHeight = Math.max( - minGroupHeight, - Math.min(calculatedHeight, maxGroupHeight) - ); + {/* Performance Analysis - Only show in development */} + {/* {process.env.NODE_ENV === 'development' && ( + + )} */} - return ( - - ); - })} -
- )} + {/* Fixed Height Task Groups Container - Asana Style */} +
+
+ {loading ? ( +
+
+ +
+
+ ) : taskGroups.length === 0 ? ( +
+ +
+ No task groups available +
+
+ Create tasks to see them organized in groups +
+
+ } + image={Empty.PRESENTED_IMAGE_SIMPLE} + /> +
+ ) : ( +
+ {taskGroups.map((group, index) => { + // PERFORMANCE OPTIMIZATION: More aggressive height calculation for better performance + const groupTasks = group.taskIds.length; + const baseHeight = 120; // Header + column headers + add task row + const taskRowsHeight = groupTasks * 40; // 40px per task row + + // PERFORMANCE OPTIMIZATION: Enhanced virtualization threshold for better UX + const shouldVirtualizeGroup = groupTasks > 25; // Increased threshold for smoother experience + const minGroupHeight = shouldVirtualizeGroup ? 200 : 120; // Minimum height for virtualized groups + const maxGroupHeight = shouldVirtualizeGroup ? 600 : 1000; // Allow more height for virtualized groups + const calculatedHeight = baseHeight + taskRowsHeight; + const groupHeight = Math.max( + minGroupHeight, + Math.min(calculatedHeight, maxGroupHeight) + ); + + // PERFORMANCE OPTIMIZATION: Removed group throttling to show all tasks + // Virtualization within each group handles performance for large task lists + + // PERFORMANCE OPTIMIZATION: Memoize group rendering + return ( + + ); + })} +
+ )} +
{dragOverlayContent} @@ -466,16 +526,87 @@ const TaskListBoard: React.FC = ({ projectId, className = ''