feat(task-management): optimize task components for performance and usability

- Refactored TaskGroup and TaskRow components to improve rendering efficiency by utilizing memoization and callbacks.
- Moved color mappings for group statuses and priorities outside of components to prevent unnecessary re-creations.
- Enhanced drag-and-drop functionality with optimistic updates and throttling for smoother user experience.
- Updated task management slice to support new properties and batch updates for better performance.
- Simplified selectors and improved error handling in the task management slice.
This commit is contained in:
chamiakJ
2025-06-23 07:29:50 +05:30
parent 05729285af
commit 687fff9c74
5 changed files with 596 additions and 398 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo, useCallback } from 'react';
import { useDroppable } from '@dnd-kit/core'; import { useDroppable } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
@@ -23,7 +23,24 @@ interface TaskGroupProps {
onToggleSubtasks?: (taskId: string) => void; onToggleSubtasks?: (taskId: string) => void;
} }
const TaskGroup: React.FC<TaskGroupProps> = ({ // Group color mapping - moved outside component for better performance
const GROUP_COLORS = {
status: {
todo: '#faad14',
doing: '#1890ff',
done: '#52c41a',
},
priority: {
critical: '#ff4d4f',
high: '#fa8c16',
medium: '#faad14',
low: '#52c41a',
},
phase: '#722ed1',
default: '#d9d9d9',
} as const;
const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
group, group,
projectId, projectId,
currentGrouping, currentGrouping,
@@ -53,57 +70,63 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
.filter((task): task is Task => task !== undefined); .filter((task): task is Task => task !== undefined);
}, [group.taskIds, allTasks]); }, [group.taskIds, allTasks]);
// Calculate group statistics // Calculate group statistics - memoized
const completedTasks = useMemo(() => { const { completedTasks, totalTasks, completionRate } = useMemo(() => {
return groupTasks.filter(task => task.progress === 100).length; const completed = groupTasks.filter(task => task.progress === 100).length;
const total = groupTasks.length;
const rate = total > 0 ? Math.round((completed / total) * 100) : 0;
return {
completedTasks: completed,
totalTasks: total,
completionRate: rate,
};
}, [groupTasks]); }, [groupTasks]);
const totalTasks = groupTasks.length; // Get group color based on grouping type - memoized
const completionRate = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0; const groupColor = useMemo(() => {
// Get group color based on grouping type
const getGroupColor = () => {
if (group.color) return group.color; if (group.color) return group.color;
// Fallback colors based on group value // Fallback colors based on group value
switch (currentGrouping) { switch (currentGrouping) {
case 'status': case 'status':
return group.groupValue === 'todo' ? '#faad14' : return GROUP_COLORS.status[group.groupValue as keyof typeof GROUP_COLORS.status] || GROUP_COLORS.default;
group.groupValue === 'doing' ? '#1890ff' : '#52c41a';
case 'priority': case 'priority':
return group.groupValue === 'critical' ? '#ff4d4f' : return GROUP_COLORS.priority[group.groupValue as keyof typeof GROUP_COLORS.priority] || GROUP_COLORS.default;
group.groupValue === 'high' ? '#fa8c16' :
group.groupValue === 'medium' ? '#faad14' : '#52c41a';
case 'phase': case 'phase':
return '#722ed1'; return GROUP_COLORS.phase;
default: default:
return '#d9d9d9'; return GROUP_COLORS.default;
} }
}; }, [group.color, group.groupValue, currentGrouping]);
const handleToggleCollapse = () => { // Memoized event handlers
const handleToggleCollapse = useCallback(() => {
setIsCollapsed(!isCollapsed); setIsCollapsed(!isCollapsed);
onToggleCollapse?.(group.id); onToggleCollapse?.(group.id);
}; }, [isCollapsed, onToggleCollapse, group.id]);
const handleAddTask = () => { const handleAddTask = useCallback(() => {
onAddTask?.(group.id); onAddTask?.(group.id);
}; }, [onAddTask, group.id]);
// Memoized style object
const containerStyle = useMemo(() => ({
backgroundColor: isOver ? '#f0f8ff' : undefined,
}), [isOver]);
return ( return (
<div <div
ref={setNodeRef} ref={setNodeRef}
className={`task-group ${isOver ? 'drag-over' : ''}`} className={`task-group ${isOver ? 'drag-over' : ''}`}
style={{ style={containerStyle}
backgroundColor: isOver ? '#f0f8ff' : undefined,
}}
> >
{/* Group Header Row */} {/* Group Header Row */}
<div className="task-group-header"> <div className="task-group-header">
<div className="task-group-header-row"> <div className="task-group-header-row">
<div <div
className="task-group-header-content" className="task-group-header-content"
style={{ backgroundColor: getGroupColor() }} style={{ backgroundColor: groupColor }}
> >
<Button <Button
type="text" type="text"
@@ -123,7 +146,7 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
{!isCollapsed && totalTasks > 0 && ( {!isCollapsed && totalTasks > 0 && (
<div <div
className="task-group-column-headers" className="task-group-column-headers"
style={{ borderLeft: `4px solid ${getGroupColor()}` }} style={{ borderLeft: `4px solid ${groupColor}` }}
> >
<div className="task-group-column-headers-row"> <div className="task-group-column-headers-row">
<div className="task-table-fixed-columns"> <div className="task-table-fixed-columns">
@@ -170,7 +193,7 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
{!isCollapsed && ( {!isCollapsed && (
<div <div
className="task-group-body" className="task-group-body"
style={{ borderLeft: `4px solid ${getGroupColor()}` }} style={{ borderLeft: `4px solid ${groupColor}` }}
> >
{groupTasks.length === 0 ? ( {groupTasks.length === 0 ? (
<div className="task-group-empty"> <div className="task-group-empty">
@@ -428,6 +451,17 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
`}</style> `}</style>
</div> </div>
); );
}; }, (prevProps, nextProps) => {
// Simplified comparison for better performance
return (
prevProps.group.id === nextProps.group.id &&
prevProps.group.taskIds.length === nextProps.group.taskIds.length &&
prevProps.group.collapsed === nextProps.group.collapsed &&
prevProps.selectedTaskIds.length === nextProps.selectedTaskIds.length &&
prevProps.currentGrouping === nextProps.currentGrouping
);
});
TaskGroup.displayName = 'TaskGroup';
export default TaskGroup; export default TaskGroup;

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState, useMemo } from 'react'; import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
import { useSelector, useDispatch } from 'react-redux'; import { useSelector, useDispatch } from 'react-redux';
import { import {
DndContext, DndContext,
@@ -21,6 +21,7 @@ import {
taskManagementSelectors, taskManagementSelectors,
reorderTasks, reorderTasks,
moveTaskToGroup, moveTaskToGroup,
optimisticTaskMove,
setLoading, setLoading,
fetchTasks fetchTasks
} from '@/features/task-management/task-management.slice'; } from '@/features/task-management/task-management.slice';
@@ -54,6 +55,27 @@ interface DragState {
activeGroupId: string | null; activeGroupId: string | null;
} }
// Throttle utility for performance optimization
const throttle = <T extends (...args: any[]) => void>(func: T, delay: number): T => {
let timeoutId: NodeJS.Timeout | null = null;
let lastExecTime = 0;
return ((...args: any[]) => {
const currentTime = Date.now();
if (currentTime - lastExecTime > delay) {
func(...args);
lastExecTime = currentTime;
} else {
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
func(...args);
lastExecTime = Date.now();
}, delay - (currentTime - lastExecTime));
}
}) as T;
};
const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = '' }) => { const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = '' }) => {
const dispatch = useDispatch<AppDispatch>(); const dispatch = useDispatch<AppDispatch>();
const [dragState, setDragState] = useState<DragState>({ const [dragState, setDragState] = useState<DragState>({
@@ -61,6 +83,9 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
activeGroupId: null, activeGroupId: null,
}); });
// Refs for performance optimization
const dragOverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Enable real-time socket updates for task changes // Enable real-time socket updates for task changes
useTaskSocketHandlers(); useTaskSocketHandlers();
@@ -72,11 +97,11 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
const loading = useSelector((state: RootState) => state.taskManagement.loading); const loading = useSelector((state: RootState) => state.taskManagement.loading);
const error = useSelector((state: RootState) => state.taskManagement.error); const error = useSelector((state: RootState) => state.taskManagement.error);
// Drag and Drop sensors // Drag and Drop sensors - optimized for better performance
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
activationConstraint: { activationConstraint: {
distance: 8, distance: 3, // Reduced from 8 for more responsive dragging
}, },
}), }),
useSensor(KeyboardSensor, { useSensor(KeyboardSensor, {
@@ -92,23 +117,17 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
} }
}, [dispatch, projectId, currentGrouping]); }, [dispatch, projectId, currentGrouping]);
// Memoized calculations // Memoized calculations - optimized
const allTaskIds = useMemo(() => { const allTaskIds = useMemo(() => tasks.map(task => task.id), [tasks]);
return tasks.map(task => task.id); const totalTasksCount = useMemo(() => tasks.length, [tasks]);
}, [tasks]);
const totalTasksCount = useMemo(() => {
return tasks.length;
}, [tasks]);
const hasSelection = selectedTaskIds.length > 0; const hasSelection = selectedTaskIds.length > 0;
// Handlers // Memoized handlers for better performance
const handleGroupingChange = (newGroupBy: typeof currentGrouping) => { const handleGroupingChange = useCallback((newGroupBy: typeof currentGrouping) => {
dispatch(setCurrentGrouping(newGroupBy)); dispatch(setCurrentGrouping(newGroupBy));
}; }, [dispatch]);
const handleDragStart = (event: DragStartEvent) => { const handleDragStart = useCallback((event: DragStartEvent) => {
const { active } = event; const { active } = event;
const taskId = active.id as string; const taskId = active.id as string;
@@ -131,28 +150,76 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
activeTask, activeTask,
activeGroupId, activeGroupId,
}); });
}; }, [tasks, currentGrouping]);
const handleDragOver = (event: DragOverEvent) => { // Throttled drag over handler for better performance
// Handle drag over logic if needed for visual feedback const handleDragOver = useCallback(throttle((event: DragOverEvent) => {
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
if (!over || !dragState.activeTask) return;
const activeTaskId = active.id as string;
const overContainer = over.id as string;
// Clear any existing timeout
if (dragOverTimeoutRef.current) {
clearTimeout(dragOverTimeoutRef.current);
}
// 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) {
// 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), [dragState, tasks, taskGroups, currentGrouping, dispatch]);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
// Clear any pending drag over timeouts
if (dragOverTimeoutRef.current) {
clearTimeout(dragOverTimeoutRef.current);
dragOverTimeoutRef.current = null;
}
// Reset drag state immediately for better UX
const currentDragState = dragState;
setDragState({ setDragState({
activeTask: null, activeTask: null,
activeGroupId: null, activeGroupId: null,
}); });
if (!over || !dragState.activeTask || !dragState.activeGroupId) { if (!over || !currentDragState.activeTask || !currentDragState.activeGroupId) {
return; return;
} }
const activeTaskId = active.id as string; const activeTaskId = active.id as string;
const overContainer = over.id as string; const overContainer = over.id as string;
// Parse the group ID to get group type and value // Parse the group ID to get group type and value - optimized
const parseGroupId = (groupId: string) => { const parseGroupId = (groupId: string) => {
const [groupType, ...groupValueParts] = groupId.split('-'); const [groupType, ...groupValueParts] = groupId.split('-');
return { return {
@@ -184,11 +251,11 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
} }
} }
const sourceGroupInfo = parseGroupId(dragState.activeGroupId); const sourceGroupInfo = parseGroupId(currentDragState.activeGroupId);
const targetGroupInfo = parseGroupId(targetGroupId); const targetGroupInfo = parseGroupId(targetGroupId);
// If moving between different groups, update the task's group property // If moving between different groups, update the task's group property
if (dragState.activeGroupId !== targetGroupId) { if (currentDragState.activeGroupId !== targetGroupId) {
dispatch(moveTaskToGroup({ dispatch(moveTaskToGroup({
taskId: activeTaskId, taskId: activeTaskId,
groupType: targetGroupInfo.groupType, groupType: targetGroupInfo.groupType,
@@ -197,37 +264,65 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
} }
// Handle reordering within the same group or between groups // Handle reordering within the same group or between groups
const sourceGroup = taskGroups.find(g => g.id === dragState.activeGroupId); const sourceGroup = taskGroups.find(g => g.id === currentDragState.activeGroupId);
const targetGroup = taskGroups.find(g => g.id === targetGroupId); const targetGroup = taskGroups.find(g => g.id === targetGroupId);
if (sourceGroup && targetGroup) { if (sourceGroup && targetGroup && targetIndex !== -1) {
const sourceIndex = sourceGroup.taskIds.indexOf(activeTaskId); const sourceIndex = sourceGroup.taskIds.indexOf(activeTaskId);
const finalTargetIndex = targetIndex === -1 ? targetGroup.taskIds.length : targetIndex; const finalTargetIndex = targetIndex === -1 ? targetGroup.taskIds.length : targetIndex;
// Calculate new order values // Only reorder if actually moving to a different position
if (sourceGroup.id !== targetGroup.id || sourceIndex !== finalTargetIndex) {
// Calculate new order values - simplified
const allTasksInTargetGroup = targetGroup.taskIds.map(id => tasks.find(t => t.id === id)!); const allTasksInTargetGroup = targetGroup.taskIds.map(id => tasks.find(t => t.id === id)!);
const newOrder = allTasksInTargetGroup.map((task, index) => { const newOrder = allTasksInTargetGroup.map((task, index) => {
if (index < finalTargetIndex) return task.order; if (index < finalTargetIndex) return task.order;
if (index === finalTargetIndex) return dragState.activeTask!.order; if (index === finalTargetIndex) return currentDragState.activeTask!.order;
return task.order + 1; return task.order + 1;
}); });
// Dispatch reorder action // Dispatch reorder action
dispatch(reorderTasks({ dispatch(reorderTasks({
taskIds: [activeTaskId, ...allTasksInTargetGroup.map(t => t.id)], taskIds: [activeTaskId, ...allTasksInTargetGroup.map(t => t.id)],
newOrder: [dragState.activeTask!.order, ...newOrder] newOrder: [currentDragState.activeTask!.order, ...newOrder]
})); }));
} }
}; }
}, [dragState, tasks, taskGroups, currentGrouping, dispatch]);
const handleSelectTask = (taskId: string, selected: boolean) => { const handleSelectTask = useCallback((taskId: string, selected: boolean) => {
dispatch(toggleTaskSelection(taskId)); dispatch(toggleTaskSelection(taskId));
}; }, [dispatch]);
const handleToggleSubtasks = (taskId: string) => { const handleToggleSubtasks = useCallback((taskId: string) => {
// Implementation for toggling subtasks // Implementation for toggling subtasks
console.log('Toggle subtasks for task:', taskId); console.log('Toggle subtasks for task:', taskId);
}, []);
// Memoized DragOverlay content for better performance
const dragOverlayContent = useMemo(() => {
if (!dragState.activeTask || !dragState.activeGroupId) return null;
return (
<TaskRow
task={dragState.activeTask}
projectId={projectId}
groupId={dragState.activeGroupId}
currentGrouping={currentGrouping}
isSelected={false}
isDragOverlay
/>
);
}, [dragState.activeTask, dragState.activeGroupId, projectId, currentGrouping]);
// Cleanup effect
useEffect(() => {
return () => {
if (dragOverTimeoutRef.current) {
clearTimeout(dragOverTimeoutRef.current);
}
}; };
}, []);
if (error) { if (error) {
return ( return (
@@ -242,6 +337,13 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
return ( return (
<div className={`task-list-board ${className}`}> <div className={`task-list-board ${className}`}>
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
{/* Task Filters */} {/* Task Filters */}
<Card <Card
size="small" size="small"
@@ -280,13 +382,6 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
/> />
</Card> </Card>
) : ( ) : (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="task-groups"> <div className="task-groups">
{taskGroups.map((group) => ( {taskGroups.map((group) => (
<TaskGroup <TaskGroup
@@ -300,23 +395,20 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
/> />
))} ))}
</div> </div>
<DragOverlay>
{dragState.activeTask ? (
<TaskRow
task={dragState.activeTask}
projectId={projectId}
groupId={dragState.activeGroupId!}
currentGrouping={currentGrouping}
isSelected={false}
isDragOverlay
/>
) : null}
</DragOverlay>
</DndContext>
)} )}
</div> </div>
<DragOverlay
adjustScale={false}
dropAnimation={null}
style={{
cursor: 'grabbing',
}}
>
{dragOverlayContent}
</DragOverlay>
</DndContext>
<style>{` <style>{`
.task-groups-container { .task-groups-container {
max-height: calc(100vh - 300px); max-height: calc(100vh - 300px);
@@ -324,13 +416,52 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
overflow-x: visible; overflow-x: visible;
padding: 8px 8px 8px 0; padding: 8px 8px 8px 0;
border-radius: 8px; border-radius: 8px;
transition: background-color 0.3s ease;
position: relative; position: relative;
/* GPU acceleration for smooth scrolling */
transform: translateZ(0);
will-change: scroll-position;
} }
.task-groups { .task-groups {
min-width: fit-content; min-width: fit-content;
position: relative; position: relative;
/* GPU acceleration for drag operations */
transform: translateZ(0);
}
/* Optimized drag overlay styles */
[data-dnd-overlay] {
/* GPU acceleration for smooth dragging */
transform: translateZ(0);
will-change: transform;
pointer-events: none;
}
/* Fix drag overlay positioning */
[data-rbd-drag-handle-dragging-id] {
transform: none !important;
}
/* DndKit drag overlay specific styles */
.dndkit-drag-overlay {
z-index: 9999;
pointer-events: none;
transform: translateZ(0);
will-change: transform;
}
/* Ensure drag overlay follows cursor properly */
[data-dnd-context] {
position: relative;
}
/* Fix for scrollable containers affecting drag overlay */
.task-groups-container [data-dnd-overlay] {
position: fixed !important;
top: 0 !important;
left: 0 !important;
transform: translateZ(0);
z-index: 9999;
} }
/* Dark mode support */ /* Dark mode support */
@@ -370,6 +501,20 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
--task-drag-over-bg: #1a2332; --task-drag-over-bg: #1a2332;
--task-drag-over-border: #40a9ff; --task-drag-over-border: #40a9ff;
} }
/* Performance optimizations */
.task-group {
contain: layout style paint;
}
.task-row {
contain: layout style;
}
/* Reduce layout thrashing */
.task-table-cell {
contain: layout;
}
`}</style> `}</style>
</div> </div>
); );

View File

@@ -24,6 +24,20 @@ interface TaskRowProps {
onToggleSubtasks?: (taskId: string) => void; onToggleSubtasks?: (taskId: string) => void;
} }
// Priority and status colors - moved outside component to avoid recreation
const PRIORITY_COLORS = {
critical: '#ff4d4f',
high: '#ff7a45',
medium: '#faad14',
low: '#52c41a',
} as const;
const STATUS_COLORS = {
todo: '#f0f0f0',
doing: '#1890ff',
done: '#52c41a',
} as const;
const TaskRow: React.FC<TaskRowProps> = React.memo(({ const TaskRow: React.FC<TaskRowProps> = React.memo(({
task, task,
projectId, projectId,
@@ -53,12 +67,9 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
}); });
// Get theme from Redux store // Get theme from Redux store
const themeMode = useSelector((state: RootState) => state.themeReducer?.mode || 'light'); const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
// Memoize derived values for performance // Memoize style calculations - simplified
const isDarkMode = useMemo(() => themeMode === 'dark', [themeMode]);
// Memoize style calculations
const style = useMemo(() => ({ const style = useMemo(() => ({
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, transition,
@@ -74,7 +85,67 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
onToggleSubtasks?.(task.id); onToggleSubtasks?.(task.id);
}, [onToggleSubtasks, task.id]); }, [onToggleSubtasks, task.id]);
// Format due date - memoized for performance // Memoize assignees for AvatarGroup to prevent unnecessary re-renders
const avatarGroupMembers = useMemo(() => {
return task.assignee_names || [];
}, [task.assignee_names]);
// Simplified class name calculations
const containerClasses = useMemo(() => {
const baseClasses = 'border-b transition-all duration-300';
const themeClasses = isDarkMode
? 'border-gray-700 bg-gray-900 hover:bg-gray-800'
: 'border-gray-200 bg-white hover:bg-gray-50';
const selectedClasses = isSelected
? (isDarkMode ? 'bg-blue-900/20 border-l-4 border-l-blue-500' : 'bg-blue-50 border-l-4 border-l-blue-500')
: '';
const overlayClasses = isDragOverlay
? `rounded shadow-lg border-2 ${isDarkMode ? 'bg-gray-900 border-gray-600 shadow-2xl' : 'bg-white border-gray-300 shadow-2xl'}`
: '';
return `${baseClasses} ${themeClasses} ${selectedClasses} ${overlayClasses}`;
}, [isDarkMode, isSelected, isDragOverlay]);
const fixedColumnsClasses = useMemo(() =>
`flex sticky left-0 z-10 border-r-2 shadow-sm ${isDarkMode ? 'bg-gray-900 border-gray-700' : 'bg-white border-gray-200'}`,
[isDarkMode]
);
const taskNameClasses = useMemo(() => {
const baseClasses = 'text-sm font-medium flex-1 overflow-hidden text-ellipsis whitespace-nowrap transition-colors duration-300';
const themeClasses = isDarkMode ? 'text-gray-100' : 'text-gray-900';
const completedClasses = task.progress === 100
? `line-through ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`
: '';
return `${baseClasses} ${themeClasses} ${completedClasses}`;
}, [isDarkMode, task.progress]);
// Get colors - using constants for better performance
const getPriorityColor = useCallback((priority: string) =>
PRIORITY_COLORS[priority as keyof typeof PRIORITY_COLORS] || '#d9d9d9', []);
const getStatusColor = useCallback((status: string) =>
STATUS_COLORS[status as keyof typeof STATUS_COLORS] || '#d9d9d9', []);
// Create adapter for LabelsSelector - memoized
const taskAdapter = useMemo(() => ({
id: task.id,
name: task.title,
parent_task_id: null,
all_labels: task.labels?.map(label => ({
id: label.id,
name: label.name,
color_code: label.color
})) || [],
labels: task.labels?.map(label => ({
id: label.id,
name: label.name,
color_code: label.color
})) || [],
} as any), [task.id, task.title, task.labels]);
// Memoize due date calculation
const dueDate = useMemo(() => { const dueDate = useMemo(() => {
if (!task.dueDate) return null; if (!task.dueDate) return null;
const date = new Date(task.dueDate); const date = new Date(task.dueDate);
@@ -93,94 +164,15 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
} }
}, [task.dueDate]); }, [task.dueDate]);
// Memoize assignees for AvatarGroup to prevent unnecessary re-renders
const avatarGroupMembers = useMemo(() => {
return task.assignees?.map(assigneeId => ({
id: assigneeId,
team_member_id: assigneeId,
name: assigneeId // TODO: Map to actual user names
})) || [];
}, [task.assignees]);
// Memoize class names for better performance
const containerClassName = useMemo(() => `
border-b transition-all duration-300
${isDarkMode
? `border-gray-700 bg-gray-900 hover:bg-gray-800 ${isSelected ? 'bg-blue-900/20' : ''}`
: `border-gray-200 bg-white hover:bg-gray-50 ${isSelected ? 'bg-blue-50' : ''}`
}
${isSelected ? 'border-l-4 border-l-blue-500' : ''}
${isDragOverlay
? `rounded shadow-lg ${isDarkMode ? 'bg-gray-900 border border-gray-600' : 'bg-white border border-gray-300'}`
: ''
}
`, [isDarkMode, isSelected, isDragOverlay]);
const fixedColumnsClassName = useMemo(() => `
flex sticky left-0 z-10 border-r-2 shadow-sm ${isDarkMode ? 'bg-gray-900 border-gray-700' : 'bg-white border-gray-200'}
`, [isDarkMode]);
const taskNameClassName = useMemo(() => `
text-sm font-medium flex-1
overflow-hidden text-ellipsis whitespace-nowrap transition-colors duration-300
${isDarkMode ? 'text-gray-100' : 'text-gray-900'}
${task.progress === 100
? `line-through ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`
: ''
}
`, [isDarkMode, task.progress]);
// Get priority color
const getPriorityColor = (priority: string) => {
const colors = {
critical: '#ff4d4f',
high: '#ff7a45',
medium: '#faad14',
low: '#52c41a',
};
return colors[priority as keyof typeof colors] || '#d9d9d9';
};
// Get status color
const getStatusColor = (status: string) => {
const colors = {
todo: '#f0f0f0',
doing: '#1890ff',
done: '#52c41a',
};
return colors[status as keyof typeof colors] || '#d9d9d9';
};
// Create adapter for LabelsSelector to work with new Task type
const taskAdapter = useMemo(() => {
// Convert new Task type to IProjectTask for compatibility
return {
id: task.id,
name: task.title,
parent_task_id: null, // TODO: Add parent task support
all_labels: task.labels?.map(label => ({
id: label.id,
name: label.name,
color_code: label.color
})) || [],
labels: task.labels?.map(label => ({
id: label.id,
name: label.name,
color_code: label.color
})) || [],
} as any; // Type assertion for compatibility
}, [task.id, task.title, task.labels]);
return ( return (
<>
<div <div
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
className={containerClassName} className={containerClasses}
> >
<div className="flex h-10 max-h-10 overflow-visible relative min-w-[1200px]"> <div className="flex h-10 max-h-10 overflow-visible relative min-w-[1200px]">
{/* Fixed Columns */} {/* Fixed Columns */}
<div className={fixedColumnsClassName}> <div className={fixedColumnsClasses}>
{/* Drag Handle */} {/* Drag Handle */}
<div className={`w-10 flex items-center justify-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}> <div className={`w-10 flex items-center justify-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
<Button <Button
@@ -191,8 +183,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
isDarkMode={isDarkMode} isDarkMode={isDarkMode}
{...attributes} {...attributes}
{...listeners} {...listeners}
> />
</Button>
</div> </div>
{/* Selection Checkbox */} {/* Selection Checkbox */}
@@ -219,7 +210,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
<div className="w-[475px] flex items-center px-2"> <div className="w-[475px] flex items-center px-2">
<div className="flex-1 min-w-0 flex flex-col justify-center h-full overflow-hidden"> <div className="flex-1 min-w-0 flex flex-col justify-center h-full overflow-hidden">
<div className="flex items-center gap-2 h-5 overflow-hidden"> <div className="flex items-center gap-2 h-5 overflow-hidden">
<span className={taskNameClassName}> <span className={taskNameClasses}>
{task.title} {task.title}
</span> </span>
</div> </div>
@@ -343,28 +334,17 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
</div> </div>
</div> </div>
</div> </div>
</>
); );
}, (prevProps, nextProps) => { }, (prevProps, nextProps) => {
// Custom comparison function for React.memo // Simplified comparison for better performance
// Only re-render if these specific props change
const labelsEqual = prevProps.task.labels.length === nextProps.task.labels.length &&
prevProps.task.labels.every((label, index) =>
label.id === nextProps.task.labels[index]?.id &&
label.name === nextProps.task.labels[index]?.name &&
label.color === nextProps.task.labels[index]?.color &&
label.end === nextProps.task.labels[index]?.end &&
JSON.stringify(label.names) === JSON.stringify(nextProps.task.labels[index]?.names)
);
return ( return (
prevProps.task.id === nextProps.task.id && prevProps.task.id === nextProps.task.id &&
prevProps.task.assignees === nextProps.task.assignees &&
prevProps.task.title === nextProps.task.title && prevProps.task.title === nextProps.task.title &&
prevProps.task.progress === nextProps.task.progress && prevProps.task.progress === nextProps.task.progress &&
prevProps.task.status === nextProps.task.status && prevProps.task.status === nextProps.task.status &&
prevProps.task.priority === nextProps.task.priority && prevProps.task.priority === nextProps.task.priority &&
labelsEqual && prevProps.task.labels?.length === nextProps.task.labels?.length &&
prevProps.task.assignee_names?.length === nextProps.task.assignee_names?.length &&
prevProps.isSelected === nextProps.isSelected && prevProps.isSelected === nextProps.isSelected &&
prevProps.isDragOverlay === nextProps.isDragOverlay && prevProps.isDragOverlay === nextProps.isDragOverlay &&
prevProps.groupId === nextProps.groupId prevProps.groupId === nextProps.groupId

View File

@@ -71,6 +71,7 @@ export const fetchTasks = createAsyncThunk(
phase: task.phase_name || 'Development', phase: task.phase_name || 'Development',
progress: typeof task.complete_ratio === 'number' ? task.complete_ratio : 0, progress: typeof task.complete_ratio === 'number' ? task.complete_ratio : 0,
assignees: task.assignees?.map((a: any) => a.team_member_id) || [], assignees: task.assignees?.map((a: any) => a.team_member_id) || [],
assignee_names: task.assignee_names || task.names || [],
labels: task.labels?.map((l: any) => ({ labels: task.labels?.map((l: any) => ({
id: l.id || l.label_id, id: l.id || l.label_id,
name: l.name, name: l.name,
@@ -147,13 +148,19 @@ const taskManagementSlice = createSlice({
tasksAdapter.removeMany(state, action.payload); tasksAdapter.removeMany(state, action.payload);
}, },
// Drag and drop operations // Optimized drag and drop operations
reorderTasks: (state, action: PayloadAction<{ taskIds: string[]; newOrder: number[] }>) => { reorderTasks: (state, action: PayloadAction<{ taskIds: string[]; newOrder: number[] }>) => {
const { taskIds, newOrder } = action.payload; const { taskIds, newOrder } = action.payload;
// Batch update for better performance
const updates = taskIds.map((id, index) => ({ const updates = taskIds.map((id, index) => ({
id, id,
changes: { order: newOrder[index] }, changes: {
order: newOrder[index],
updatedAt: new Date().toISOString(),
},
})); }));
tasksAdapter.updateMany(state, updates); tasksAdapter.updateMany(state, updates);
}, },
@@ -175,6 +182,34 @@ const taskManagementSlice = createSlice({
tasksAdapter.updateOne(state, { id: taskId, changes }); tasksAdapter.updateOne(state, { id: taskId, changes });
}, },
// Optimistic update for drag operations - reduces perceived lag
optimisticTaskMove: (state, action: PayloadAction<{ taskId: string; newGroupId: string; newIndex: number }>) => {
const { taskId, newGroupId, newIndex } = action.payload;
const task = state.entities[taskId];
if (task) {
// Parse group ID to determine new values
const [groupType, ...groupValueParts] = newGroupId.split('-');
const groupValue = groupValueParts.join('-');
const changes: Partial<Task> = {
order: newIndex,
updatedAt: new Date().toISOString(),
};
// Update group-specific field
if (groupType === 'status') {
changes.status = groupValue as Task['status'];
} else if (groupType === 'priority') {
changes.priority = groupValue as Task['priority'];
} else if (groupType === 'phase') {
changes.phase = groupValue;
}
tasksAdapter.updateOne(state, { id: taskId, changes });
}
},
// Loading states // Loading states
setLoading: (state, action: PayloadAction<boolean>) => { setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload; state.loading = action.payload;
@@ -198,7 +233,7 @@ const taskManagementSlice = createSlice({
}) })
.addCase(fetchTasks.rejected, (state, action) => { .addCase(fetchTasks.rejected, (state, action) => {
state.loading = false; state.loading = false;
state.error = action.payload as string; state.error = action.payload as string || 'Failed to fetch tasks';
}); });
}, },
}); });
@@ -212,16 +247,19 @@ export const {
bulkDeleteTasks, bulkDeleteTasks,
reorderTasks, reorderTasks,
moveTaskToGroup, moveTaskToGroup,
optimisticTaskMove,
setLoading, setLoading,
setError, setError,
} = taskManagementSlice.actions; } = taskManagementSlice.actions;
export default taskManagementSlice.reducer;
// Selectors // Selectors
export const taskManagementSelectors = tasksAdapter.getSelectors<RootState>( export const taskManagementSelectors = tasksAdapter.getSelectors<RootState>(
(state) => state.taskManagement (state) => state.taskManagement
); );
// Additional selectors // Enhanced selectors for better performance
export const selectTasksByStatus = (state: RootState, status: string) => export const selectTasksByStatus = (state: RootState, status: string) =>
taskManagementSelectors.selectAll(state).filter(task => task.status === status); taskManagementSelectors.selectAll(state).filter(task => task.status === status);
@@ -233,5 +271,3 @@ export const selectTasksByPhase = (state: RootState, phase: string) =>
export const selectTasksLoading = (state: RootState) => state.taskManagement.loading; export const selectTasksLoading = (state: RootState) => state.taskManagement.loading;
export const selectTasksError = (state: RootState) => state.taskManagement.error; export const selectTasksError = (state: RootState) => state.taskManagement.error;
export default taskManagementSlice.reducer;

View File

@@ -1,3 +1,5 @@
import { InlineMember } from './teamMembers/inlineMember.types';
export interface Task { export interface Task {
id: string; id: string;
task_key: string; task_key: string;
@@ -8,6 +10,7 @@ export interface Task {
phase: string; // Custom phases like 'planning', 'development', 'testing', 'deployment' phase: string; // Custom phases like 'planning', 'development', 'testing', 'deployment'
progress: number; // 0-100 progress: number; // 0-100
assignees: string[]; assignees: string[];
assignee_names?: InlineMember[];
labels: Label[]; labels: Label[];
dueDate?: string; dueDate?: string;
timeTracking: { timeTracking: {