Merge branch 'release/v2.0.4' into fix/kanban-board-enhanced-feat
This commit is contained in:
@@ -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
|
||||
$$;
|
||||
|
||||
@@ -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<string, { result: boolean; timestamp: number }>();
|
||||
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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -38,11 +38,12 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = ({
|
||||
onTaskRender?.(task, index);
|
||||
|
||||
return (
|
||||
<div className="virtualized-task-row">
|
||||
<div className="virtualized-task-row" style={style}>
|
||||
<EnhancedKanbanTaskCard
|
||||
task={task}
|
||||
isActive={task.id === activeTaskId}
|
||||
isDropTarget={overId === task.id}
|
||||
sectionId={task.status || 'default'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -52,10 +53,11 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = ({
|
||||
const VirtualizedList = useMemo(() => (
|
||||
<List
|
||||
height={height}
|
||||
width="100%"
|
||||
itemCount={tasks.length}
|
||||
itemSize={itemHeight}
|
||||
itemData={taskData}
|
||||
overscanCount={5} // Render 5 extra items for smooth scrolling
|
||||
overscanCount={10} // Increased overscan for smoother scrolling experience
|
||||
className="virtualized-task-list"
|
||||
>
|
||||
{Row}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'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<string, { light: string; dark: string }> = {
|
||||
'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 (
|
||||
<Select
|
||||
variant="borderless"
|
||||
value={currentPriority.id}
|
||||
onChange={handlePriorityChange}
|
||||
dropdownStyle={{ borderRadius: 8, minWidth: 150, maxWidth: 200 }}
|
||||
style={{
|
||||
backgroundColor:
|
||||
themeMode === 'dark'
|
||||
? currentPriority.color_code_dark
|
||||
: currentPriority.color_code + ALPHA_CHANNEL,
|
||||
borderRadius: 16,
|
||||
height: 22,
|
||||
}}
|
||||
labelRender={() => {
|
||||
return (
|
||||
<Typography.Text style={{ fontSize: 13, color: '#383838' }}>
|
||||
{currentPriority.name}
|
||||
</Typography.Text>
|
||||
);
|
||||
}}
|
||||
options={options}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback rendering for raw priority values or when priority list is not loaded
|
||||
return (
|
||||
<>
|
||||
{task.priority && (
|
||||
<Select
|
||||
variant="borderless"
|
||||
value={task.priority}
|
||||
onChange={handlePriorityChange}
|
||||
dropdownStyle={{ borderRadius: 8, minWidth: 150, maxWidth: 200 }}
|
||||
style={{
|
||||
backgroundColor:
|
||||
themeMode === 'dark'
|
||||
? selectedPriority?.color_code_dark
|
||||
: selectedPriority?.color_code + ALPHA_CHANNEL,
|
||||
borderRadius: 16,
|
||||
height: 22,
|
||||
}}
|
||||
labelRender={value => {
|
||||
const priority = priorityList.find(priority => priority.id === value.value);
|
||||
return priority ? (
|
||||
<Typography.Text style={{ fontSize: 13, color: '#383838' }}>
|
||||
{priority.name}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
''
|
||||
);
|
||||
}}
|
||||
options={options}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<div
|
||||
className="px-2 py-1 text-xs rounded"
|
||||
style={{
|
||||
backgroundColor: getPriorityColor(task.priority) + ALPHA_CHANNEL,
|
||||
borderRadius: 16,
|
||||
height: 22,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 13,
|
||||
color: '#383838',
|
||||
minWidth: 60,
|
||||
}}
|
||||
>
|
||||
{getPriorityDisplayName(task.priority)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'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<string, { light: string; dark: string }> = {
|
||||
'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 (
|
||||
<Select
|
||||
variant="borderless"
|
||||
value={currentStatus.id}
|
||||
onChange={handleStatusChange}
|
||||
dropdownStyle={{ borderRadius: 8, minWidth: 150, maxWidth: 200 }}
|
||||
style={{
|
||||
backgroundColor: themeMode === 'dark' ? currentStatus.color_code_dark : currentStatus.color_code,
|
||||
borderRadius: 16,
|
||||
height: 22,
|
||||
}}
|
||||
labelRender={() => {
|
||||
return <span style={{ fontSize: 13 }}>{currentStatus.name}</span>;
|
||||
}}
|
||||
options={options}
|
||||
optionRender={(option) => (
|
||||
<Flex align="center">
|
||||
{option.label}
|
||||
</Flex>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback rendering for raw status values or when status list is not loaded
|
||||
return (
|
||||
<>
|
||||
{task.status && (
|
||||
<Select
|
||||
variant="borderless"
|
||||
value={task.status}
|
||||
onChange={handleStatusChange}
|
||||
dropdownStyle={{ borderRadius: 8, minWidth: 150, maxWidth: 200 }}
|
||||
style={{
|
||||
backgroundColor: themeMode === 'dark' ? task.status_color_dark : task.status_color,
|
||||
borderRadius: 16,
|
||||
height: 22,
|
||||
}}
|
||||
labelRender={status => {
|
||||
return status ? <span style={{ fontSize: 13 }}>{status.label}</span> : '';
|
||||
}}
|
||||
options={options}
|
||||
optionRender={(option) => (
|
||||
<Flex align="center">
|
||||
{option.label}
|
||||
</Flex>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<div
|
||||
className="px-2 py-1 text-xs rounded"
|
||||
style={{
|
||||
backgroundColor: getStatusColor(task.status),
|
||||
borderRadius: 16,
|
||||
height: 22,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 13,
|
||||
color: '#383838',
|
||||
minWidth: 60,
|
||||
}}
|
||||
>
|
||||
{getStatusDisplayName(task.status)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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: () => (
|
||||
<div className="p-4 border rounded bg-blue-50">
|
||||
<Text strong>🚀 Heavy Assignee Selector Loaded!</Text>
|
||||
<br />
|
||||
<Text type="secondary">This component contains:</Text>
|
||||
<ul className="mt-2 text-sm">
|
||||
<li>Team member search logic</li>
|
||||
<li>Avatar rendering</li>
|
||||
<li>Permission checking</li>
|
||||
<li>Socket connections</li>
|
||||
<li>Optimistic updates</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}), 1000) // Simulate 1s load time
|
||||
)
|
||||
);
|
||||
|
||||
const HeavyDatePicker = React.lazy(() =>
|
||||
new Promise<{ default: React.ComponentType }>((resolve) =>
|
||||
setTimeout(() => resolve({
|
||||
default: () => (
|
||||
<div className="p-4 border rounded bg-green-50">
|
||||
<Text strong>📅 Heavy Date Picker Loaded!</Text>
|
||||
<br />
|
||||
<Text type="secondary">This component contains:</Text>
|
||||
<ul className="mt-2 text-sm">
|
||||
<li>Calendar rendering logic</li>
|
||||
<li>Date validation</li>
|
||||
<li>Timezone handling</li>
|
||||
<li>Locale support</li>
|
||||
<li>Accessibility features</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}), 800) // Simulate 0.8s load time
|
||||
)
|
||||
);
|
||||
|
||||
const HeavyPrioritySelector = React.lazy(() =>
|
||||
new Promise<{ default: React.ComponentType }>((resolve) =>
|
||||
setTimeout(() => resolve({
|
||||
default: () => (
|
||||
<div className="p-4 border rounded bg-orange-50">
|
||||
<Text strong>🔥 Heavy Priority Selector Loaded!</Text>
|
||||
<br />
|
||||
<Text type="secondary">This component contains:</Text>
|
||||
<ul className="mt-2 text-sm">
|
||||
<li>Priority level logic</li>
|
||||
<li>Color calculations</li>
|
||||
<li>Business rules</li>
|
||||
<li>Validation</li>
|
||||
<li>State management</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}), 600) // Simulate 0.6s load time
|
||||
)
|
||||
);
|
||||
|
||||
const HeavyLabelsSelector = React.lazy(() =>
|
||||
new Promise<{ default: React.ComponentType }>((resolve) =>
|
||||
setTimeout(() => resolve({
|
||||
default: () => (
|
||||
<div className="p-4 border rounded bg-purple-50">
|
||||
<Text strong>🏷️ Heavy Labels Selector Loaded!</Text>
|
||||
<br />
|
||||
<Text type="secondary">This component contains:</Text>
|
||||
<ul className="mt-2 text-sm">
|
||||
<li>Label management</li>
|
||||
<li>Color picker</li>
|
||||
<li>Search functionality</li>
|
||||
<li>CRUD operations</li>
|
||||
<li>Drag & drop</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}), 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 }) => (
|
||||
<Button
|
||||
size="small"
|
||||
icon={loaded ? <LoadingOutlined spin /> : icon}
|
||||
onClick={onClick}
|
||||
className={`${loaded ? 'border-blue-500 bg-blue-50' : ''}`}
|
||||
>
|
||||
{loaded ? 'Loading...' : label}
|
||||
</Button>
|
||||
);
|
||||
|
||||
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 (
|
||||
<Card className="max-w-4xl mx-auto">
|
||||
<Title level={3}>🎯 Asana-Style Lazy Loading Demo</Title>
|
||||
|
||||
<div className="mb-4 p-4 bg-gray-50 rounded">
|
||||
<Text strong>Performance Benefits:</Text>
|
||||
<ul className="mt-2 text-sm">
|
||||
<li>✅ <strong>Faster Initial Load:</strong> Only lightweight placeholders load initially</li>
|
||||
<li>✅ <strong>Reduced Bundle Size:</strong> Heavy components split into separate chunks</li>
|
||||
<li>✅ <strong>Better UX:</strong> Instant visual feedback, components load on demand</li>
|
||||
<li>✅ <strong>Memory Efficient:</strong> Components only consume memory when needed</li>
|
||||
<li>✅ <strong>Network Optimized:</strong> Parallel loading of components as user interacts</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Text strong>Task Management Components (Click to Load):</Text>
|
||||
<div className="mt-2 flex gap-2 flex-wrap">
|
||||
<PlaceholderButton
|
||||
icon={<UserAddOutlined />}
|
||||
label="Add Assignee"
|
||||
onClick={() => handleLoad('assignee')}
|
||||
loaded={loadedComponents.assignee && !showComponents.assignee}
|
||||
/>
|
||||
<PlaceholderButton
|
||||
icon={<CalendarOutlined />}
|
||||
label="Set Date"
|
||||
onClick={() => handleLoad('date')}
|
||||
loaded={loadedComponents.date && !showComponents.date}
|
||||
/>
|
||||
<PlaceholderButton
|
||||
icon={<FlagOutlined />}
|
||||
label="Set Priority"
|
||||
onClick={() => handleLoad('priority')}
|
||||
loaded={loadedComponents.priority && !showComponents.priority}
|
||||
/>
|
||||
<PlaceholderButton
|
||||
icon={<TagOutlined />}
|
||||
label="Add Labels"
|
||||
onClick={() => handleLoad('labels')}
|
||||
loaded={loadedComponents.labels && !showComponents.labels}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={resetDemo} size="small">
|
||||
Reset Demo
|
||||
</Button>
|
||||
<Text type="secondary" className="self-center">
|
||||
Components loaded: {Object.values(showComponents).filter(Boolean).length}/4
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="space-y-4">
|
||||
{showComponents.assignee && (
|
||||
<Suspense fallback={<div className="p-4 border rounded bg-gray-100">Loading assignee selector...</div>}>
|
||||
<HeavyAssigneeSelector />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{showComponents.date && (
|
||||
<Suspense fallback={<div className="p-4 border rounded bg-gray-100">Loading date picker...</div>}>
|
||||
<HeavyDatePicker />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{showComponents.priority && (
|
||||
<Suspense fallback={<div className="p-4 border rounded bg-gray-100">Loading priority selector...</div>}>
|
||||
<HeavyPrioritySelector />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{showComponents.labels && (
|
||||
<Suspense fallback={<div className="p-4 border rounded bg-gray-100">Loading labels selector...</div>}>
|
||||
<HeavyLabelsSelector />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="text-sm text-gray-600">
|
||||
<Text strong>How it works:</Text>
|
||||
<ol className="mt-2 space-y-1">
|
||||
<li>1. Page loads instantly with lightweight placeholder buttons</li>
|
||||
<li>2. User clicks a button to interact with a feature</li>
|
||||
<li>3. Heavy component starts loading in the background</li>
|
||||
<li>4. Loading state shows immediate feedback</li>
|
||||
<li>5. Full component renders when ready</li>
|
||||
<li>6. Subsequent interactions are instant (component cached)</li>
|
||||
</ol>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AsanaStyleLazyDemo;
|
||||
@@ -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<DefaultEventsMap, DefaultEventsMap> | null;
|
||||
dispatch: ThunkDispatch<any, any, any> & Dispatch<any>;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
position: { top: number; left: number };
|
||||
}
|
||||
|
||||
const AssigneeDropdownContent: React.FC<AssigneeDropdownContentProps> = ({
|
||||
task,
|
||||
groupId = null,
|
||||
isDarkMode = false,
|
||||
projectId,
|
||||
currentSession,
|
||||
socket,
|
||||
dispatch,
|
||||
isOpen,
|
||||
onClose,
|
||||
position,
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [teamMembers, setTeamMembers] = useState<ITeamMembersViewModel>({ data: [], total: 0 });
|
||||
const [optimisticAssignees, setOptimisticAssignees] = useState<string[]>([]);
|
||||
const [pendingChanges, setPendingChanges] = useState<Set<string>>(new Set());
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(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 (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className={`
|
||||
fixed z-[9999] w-72 rounded-md shadow-lg border
|
||||
${isDarkMode
|
||||
? 'bg-gray-800 border-gray-600'
|
||||
: 'bg-white border-gray-200'
|
||||
}
|
||||
`}
|
||||
style={{
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className={`p-2 border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`}>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => 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
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Members List */}
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{filteredMembers && filteredMembers.length > 0 ? (
|
||||
filteredMembers.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className={`
|
||||
flex items-center gap-2 p-2 cursor-pointer transition-colors relative
|
||||
${member.pending_invitation
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: isDarkMode
|
||||
? 'hover:bg-gray-700'
|
||||
: 'hover:bg-gray-50'
|
||||
}
|
||||
`}
|
||||
onClick={() => {
|
||||
if (!member.pending_invitation) {
|
||||
const isSelected = checkMemberSelected(member.id || '');
|
||||
handleMemberToggle(member.id || '', !isSelected);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<Checkbox
|
||||
checked={checkMemberSelected(member.id || '')}
|
||||
onChange={(checked) => handleMemberToggle(member.id || '', checked)}
|
||||
disabled={member.pending_invitation || pendingChanges.has(member.id || '')}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
{pendingChanges.has(member.id || '') && (
|
||||
<div className={`absolute inset-0 flex items-center justify-center ${
|
||||
isDarkMode ? 'bg-gray-800/50' : 'bg-white/50'
|
||||
}`}>
|
||||
<div className={`w-3 h-3 border border-t-transparent rounded-full animate-spin ${
|
||||
isDarkMode ? 'border-blue-400' : 'border-blue-600'
|
||||
}`} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Avatar
|
||||
src={member.avatar_url}
|
||||
name={member.name || ''}
|
||||
size={24}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-xs font-medium truncate ${isDarkMode ? 'text-gray-100' : 'text-gray-900'}`}>
|
||||
{member.name}
|
||||
</div>
|
||||
<div className={`text-xs truncate ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
{member.email}
|
||||
{member.pending_invitation && (
|
||||
<span className="text-red-400 ml-1">(Pending)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-4 text-center">
|
||||
<div className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
No members found
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer - Invite button */}
|
||||
<div className={`p-2 border-t ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`}>
|
||||
<Button
|
||||
icon={<UserAddOutlined />}
|
||||
type="text"
|
||||
onClick={handleInviteProjectMemberDrawer}
|
||||
className={`
|
||||
w-full text-left justify-start
|
||||
${isDarkMode
|
||||
? 'text-blue-400 hover:bg-gray-700'
|
||||
: 'text-blue-600 hover:bg-blue-50'
|
||||
}
|
||||
`}
|
||||
style={{ fontSize: '12px' }}
|
||||
>
|
||||
Invite team member
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssigneeDropdownContent;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -395,7 +395,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"
|
||||
@@ -647,7 +647,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"
|
||||
@@ -775,25 +775,26 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
|
||||
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
|
||||
@@ -1100,10 +1101,11 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
|
||||
type="checkbox"
|
||||
checked={showArchived}
|
||||
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-gray-300 bg-white focus:ring-offset-white'
|
||||
}`}
|
||||
className={`w-3.5 h-3.5 text-blue-600 rounded focus:ring-blue-500 transition-colors duration-150 ${
|
||||
isDarkMode
|
||||
? 'border-[#303030] bg-[#141414] focus:ring-offset-gray-800'
|
||||
: 'border-gray-300 bg-white focus:ring-offset-white'
|
||||
}`}
|
||||
/>
|
||||
<span className={`text-xs ${themeClasses.optionText}`}>
|
||||
Show archived
|
||||
|
||||
@@ -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 }) => (
|
||||
<div
|
||||
className={`
|
||||
w-5 h-5 rounded-full border border-dashed flex items-center justify-center
|
||||
transition-colors duration-200 animate-pulse
|
||||
${isDarkMode
|
||||
? 'border-gray-600 bg-gray-800 text-gray-400'
|
||||
: 'border-gray-300 bg-gray-100 text-gray-600'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<PlusOutlined className="text-xs" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const LazyAssigneeSelectorWrapper: React.FC<LazyAssigneeSelectorProps> = ({
|
||||
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 (
|
||||
<button
|
||||
onClick={handleInteraction}
|
||||
onMouseEnter={handleInteraction} // Preload on hover for better UX
|
||||
className={`
|
||||
w-5 h-5 rounded-full border border-dashed flex items-center justify-center
|
||||
transition-colors duration-200
|
||||
${isDarkMode
|
||||
? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400'
|
||||
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600'
|
||||
}
|
||||
`}
|
||||
title="Add assignee"
|
||||
>
|
||||
<PlusOutlined className="text-xs" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Once loaded, show the full component
|
||||
return (
|
||||
<Suspense fallback={<LoadingPlaceholder isDarkMode={isDarkMode} />}>
|
||||
<LazyAssigneeSelector
|
||||
task={task}
|
||||
groupId={groupId}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export default LazyAssigneeSelectorWrapper;
|
||||
@@ -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
|
||||
}) => (
|
||||
<div
|
||||
className={`
|
||||
flex items-center gap-1 px-2 py-1 text-xs rounded border cursor-pointer
|
||||
transition-colors duration-200 animate-pulse min-w-[80px]
|
||||
${isDarkMode
|
||||
? 'border-gray-600 bg-gray-800 text-gray-400'
|
||||
: 'border-gray-300 bg-gray-100 text-gray-600'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<CalendarOutlined className="text-xs" />
|
||||
<span>{value ? formatDate(value) : (placeholder || 'Select date')}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const LazyDatePickerWrapper: React.FC<LazyDatePickerProps> = ({
|
||||
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 (
|
||||
<div
|
||||
onClick={handleInteraction}
|
||||
onMouseEnter={handleInteraction} // Preload on hover
|
||||
className={`
|
||||
flex items-center gap-1 px-2 py-1 text-xs rounded border cursor-pointer
|
||||
transition-colors duration-200 min-w-[80px] ${className}
|
||||
${isDarkMode
|
||||
? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400'
|
||||
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600'
|
||||
}
|
||||
`}
|
||||
title="Select date"
|
||||
>
|
||||
<CalendarOutlined className="text-xs" />
|
||||
<span>{value ? formatDate(value) : placeholder}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Once loaded, show the full DatePicker
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<DateLoadingPlaceholder
|
||||
isDarkMode={isDarkMode}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<LazyDatePicker
|
||||
value={value ? new Date(value) : null}
|
||||
onChange={(date) => onChange?.(date ? date.toISOString() : null)}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
size="small"
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export default LazyDatePickerWrapper;
|
||||
@@ -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<PerformanceAnalysisProps> = ({ projectId }) => {
|
||||
const [isMonitoring, setIsMonitoring] = useState(false);
|
||||
const [metrics, setMetrics] = useState<any>({});
|
||||
const [report, setReport] = useState<string>('');
|
||||
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 code style={{ fontSize: '12px' }}>
|
||||
{text}
|
||||
</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 style={{ color, fontWeight: 500 }}>{text}</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 style={{ color, fontWeight: 500 }}>{text}</Text>;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Performance Analysis"
|
||||
style={{ marginBottom: 16 }}
|
||||
extra={
|
||||
<Space>
|
||||
{!isMonitoring ? (
|
||||
<Button type="primary" onClick={startMonitoring}>
|
||||
Start Monitoring
|
||||
</Button>
|
||||
) : (
|
||||
<Button danger onClick={handleStopMonitoring}>
|
||||
Stop Monitoring
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={clearMetrics}>Clear</Button>
|
||||
{report && (
|
||||
<Button onClick={downloadReport}>Download Report</Button>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{isMonitoring && (
|
||||
<Alert
|
||||
message="Performance monitoring is active"
|
||||
description="Collecting metrics for component renders, Redux operations, memory usage, and frame rate."
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{Object.keys(metrics).length > 0 && (
|
||||
<>
|
||||
<Title level={5}>Performance Metrics</Title>
|
||||
<Table
|
||||
dataSource={tableData}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
size="small"
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Title level={5}>Key Performance Indicators</Title>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16 }}>
|
||||
{metrics.fps && (
|
||||
<Card size="small">
|
||||
<Text>Frame Rate</Text>
|
||||
<div style={{ fontSize: '24px', fontWeight: 'bold', color: getMetricStatus('fps', metrics.fps.average) === 'error' ? '#ff4d4f' : '#52c41a' }}>
|
||||
{metrics.fps.average.toFixed(1)} FPS
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.min((metrics.fps.average / 60) * 100, 100)}
|
||||
size="small"
|
||||
status={getMetricStatus('fps', metrics.fps.average) === 'error' ? 'exception' : 'active'}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{metrics['memory-used'] && metrics['memory-limit'] && (
|
||||
<Card size="small">
|
||||
<Text>Memory Usage</Text>
|
||||
<div style={{ fontSize: '24px', fontWeight: 'bold' }}>
|
||||
{((metrics['memory-used'].average / metrics['memory-limit'].average) * 100).toFixed(1)}%
|
||||
</div>
|
||||
<Progress
|
||||
percent={(metrics['memory-used'].average / metrics['memory-limit'].average) * 100}
|
||||
size="small"
|
||||
status={(metrics['memory-used'].average / metrics['memory-limit'].average) * 100 > 80 ? 'exception' : 'active'}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{metrics['layout-thrashing-count'] && (
|
||||
<Card size="small">
|
||||
<Text>Layout Thrashing</Text>
|
||||
<div style={{ fontSize: '24px', fontWeight: 'bold', color: metrics['layout-thrashing-count'].count > 10 ? '#ff4d4f' : '#52c41a' }}>
|
||||
{metrics['layout-thrashing-count'].count}
|
||||
</div>
|
||||
<Text type="secondary">Detected instances</Text>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{metrics['long-task-duration'] && (
|
||||
<Card size="small">
|
||||
<Text>Long Tasks</Text>
|
||||
<div style={{ fontSize: '24px', fontWeight: 'bold', color: metrics['long-task-duration'].count > 0 ? '#ff4d4f' : '#52c41a' }}>
|
||||
{metrics['long-task-duration'].count}
|
||||
</div>
|
||||
<Text type="secondary">Tasks > 50ms</Text>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<>
|
||||
<Divider />
|
||||
<Title level={5}>Performance Report</Title>
|
||||
<pre style={{
|
||||
backgroundColor: '#f5f5f5',
|
||||
padding: 16,
|
||||
borderRadius: 4,
|
||||
fontSize: '12px',
|
||||
maxHeight: '300px',
|
||||
overflow: 'auto'
|
||||
}}>
|
||||
{report}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PerformanceAnalysis;
|
||||
@@ -487,13 +487,40 @@ const TaskGroup: React.FC<TaskGroupProps> = 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 {
|
||||
|
||||
@@ -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<TaskListBoardProps> = ({ 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<TaskListBoardProps> = ({ 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<string, Task> = {};
|
||||
// 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<TaskListBoardProps> = ({ 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<TaskListBoardProps> = ({ 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<TaskListBoardProps> = ({ 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<TaskListBoardProps> = ({ 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<TaskListBoardProps> = ({ 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<TaskListBoardProps> = ({ projectId, className = ''
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`task-list-board ${className}`} ref={containerRef}>
|
||||
<div className={`task-list-board ${className} ${themeClass}`} ref={containerRef}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
@@ -392,73 +436,89 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
<ImprovedTaskFilters position="list" />
|
||||
</div>
|
||||
|
||||
{/* Virtualized Task Groups Container */}
|
||||
<div className="task-groups-container">
|
||||
{loading ? (
|
||||
<Card>
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
</Card>
|
||||
) : taskGroups.length === 0 ? (
|
||||
<Card>
|
||||
<Empty
|
||||
description={
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: 500, marginBottom: '4px' }}>
|
||||
No task groups available
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--task-text-secondary, #595959)' }}>
|
||||
Create tasks to see them organized in groups
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="virtualized-task-groups">
|
||||
{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' && (
|
||||
<PerformanceAnalysis projectId={projectId} />
|
||||
)} */}
|
||||
|
||||
return (
|
||||
<VirtualizedTaskList
|
||||
key={group.id}
|
||||
group={group}
|
||||
projectId={projectId}
|
||||
currentGrouping={
|
||||
(currentGrouping as 'status' | 'priority' | 'phase') || 'status'
|
||||
}
|
||||
selectedTaskIds={selectedTaskIds}
|
||||
onSelectTask={handleSelectTask}
|
||||
onToggleSubtasks={handleToggleSubtasks}
|
||||
height={groupHeight}
|
||||
width={1200}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* Fixed Height Task Groups Container - Asana Style */}
|
||||
<div className="task-groups-container-fixed">
|
||||
<div className="task-groups-scrollable">
|
||||
{loading ? (
|
||||
<div className="loading-container">
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
</div>
|
||||
) : taskGroups.length === 0 ? (
|
||||
<div className="empty-container">
|
||||
<Empty
|
||||
description={
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: 500, marginBottom: '4px' }}>
|
||||
No task groups available
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--task-text-secondary, #595959)' }}>
|
||||
Create tasks to see them organized in groups
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="virtualized-task-groups">
|
||||
{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 (
|
||||
<VirtualizedTaskList
|
||||
key={group.id}
|
||||
group={group}
|
||||
projectId={projectId}
|
||||
currentGrouping={
|
||||
(currentGrouping as 'status' | 'priority' | 'phase') || 'status'
|
||||
}
|
||||
selectedTaskIds={selectedTaskIds}
|
||||
onSelectTask={handleSelectTask}
|
||||
onToggleSubtasks={handleToggleSubtasks}
|
||||
height={groupHeight}
|
||||
width={1200}
|
||||
tasksById={tasksById}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DragOverlay
|
||||
adjustScale={false}
|
||||
dropAnimation={null}
|
||||
dropAnimation={{
|
||||
duration: 200,
|
||||
easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)',
|
||||
}}
|
||||
style={{
|
||||
cursor: 'grabbing',
|
||||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
{dragOverlayContent}
|
||||
@@ -466,16 +526,87 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
</DndContext>
|
||||
|
||||
<style>{`
|
||||
.task-groups-container {
|
||||
max-height: calc(100vh - 300px);
|
||||
overflow-y: auto;
|
||||
overflow-x: visible;
|
||||
padding: 8px 8px 8px 0;
|
||||
border-radius: 8px;
|
||||
/* Fixed height container - Asana style */
|
||||
.task-groups-container-fixed {
|
||||
height: calc(100vh - 200px); /* Fixed height, adjust based on your header height */
|
||||
min-height: 400px;
|
||||
max-height: calc(100vh - 120px);
|
||||
position: relative;
|
||||
border: 1px solid var(--task-border-primary, #e8e8e8);
|
||||
border-radius: 8px;
|
||||
background: var(--task-bg-primary, white);
|
||||
overflow: hidden;
|
||||
/* GPU acceleration for smooth scrolling */
|
||||
transform: translateZ(0);
|
||||
will-change: scroll-position;
|
||||
/* Responsive adjustments */
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Responsive height adjustments */
|
||||
@media (max-height: 800px) {
|
||||
.task-groups-container-fixed {
|
||||
height: calc(100vh - 160px);
|
||||
min-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 600px) {
|
||||
.task-groups-container-fixed {
|
||||
height: calc(100vh - 120px);
|
||||
min-height: 250px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-height: 1200px) {
|
||||
.task-groups-container-fixed {
|
||||
height: calc(100vh - 240px);
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
.task-groups-scrollable {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 8px 8px 8px 0;
|
||||
/* Smooth scrolling */
|
||||
scroll-behavior: smooth;
|
||||
/* Custom scrollbar styling */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--task-border-tertiary, #d9d9d9) transparent;
|
||||
/* Performance optimizations */
|
||||
contain: layout style paint;
|
||||
transform: translateZ(0);
|
||||
will-change: scroll-position;
|
||||
}
|
||||
|
||||
.task-groups-scrollable::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.task-groups-scrollable::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.task-groups-scrollable::-webkit-scrollbar-thumb {
|
||||
background-color: var(--task-border-tertiary, #d9d9d9);
|
||||
border-radius: 3px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.task-groups-scrollable::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--task-border-primary, #e8e8e8);
|
||||
}
|
||||
|
||||
/* Loading and empty state containers */
|
||||
.loading-container,
|
||||
.empty-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.virtualized-task-groups {
|
||||
@@ -506,7 +637,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
|
||||
.task-group-header-row {
|
||||
display: inline-flex;
|
||||
height: auto;
|
||||
height: inherit;
|
||||
max-height: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -568,37 +699,76 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Add task row styles */
|
||||
/* Add task row styles - Fixed width responsive design */
|
||||
.task-group-add-task {
|
||||
background: var(--task-bg-primary, white);
|
||||
border-top: 1px solid var(--task-border-secondary, #f0f0f0);
|
||||
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 {
|
||||
display: flex;
|
||||
background: var(--task-bg-secondary, #f5f5f5);
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 11;
|
||||
border-right: 2px solid var(--task-border-primary, #e8e8e8);
|
||||
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
/* Background will be set inline to match theme */
|
||||
}
|
||||
|
||||
/* Ensure task rows have proper overflow handling */
|
||||
.task-row-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.task-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.task-table-scrollable-columns {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.task-table-cell {
|
||||
@@ -641,6 +811,43 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Simplified drag overlay styles */
|
||||
.drag-overlay-simplified {
|
||||
position: relative;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
transform: translateZ(0);
|
||||
will-change: transform;
|
||||
user-select: none;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
animation: dragOverlayEntrance 0.15s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
@keyframes dragOverlayEntrance {
|
||||
0% {
|
||||
transform: scale(0.95) translateZ(0);
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.02) translateZ(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.drag-overlay-simplified:hover {
|
||||
/* Disable hover effects during drag */
|
||||
background-color: inherit !important;
|
||||
}
|
||||
|
||||
/* Smooth drag handle animation */
|
||||
.drag-handle-icon {
|
||||
transition: transform 0.1s ease-out;
|
||||
}
|
||||
|
||||
.task-title-drag {
|
||||
transition: color 0.1s ease-out;
|
||||
}
|
||||
|
||||
/* Ensure drag overlay follows cursor properly */
|
||||
[data-dnd-context] {
|
||||
position: relative;
|
||||
@@ -683,8 +890,10 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
--task-drag-over-border: #40a9ff;
|
||||
}
|
||||
|
||||
.dark .task-groups-container,
|
||||
[data-theme="dark"] .task-groups-container {
|
||||
.dark .task-groups-container-fixed,
|
||||
[data-theme="dark"] .task-groups-container-fixed,
|
||||
.dark .task-groups-scrollable,
|
||||
[data-theme="dark"] .task-groups-scrollable {
|
||||
--task-bg-primary: #1f1f1f;
|
||||
--task-bg-secondary: #141414;
|
||||
--task-bg-tertiary: #262626;
|
||||
@@ -702,6 +911,17 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
--task-drag-over-border: #40a9ff;
|
||||
}
|
||||
|
||||
/* Dark mode scrollbar */
|
||||
.dark .task-groups-scrollable::-webkit-scrollbar-thumb,
|
||||
[data-theme="dark"] .task-groups-scrollable::-webkit-scrollbar-thumb {
|
||||
background-color: #505050;
|
||||
}
|
||||
|
||||
.dark .task-groups-scrollable::-webkit-scrollbar-thumb:hover,
|
||||
[data-theme="dark"] .task-groups-scrollable::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #606060;
|
||||
}
|
||||
|
||||
/* Dark mode empty state */
|
||||
.dark .empty-tasks-container .ant-empty-description,
|
||||
[data-theme="dark"] .empty-tasks-container .ant-empty-description {
|
||||
@@ -720,6 +940,20 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
|
||||
.task-row {
|
||||
contain: layout style;
|
||||
/* GPU acceleration for smooth scrolling */
|
||||
transform: translateZ(0);
|
||||
will-change: transform;
|
||||
transition: opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1), filter 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.task-row.is-dragging {
|
||||
transition: opacity 0.15s cubic-bezier(0.4, 0, 0.2, 1), filter 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Smooth hover effects */
|
||||
.task-row:hover:not(.is-dragging) {
|
||||
transform: translateZ(0) translateY(-1px);
|
||||
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Reduce layout thrashing */
|
||||
@@ -727,6 +961,33 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
contain: layout;
|
||||
}
|
||||
|
||||
/* Optimize progressive component loading */
|
||||
.progressive-component-placeholder {
|
||||
contain: layout style paint;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
/* Shimmer animation optimization */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200px 0;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
background-position: calc(200px + 100%) 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Optimize shimmer performance */
|
||||
.shimmer-element {
|
||||
background: linear-gradient(90deg, transparent 25%, rgba(255,255,255,0.2) 50%, transparent 75%);
|
||||
background-size: 200px 100%;
|
||||
animation: shimmer 1.5s infinite linear;
|
||||
transform: translateZ(0);
|
||||
will-change: background-position;
|
||||
}
|
||||
|
||||
/* React Window specific optimizations */
|
||||
.react-window-list {
|
||||
outline: none;
|
||||
|
||||
@@ -2,42 +2,56 @@
|
||||
|
||||
.task-row-optimized {
|
||||
contain: layout style;
|
||||
will-change: transform;
|
||||
transform: translateZ(0); /* Force GPU acceleration */
|
||||
/* Remove conflicting will-change and transform */
|
||||
transition: background-color 0.15s ease-out, border-color 0.15s ease-out;
|
||||
background: var(--task-bg-primary, #fff);
|
||||
color: var(--task-text-primary, #262626);
|
||||
border-color: var(--task-border-primary, #e8e8e8);
|
||||
}
|
||||
|
||||
/* HOVER STATE FIX: Ensure hover states reset properly */
|
||||
.task-row-optimized:not(:hover) {
|
||||
/* Force reset of any stuck hover states */
|
||||
contain: layout style;
|
||||
/* SIMPLIFIED HOVER STATE: Remove complex containment and transforms */
|
||||
.task-row-optimized:hover {
|
||||
/* Remove transform that was causing GPU conflicts */
|
||||
/* Remove complex containment rules */
|
||||
}
|
||||
|
||||
.task-row-optimized:not(:hover) .task-open-button {
|
||||
opacity: 0 !important;
|
||||
visibility: hidden;
|
||||
/* OPTIMIZED HOVER BUTTONS: Use opacity only, no visibility changes */
|
||||
.task-open-button {
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease-out;
|
||||
/* Remove will-change to prevent GPU conflicts */
|
||||
}
|
||||
|
||||
.task-row-optimized:not(:hover) .expand-icon-container.hover-only {
|
||||
opacity: 0 !important;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* Force visibility on hover */
|
||||
.task-row-optimized:hover .task-open-button {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* OPTIMIZED EXPAND ICON: Simplified hover behavior */
|
||||
.expand-icon-container.hover-only {
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease-out;
|
||||
}
|
||||
|
||||
.task-row-optimized:hover .expand-icon-container.hover-only {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.task-row-optimized:hover {
|
||||
contain: layout style;
|
||||
/* Don't use paint containment on hover as it can interfere with hover effects */
|
||||
/* Force repaint to ensure hover states update properly */
|
||||
transform: translateZ(0.001px);
|
||||
/* REMOVE COMPLEX CONTAINMENT RULES that were causing layout thrashing */
|
||||
.task-row-optimized:not(:hover) {
|
||||
/* Remove forced containment and transforms */
|
||||
}
|
||||
|
||||
.task-row-optimized:not(:hover) .task-open-button {
|
||||
opacity: 0;
|
||||
/* Remove !important and visibility changes */
|
||||
}
|
||||
|
||||
.task-row-optimized:not(:hover) .expand-icon-container.hover-only {
|
||||
opacity: 0;
|
||||
/* Remove !important and visibility changes */
|
||||
}
|
||||
|
||||
/* DRAG STATE: Simplified */
|
||||
.task-row-optimized.task-row-dragging {
|
||||
contain: layout;
|
||||
will-change: transform;
|
||||
@@ -61,23 +75,21 @@
|
||||
|
||||
.task-row-optimized.fully-loaded {
|
||||
contain: layout style;
|
||||
will-change: transform;
|
||||
/* Remove will-change: transform to prevent conflicts */
|
||||
}
|
||||
|
||||
/* REAL-TIME UPDATES: Prevent flickering during socket updates */
|
||||
/* REAL-TIME UPDATES: Simplified stable content */
|
||||
.task-row-optimized.stable-content {
|
||||
contain: layout style;
|
||||
will-change: transform;
|
||||
/* Prevent content from disappearing during real-time updates */
|
||||
/* Remove will-change to prevent GPU conflicts */
|
||||
min-height: 40px;
|
||||
/* Keep transitions for hover states but disable for layout changes */
|
||||
transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out;
|
||||
/* Simplified transitions */
|
||||
transition: background-color 0.15s ease-out, border-color 0.15s ease-out;
|
||||
}
|
||||
|
||||
.task-row-optimized.stable-content * {
|
||||
contain: layout;
|
||||
will-change: auto;
|
||||
/* Don't force opacity - let hover states work naturally */
|
||||
/* Remove will-change to prevent conflicts */
|
||||
}
|
||||
|
||||
/* Optimize initial render performance */
|
||||
@@ -184,11 +196,14 @@
|
||||
/* Dark mode optimizations */
|
||||
.dark .task-row-optimized {
|
||||
contain: layout style;
|
||||
background: var(--task-bg-primary, #1f1f1f);
|
||||
color: var(--task-text-primary, #fff);
|
||||
border-color: var(--task-border-primary, #303030);
|
||||
}
|
||||
|
||||
.dark .task-row-optimized:hover {
|
||||
contain: layout style;
|
||||
/* Don't use paint containment on hover as it can interfere with hover effects */
|
||||
/* Remove complex containment rules */
|
||||
}
|
||||
|
||||
/* Animation performance */
|
||||
@@ -220,21 +235,20 @@
|
||||
contain: strict;
|
||||
}
|
||||
|
||||
/* PERFORMANCE OPTIMIZATION: GPU acceleration for better scrolling */
|
||||
/* PERFORMANCE OPTIMIZATION: Simplified GPU acceleration */
|
||||
.task-row-optimized {
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
transform-style: preserve-3d;
|
||||
-webkit-transform-style: preserve-3d;
|
||||
/* Remove transform-style to prevent conflicts */
|
||||
}
|
||||
|
||||
/* Optimize rendering layers */
|
||||
.task-row-optimized.initial-load {
|
||||
transform: translate3d(0, 0, 0);
|
||||
/* Remove transform to prevent conflicts */
|
||||
}
|
||||
|
||||
.task-row-optimized.fully-loaded {
|
||||
transform: translate3d(0, 0, 0);
|
||||
/* Remove transform to prevent conflicts */
|
||||
}
|
||||
|
||||
/* Performance debugging */
|
||||
@@ -282,29 +296,19 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Task row hover effects for better performance */
|
||||
/* SIMPLIFIED TASK ROW HOVER EFFECTS */
|
||||
.task-cell-container:hover .task-open-button {
|
||||
opacity: 1 !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.task-cell-container:not(:hover) .task-open-button {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
.task-open-button {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
/* Force hardware acceleration for smoother transitions */
|
||||
transform: translateZ(0);
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
/* Expand icon smart visibility */
|
||||
/* Expand icon smart visibility - simplified */
|
||||
.expand-icon-container {
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
/* Force hardware acceleration for smoother transitions */
|
||||
transform: translateZ(0);
|
||||
will-change: opacity;
|
||||
transition: opacity 0.15s ease-out;
|
||||
/* Remove transform and will-change to prevent conflicts */
|
||||
}
|
||||
|
||||
/* Always show expand icon if task has subtasks */
|
||||
@@ -314,7 +318,7 @@
|
||||
|
||||
.expand-icon-container.has-subtasks .expand-toggle-btn {
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
transition: opacity 0.15s ease-out;
|
||||
}
|
||||
|
||||
.task-cell-container:hover .expand-icon-container.has-subtasks .expand-toggle-btn {
|
||||
@@ -340,7 +344,7 @@
|
||||
|
||||
.expand-icon-container.hover-only .expand-toggle-btn {
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
transition: opacity 0.15s ease-out;
|
||||
}
|
||||
|
||||
.task-cell-container:hover .expand-icon-container.hover-only .expand-toggle-btn {
|
||||
@@ -394,7 +398,7 @@
|
||||
|
||||
/* Task indicators hover effects */
|
||||
.task-indicators .indicator-badge {
|
||||
transition: all 0.2s ease-in-out;
|
||||
transition: all 0.15s ease-out;
|
||||
}
|
||||
|
||||
.task-indicators .indicator-badge:hover {
|
||||
@@ -407,7 +411,7 @@
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* HOVER STATE DEBUGGING: Force hover state reset on mouse leave */
|
||||
/* SIMPLIFIED HOVER STATE MANAGEMENT */
|
||||
.task-row-optimized {
|
||||
/* Ensure proper hover state management */
|
||||
pointer-events: auto;
|
||||
@@ -418,11 +422,71 @@
|
||||
pointer-events: inherit;
|
||||
}
|
||||
|
||||
/* Force browser to recalculate hover states */
|
||||
/* Remove complex hover state forcing */
|
||||
@supports (contain: layout) {
|
||||
.task-row-optimized:not(:hover) {
|
||||
contain: layout;
|
||||
/* Force style recalculation */
|
||||
animation: none;
|
||||
/* Remove animation forcing */
|
||||
}
|
||||
}
|
||||
|
||||
.task-row-optimized.selected {
|
||||
background: var(--task-selected-bg, #e6f7ff) !important;
|
||||
border-left-color: var(--task-selected-border, #1890ff);
|
||||
}
|
||||
|
||||
.task-row-optimized.drag-overlay {
|
||||
background: var(--task-bg-primary, #fff);
|
||||
color: var(--task-text-primary, #262626);
|
||||
border: 1px solid var(--task-border-primary, #e8e8e8);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 6px 16px rgba(0,0,0,0.12);
|
||||
z-index: 1000;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
.dark .task-row-optimized.selected,
|
||||
[data-theme="dark"] .task-row-optimized.selected {
|
||||
background: var(--task-selected-bg, #1a2332) !important;
|
||||
border-left-color: var(--task-selected-border, #1890ff);
|
||||
}
|
||||
|
||||
.dark .task-row-optimized.drag-overlay,
|
||||
[data-theme="dark"] .task-row-optimized.drag-overlay {
|
||||
background: var(--task-bg-primary, #1f1f1f);
|
||||
color: var(--task-text-primary, #fff);
|
||||
border: 1px solid var(--task-border-primary, #303030);
|
||||
box-shadow: 0 6px 16px rgba(0,0,0,0.32);
|
||||
}
|
||||
|
||||
.task-row-optimized.is-dragging {
|
||||
border: 3px solid #1890ff !important;
|
||||
box-shadow: 0 0 24px 4px #1890ff, 0 6px 16px rgba(0,0,0,0.18);
|
||||
opacity: 0.85 !important;
|
||||
background: var(--task-bg-primary, #fff) !important;
|
||||
z-index: 2000 !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.dark .task-row-optimized.is-dragging,
|
||||
[data-theme="dark"] .task-row-optimized.is-dragging {
|
||||
border: 3px solid #40a9ff !important;
|
||||
box-shadow: 0 0 24px 4px #40a9ff, 0 6px 16px rgba(0,0,0,0.38);
|
||||
background: var(--task-bg-primary, #1f1f1f) !important;
|
||||
}
|
||||
|
||||
.task-row-optimized.drag-overlay {
|
||||
border: 3px dashed #ff4d4f !important;
|
||||
box-shadow: 0 0 32px 8px #ff4d4f, 0 6px 16px rgba(0,0,0,0.22);
|
||||
background: #fffbe6 !important;
|
||||
opacity: 0.95 !important;
|
||||
z-index: 3000 !important;
|
||||
}
|
||||
|
||||
.dark .task-row-optimized.drag-overlay,
|
||||
[data-theme="dark"] .task-row-optimized.drag-overlay {
|
||||
border: 3px dashed #ff7875 !important;
|
||||
box-shadow: 0 0 32px 8px #ff7875, 0 6px 16px rgba(0,0,0,0.42);
|
||||
background: #2a2a2a !important;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { dayjs } from './antd-imports';
|
||||
// Performance constants
|
||||
export const PERFORMANCE_CONSTANTS = {
|
||||
CACHE_CLEAR_INTERVAL: 300000, // 5 minutes
|
||||
VIRTUALIZATION_THRESHOLD: 50,
|
||||
VIRTUALIZATION_THRESHOLD: 25, // Updated to match main virtualization threshold
|
||||
DRAG_THROTTLE_MS: 50,
|
||||
RENDER_TIMEOUT_MS: 16, // 60fps target
|
||||
MAX_CACHE_SIZE: 1000,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useCallback, useState, useRef, useEffect } from 'react';
|
||||
import React, { useMemo, useCallback, useState, useRef, useEffect, lazy } from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { useSelector } from 'react-redux';
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
import './task-row-optimized.css';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { setSelectedTaskId, setShowTaskDrawer, fetchTask } from '@/features/task-drawer/task-drawer.slice';
|
||||
import useDragCursor from '@/hooks/useDragCursor';
|
||||
|
||||
interface TaskRowProps {
|
||||
task: Task;
|
||||
@@ -68,17 +69,22 @@ const STATUS_COLORS = {
|
||||
done: '#52c41a',
|
||||
} as const;
|
||||
|
||||
// Memoized sub-components for better performance
|
||||
// Memoized sub-components for maximum performance
|
||||
const DragHandle = React.memo<{ isDarkMode: boolean; attributes: any; listeners: any }>(({ isDarkMode, attributes, listeners }) => (
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
icon={<HolderOutlined />}
|
||||
className="opacity-40 hover:opacity-100 cursor-grab active:cursor-grabbing"
|
||||
isDarkMode={isDarkMode}
|
||||
<div
|
||||
className="drag-handle-optimized flex items-center justify-center w-6 h-6 opacity-60 hover:opacity-100"
|
||||
style={{
|
||||
transition: 'opacity 0.1s ease', // Faster transition
|
||||
}}
|
||||
data-dnd-drag-handle="true"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
/>
|
||||
>
|
||||
<HolderOutlined
|
||||
className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}
|
||||
style={{ pointerEvents: 'none' }} // Prevent icon from interfering
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
|
||||
const TaskKey = React.memo<{ taskKey: string; isDarkMode: boolean }>(({ taskKey, isDarkMode }) => (
|
||||
@@ -164,6 +170,192 @@ const TaskReporter = React.memo<{ reporter?: string; isDarkMode: boolean }>(({ r
|
||||
</div>
|
||||
));
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Lightweight placeholder components for better performance
|
||||
const AssigneePlaceholder = React.memo<{ isDarkMode: boolean; memberCount?: number }>(({ isDarkMode, memberCount = 0 }) => (
|
||||
<div className="flex items-center gap-1">
|
||||
{memberCount > 0 ? (
|
||||
<div className="flex -space-x-1">
|
||||
{Array.from({ length: Math.min(memberCount, 3) }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-6 h-6 rounded-full border-2 ${
|
||||
isDarkMode ? 'bg-gray-600 border-gray-700' : 'bg-gray-200 border-gray-300'
|
||||
}`}
|
||||
style={{ zIndex: 3 - i }}
|
||||
/>
|
||||
))}
|
||||
{memberCount > 3 && (
|
||||
<div
|
||||
className={`w-6 h-6 rounded-full border-2 flex items-center justify-center text-xs font-medium ${
|
||||
isDarkMode
|
||||
? 'bg-gray-600 border-gray-700 text-gray-300'
|
||||
: 'bg-gray-200 border-gray-300 text-gray-600'
|
||||
}`}
|
||||
style={{ zIndex: 0 }}
|
||||
>
|
||||
+{memberCount - 3}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={`w-6 h-6 rounded-full ${isDarkMode ? 'bg-gray-600' : 'bg-gray-200'}`} />
|
||||
)}
|
||||
<div className={`w-4 h-4 rounded ${isDarkMode ? 'bg-gray-600' : 'bg-gray-200'}`} />
|
||||
</div>
|
||||
));
|
||||
|
||||
const StatusPlaceholder = React.memo<{ status?: string; isDarkMode: boolean }>(({ status, isDarkMode }) => (
|
||||
<div
|
||||
className={`px-2 py-1 text-xs rounded-full min-w-16 h-6 flex items-center justify-center ${
|
||||
isDarkMode ? 'bg-gray-600 text-gray-300' : 'bg-gray-200 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{status || '...'}
|
||||
</div>
|
||||
));
|
||||
|
||||
const PriorityPlaceholder = React.memo<{ priority?: string; isDarkMode: boolean }>(({ priority, isDarkMode }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${isDarkMode ? 'bg-gray-500' : 'bg-gray-300'}`} />
|
||||
<span className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
{priority || '...'}
|
||||
</span>
|
||||
</div>
|
||||
));
|
||||
|
||||
const PhasePlaceholder = React.memo<{ phase?: string; isDarkMode: boolean }>(({ phase, isDarkMode }) => (
|
||||
<div
|
||||
className={`px-2 py-1 text-xs rounded min-w-16 h-6 flex items-center justify-center ${
|
||||
isDarkMode ? 'bg-gray-600 text-gray-300' : 'bg-gray-200 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{phase || '...'}
|
||||
</div>
|
||||
));
|
||||
|
||||
const LabelsPlaceholder = React.memo<{ labelCount?: number; isDarkMode: boolean }>(({ labelCount = 0, isDarkMode }) => (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{labelCount > 0 ? (
|
||||
Array.from({ length: Math.min(labelCount, 3) }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`px-2 py-0.5 text-xs rounded-full ${
|
||||
isDarkMode ? 'bg-gray-600 text-gray-300' : 'bg-gray-200 text-gray-600'
|
||||
}`}
|
||||
style={{
|
||||
width: `${40 + Math.random() * 30}px`,
|
||||
height: '20px'
|
||||
}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className={`w-4 h-4 rounded ${isDarkMode ? 'bg-gray-600' : 'bg-gray-200'}`} />
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Simplified placeholders without animations under memory pressure
|
||||
const SimplePlaceholder = React.memo<{ width: number; height: number; isDarkMode: boolean }>(({ width, height, isDarkMode }) => (
|
||||
<div
|
||||
className={`rounded ${isDarkMode ? 'bg-gray-600' : 'bg-gray-200'}`}
|
||||
style={{ width, height }}
|
||||
/>
|
||||
));
|
||||
|
||||
// Lazy-loaded components with Suspense fallbacks
|
||||
const LazyAssigneeSelector = React.lazy(() =>
|
||||
import('./lazy-assignee-selector').then(module => ({ default: module.default }))
|
||||
);
|
||||
|
||||
const LazyTaskStatusDropdown = React.lazy(() =>
|
||||
import('./task-status-dropdown').then(module => ({ default: module.default }))
|
||||
);
|
||||
|
||||
const LazyTaskPriorityDropdown = React.lazy(() =>
|
||||
import('./task-priority-dropdown').then(module => ({ default: module.default }))
|
||||
);
|
||||
|
||||
const LazyTaskPhaseDropdown = React.lazy(() =>
|
||||
import('./task-phase-dropdown').then(module => ({ default: module.default }))
|
||||
);
|
||||
|
||||
const LazyLabelsSelector = React.lazy(() =>
|
||||
import('@/components/LabelsSelector').then(module => ({ default: module.default }))
|
||||
);
|
||||
|
||||
// Enhanced component wrapper with progressive loading
|
||||
const ProgressiveComponent = React.memo<{
|
||||
isLoaded: boolean;
|
||||
placeholder: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
}>(({ isLoaded, placeholder, children, fallback }) => {
|
||||
if (!isLoaded) {
|
||||
return <>{placeholder}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={fallback || placeholder}>
|
||||
{children}
|
||||
</React.Suspense>
|
||||
);
|
||||
});
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Frame-rate aware rendering hooks
|
||||
const useFrameRateOptimizedLoading = (index?: number) => {
|
||||
const [canRender, setCanRender] = useState((index !== undefined && index < 3) || false);
|
||||
const renderRequestRef = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (index === undefined || canRender) return;
|
||||
|
||||
// Use requestIdleCallback for non-critical rendering
|
||||
const scheduleRender = () => {
|
||||
if ('requestIdleCallback' in window) {
|
||||
(window as any).requestIdleCallback(() => {
|
||||
setCanRender(true);
|
||||
}, { timeout: 100 });
|
||||
} else {
|
||||
// Fallback for browsers without requestIdleCallback
|
||||
setTimeout(() => setCanRender(true), 50);
|
||||
}
|
||||
};
|
||||
|
||||
renderRequestRef.current = requestAnimationFrame(scheduleRender);
|
||||
|
||||
return () => {
|
||||
if (renderRequestRef.current) {
|
||||
cancelAnimationFrame(renderRequestRef.current);
|
||||
}
|
||||
};
|
||||
}, [index, canRender]);
|
||||
|
||||
return canRender;
|
||||
};
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Memory pressure detection
|
||||
const useMemoryPressure = () => {
|
||||
const [isUnderPressure, setIsUnderPressure] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!('memory' in performance)) return;
|
||||
|
||||
const checkMemory = () => {
|
||||
const memory = (performance as any).memory;
|
||||
if (memory) {
|
||||
const usedRatio = memory.usedJSHeapSize / memory.jsHeapSizeLimit;
|
||||
setIsUnderPressure(usedRatio > 0.6); // Conservative threshold
|
||||
}
|
||||
};
|
||||
|
||||
checkMemory();
|
||||
const interval = setInterval(checkMemory, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return isUnderPressure;
|
||||
};
|
||||
|
||||
const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
task,
|
||||
projectId,
|
||||
@@ -178,12 +370,18 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
fixedColumns,
|
||||
scrollableColumns,
|
||||
}) => {
|
||||
// PERFORMANCE OPTIMIZATION: Implement progressive loading
|
||||
// Immediately load first few tasks to prevent blank content for visible items
|
||||
const [isFullyLoaded, setIsFullyLoaded] = useState((index !== undefined && index < 10) || false);
|
||||
// PERFORMANCE OPTIMIZATION: Frame-rate aware loading
|
||||
const canRenderComplex = useFrameRateOptimizedLoading(index);
|
||||
const isMemoryPressured = useMemoryPressure();
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: More aggressive performance - only load first 2 immediately
|
||||
const [isFullyLoaded, setIsFullyLoaded] = useState((index !== undefined && index < 2) || false);
|
||||
const [isIntersecting, setIsIntersecting] = useState(false);
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
const hasBeenFullyLoadedOnce = useRef((index !== undefined && index < 10) || false); // Track if we've ever been fully loaded
|
||||
const hasBeenFullyLoadedOnce = useRef((index !== undefined && index < 2) || false);
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Conditional component loading based on memory pressure
|
||||
const [shouldShowComponents, setShouldShowComponents] = useState((index !== undefined && index < 2) || false);
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Only connect to socket after component is visible
|
||||
const { socket, connected } = useSocket();
|
||||
@@ -210,19 +408,20 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
const [entry] = entries;
|
||||
if (entry.isIntersecting && !isIntersecting && !hasBeenFullyLoadedOnce.current) {
|
||||
setIsIntersecting(true);
|
||||
// Delay full loading slightly to prioritize visible content
|
||||
const timeoutId = setTimeout(() => {
|
||||
setIsFullyLoaded(true);
|
||||
hasBeenFullyLoadedOnce.current = true; // Mark as fully loaded once
|
||||
}, 50);
|
||||
// Immediate loading when intersecting - no delay
|
||||
setIsFullyLoaded(true);
|
||||
hasBeenFullyLoadedOnce.current = true; // Mark as fully loaded once
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
// Add a tiny delay for component loading to prevent browser freeze
|
||||
setTimeout(() => {
|
||||
setShouldShowComponents(true);
|
||||
}, 8); // Half frame delay for even more responsive experience
|
||||
}
|
||||
},
|
||||
{
|
||||
root: null,
|
||||
rootMargin: '100px', // Start loading 100px before coming into view
|
||||
threshold: 0.1,
|
||||
rootMargin: '200px', // Increased to load components earlier before they're visible
|
||||
threshold: 0, // Load as soon as any part enters the extended viewport
|
||||
}
|
||||
);
|
||||
|
||||
@@ -235,7 +434,14 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Skip expensive operations during initial render
|
||||
// Once fully loaded, always render full to prevent blanking during real-time updates
|
||||
const shouldRenderFull = isFullyLoaded || hasBeenFullyLoadedOnce.current || isDragOverlay || editTaskName;
|
||||
const shouldRenderFull = (isFullyLoaded && shouldShowComponents) || hasBeenFullyLoadedOnce.current || isDragOverlay || editTaskName;
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Minimal initial render for non-visible tasks
|
||||
// Only render essential columns during initial load to reduce DOM nodes
|
||||
const shouldRenderMinimal = !shouldRenderFull && !isDragOverlay;
|
||||
|
||||
// DRAG OVERLAY: When dragging, show only task name for cleaner experience
|
||||
const shouldRenderDragOverlay = isDragOverlay;
|
||||
|
||||
// REAL-TIME UPDATES: Ensure content stays loaded during socket updates
|
||||
useEffect(() => {
|
||||
@@ -244,7 +450,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
}
|
||||
}, [shouldRenderFull]);
|
||||
|
||||
// Optimized drag and drop setup with better performance
|
||||
// Optimized drag and drop setup with maximum performance
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
@@ -260,8 +466,9 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
groupId,
|
||||
},
|
||||
disabled: isDragOverlay || !shouldRenderFull, // Disable drag until fully loaded
|
||||
// Optimize animation performance
|
||||
animateLayoutChanges: () => false, // Disable layout animations for better performance
|
||||
// PERFORMANCE OPTIMIZATION: Disable all animations for maximum performance
|
||||
animateLayoutChanges: () => false, // Disable layout animations
|
||||
transition: null, // Disable transitions
|
||||
});
|
||||
|
||||
// Get theme from Redux store - memoized selector
|
||||
@@ -270,6 +477,22 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
// Translation hook
|
||||
const { t } = useTranslation('task-management');
|
||||
|
||||
// Optimized task name save handler
|
||||
const handleTaskNameSave = useCallback(() => {
|
||||
const newTaskName = taskName?.trim();
|
||||
if (newTaskName && connected && newTaskName !== task.title) {
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_NAME_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
task_id: task.id,
|
||||
name: newTaskName,
|
||||
parent_task: null,
|
||||
})
|
||||
);
|
||||
}
|
||||
setEditTaskName(false);
|
||||
}, [connected, socket, task.id, task.title, taskName]);
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Only setup click outside detection when editing
|
||||
useEffect(() => {
|
||||
if (!editTaskName || !shouldRenderFull) return;
|
||||
@@ -286,23 +509,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [editTaskName, shouldRenderFull]);
|
||||
|
||||
// Optimized task name save handler
|
||||
const handleTaskNameSave = useCallback(() => {
|
||||
const newTaskName = taskName?.trim();
|
||||
if (newTaskName && connected && newTaskName !== task.title) {
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_NAME_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
task_id: task.id,
|
||||
name: newTaskName,
|
||||
parent_task: null,
|
||||
})
|
||||
);
|
||||
}
|
||||
setEditTaskName(false);
|
||||
}, [connected, socket, task.id, task.title, taskName]);
|
||||
}, [editTaskName, shouldRenderFull, handleTaskNameSave]);
|
||||
|
||||
// Handle adding new subtask
|
||||
const handleAddSubtask = useCallback(() => {
|
||||
@@ -327,19 +534,20 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
setShowAddSubtask(false);
|
||||
}, []);
|
||||
|
||||
// Optimized style calculations with better memoization
|
||||
// Optimized style calculations with maximum performance
|
||||
const dragStyle = useMemo(() => {
|
||||
if (!isDragging && !transform) return {};
|
||||
|
||||
return {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
transition: isDragging ? 'opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1)' : 'none',
|
||||
opacity: isDragging ? 0.3 : 1,
|
||||
zIndex: isDragging ? 1000 : 'auto',
|
||||
// Add GPU acceleration for better performance
|
||||
willChange: isDragging ? 'transform' : 'auto',
|
||||
// PERFORMANCE OPTIMIZATION: Force GPU acceleration
|
||||
willChange: 'transform, opacity',
|
||||
filter: isDragging ? 'blur(0.5px)' : 'none',
|
||||
};
|
||||
}, [transform, transition, isDragging]);
|
||||
}, [transform, isDragging]);
|
||||
|
||||
// Memoized event handlers with better dependency tracking
|
||||
const handleSelectChange = useCallback((checked: boolean) => {
|
||||
@@ -397,7 +605,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
|
||||
// Optimized class name calculations with better memoization
|
||||
const styleClasses = useMemo(() => {
|
||||
const base = 'border-b transition-all duration-200'; // Reduced duration for better performance
|
||||
const base = 'border-b transition-all duration-150'; // Reduced duration for better performance
|
||||
const theme = isDarkMode
|
||||
? 'border-gray-600 hover:bg-gray-800'
|
||||
: 'border-gray-300 hover:bg-gray-50';
|
||||
@@ -411,7 +619,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
|
||||
return {
|
||||
container: `${base} ${theme} ${background} ${selected} ${overlay}`,
|
||||
taskName: `text-sm font-medium flex-1 overflow-hidden text-ellipsis whitespace-nowrap transition-colors duration-200 cursor-pointer ${
|
||||
taskName: `text-sm font-medium flex-1 overflow-hidden text-ellipsis whitespace-nowrap transition-colors duration-150 cursor-pointer ${
|
||||
isDarkMode ? 'text-gray-100 hover:text-blue-400' : 'text-gray-900 hover:text-blue-600'
|
||||
} ${task.progress === 100 ? `line-through ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}` : ''}`,
|
||||
};
|
||||
@@ -423,16 +631,14 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
assignee: createAssigneeAdapter(task),
|
||||
}), [task]);
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Simplified column rendering for initial load
|
||||
const renderColumnSimple = useCallback((col: { key: string; width: number }, isFixed: boolean, index: number, totalColumns: number) => {
|
||||
// Fix border logic: if this is a fixed column, only consider it "last" if there are no scrollable columns
|
||||
// If this is a scrollable column, use the normal logic
|
||||
// PERFORMANCE OPTIMIZATION: Minimal column rendering for initial load
|
||||
const renderMinimalColumn = useCallback((col: { key: string; width: number }, isFixed: boolean, index: number, totalColumns: number) => {
|
||||
const isActuallyLast = isFixed
|
||||
? (index === totalColumns - 1 && (!scrollableColumns || scrollableColumns.length === 0))
|
||||
: (index === totalColumns - 1);
|
||||
const borderClasses = `${isActuallyLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
|
||||
|
||||
// Only render essential columns during initial load
|
||||
// Only render essential columns during minimal load
|
||||
switch (col.key) {
|
||||
case 'drag':
|
||||
return (
|
||||
@@ -464,6 +670,8 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
|
||||
<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">
|
||||
{/* Always reserve space for expand icon */}
|
||||
<div style={{ width: 20, display: 'inline-block' }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<Typography.Text
|
||||
ellipsis={{ tooltip: task.title }}
|
||||
@@ -477,57 +685,21 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'status':
|
||||
return (
|
||||
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
|
||||
<div className={`px-2 py-1 text-xs rounded ${isDarkMode ? 'bg-gray-700 text-gray-300' : 'bg-gray-100 text-gray-600'}`}>
|
||||
{task.status || 'Todo'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'progress':
|
||||
return (
|
||||
<div key={col.key} className={`flex items-center justify-center px-2 ${borderClasses}`} style={{ width: col.width }}>
|
||||
<div className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
{task.progress || 0}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'priority':
|
||||
return (
|
||||
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
|
||||
<div className={`px-2 py-1 text-xs rounded ${isDarkMode ? 'bg-gray-700 text-gray-300' : 'bg-gray-100 text-gray-600'}`}>
|
||||
{task.priority || 'Medium'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'phase':
|
||||
return (
|
||||
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
|
||||
<div className={`px-2 py-1 text-xs rounded ${isDarkMode ? 'bg-gray-700 text-gray-300' : 'bg-gray-100 text-gray-600'}`}>
|
||||
{task.phase || 'No Phase'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
// For non-essential columns, show placeholder during initial load
|
||||
// For non-essential columns, show minimal placeholder
|
||||
return (
|
||||
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
|
||||
<div className={`w-8 h-3 rounded ${isDarkMode ? 'bg-gray-700' : 'bg-gray-200'} animate-pulse`}></div>
|
||||
<div className={`w-6 h-3 rounded ${isDarkMode ? 'bg-gray-700' : 'bg-gray-200'}`}></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}, [isDarkMode, task, isSelected, handleSelectChange, styleClasses]);
|
||||
}, [isDarkMode, task, isSelected, handleSelectChange, styleClasses, scrollableColumns]);
|
||||
|
||||
// Optimized column rendering with better performance
|
||||
const renderColumn = useCallback((col: { key: string; width: number }, isFixed: boolean, index: number, totalColumns: number) => {
|
||||
// Use simplified rendering for initial load
|
||||
if (!shouldRenderFull) {
|
||||
return renderColumnSimple(col, isFixed, index, totalColumns);
|
||||
return renderMinimalColumn(col, isFixed, index, totalColumns);
|
||||
}
|
||||
|
||||
// Full rendering logic (existing code)
|
||||
@@ -741,19 +913,37 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
return (
|
||||
<div key={col.key} className={`flex items-center px-2 ${borderClasses} overflow-visible`} style={{ width: col.width }}>
|
||||
<div className="flex items-center gap-2 overflow-visible">
|
||||
{task.assignee_names && task.assignee_names.length > 0 && (
|
||||
<AvatarGroup
|
||||
members={task.assignee_names}
|
||||
size={24}
|
||||
maxCount={3}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
)}
|
||||
<AssigneeSelector
|
||||
task={adapters.assignee}
|
||||
groupId={groupId}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
<ProgressiveComponent
|
||||
isLoaded={shouldRenderFull}
|
||||
placeholder={
|
||||
<AssigneePlaceholder
|
||||
isDarkMode={isDarkMode}
|
||||
memberCount={task.assignee_names?.length || 0}
|
||||
/>
|
||||
}
|
||||
fallback={
|
||||
<AssigneePlaceholder
|
||||
isDarkMode={isDarkMode}
|
||||
memberCount={task.assignee_names?.length || 0}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2 overflow-visible">
|
||||
{task.assignee_names && task.assignee_names.length > 0 && (
|
||||
<AvatarGroup
|
||||
members={task.assignee_names}
|
||||
size={24}
|
||||
maxCount={3}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
)}
|
||||
<LazyAssigneeSelector
|
||||
task={adapters.assignee}
|
||||
groupId={groupId}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
</div>
|
||||
</ProgressiveComponent>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -762,26 +952,44 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
return (
|
||||
<div key={col.key} className={`max-w-[200px] flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
|
||||
<div className="flex items-center gap-1 flex-wrap h-full w-full overflow-visible relative">
|
||||
{task.labels?.map((label, index) => (
|
||||
label.end && label.names && label.name ? (
|
||||
<CustomNumberLabel
|
||||
key={`${label.id}-${index}`}
|
||||
labelList={label.names}
|
||||
namesString={label.name}
|
||||
<ProgressiveComponent
|
||||
isLoaded={shouldRenderFull}
|
||||
placeholder={
|
||||
<LabelsPlaceholder
|
||||
labelCount={task.labels?.length || 0}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
) : (
|
||||
<CustomColordLabel
|
||||
key={`${label.id}-${index}`}
|
||||
label={label}
|
||||
}
|
||||
fallback={
|
||||
<LabelsPlaceholder
|
||||
labelCount={task.labels?.length || 0}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
<LabelsSelector
|
||||
task={adapters.labels}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<>
|
||||
{task.labels?.map((label, index) => (
|
||||
label.end && label.names && label.name ? (
|
||||
<CustomNumberLabel
|
||||
key={`${label.id}-${index}`}
|
||||
labelList={label.names}
|
||||
namesString={label.name}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
) : (
|
||||
<CustomColordLabel
|
||||
key={`${label.id}-${index}`}
|
||||
label={label}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
<LazyLabelsSelector
|
||||
task={adapters.labels}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
</>
|
||||
</ProgressiveComponent>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -790,11 +998,27 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
return (
|
||||
<div key={col.key} className={`flex items-center px-2 ${borderClasses} overflow-visible`} style={{ width: col.width, minWidth: col.width }}>
|
||||
<div className="w-full">
|
||||
<TaskPhaseDropdown
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
<ProgressiveComponent
|
||||
isLoaded={shouldRenderFull}
|
||||
placeholder={
|
||||
<PhasePlaceholder
|
||||
phase={task.phase}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
}
|
||||
fallback={
|
||||
<PhasePlaceholder
|
||||
phase={task.phase}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<LazyTaskPhaseDropdown
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
</ProgressiveComponent>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -803,11 +1027,27 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
return (
|
||||
<div key={col.key} className={`flex items-center px-2 ${borderClasses} overflow-visible`} style={{ width: col.width, minWidth: col.width }}>
|
||||
<div className="w-full">
|
||||
<TaskStatusDropdown
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
<ProgressiveComponent
|
||||
isLoaded={shouldRenderFull}
|
||||
placeholder={
|
||||
<StatusPlaceholder
|
||||
status={task.status}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
}
|
||||
fallback={
|
||||
<StatusPlaceholder
|
||||
status={task.status}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<LazyTaskStatusDropdown
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
</ProgressiveComponent>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -816,11 +1056,27 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
return (
|
||||
<div key={col.key} className={`flex items-center px-2 ${borderClasses} overflow-visible`} style={{ width: col.width, minWidth: col.width }}>
|
||||
<div className="w-full">
|
||||
<TaskPriorityDropdown
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
<ProgressiveComponent
|
||||
isLoaded={shouldRenderFull}
|
||||
placeholder={
|
||||
<PriorityPlaceholder
|
||||
priority={task.priority}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
}
|
||||
fallback={
|
||||
<PriorityPlaceholder
|
||||
priority={task.priority}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<LazyTaskPriorityDropdown
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
</ProgressiveComponent>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -914,11 +1170,73 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
return null;
|
||||
}
|
||||
}, [
|
||||
shouldRenderFull, renderColumnSimple, isDarkMode, task, isSelected, editTaskName, taskName, adapters, groupId, projectId,
|
||||
shouldRenderFull, renderMinimalColumn, isDarkMode, task, isSelected, editTaskName, taskName, adapters, groupId, projectId,
|
||||
attributes, listeners, handleSelectChange, handleTaskNameSave, handleDateChange,
|
||||
dateValues, styleClasses
|
||||
]);
|
||||
|
||||
// Apply global cursor style when dragging
|
||||
useDragCursor(isDragging);
|
||||
|
||||
// Compute theme class
|
||||
const themeClass = isDarkMode ? 'dark' : '';
|
||||
|
||||
if (isDragging) {
|
||||
console.log('TaskRow isDragging:', task.id);
|
||||
}
|
||||
|
||||
// DRAG OVERLAY: Render simplified version when dragging
|
||||
if (isDragOverlay) {
|
||||
return (
|
||||
<div
|
||||
className={`drag-overlay-simplified ${themeClass}`}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
backgroundColor: isDarkMode ? 'rgba(42, 42, 42, 0.95)' : 'rgba(255, 255, 255, 0.95)',
|
||||
border: `1px solid ${isDarkMode ? 'rgba(74, 158, 255, 0.8)' : 'rgba(24, 144, 255, 0.8)'}`,
|
||||
borderRadius: '8px',
|
||||
boxShadow: isDarkMode
|
||||
? '0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(74, 158, 255, 0.2)'
|
||||
: '0 8px 32px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(24, 144, 255, 0.15)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
maxWidth: '320px',
|
||||
minWidth: '200px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
transform: 'scale(1.02)',
|
||||
transition: 'none',
|
||||
willChange: 'transform',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`drag-handle-icon ${isDarkMode ? 'text-blue-400' : 'text-blue-600'}`}
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
opacity: 0.8,
|
||||
transform: 'translateZ(0)',
|
||||
}}
|
||||
>
|
||||
<HolderOutlined />
|
||||
</div>
|
||||
<span
|
||||
className={`task-title-drag ${isDarkMode ? 'text-gray-100' : 'text-gray-900'}`}
|
||||
title={task.title}
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.01em',
|
||||
lineHeight: '1.4',
|
||||
}}
|
||||
>
|
||||
{task.title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(node) => {
|
||||
@@ -926,32 +1244,50 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
rowRef.current = node;
|
||||
}}
|
||||
style={dragStyle}
|
||||
className={`${styleClasses.container} task-row-optimized ${shouldRenderFull ? 'fully-loaded' : 'initial-load'} ${hasBeenFullyLoadedOnce.current ? 'stable-content' : ''}`}
|
||||
className={`task-row task-row-optimized ${themeClass} ${isSelected ? 'selected' : ''} ${isDragOverlay ? 'drag-overlay' : ''} ${isDragging ? 'is-dragging' : ''}`}
|
||||
data-dnd-draggable="true"
|
||||
data-dnd-dragging={isDragging ? 'true' : 'false'}
|
||||
data-task-id={task.id}
|
||||
data-group-id={groupId}
|
||||
>
|
||||
<div className="flex h-10 max-h-10 overflow-visible relative">
|
||||
{/* Fixed Columns */}
|
||||
{fixedColumns && fixedColumns.length > 0 && (
|
||||
<div
|
||||
className="flex overflow-visible"
|
||||
className="task-table-fixed-columns flex overflow-visible"
|
||||
style={{
|
||||
width: fixedColumns.reduce((sum, col) => sum + col.width, 0),
|
||||
position: 'sticky',
|
||||
left: 0,
|
||||
zIndex: 10,
|
||||
background: isDarkMode ? 'var(--task-bg-primary, #1f1f1f)' : 'var(--task-bg-primary, white)',
|
||||
borderRight: scrollableColumns && scrollableColumns.length > 0 ? '2px solid var(--task-border-primary, #e8e8e8)' : 'none',
|
||||
boxShadow: scrollableColumns && scrollableColumns.length > 0 ? '2px 0 4px rgba(0, 0, 0, 0.1)' : 'none',
|
||||
}}
|
||||
>
|
||||
{fixedColumns.map((col, index) => renderColumn(col, true, index, fixedColumns.length))}
|
||||
{fixedColumns.map((col, index) =>
|
||||
shouldRenderMinimal
|
||||
? renderMinimalColumn(col, true, index, fixedColumns.length)
|
||||
: renderColumn(col, true, index, fixedColumns.length)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scrollable Columns */}
|
||||
{scrollableColumns && scrollableColumns.length > 0 && (
|
||||
<div
|
||||
className="overflow-visible"
|
||||
className="task-table-scrollable-columns overflow-visible"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
minWidth: scrollableColumns.reduce((sum, col) => sum + col.width, 0)
|
||||
}}
|
||||
>
|
||||
{scrollableColumns.map((col, index) => renderColumn(col, false, index, scrollableColumns.length))}
|
||||
{scrollableColumns.map((col, index) =>
|
||||
shouldRenderMinimal
|
||||
? renderMinimalColumn(col, false, index, scrollableColumns.length)
|
||||
: renderColumn(col, false, index, scrollableColumns.length)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -963,16 +1299,22 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
{/* Fixed Columns for Add Subtask */}
|
||||
{fixedColumns && fixedColumns.length > 0 && (
|
||||
<div
|
||||
className="flex overflow-visible"
|
||||
className="task-table-fixed-columns flex overflow-visible"
|
||||
style={{
|
||||
width: fixedColumns.reduce((sum, col) => sum + col.width, 0),
|
||||
position: 'sticky',
|
||||
left: 0,
|
||||
zIndex: 10,
|
||||
background: isDarkMode ? 'var(--task-bg-primary, #1f1f1f)' : 'var(--task-bg-primary, white)',
|
||||
borderRight: scrollableColumns && scrollableColumns.length > 0 ? '2px solid var(--task-border-primary, #e8e8e8)' : 'none',
|
||||
boxShadow: scrollableColumns && scrollableColumns.length > 0 ? '2px 0 4px rgba(0, 0, 0, 0.1)' : 'none',
|
||||
}}
|
||||
>
|
||||
{fixedColumns.map((col, index) => {
|
||||
// Fix border logic for add subtask row: fixed columns should have right border if scrollable columns exist
|
||||
const isActuallyLast = index === fixedColumns.length - 1 && (!scrollableColumns || scrollableColumns.length === 0);
|
||||
const borderClasses = `${isActuallyLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
|
||||
|
||||
|
||||
if (col.key === 'task') {
|
||||
return (
|
||||
<div
|
||||
@@ -1032,16 +1374,17 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
{/* Scrollable Columns for Add Subtask */}
|
||||
{scrollableColumns && scrollableColumns.length > 0 && (
|
||||
<div
|
||||
className="overflow-visible"
|
||||
className="task-table-scrollable-columns overflow-visible"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
minWidth: scrollableColumns.reduce((sum, col) => sum + col.width, 0)
|
||||
}}
|
||||
>
|
||||
{scrollableColumns.map((col, index) => {
|
||||
const isLast = index === scrollableColumns.length - 1;
|
||||
const borderClasses = `${isLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
key={col.key}
|
||||
@@ -1082,9 +1425,9 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
// REAL-TIME UPDATES: Compare assignees and labels content (not just length)
|
||||
if (prevProps.task.assignees?.length !== nextProps.task.assignees?.length) return false;
|
||||
if (prevProps.task.assignees?.length > 0) {
|
||||
// Deep compare assignee IDs
|
||||
const prevAssigneeIds = prevProps.task.assignees.sort();
|
||||
const nextAssigneeIds = nextProps.task.assignees.sort();
|
||||
// Deep compare assignee IDs - create copies before sorting to avoid mutating read-only arrays
|
||||
const prevAssigneeIds = [...prevProps.task.assignees].sort();
|
||||
const nextAssigneeIds = [...nextProps.task.assignees].sort();
|
||||
for (let i = 0; i < prevAssigneeIds.length; i++) {
|
||||
if (prevAssigneeIds[i] !== nextAssigneeIds[i]) return false;
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ const VirtualizedTaskGroup: React.FC<VirtualizedTaskGroupProps> = React.memo(({
|
||||
width={width}
|
||||
itemCount={groupTasks.length + 3} // +3 for header, column headers, and add task row
|
||||
itemSize={TASK_ROW_HEIGHT}
|
||||
overscanCount={5} // Render 5 extra items for smooth scrolling
|
||||
overscanCount={10} // Increased overscan for smoother scrolling experience
|
||||
>
|
||||
{Row}
|
||||
</List>
|
||||
|
||||
@@ -21,6 +21,7 @@ interface VirtualizedTaskListProps {
|
||||
onToggleSubtasks: (taskId: string) => void;
|
||||
height: number;
|
||||
width: number;
|
||||
tasksById: Record<string, Task>;
|
||||
}
|
||||
|
||||
const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
||||
@@ -31,9 +32,9 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
||||
onSelectTask,
|
||||
onToggleSubtasks,
|
||||
height,
|
||||
width
|
||||
width,
|
||||
tasksById
|
||||
}) => {
|
||||
const allTasks = useSelector(taskManagementSelectors.selectAll);
|
||||
const { t } = useTranslation('task-management');
|
||||
|
||||
// Get theme from Redux store
|
||||
@@ -42,26 +43,45 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
||||
// Get field visibility from taskListFields slice
|
||||
const taskListFields = useSelector((state: RootState) => state.taskManagementFields) as TaskListField[];
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Reduce virtualization threshold for better performance
|
||||
const VIRTUALIZATION_THRESHOLD = 20; // Reduced from 100 to 20 - virtualize even smaller lists
|
||||
// PERFORMANCE OPTIMIZATION: Improved virtualization for better user experience
|
||||
const VIRTUALIZATION_THRESHOLD = 25; // Increased threshold - virtualize when there are more tasks
|
||||
const TASK_ROW_HEIGHT = 40;
|
||||
const HEADER_HEIGHT = 40;
|
||||
const COLUMN_HEADER_HEIGHT = 40;
|
||||
const ADD_TASK_ROW_HEIGHT = 40;
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Batch rendering to prevent long tasks
|
||||
const RENDER_BATCH_SIZE = 5; // Render max 5 tasks per frame
|
||||
const FRAME_BUDGET_MS = 8; // Leave 8ms per frame for other operations
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Add early return for empty groups
|
||||
if (!group || !group.taskIds || group.taskIds.length === 0) {
|
||||
const emptyGroupHeight = HEADER_HEIGHT + COLUMN_HEADER_HEIGHT + 120 + ADD_TASK_ROW_HEIGHT; // 120px for empty state
|
||||
|
||||
return (
|
||||
<div className="virtualized-task-list empty-group" style={{ height: emptyGroupHeight }}>
|
||||
<div className="virtualized-task-list empty-group" style={{ height: emptyGroupHeight, position: 'relative' }}>
|
||||
{/* Sticky Group Color Border */}
|
||||
<div
|
||||
className="sticky-group-border"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '4px',
|
||||
backgroundColor: group?.color || '#f0f0f0',
|
||||
zIndex: 15,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="task-group-header" style={{ height: HEADER_HEIGHT }}>
|
||||
<div className="task-group-header-row">
|
||||
<div
|
||||
className="task-group-header-content"
|
||||
style={{
|
||||
backgroundColor: group?.color || '#f0f0f0',
|
||||
borderLeft: `4px solid ${group?.color || '#f0f0f0'}`
|
||||
// No margin - header should overlap the sticky border
|
||||
}}
|
||||
>
|
||||
<span className="task-group-header-text">
|
||||
@@ -73,7 +93,7 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
||||
|
||||
{/* Column Headers */}
|
||||
<div className="task-group-column-headers" style={{
|
||||
borderLeft: `4px solid ${group?.color || '#f0f0f0'}`,
|
||||
marginLeft: '4px', // Account for sticky border
|
||||
height: COLUMN_HEADER_HEIGHT,
|
||||
background: 'var(--task-bg-secondary, #f5f5f5)',
|
||||
borderBottom: '1px solid var(--task-border-tertiary, #d9d9d9)',
|
||||
@@ -92,7 +112,7 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderLeft: `4px solid ${group?.color || '#f0f0f0'}`,
|
||||
marginLeft: '4px', // Account for sticky border
|
||||
backgroundColor: 'var(--task-bg-primary, white)'
|
||||
}}>
|
||||
<Empty
|
||||
@@ -114,21 +134,25 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="task-group-add-task" style={{ borderLeft: `4px solid ${group?.color || '#f0f0f0'}`, height: ADD_TASK_ROW_HEIGHT }}>
|
||||
<div className="task-group-add-task" style={{ marginLeft: '4px', height: ADD_TASK_ROW_HEIGHT }}>
|
||||
<AddTaskListRow groupId={group?.id} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Get tasks for this group using memoization for performance
|
||||
// PERFORMANCE OPTIMIZATION: Get tasks for this group using direct lookup (no mapping/filtering)
|
||||
const groupTasks = useMemo(() => {
|
||||
const tasks = group.taskIds
|
||||
.map((taskId: string) => allTasks.find((task: Task) => task.id === taskId))
|
||||
.filter((task: Task | undefined): task is Task => task !== undefined);
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Use for loop instead of map for better performance
|
||||
const tasks: Task[] = [];
|
||||
for (let i = 0; i < group.taskIds.length; i++) {
|
||||
const task = tasksById[group.taskIds[i]];
|
||||
if (task) {
|
||||
tasks.push(task);
|
||||
}
|
||||
}
|
||||
return tasks;
|
||||
}, [group.taskIds, allTasks]);
|
||||
}, [group.taskIds, tasksById]);
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Only calculate selection state when needed
|
||||
const selectionState = useMemo(() => {
|
||||
@@ -136,35 +160,56 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
||||
return { isAllSelected: false, isIndeterminate: false };
|
||||
}
|
||||
|
||||
const selectedTasksInGroup = groupTasks.filter((task: Task) => selectedTaskIds.includes(task.id));
|
||||
const isAllSelected = selectedTasksInGroup.length === groupTasks.length;
|
||||
const isIndeterminate = selectedTasksInGroup.length > 0 && selectedTasksInGroup.length < groupTasks.length;
|
||||
// PERFORMANCE OPTIMIZATION: Use for loop instead of filter for better performance
|
||||
let selectedCount = 0;
|
||||
for (let i = 0; i < groupTasks.length; i++) {
|
||||
if (selectedTaskIds.includes(groupTasks[i].id)) {
|
||||
selectedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const isAllSelected = selectedCount === groupTasks.length;
|
||||
const isIndeterminate = selectedCount > 0 && selectedCount < groupTasks.length;
|
||||
|
||||
return { isAllSelected, isIndeterminate };
|
||||
}, [groupTasks, selectedTaskIds]);
|
||||
|
||||
// Handle select all tasks in group - optimized with useCallback
|
||||
const handleSelectAllInGroup = useCallback((checked: boolean) => {
|
||||
// PERFORMANCE OPTIMIZATION: Batch selection updates
|
||||
const tasksToUpdate: Array<{ taskId: string; selected: boolean }> = [];
|
||||
|
||||
if (checked) {
|
||||
// Select all tasks in the group
|
||||
groupTasks.forEach((task: Task) => {
|
||||
for (let i = 0; i < groupTasks.length; i++) {
|
||||
const task = groupTasks[i];
|
||||
if (!selectedTaskIds.includes(task.id)) {
|
||||
onSelectTask(task.id, true);
|
||||
tasksToUpdate.push({ taskId: task.id, selected: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Deselect all tasks in the group
|
||||
groupTasks.forEach((task: Task) => {
|
||||
for (let i = 0; i < groupTasks.length; i++) {
|
||||
const task = groupTasks[i];
|
||||
if (selectedTaskIds.includes(task.id)) {
|
||||
onSelectTask(task.id, false);
|
||||
tasksToUpdate.push({ taskId: task.id, selected: false });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Batch update all selections
|
||||
tasksToUpdate.forEach(({ taskId, selected }) => {
|
||||
onSelectTask(taskId, selected);
|
||||
});
|
||||
}, [groupTasks, selectedTaskIds, onSelectTask]);
|
||||
|
||||
// Calculate dynamic height for the group
|
||||
// PERFORMANCE OPTIMIZATION: Use passed height prop and calculate available space for tasks
|
||||
const taskRowsHeight = groupTasks.length * TASK_ROW_HEIGHT;
|
||||
const groupHeight = HEADER_HEIGHT + COLUMN_HEADER_HEIGHT + taskRowsHeight + ADD_TASK_ROW_HEIGHT;
|
||||
const groupHeight = height; // Use the height passed from parent
|
||||
const availableTaskRowsHeight = Math.max(0, groupHeight - HEADER_HEIGHT - COLUMN_HEADER_HEIGHT - ADD_TASK_ROW_HEIGHT);
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Limit visible columns for large lists
|
||||
const maxVisibleColumns = groupTasks.length > 50 ? 6 : 12; // Further reduce columns for large lists
|
||||
|
||||
// Define all possible columns
|
||||
const allFixedColumns = [
|
||||
@@ -210,7 +255,7 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
||||
}, [taskListFields, allFixedColumns]);
|
||||
|
||||
const scrollableColumns = useMemo(() => {
|
||||
return allScrollableColumns.filter(col => {
|
||||
const filtered = allScrollableColumns.filter(col => {
|
||||
// For scrollable columns, check field visibility
|
||||
if (col.fieldKey) {
|
||||
const field = taskListFields.find(f => f.key === col.fieldKey);
|
||||
@@ -219,26 +264,38 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
||||
|
||||
return false;
|
||||
});
|
||||
}, [taskListFields, allScrollableColumns]);
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Limit columns for large lists
|
||||
return filtered.slice(0, maxVisibleColumns);
|
||||
}, [taskListFields, allScrollableColumns, maxVisibleColumns]);
|
||||
|
||||
const fixedWidth = fixedColumns.reduce((sum, col) => sum + col.width, 0);
|
||||
const scrollableWidth = scrollableColumns.reduce((sum, col) => sum + col.width, 0);
|
||||
const totalTableWidth = fixedWidth + scrollableWidth;
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Increase overscanCount for better perceived performance
|
||||
// PERFORMANCE OPTIMIZATION: Enhanced overscan for smoother scrolling experience
|
||||
const shouldVirtualize = groupTasks.length > VIRTUALIZATION_THRESHOLD;
|
||||
const overscanCount = shouldVirtualize ? Math.min(10, Math.ceil(groupTasks.length * 0.1)) : 0; // Dynamic overscan
|
||||
const overscanCount = useMemo(() => {
|
||||
if (groupTasks.length <= 20) return 5; // Small lists: 5 items overscan
|
||||
if (groupTasks.length <= 100) return 10; // Medium lists: 10 items overscan
|
||||
if (groupTasks.length <= 500) return 15; // Large lists: 15 items overscan
|
||||
return 20; // Very large lists: 20 items overscan for smooth scrolling
|
||||
}, [groupTasks.length]);
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Memoize row renderer with better dependency management
|
||||
const Row = useCallback(({ index, style }: { index: number; style: React.CSSProperties }) => {
|
||||
const task: Task | undefined = groupTasks[index];
|
||||
if (!task) return null;
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Pre-calculate selection state
|
||||
const isSelected = selectedTaskIds.includes(task.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="task-row-container"
|
||||
style={{
|
||||
...style,
|
||||
marginLeft: '4px', // Account for sticky border
|
||||
'--group-color': group.color || '#f0f0f0',
|
||||
contain: 'layout style', // CSS containment for better performance
|
||||
} as React.CSSProperties}
|
||||
@@ -248,7 +305,7 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
||||
projectId={projectId}
|
||||
groupId={group.id}
|
||||
currentGrouping={currentGrouping}
|
||||
isSelected={selectedTaskIds.includes(task.id)}
|
||||
isSelected={isSelected}
|
||||
index={index}
|
||||
onSelect={onSelectTask}
|
||||
onToggleSubtasks={onToggleSubtasks}
|
||||
@@ -262,26 +319,43 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const headerScrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Throttled scroll handler
|
||||
const handleScroll = useCallback(() => {
|
||||
if (headerScrollRef.current && scrollContainerRef.current) {
|
||||
headerScrollRef.current.scrollLeft = scrollContainerRef.current.scrollLeft;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Synchronize header scroll with body scroll
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (headerScrollRef.current && scrollContainerRef.current) {
|
||||
headerScrollRef.current.scrollLeft = scrollContainerRef.current.scrollLeft;
|
||||
}
|
||||
};
|
||||
const scrollDiv = scrollContainerRef.current;
|
||||
if (scrollDiv) {
|
||||
scrollDiv.addEventListener('scroll', handleScroll);
|
||||
scrollDiv.addEventListener('scroll', handleScroll, { passive: true });
|
||||
}
|
||||
return () => {
|
||||
if (scrollDiv) {
|
||||
scrollDiv.removeEventListener('scroll', handleScroll);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [handleScroll]);
|
||||
|
||||
return (
|
||||
<div className="virtualized-task-list" style={{ height: groupHeight }}>
|
||||
<div className="virtualized-task-list" style={{ height: groupHeight, position: 'relative' }}>
|
||||
{/* Sticky Group Color Border */}
|
||||
<div
|
||||
className="sticky-group-border"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '4px',
|
||||
backgroundColor: group.color || '#f0f0f0',
|
||||
zIndex: 15,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Group Header */}
|
||||
<div className="task-group-header" style={{ height: HEADER_HEIGHT }}>
|
||||
<div className="task-group-header-row">
|
||||
@@ -289,7 +363,7 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
||||
className="task-group-header-content"
|
||||
style={{
|
||||
backgroundColor: group.color || '#f0f0f0',
|
||||
borderLeft: `4px solid ${group.color || '#f0f0f0'}`
|
||||
// No margin - header should overlap the sticky border
|
||||
}}
|
||||
>
|
||||
<span className="task-group-header-text">
|
||||
@@ -306,9 +380,21 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
||||
>
|
||||
<div
|
||||
className="task-group-column-headers"
|
||||
style={{ borderLeft: `4px solid ${group.color || '#f0f0f0'}`, minWidth: totalTableWidth, display: 'flex', position: 'relative' }}
|
||||
style={{ marginLeft: '4px', minWidth: totalTableWidth, display: 'flex', position: 'relative' }}
|
||||
>
|
||||
<div className="fixed-columns-header" style={{ display: 'flex', position: 'sticky', left: 0, zIndex: 2, background: 'inherit', width: fixedWidth }}>
|
||||
<div
|
||||
className="task-table-fixed-columns fixed-columns-header"
|
||||
style={{
|
||||
display: 'flex',
|
||||
position: 'sticky',
|
||||
left: 0,
|
||||
zIndex: 12,
|
||||
background: isDarkMode ? 'var(--task-bg-secondary, #141414)' : 'var(--task-bg-secondary, #f5f5f5)',
|
||||
width: fixedWidth,
|
||||
borderRight: scrollableColumns.length > 0 ? '2px solid var(--task-border-primary, #e8e8e8)' : 'none',
|
||||
boxShadow: scrollableColumns.length > 0 ? '2px 0 4px rgba(0, 0, 0, 0.1)' : 'none',
|
||||
}}
|
||||
>
|
||||
{fixedColumns.map(col => (
|
||||
<div
|
||||
key={col.key}
|
||||
@@ -352,63 +438,62 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
||||
overflowY: 'auto',
|
||||
width: '100%',
|
||||
minWidth: totalTableWidth,
|
||||
height: groupTasks.length > 0 ? taskRowsHeight : 'auto',
|
||||
height: groupTasks.length > 0 ? availableTaskRowsHeight : 'auto',
|
||||
contain: 'layout style', // CSS containment for better performance
|
||||
}}
|
||||
>
|
||||
<SortableContext items={group.taskIds} strategy={verticalListSortingStrategy}>
|
||||
{shouldVirtualize ? (
|
||||
<List
|
||||
height={taskRowsHeight}
|
||||
width={width}
|
||||
height={availableTaskRowsHeight}
|
||||
width={totalTableWidth}
|
||||
itemCount={groupTasks.length}
|
||||
itemSize={TASK_ROW_HEIGHT}
|
||||
overscanCount={overscanCount} // Dynamic overscan
|
||||
overscanCount={overscanCount}
|
||||
className="react-window-list"
|
||||
style={{ minWidth: totalTableWidth }}
|
||||
// PERFORMANCE OPTIMIZATION: Add performance-focused props
|
||||
useIsScrolling={true}
|
||||
itemData={{
|
||||
groupTasks,
|
||||
group,
|
||||
projectId,
|
||||
currentGrouping,
|
||||
selectedTaskIds,
|
||||
onSelectTask,
|
||||
onToggleSubtasks,
|
||||
fixedColumns,
|
||||
scrollableColumns
|
||||
}}
|
||||
// PERFORMANCE OPTIMIZATION: Remove all expensive props for maximum performance
|
||||
useIsScrolling={false}
|
||||
itemData={undefined}
|
||||
// Disable all animations and transitions
|
||||
onItemsRendered={() => {}}
|
||||
onScroll={() => {}}
|
||||
>
|
||||
{Row}
|
||||
</List>
|
||||
) : (
|
||||
// PERFORMANCE OPTIMIZATION: Use React.Fragment to reduce DOM nodes
|
||||
<React.Fragment>
|
||||
{groupTasks.map((task: Task, index: number) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="task-row-container"
|
||||
style={{
|
||||
height: TASK_ROW_HEIGHT,
|
||||
'--group-color': group.color || '#f0f0f0',
|
||||
contain: 'layout style', // CSS containment
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<TaskRow
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
groupId={group.id}
|
||||
currentGrouping={currentGrouping}
|
||||
isSelected={selectedTaskIds.includes(task.id)}
|
||||
index={index}
|
||||
onSelect={onSelectTask}
|
||||
onToggleSubtasks={onToggleSubtasks}
|
||||
fixedColumns={fixedColumns}
|
||||
scrollableColumns={scrollableColumns}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{groupTasks.map((task: Task, index: number) => {
|
||||
// PERFORMANCE OPTIMIZATION: Pre-calculate selection state
|
||||
const isSelected = selectedTaskIds.includes(task.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className="task-row-container"
|
||||
style={{
|
||||
height: TASK_ROW_HEIGHT,
|
||||
marginLeft: '4px', // Account for sticky border
|
||||
'--group-color': group.color || '#f0f0f0',
|
||||
contain: 'layout style', // CSS containment
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<TaskRow
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
groupId={group.id}
|
||||
currentGrouping={currentGrouping}
|
||||
isSelected={isSelected}
|
||||
index={index}
|
||||
onSelect={onSelectTask}
|
||||
onToggleSubtasks={onToggleSubtasks}
|
||||
fixedColumns={fixedColumns}
|
||||
scrollableColumns={scrollableColumns}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
)}
|
||||
</SortableContext>
|
||||
@@ -416,7 +501,7 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
||||
{/* Add Task Row - Always show at the bottom */}
|
||||
<div
|
||||
className="task-group-add-task"
|
||||
style={{ borderLeft: `4px solid ${group.color || '#f0f0f0'}`, height: ADD_TASK_ROW_HEIGHT }}
|
||||
style={{ marginLeft: '4px', height: ADD_TASK_ROW_HEIGHT }}
|
||||
>
|
||||
<AddTaskListRow groupId={group.id} />
|
||||
</div>
|
||||
@@ -469,16 +554,6 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
||||
position: relative;
|
||||
background: var(--task-bg-primary, white);
|
||||
}
|
||||
.task-row-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background-color: var(--group-color, #f0f0f0);
|
||||
z-index: 10;
|
||||
}
|
||||
/* Ensure no gaps between list items */
|
||||
.react-window-list > div {
|
||||
margin: 0;
|
||||
@@ -532,20 +607,47 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
||||
letter-spacing: 0.5px;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
/* Add task row styles */
|
||||
/* Add task row styles - Fixed width responsive design */
|
||||
.task-group-add-task {
|
||||
background: var(--task-bg-primary, white);
|
||||
border-top: 1px solid var(--task-border-secondary, #f0f0f0);
|
||||
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;
|
||||
flex-shrink: 0;
|
||||
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 {
|
||||
display: flex;
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px 6px 0 0;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border: none;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
@@ -42,13 +42,11 @@
|
||||
[data-theme="default"] .project-view-tabs .ant-tabs-tab {
|
||||
color: #64748b;
|
||||
background: #f8fafc;
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
[data-theme="default"] .project-view-tabs .ant-tabs-tab:hover {
|
||||
color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
border-color: #bfdbfe;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
@@ -56,32 +54,26 @@
|
||||
[data-theme="default"] .project-view-tabs .ant-tabs-tab-active {
|
||||
color: #1e40af !important;
|
||||
background: #ffffff !important;
|
||||
border-color: #3b82f6 !important;
|
||||
border-bottom-color: #ffffff !important;
|
||||
box-shadow: 0 -2px 8px rgba(59, 130, 246, 0.1), 0 4px 16px rgba(59, 130, 246, 0.1);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Dark mode tab styles */
|
||||
/* Dark mode tab styles - matching task list row colors */
|
||||
[data-theme="dark"] .project-view-tabs .ant-tabs-tab {
|
||||
color: #94a3b8;
|
||||
background: #1e293b;
|
||||
border-color: #334155;
|
||||
background: #141414;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .project-view-tabs .ant-tabs-tab:hover {
|
||||
color: #60a5fa;
|
||||
background: #1e3a8a;
|
||||
border-color: #3b82f6;
|
||||
background: #262626;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(96, 165, 250, 0.2);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .project-view-tabs .ant-tabs-tab-active {
|
||||
color: #60a5fa !important;
|
||||
background: #0f172a !important;
|
||||
border-color: #3b82f6 !important;
|
||||
border-bottom-color: #0f172a !important;
|
||||
background: #1f1f1f !important;
|
||||
box-shadow: 0 -2px 8px rgba(96, 165, 250, 0.15), 0 4px 16px rgba(96, 165, 250, 0.15);
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -102,8 +94,8 @@
|
||||
}
|
||||
|
||||
[data-theme="dark"] .project-view-tabs .ant-tabs-content-holder {
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
background: #1f1f1f;
|
||||
border: 1px solid #303030;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
|
||||
@@ -159,16 +159,86 @@ const CustomCell = React.memo(({
|
||||
renderColumnContent: any;
|
||||
updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void;
|
||||
}) => {
|
||||
if (column.custom_column && column.key && column.pinned) {
|
||||
return renderCustomColumnContent(
|
||||
column.custom_column_obj || {},
|
||||
column.custom_column_obj?.fieldType,
|
||||
task,
|
||||
column.key,
|
||||
updateTaskCustomColumnValue
|
||||
);
|
||||
try {
|
||||
if (column.custom_column && column.key && column.pinned) {
|
||||
return renderCustomColumnContent(
|
||||
column.custom_column_obj || {},
|
||||
column.custom_column_obj?.fieldType,
|
||||
task,
|
||||
column.key,
|
||||
updateTaskCustomColumnValue
|
||||
);
|
||||
}
|
||||
|
||||
const result = renderColumnContent(column.key || '', task, isSubtask);
|
||||
|
||||
// If renderColumnContent returns null or undefined, provide a fallback
|
||||
if (result === null || result === undefined) {
|
||||
// Handle specific column types with fallbacks
|
||||
switch (column.key) {
|
||||
case 'STATUS':
|
||||
return (
|
||||
<div className="px-2 py-1 text-xs rounded bg-gray-100 text-gray-600">
|
||||
{task.status_name || task.status || 'To Do'}
|
||||
</div>
|
||||
);
|
||||
case 'PRIORITY':
|
||||
return (
|
||||
<div className="px-2 py-1 text-xs rounded bg-gray-100 text-gray-600">
|
||||
{task.priority_name || task.priority || 'Medium'}
|
||||
</div>
|
||||
);
|
||||
case 'ASSIGNEES':
|
||||
return (
|
||||
<div className="text-xs text-gray-500">
|
||||
{task.assignees?.length ? `${task.assignees.length} assignee(s)` : 'No assignees'}
|
||||
</div>
|
||||
);
|
||||
case 'LABELS':
|
||||
return (
|
||||
<div className="text-xs text-gray-500">
|
||||
{task.labels?.length ? `${task.labels.length} label(s)` : 'No labels'}
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return <div className="text-xs text-gray-400">-</div>;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error rendering task cell:', error, { column: column.key, task: task.id });
|
||||
|
||||
// Fallback rendering for errors
|
||||
switch (column.key) {
|
||||
case 'STATUS':
|
||||
return (
|
||||
<div className="px-2 py-1 text-xs rounded bg-red-100 text-red-600">
|
||||
{task.status_name || task.status || 'Error'}
|
||||
</div>
|
||||
);
|
||||
case 'PRIORITY':
|
||||
return (
|
||||
<div className="px-2 py-1 text-xs rounded bg-red-100 text-red-600">
|
||||
{task.priority_name || task.priority || 'Error'}
|
||||
</div>
|
||||
);
|
||||
case 'ASSIGNEES':
|
||||
return (
|
||||
<div className="text-xs text-red-500">
|
||||
Error loading assignees
|
||||
</div>
|
||||
);
|
||||
case 'LABELS':
|
||||
return (
|
||||
<div className="text-xs text-red-500">
|
||||
Error loading labels
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return <div className="text-xs text-red-400">Error</div>;
|
||||
}
|
||||
}
|
||||
return renderColumnContent(column.key || '', task, isSubtask);
|
||||
});
|
||||
|
||||
// First, let's extract the custom column cell to a completely separate component
|
||||
|
||||
@@ -472,23 +472,23 @@
|
||||
background-color: #262626 !important;
|
||||
}
|
||||
|
||||
/* System preference fallback */
|
||||
/* System preference fallback - only apply when explicitly in dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.task-list-board:not(.light) {
|
||||
.task-list-board.dark:not(.light) {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.task-group:not(.light) {
|
||||
.task-group.dark:not(.light) {
|
||||
background-color: #1f1f1f;
|
||||
}
|
||||
|
||||
.task-row:not(.light) {
|
||||
.task-row.dark:not(.light) {
|
||||
background-color: #1f1f1f;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
border-color: #303030;
|
||||
}
|
||||
|
||||
.task-row:not(.light):hover {
|
||||
.task-row.dark:not(.light):hover {
|
||||
background-color: #262626 !important;
|
||||
border-left-color: #595959;
|
||||
}
|
||||
|
||||
284
worklenz-frontend/src/utils/debug-performance.ts
Normal file
284
worklenz-frontend/src/utils/debug-performance.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
// Debug utility for identifying performance bottlenecks with 400 tasks
|
||||
|
||||
export const debugPerformance = {
|
||||
// Log component render times
|
||||
logComponentRender: (componentName: string, startTime: number) => {
|
||||
const renderTime = performance.now() - startTime;
|
||||
if (renderTime > 16) { // Log slow renders (>16ms)
|
||||
console.warn(`Slow render detected: ${componentName} took ${renderTime.toFixed(2)}ms`);
|
||||
}
|
||||
},
|
||||
|
||||
// Log Redux selector performance
|
||||
logSelectorPerformance: (selectorName: string, startTime: number) => {
|
||||
const executionTime = performance.now() - startTime;
|
||||
if (executionTime > 5) { // Log slow selectors (>5ms)
|
||||
console.warn(`Slow selector detected: ${selectorName} took ${executionTime.toFixed(2)}ms`);
|
||||
}
|
||||
},
|
||||
|
||||
// Log memory usage
|
||||
logMemoryUsage: () => {
|
||||
if ('memory' in performance) {
|
||||
const memory = (performance as any).memory;
|
||||
const usedMB = memory.usedJSHeapSize / 1024 / 1024;
|
||||
const totalMB = memory.totalJSHeapSize / 1024 / 1024;
|
||||
const limitMB = memory.jsHeapSizeLimit / 1024 / 1024;
|
||||
|
||||
console.log(`Memory Usage: ${usedMB.toFixed(1)}MB / ${totalMB.toFixed(1)}MB (${limitMB.toFixed(1)}MB limit)`);
|
||||
|
||||
if (usedMB > 100) {
|
||||
console.warn(`High memory usage detected: ${usedMB.toFixed(1)}MB`);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Log DOM node count
|
||||
logDOMNodes: () => {
|
||||
const nodeCount = document.querySelectorAll('*').length;
|
||||
console.log(`Total DOM nodes: ${nodeCount}`);
|
||||
|
||||
if (nodeCount > 1000) {
|
||||
console.warn(`High DOM node count detected: ${nodeCount} nodes`);
|
||||
}
|
||||
},
|
||||
|
||||
// Log React component count
|
||||
logReactComponents: () => {
|
||||
// This is a rough estimate - React DevTools would be more accurate
|
||||
const reactComponents = document.querySelectorAll('[data-reactroot], [data-reactid]').length;
|
||||
console.log(`React components (estimate): ${reactComponents}`);
|
||||
},
|
||||
|
||||
// Log scroll performance
|
||||
logScrollPerformance: () => {
|
||||
let lastScrollTime = 0;
|
||||
let scrollCount = 0;
|
||||
|
||||
const handleScroll = () => {
|
||||
const currentTime = performance.now();
|
||||
const timeSinceLastScroll = currentTime - lastScrollTime;
|
||||
|
||||
if (timeSinceLastScroll < 16) { // Less than 60fps
|
||||
scrollCount++;
|
||||
if (scrollCount > 5) {
|
||||
console.warn(`Poor scroll performance detected: ${timeSinceLastScroll.toFixed(2)}ms between scrolls`);
|
||||
}
|
||||
} else {
|
||||
scrollCount = 0;
|
||||
}
|
||||
|
||||
lastScrollTime = currentTime;
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
},
|
||||
|
||||
// Log long tasks
|
||||
logLongTasks: () => {
|
||||
if ('PerformanceObserver' in window) {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (entry.entryType === 'longtask') {
|
||||
console.warn(`Long task detected: ${entry.duration.toFixed(2)}ms`, entry);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe({ entryTypes: ['longtask'] });
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}
|
||||
|
||||
return () => {};
|
||||
},
|
||||
|
||||
// NEW: Monitor hover performance specifically
|
||||
logHoverPerformance: () => {
|
||||
let hoverStartTime = 0;
|
||||
let hoverCount = 0;
|
||||
let totalHoverTime = 0;
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
hoverStartTime = performance.now();
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (hoverStartTime > 0) {
|
||||
const hoverDuration = performance.now() - hoverStartTime;
|
||||
totalHoverTime += hoverDuration;
|
||||
hoverCount++;
|
||||
|
||||
if (hoverDuration > 50) { // Log slow hover operations (>50ms)
|
||||
console.warn(`Slow hover operation detected: ${hoverDuration.toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
// Log average hover time every 10 hovers
|
||||
if (hoverCount % 10 === 0) {
|
||||
const avgHoverTime = totalHoverTime / hoverCount;
|
||||
console.log(`Average hover time: ${avgHoverTime.toFixed(2)}ms (${hoverCount} hovers)`);
|
||||
}
|
||||
|
||||
hoverStartTime = 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Monitor hover events on task rows specifically
|
||||
const taskRows = document.querySelectorAll('.task-row-optimized, .task-row');
|
||||
taskRows.forEach(row => {
|
||||
row.addEventListener('mouseenter', handleMouseEnter, { passive: true });
|
||||
row.addEventListener('mouseleave', handleMouseLeave, { passive: true });
|
||||
});
|
||||
|
||||
return () => {
|
||||
taskRows.forEach(row => {
|
||||
row.removeEventListener('mouseenter', handleMouseEnter);
|
||||
row.removeEventListener('mouseleave', handleMouseLeave);
|
||||
});
|
||||
};
|
||||
},
|
||||
|
||||
// NEW: Monitor CSS transitions and animations
|
||||
logCSSPerformance: () => {
|
||||
if ('PerformanceObserver' in window) {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (entry.entryType === 'measure') {
|
||||
const duration = entry.duration;
|
||||
if (duration > 16) { // Log slow CSS operations (>16ms)
|
||||
console.warn(`Slow CSS operation detected: ${duration.toFixed(2)}ms - ${entry.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe({ entryTypes: ['measure'] });
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}
|
||||
|
||||
return () => {};
|
||||
},
|
||||
|
||||
// Comprehensive performance check
|
||||
runPerformanceCheck: () => {
|
||||
console.group('🔍 Performance Check');
|
||||
|
||||
// Memory usage
|
||||
debugPerformance.logMemoryUsage();
|
||||
|
||||
// DOM nodes
|
||||
debugPerformance.logDOMNodes();
|
||||
|
||||
// React components
|
||||
debugPerformance.logReactComponents();
|
||||
|
||||
// Start monitoring
|
||||
const stopScroll = debugPerformance.logScrollPerformance();
|
||||
const stopLongTasks = debugPerformance.logLongTasks();
|
||||
const stopHover = debugPerformance.logHoverPerformance();
|
||||
const stopCSS = debugPerformance.logCSSPerformance();
|
||||
|
||||
console.groupEnd();
|
||||
|
||||
return () => {
|
||||
stopScroll();
|
||||
stopLongTasks();
|
||||
stopHover();
|
||||
stopCSS();
|
||||
};
|
||||
},
|
||||
|
||||
// Monitor specific component
|
||||
monitorComponent: (componentName: string) => {
|
||||
const startTime = performance.now();
|
||||
|
||||
return () => {
|
||||
debugPerformance.logComponentRender(componentName, startTime);
|
||||
};
|
||||
},
|
||||
|
||||
// Monitor Redux selector
|
||||
monitorSelector: (selectorName: string, selectorFn: () => any) => {
|
||||
const startTime = performance.now();
|
||||
const result = selectorFn();
|
||||
debugPerformance.logSelectorPerformance(selectorName, startTime);
|
||||
return result;
|
||||
},
|
||||
|
||||
// NEW: Quick hover performance test
|
||||
testHoverPerformance: () => {
|
||||
console.group('🧪 Hover Performance Test');
|
||||
|
||||
const taskRows = document.querySelectorAll('.task-row-optimized, .task-row');
|
||||
console.log(`Found ${taskRows.length} task rows to test`);
|
||||
|
||||
let totalHoverTime = 0;
|
||||
let hoverCount = 0;
|
||||
|
||||
const testHover = (row: Element) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
const startTime = performance.now();
|
||||
|
||||
// Simulate hover
|
||||
row.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
|
||||
setTimeout(() => {
|
||||
row.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
|
||||
const hoverTime = performance.now() - startTime;
|
||||
totalHoverTime += hoverTime;
|
||||
hoverCount++;
|
||||
|
||||
if (hoverTime > 50) {
|
||||
console.warn(`Slow hover on row ${hoverCount}: ${hoverTime.toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
resolve();
|
||||
}, 100);
|
||||
});
|
||||
};
|
||||
|
||||
// Test first 5 rows
|
||||
const testRows = Array.from(taskRows).slice(0, 5);
|
||||
Promise.all(testRows.map(testHover)).then(() => {
|
||||
const avgHoverTime = totalHoverTime / hoverCount;
|
||||
console.log(`Average hover time: ${avgHoverTime.toFixed(2)}ms (${hoverCount} tests)`);
|
||||
|
||||
if (avgHoverTime > 30) {
|
||||
console.error(`🚨 Poor hover performance detected: ${avgHoverTime.toFixed(2)}ms average`);
|
||||
} else if (avgHoverTime > 16) {
|
||||
console.warn(`⚠️ Suboptimal hover performance: ${avgHoverTime.toFixed(2)}ms average`);
|
||||
} else {
|
||||
console.log(`✅ Good hover performance: ${avgHoverTime.toFixed(2)}ms average`);
|
||||
}
|
||||
|
||||
console.groupEnd();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-run performance check in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
// Run initial check after page load
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(() => {
|
||||
debugPerformance.runPerformanceCheck();
|
||||
|
||||
// Run hover performance test after 3 seconds
|
||||
setTimeout(() => {
|
||||
debugPerformance.testHoverPerformance();
|
||||
}, 3000);
|
||||
}, 2000); // Wait for initial render
|
||||
});
|
||||
}
|
||||
|
||||
// Export for manual use
|
||||
export default debugPerformance;
|
||||
304
worklenz-frontend/src/utils/performance-monitor.ts
Normal file
304
worklenz-frontend/src/utils/performance-monitor.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import React from 'react';
|
||||
|
||||
// Performance monitoring utility for task list performance analysis
|
||||
export class PerformanceMonitor {
|
||||
private static instance: PerformanceMonitor;
|
||||
private metrics: Map<string, number[]> = new Map();
|
||||
private marks: Map<string, number> = new Map();
|
||||
private observers: Map<string, PerformanceObserver> = new Map();
|
||||
|
||||
static getInstance(): PerformanceMonitor {
|
||||
if (!PerformanceMonitor.instance) {
|
||||
PerformanceMonitor.instance = new PerformanceMonitor();
|
||||
}
|
||||
return PerformanceMonitor.instance;
|
||||
}
|
||||
|
||||
// Mark a performance point
|
||||
mark(name: string): void {
|
||||
this.marks.set(name, performance.now());
|
||||
performance.mark(name);
|
||||
}
|
||||
|
||||
// Measure time between two marks
|
||||
measure(name: string, startMark: string, endMark: string): void {
|
||||
try {
|
||||
performance.measure(name, startMark, endMark);
|
||||
const measure = performance.getEntriesByName(name, 'measure')[0];
|
||||
if (measure) {
|
||||
this.addMetric(name, measure.duration);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to measure ${name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Add a metric value
|
||||
addMetric(name: string, value: number): void {
|
||||
if (!this.metrics.has(name)) {
|
||||
this.metrics.set(name, []);
|
||||
}
|
||||
this.metrics.get(name)!.push(value);
|
||||
}
|
||||
|
||||
// Get average for a metric
|
||||
getAverage(name: string): number {
|
||||
const values = this.metrics.get(name);
|
||||
if (!values || values.length === 0) return 0;
|
||||
return values.reduce((sum, val) => sum + val, 0) / values.length;
|
||||
}
|
||||
|
||||
// Get all metrics
|
||||
getMetrics(): Record<string, { average: number; count: number; min: number; max: number }> {
|
||||
const result: Record<string, { average: number; count: number; min: number; max: number }> = {};
|
||||
|
||||
this.metrics.forEach((values, name) => {
|
||||
if (values.length > 0) {
|
||||
result[name] = {
|
||||
average: this.getAverage(name),
|
||||
count: values.length,
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Monitor React component render times
|
||||
monitorComponentRender(componentName: string): () => void {
|
||||
const startMark = `${componentName}-render-start`;
|
||||
const endMark = `${componentName}-render-end`;
|
||||
|
||||
this.mark(startMark);
|
||||
|
||||
return () => {
|
||||
this.mark(endMark);
|
||||
this.measure(`${componentName}-render-time`, startMark, endMark);
|
||||
};
|
||||
}
|
||||
|
||||
// Monitor Redux selector performance
|
||||
monitorSelector(selectorName: string, selectorFn: () => any): any {
|
||||
const startTime = performance.now();
|
||||
const result = selectorFn();
|
||||
const endTime = performance.now();
|
||||
|
||||
this.addMetric(`${selectorName}-execution-time`, endTime - startTime);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Monitor DOM operations
|
||||
monitorDOMOperation(operationName: string, operation: () => void): void {
|
||||
const startTime = performance.now();
|
||||
operation();
|
||||
const endTime = performance.now();
|
||||
|
||||
this.addMetric(`${operationName}-dom-time`, endTime - startTime);
|
||||
}
|
||||
|
||||
// Monitor memory usage
|
||||
monitorMemory(): void {
|
||||
if ('memory' in performance) {
|
||||
const memory = (performance as any).memory;
|
||||
this.addMetric('memory-used', memory.usedJSHeapSize);
|
||||
this.addMetric('memory-total', memory.totalJSHeapSize);
|
||||
this.addMetric('memory-limit', memory.jsHeapSizeLimit);
|
||||
}
|
||||
}
|
||||
|
||||
// Monitor frame rate
|
||||
startFrameRateMonitoring(): () => void {
|
||||
let frameCount = 0;
|
||||
let lastTime = performance.now();
|
||||
|
||||
const measureFrameRate = () => {
|
||||
frameCount++;
|
||||
const currentTime = performance.now();
|
||||
|
||||
if (currentTime - lastTime >= 1000) { // Every second
|
||||
const fps = frameCount / ((currentTime - lastTime) / 1000);
|
||||
this.addMetric('fps', fps);
|
||||
frameCount = 0;
|
||||
lastTime = currentTime;
|
||||
}
|
||||
|
||||
requestAnimationFrame(measureFrameRate);
|
||||
};
|
||||
|
||||
const animationId = requestAnimationFrame(measureFrameRate);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationId);
|
||||
};
|
||||
}
|
||||
|
||||
// Monitor long tasks
|
||||
startLongTaskMonitoring(): () => void {
|
||||
if ('PerformanceObserver' in window) {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (entry.entryType === 'longtask') {
|
||||
this.addMetric('long-task-duration', entry.duration);
|
||||
console.warn('Long task detected:', entry);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe({ entryTypes: ['longtask'] });
|
||||
this.observers.set('longtask', observer);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
this.observers.delete('longtask');
|
||||
};
|
||||
}
|
||||
|
||||
return () => {};
|
||||
}
|
||||
|
||||
// Monitor layout thrashing
|
||||
startLayoutThrashingMonitoring(): () => void {
|
||||
let layoutCount = 0;
|
||||
let lastLayoutTime = 0;
|
||||
const monitor = this;
|
||||
|
||||
const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
|
||||
|
||||
Element.prototype.getBoundingClientRect = function() {
|
||||
const currentTime = performance.now();
|
||||
if (currentTime - lastLayoutTime < 16) { // Less than 16ms between calls
|
||||
layoutCount++;
|
||||
monitor.addMetric('layout-thrashing-count', layoutCount);
|
||||
}
|
||||
lastLayoutTime = currentTime;
|
||||
return originalGetBoundingClientRect.call(this);
|
||||
};
|
||||
|
||||
return () => {
|
||||
Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
|
||||
};
|
||||
}
|
||||
|
||||
// Generate performance report
|
||||
generateReport(): string {
|
||||
const metrics = this.getMetrics();
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
metrics,
|
||||
summary: this.generateSummary(metrics),
|
||||
recommendations: this.generateRecommendations(metrics),
|
||||
};
|
||||
|
||||
return JSON.stringify(report, null, 2);
|
||||
}
|
||||
|
||||
private generateSummary(metrics: Record<string, any>): Record<string, string> {
|
||||
const summary: Record<string, string> = {};
|
||||
|
||||
// Component render times
|
||||
const renderTimes = Object.keys(metrics).filter(key => key.includes('render-time'));
|
||||
if (renderTimes.length > 0) {
|
||||
const avgRenderTime = renderTimes.reduce((sum, key) => sum + metrics[key].average, 0) / renderTimes.length;
|
||||
summary.renderPerformance = avgRenderTime > 16 ? 'Poor' : avgRenderTime > 8 ? 'Fair' : 'Good';
|
||||
}
|
||||
|
||||
// FPS
|
||||
if (metrics.fps) {
|
||||
summary.frameRate = metrics.fps.average > 55 ? 'Good' : metrics.fps.average > 30 ? 'Fair' : 'Poor';
|
||||
}
|
||||
|
||||
// Memory usage
|
||||
if (metrics['memory-used'] && metrics['memory-limit']) {
|
||||
const memoryUsage = (metrics['memory-used'].average / metrics['memory-limit'].average) * 100;
|
||||
summary.memoryUsage = memoryUsage > 80 ? 'High' : memoryUsage > 50 ? 'Moderate' : 'Low';
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
private generateRecommendations(metrics: Record<string, any>): string[] {
|
||||
const recommendations: string[] = [];
|
||||
|
||||
// Check for slow component renders
|
||||
const slowRenders = Object.keys(metrics).filter(key =>
|
||||
key.includes('render-time') && metrics[key].average > 16
|
||||
);
|
||||
if (slowRenders.length > 0) {
|
||||
recommendations.push(`Optimize component renders: ${slowRenders.join(', ')}`);
|
||||
}
|
||||
|
||||
// Check for layout thrashing
|
||||
if (metrics['layout-thrashing-count'] && metrics['layout-thrashing-count'].count > 10) {
|
||||
recommendations.push('Reduce layout thrashing by batching DOM reads and writes');
|
||||
}
|
||||
|
||||
// Check for long tasks
|
||||
if (metrics['long-task-duration'] && metrics['long-task-duration'].count > 0) {
|
||||
recommendations.push('Break down long tasks into smaller chunks');
|
||||
}
|
||||
|
||||
// Check for low FPS
|
||||
if (metrics.fps && metrics.fps.average < 30) {
|
||||
recommendations.push('Optimize rendering performance to maintain 60fps');
|
||||
}
|
||||
|
||||
// Check for high memory usage
|
||||
if (metrics['memory-used'] && metrics['memory-limit']) {
|
||||
const memoryUsage = (metrics['memory-used'].average / metrics['memory-limit'].average) * 100;
|
||||
if (memoryUsage > 80) {
|
||||
recommendations.push('Reduce memory usage to prevent performance degradation');
|
||||
}
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
|
||||
// Clear all metrics
|
||||
clear(): void {
|
||||
this.metrics.clear();
|
||||
this.marks.clear();
|
||||
performance.clearMarks();
|
||||
performance.clearMeasures();
|
||||
}
|
||||
|
||||
// Stop all monitoring
|
||||
stop(): void {
|
||||
this.observers.forEach((observer) => {
|
||||
observer.disconnect();
|
||||
});
|
||||
this.observers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience functions
|
||||
export const performanceMonitor = PerformanceMonitor.getInstance();
|
||||
|
||||
// React hook for monitoring component performance
|
||||
export const usePerformanceMonitor = (componentName: string) => {
|
||||
const endMonitoring = React.useCallback(() => {
|
||||
return performanceMonitor.monitorComponentRender(componentName);
|
||||
}, [componentName]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const cleanup = endMonitoring();
|
||||
return cleanup;
|
||||
}, [endMonitoring]);
|
||||
};
|
||||
|
||||
// Redux middleware for monitoring selector performance
|
||||
export const performanceMiddleware = (store: any) => (next: any) => (action: any) => {
|
||||
const startTime = performance.now();
|
||||
const result = next(action);
|
||||
const endTime = performance.now();
|
||||
|
||||
performanceMonitor.addMetric(`redux-action-${action.type}`, endTime - startTime);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Export for global access
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).performanceMonitor = performanceMonitor;
|
||||
}
|
||||
297
worklenz-frontend/src/utils/performance-optimizer.ts
Normal file
297
worklenz-frontend/src/utils/performance-optimizer.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
// Performance optimization utility for reducing long tasks and improving frame rate
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface PerformanceMetrics {
|
||||
longTaskCount: number;
|
||||
averageLongTaskDuration: number;
|
||||
frameRate: number;
|
||||
memoryUsage: number;
|
||||
layoutThrashingCount: number;
|
||||
}
|
||||
|
||||
class PerformanceOptimizer {
|
||||
private longTaskObserver: PerformanceObserver | null = null;
|
||||
private frameRateObserver: PerformanceObserver | null = null;
|
||||
private layoutThrashingCount = 0;
|
||||
private metrics: PerformanceMetrics = {
|
||||
longTaskCount: 0,
|
||||
averageLongTaskDuration: 0,
|
||||
frameRate: 0,
|
||||
memoryUsage: 0,
|
||||
layoutThrashingCount: 0,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
this.initializeObservers();
|
||||
}
|
||||
|
||||
private initializeObservers() {
|
||||
// Monitor long tasks
|
||||
if ('PerformanceObserver' in window) {
|
||||
this.longTaskObserver = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (entry.entryType === 'longtask') {
|
||||
this.metrics.longTaskCount++;
|
||||
this.metrics.averageLongTaskDuration =
|
||||
(this.metrics.averageLongTaskDuration * (this.metrics.longTaskCount - 1) + entry.duration) / this.metrics.longTaskCount;
|
||||
|
||||
console.warn(`🚨 Long task detected: ${entry.duration.toFixed(2)}ms - Consider chunking this operation`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.longTaskObserver.observe({ entryTypes: ['longtask'] });
|
||||
}
|
||||
|
||||
// Monitor frame rate
|
||||
this.startFrameRateMonitoring();
|
||||
}
|
||||
|
||||
private startFrameRateMonitoring() {
|
||||
let frameCount = 0;
|
||||
let lastTime = performance.now();
|
||||
|
||||
const measureFrameRate = () => {
|
||||
frameCount++;
|
||||
const currentTime = performance.now();
|
||||
|
||||
if (currentTime - lastTime >= 1000) {
|
||||
this.metrics.frameRate = frameCount;
|
||||
frameCount = 0;
|
||||
lastTime = currentTime;
|
||||
|
||||
if (this.metrics.frameRate < 30) {
|
||||
console.warn(`⚠️ Low frame rate detected: ${this.metrics.frameRate}fps`);
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(measureFrameRate);
|
||||
};
|
||||
|
||||
requestAnimationFrame(measureFrameRate);
|
||||
}
|
||||
|
||||
// Chunk large operations to prevent long tasks
|
||||
static chunkOperation<T>(
|
||||
items: T[],
|
||||
operation: (item: T, index: number) => void,
|
||||
chunkSize: number = 10,
|
||||
delay: number = 16
|
||||
): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
let index = 0;
|
||||
|
||||
const processChunk = () => {
|
||||
const startTime = performance.now();
|
||||
const endIndex = Math.min(index + chunkSize, items.length);
|
||||
|
||||
// Process items in this chunk
|
||||
for (let i = index; i < endIndex; i++) {
|
||||
operation(items[i], i);
|
||||
}
|
||||
|
||||
index = endIndex;
|
||||
|
||||
// Check if we need to yield to prevent long tasks
|
||||
const processingTime = performance.now() - startTime;
|
||||
if (processingTime > 16) {
|
||||
console.warn(`⚠️ Chunk processing took ${processingTime.toFixed(2)}ms - consider smaller chunks`);
|
||||
}
|
||||
|
||||
if (index < items.length) {
|
||||
// Schedule next chunk with delay to prevent blocking
|
||||
setTimeout(processChunk, delay);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
processChunk();
|
||||
});
|
||||
}
|
||||
|
||||
// Optimize DOM operations to prevent layout thrashing
|
||||
static batchDOMOperations(operations: (() => void)[]): void {
|
||||
// Use requestAnimationFrame to batch DOM updates
|
||||
requestAnimationFrame(() => {
|
||||
// Force layout read first
|
||||
document.body.offsetHeight;
|
||||
|
||||
// Perform all write operations
|
||||
operations.forEach(operation => operation());
|
||||
});
|
||||
}
|
||||
|
||||
// Debounce function for expensive operations
|
||||
static debounce<T extends (...args: any[]) => void>(
|
||||
func: T,
|
||||
delay: number
|
||||
): T {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
|
||||
return ((...args: any[]) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => func(...args), delay);
|
||||
}) as T;
|
||||
}
|
||||
|
||||
// Throttle function for frequent operations
|
||||
static throttle<T extends (...args: any[]) => void>(
|
||||
func: T,
|
||||
delay: number
|
||||
): T {
|
||||
let lastExecTime = 0;
|
||||
|
||||
return ((...args: any[]) => {
|
||||
const currentTime = performance.now();
|
||||
|
||||
if (currentTime - lastExecTime > delay) {
|
||||
func(...args);
|
||||
lastExecTime = currentTime;
|
||||
}
|
||||
}) as T;
|
||||
}
|
||||
|
||||
// Optimize list rendering with virtualization hints
|
||||
static optimizeListRendering<T>(
|
||||
items: T[],
|
||||
renderItem: (item: T, index: number) => React.ReactNode,
|
||||
options: {
|
||||
chunkSize?: number;
|
||||
virtualizationThreshold?: number;
|
||||
overscanCount?: number;
|
||||
} = {}
|
||||
): React.ReactNode[] {
|
||||
const {
|
||||
chunkSize = 50,
|
||||
virtualizationThreshold = 100,
|
||||
overscanCount = 5
|
||||
} = options;
|
||||
|
||||
// For small lists, render everything
|
||||
if (items.length <= virtualizationThreshold) {
|
||||
return items.map((item, index) => renderItem(item, index));
|
||||
}
|
||||
|
||||
// For large lists, use chunked rendering
|
||||
const chunks: React.ReactNode[] = [];
|
||||
|
||||
for (let i = 0; i < items.length; i += chunkSize) {
|
||||
const chunk = items.slice(i, i + chunkSize);
|
||||
chunks.push(
|
||||
React.createElement('div', { key: `chunk-${i}`, className: 'virtualized-chunk' },
|
||||
chunk.map((item, index) => renderItem(item, i + index))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
// Monitor memory usage
|
||||
static getMemoryUsage(): { used: number; total: number; limit: number } {
|
||||
if ('memory' in performance) {
|
||||
const memory = (performance as any).memory;
|
||||
return {
|
||||
used: memory.usedJSHeapSize / 1024 / 1024,
|
||||
total: memory.totalJSHeapSize / 1024 / 1024,
|
||||
limit: memory.jsHeapSizeLimit / 1024 / 1024
|
||||
};
|
||||
}
|
||||
return { used: 0, total: 0, limit: 0 };
|
||||
}
|
||||
|
||||
// Optimize scroll performance
|
||||
static optimizeScroll(container: HTMLElement, handler: (event: Event) => void): () => void {
|
||||
let ticking = false;
|
||||
|
||||
const optimizedHandler = (event: Event) => {
|
||||
if (!ticking) {
|
||||
requestAnimationFrame(() => {
|
||||
handler(event);
|
||||
ticking = false;
|
||||
});
|
||||
ticking = true;
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener('scroll', optimizedHandler, { passive: true });
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('scroll', optimizedHandler);
|
||||
};
|
||||
}
|
||||
|
||||
// Optimize hover performance
|
||||
static optimizeHover(
|
||||
element: HTMLElement,
|
||||
onEnter: () => void,
|
||||
onLeave: () => void,
|
||||
delay: number = 50
|
||||
): () => void {
|
||||
let enterTimeout: NodeJS.Timeout;
|
||||
let leaveTimeout: NodeJS.Timeout;
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
clearTimeout(leaveTimeout);
|
||||
enterTimeout = setTimeout(onEnter, delay);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
clearTimeout(enterTimeout);
|
||||
leaveTimeout = setTimeout(onLeave, delay);
|
||||
};
|
||||
|
||||
element.addEventListener('mouseenter', handleMouseEnter, { passive: true });
|
||||
element.addEventListener('mouseleave', handleMouseLeave, { passive: true });
|
||||
|
||||
return () => {
|
||||
clearTimeout(enterTimeout);
|
||||
clearTimeout(leaveTimeout);
|
||||
element.removeEventListener('mouseenter', handleMouseEnter);
|
||||
element.removeEventListener('mouseleave', handleMouseLeave);
|
||||
};
|
||||
}
|
||||
|
||||
// Get current performance metrics
|
||||
getMetrics(): PerformanceMetrics {
|
||||
const memory = PerformanceOptimizer.getMemoryUsage();
|
||||
this.metrics.memoryUsage = memory.used;
|
||||
this.metrics.layoutThrashingCount = this.layoutThrashingCount;
|
||||
|
||||
return { ...this.metrics };
|
||||
}
|
||||
|
||||
// Reset metrics
|
||||
resetMetrics(): void {
|
||||
this.metrics = {
|
||||
longTaskCount: 0,
|
||||
averageLongTaskDuration: 0,
|
||||
frameRate: 0,
|
||||
memoryUsage: 0,
|
||||
layoutThrashingCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Cleanup observers
|
||||
destroy(): void {
|
||||
if (this.longTaskObserver) {
|
||||
this.longTaskObserver.disconnect();
|
||||
}
|
||||
if (this.frameRateObserver) {
|
||||
this.frameRateObserver.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create global instance
|
||||
const performanceOptimizer = new PerformanceOptimizer();
|
||||
|
||||
// Auto-cleanup on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
performanceOptimizer.destroy();
|
||||
});
|
||||
|
||||
export { PerformanceOptimizer, performanceOptimizer };
|
||||
export default PerformanceOptimizer;
|
||||
Reference in New Issue
Block a user