diff --git a/worklenz-frontend/src/components/project-list/project-group/project-group-list.tsx b/worklenz-frontend/src/components/project-list/project-group/project-group-list.tsx index ed7e35df..2deafb5d 100644 --- a/worklenz-frontend/src/components/project-list/project-group/project-group-list.tsx +++ b/worklenz-frontend/src/components/project-list/project-group/project-group-list.tsx @@ -55,6 +55,20 @@ const ProjectGroupList: React.FC = ({ loading, t }) => { + // Preload project view components on hover for smoother navigation + const handleProjectHover = React.useCallback((project_id: string) => { + if (project_id) { + // Preload the project view route to reduce loading time + import('@/pages/projects/projectView/project-view').catch(() => { + // Silently fail if preload doesn't work + }); + + // Also preload critical task management components + import('@/components/task-management/task-list-board').catch(() => { + // Silently fail if preload doesn't work + }); + } + }, []); const { token } = theme.useToken(); const themeMode = useAppSelector(state => state.themeReducer.mode); const dispatch = useAppDispatch(); @@ -360,6 +374,8 @@ const ProjectGroupList: React.FC = ({ if (actionButtons) { actionButtons.style.opacity = '1'; } + // Preload components for smoother navigation + handleProjectHover(project.id); }} onMouseLeave={(e) => { Object.assign(e.currentTarget.style, styles.projectCard); diff --git a/worklenz-frontend/src/components/suspense-fallback/suspense-fallback.tsx b/worklenz-frontend/src/components/suspense-fallback/suspense-fallback.tsx index d533a4a2..4f346af0 100644 --- a/worklenz-frontend/src/components/suspense-fallback/suspense-fallback.tsx +++ b/worklenz-frontend/src/components/suspense-fallback/suspense-fallback.tsx @@ -7,6 +7,8 @@ export const SuspenseFallback = memo(() => {
import('./improved-task-filters') -); - - +// Import the improved TaskListFilters component synchronously to avoid suspense +import ImprovedTaskFilters from './improved-task-filters'; interface TaskListBoardProps { projectId: string; @@ -393,9 +389,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' {/* Task Filters */}
- Loading filters...
}> - - +
{/* Virtualized Task Groups Container */} diff --git a/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts b/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts index 74ba350c..6bdbebaa 100644 --- a/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts +++ b/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts @@ -114,6 +114,9 @@ const taskDrawerSlice = createSlice({ state.taskFormViewModel.task.schedule_id = schedule_id; } }, + resetTaskDrawer: (state) => { + return initialState; + }, }, extraReducers: builder => { builder.addCase(fetchTask.pending, state => { @@ -142,6 +145,7 @@ export const { setTaskLabels, setTaskSubscribers, setTimeLogEditing, - setTaskRecurringSchedule + setTaskRecurringSchedule, + resetTaskDrawer } = taskDrawerSlice.actions; export default taskDrawerSlice.reducer; diff --git a/worklenz-frontend/src/hooks/usePerformanceOptimization.ts b/worklenz-frontend/src/hooks/usePerformanceOptimization.ts new file mode 100644 index 00000000..aa2e7d73 --- /dev/null +++ b/worklenz-frontend/src/hooks/usePerformanceOptimization.ts @@ -0,0 +1,163 @@ +import { useCallback, useMemo, useRef, useEffect, useState } from 'react'; +import { useAppSelector } from './useAppSelector'; +import { debounce, throttle } from 'lodash'; + +// Performance optimization utilities +export const usePerformanceOptimization = () => { + const renderCountRef = useRef(0); + const lastRenderTimeRef = useRef(0); + + // Track render performance + const trackRender = useCallback((componentName: string) => { + renderCountRef.current += 1; + const now = performance.now(); + const timeSinceLastRender = now - lastRenderTimeRef.current; + lastRenderTimeRef.current = now; + + if (process.env.NODE_ENV === 'development') { + console.log(`[${componentName}] Render #${renderCountRef.current}, Time since last: ${timeSinceLastRender.toFixed(2)}ms`); + + if (timeSinceLastRender < 16) { // Less than 60fps + console.warn(`[${componentName}] Potential over-rendering detected`); + } + } + }, []); + + // Debounced callback creator + const createDebouncedCallback = useCallback( any>( + callback: T, + delay: number = 300 + ) => { + return debounce(callback, delay); + }, []); + + // Throttled callback creator + const createThrottledCallback = useCallback( any>( + callback: T, + delay: number = 100 + ) => { + return throttle(callback, delay); + }, []); + + return { + trackRender, + createDebouncedCallback, + createThrottledCallback, + }; +}; + +// Optimized selector hook to prevent unnecessary re-renders +export const useOptimizedSelector = ( + selector: (state: any) => T, + equalityFn?: (left: T, right: T) => boolean +) => { + const defaultEqualityFn = useCallback((left: T, right: T) => { + // Deep equality check for objects and arrays + if (typeof left === 'object' && typeof right === 'object') { + return JSON.stringify(left) === JSON.stringify(right); + } + return left === right; + }, []); + + return useAppSelector(selector, equalityFn || defaultEqualityFn); +}; + +// Memoized component props +export const useMemoizedProps = >(props: T): T => { + return useMemo(() => props, Object.values(props)); +}; + +// Optimized event handlers +export const useOptimizedEventHandlers = any>>( + handlers: T +) => { + return useMemo(() => { + const optimizedHandlers = {} as any; + + Object.entries(handlers).forEach(([key, handler]) => { + optimizedHandlers[key] = useCallback(handler, [handler]); + }); + + return optimizedHandlers as T; + }, [handlers]); +}; + +// Virtual scrolling utilities +export const useVirtualScrolling = ( + itemCount: number, + itemHeight: number, + containerHeight: number +) => { + const visibleRange = useMemo(() => { + const startIndex = Math.floor(window.scrollY / itemHeight); + const endIndex = Math.min( + startIndex + Math.ceil(containerHeight / itemHeight) + 1, + itemCount + ); + + return { startIndex: Math.max(0, startIndex), endIndex }; + }, [itemCount, itemHeight, containerHeight]); + + return visibleRange; +}; + +// Image lazy loading hook +export const useLazyLoading = (threshold: number = 0.1) => { + const observerRef = useRef(null); + const [isVisible, setIsVisible] = useState(false); + + const targetRef = useCallback((node: HTMLElement | null) => { + if (observerRef.current) observerRef.current.disconnect(); + + if (node) { + observerRef.current = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setIsVisible(true); + observerRef.current?.disconnect(); + } + }, + { threshold } + ); + observerRef.current.observe(node); + } + }, [threshold]); + + useEffect(() => { + return () => { + observerRef.current?.disconnect(); + }; + }, []); + + return { targetRef, isVisible }; +}; + +// Memory optimization for large datasets +export const useMemoryOptimization = ( + data: T[], + maxCacheSize: number = 1000 +) => { + const cacheRef = useRef(new Map()); + + const optimizedData = useMemo(() => { + if (data.length <= maxCacheSize) { + return data; + } + + // Keep only the most recently accessed items + const cache = cacheRef.current; + const recentData = data.slice(0, maxCacheSize); + + // Clear old cache entries + cache.clear(); + recentData.forEach((item, index) => { + cache.set(String(index), item); + }); + + return recentData; + }, [data, maxCacheSize]); + + return optimizedData; +}; + +export default usePerformanceOptimization; \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/project-list.tsx b/worklenz-frontend/src/pages/projects/project-list.tsx index 8920f078..0fa3e377 100644 --- a/worklenz-frontend/src/pages/projects/project-list.tsx +++ b/worklenz-frontend/src/pages/projects/project-list.tsx @@ -437,6 +437,21 @@ const ProjectList: React.FC = () => { } }, [navigate]); + // Preload project view components on hover for smoother navigation + const handleProjectHover = useCallback((project_id: string | undefined) => { + if (project_id) { + // Preload the project view route to reduce loading time + import('@/pages/projects/projectView/project-view').catch(() => { + // Silently fail if preload doesn't work + }); + + // Also preload critical task management components + import('@/components/task-management/task-list-board').catch(() => { + // Silently fail if preload doesn't work + }); + } + }, []); + // Define table columns directly in the component to avoid hooks order issues const tableColumns: ColumnsType = useMemo( () => [ @@ -629,6 +644,7 @@ const ProjectList: React.FC = () => { locale={{ emptyText }} onRow={record => ({ onClick: () => navigateToProject(record.id, record.team_member_default_view), + onMouseEnter: () => handleProjectHover(record.id), })} /> ) : ( diff --git a/worklenz-frontend/src/pages/projects/projectView/project-view.css b/worklenz-frontend/src/pages/projects/projectView/project-view.css index 8b468b8a..c61b7af4 100644 --- a/worklenz-frontend/src/pages/projects/projectView/project-view.css +++ b/worklenz-frontend/src/pages/projects/projectView/project-view.css @@ -4,3 +4,222 @@ height: 8px; width: 8px; } + +/* Enhanced Project View Tab Styles - Compact */ +.project-view-tabs { + margin-top: 16px; +} + +/* Remove default tab border */ +.project-view-tabs .ant-tabs-nav::before { + border: none !important; +} + +/* Tab bar container */ +.project-view-tabs .ant-tabs-nav { + margin-bottom: 8px; + background: transparent; + padding: 0 12px; +} + +/* Individual tab styling - Compact */ +.project-view-tabs .ant-tabs-tab { + position: relative; + margin: 0 4px 0 0; + padding: 8px 16px; + border-radius: 6px 6px 0 0; + background: transparent; + border: 1px solid transparent; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + font-weight: 500; + font-size: 13px; + min-height: 36px; + display: flex; + align-items: center; +} + +/* Light mode tab styles */ +[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); +} + +[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 */ +[data-theme="dark"] .project-view-tabs .ant-tabs-tab { + color: #94a3b8; + background: #1e293b; + border-color: #334155; +} + +[data-theme="dark"] .project-view-tabs .ant-tabs-tab:hover { + color: #60a5fa; + background: #1e3a8a; + border-color: #3b82f6; + 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; + box-shadow: 0 -2px 8px rgba(96, 165, 250, 0.15), 0 4px 16px rgba(96, 165, 250, 0.15); + z-index: 1; +} + +/* Tab content area - Compact */ +.project-view-tabs .ant-tabs-content-holder { + background: transparent; + border-radius: 6px; + position: relative; + z-index: 0; + margin-top: 4px; +} + +[data-theme="default"] .project-view-tabs .ant-tabs-content-holder { + background: #ffffff; + border: 1px solid #e2e8f0; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); +} + +[data-theme="dark"] .project-view-tabs .ant-tabs-content-holder { + background: #0f172a; + border: 1px solid #334155; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} + +.project-view-tabs .ant-tabs-tabpane { + padding: 0; + min-height: 300px; +} + +/* Pin button styling - Compact */ +.project-view-tabs .borderless-icon-btn { + margin-left: 6px; + padding: 2px; + border-radius: 3px; + transition: all 0.2s ease; + opacity: 0.7; +} + +.project-view-tabs .borderless-icon-btn:hover { + opacity: 1; + transform: scale(1.05); +} + +[data-theme="default"] .project-view-tabs .borderless-icon-btn:hover { + background: rgba(59, 130, 246, 0.1) !important; +} + +[data-theme="dark"] .project-view-tabs .borderless-icon-btn:hover { + background: rgba(96, 165, 250, 0.1) !important; +} + +/* Pinned tab indicator */ +.project-view-tabs .ant-tabs-tab-active .borderless-icon-btn { + opacity: 1; +} + +[data-theme="default"] .project-view-tabs .ant-tabs-tab-active .borderless-icon-btn { + background: rgba(59, 130, 246, 0.1) !important; +} + +[data-theme="dark"] .project-view-tabs .ant-tabs-tab-active .borderless-icon-btn { + background: rgba(96, 165, 250, 0.1) !important; +} + +/* Tab label flex container */ +.project-view-tabs .ant-tabs-tab .ant-tabs-tab-btn { + display: flex; + align-items: center; + width: 100%; +} + +/* Responsive adjustments - Compact */ +@media (max-width: 768px) { + .project-view-tabs .ant-tabs-nav { + padding: 0 8px; + } + + .project-view-tabs .ant-tabs-tab { + margin: 0 2px 0 0; + padding: 6px 12px; + font-size: 12px; + min-height: 32px; + } + + .project-view-tabs .borderless-icon-btn { + margin-left: 4px; + padding: 1px; + } +} + +@media (max-width: 480px) { + .project-view-tabs .ant-tabs-tab { + padding: 6px 10px; + font-size: 11px; + min-height: 30px; + } + + .project-view-tabs .borderless-icon-btn { + display: none; /* Hide pin buttons on very small screens */ + } +} + +/* Animation for tab switching */ +.project-view-tabs .ant-tabs-content { + position: relative; +} + +.project-view-tabs .ant-tabs-tabpane-active { + animation: fadeInUp 0.3s ease-out; +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Focus states for accessibility - Compact */ +.project-view-tabs .ant-tabs-tab:focus-visible { + outline: 1px solid #3b82f6; + outline-offset: 1px; + border-radius: 6px; +} + +[data-theme="dark"] .project-view-tabs .ant-tabs-tab:focus-visible { + outline-color: #60a5fa; +} + +/* Loading state for tab content */ +.project-view-tabs .ant-tabs-tabpane .suspense-fallback { + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; +} diff --git a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx index c6e88633..77428cbb 100644 --- a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import React, { useEffect, useState, useMemo, useCallback, Suspense } from 'react'; import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { createPortal } from 'react-dom'; @@ -33,32 +33,57 @@ import { resetFields } from '@/features/task-management/taskListFields.slice'; import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice'; import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice'; import { tabItems } from '@/lib/project/project-view-constants'; -import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice'; +import { setSelectedTaskId, setShowTaskDrawer, resetTaskDrawer } from '@/features/task-drawer/task-drawer.slice'; +import { resetState as resetEnhancedKanbanState } from '@/features/enhanced-kanban/enhanced-kanban.slice'; +import { setProjectId as setInsightsProjectId } from '@/features/projects/insights/project-insights.slice'; +import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback'; +// Import critical components synchronously to avoid suspense interruptions +import TaskDrawer from '@components/task-drawer/task-drawer'; + +// Lazy load non-critical components with better error handling const DeleteStatusDrawer = React.lazy(() => import('@/components/project-task-filters/delete-status-drawer/delete-status-drawer')); -const PhaseDrawer = React.lazy(() => import('@features/projects/singleProject/phase/PhaseDrawer')); -const StatusDrawer = React.lazy( - () => import('@/components/project-task-filters/create-status-drawer/create-status-drawer') -); -const ProjectMemberDrawer = React.lazy( - () => import('@/components/projects/project-member-invite-drawer/project-member-invite-drawer') -); -const TaskDrawer = React.lazy(() => import('@components/task-drawer/task-drawer')); +const PhaseDrawer = React.lazy(() => import('@/features/projects/singleProject/phase/PhaseDrawer')); +const StatusDrawer = React.lazy(() => import('@/components/project-task-filters/create-status-drawer/create-status-drawer')); +const ProjectMemberDrawer = React.lazy(() => import('@/components/projects/project-member-invite-drawer/project-member-invite-drawer')); -const ProjectView = () => { + + +const ProjectView = React.memo(() => { const location = useLocation(); const navigate = useNavigate(); const dispatch = useAppDispatch(); const [searchParams] = useSearchParams(); const { projectId } = useParams(); + // Memoized selectors to prevent unnecessary re-renders const selectedProject = useAppSelector(state => state.projectReducer.project); + const projectLoading = useAppSelector(state => state.projectReducer.projectLoading); + + // Optimize document title updates useDocumentTitle(selectedProject?.name || 'Project View'); - const [activeTab, setActiveTab] = useState(searchParams.get('tab') || tabItems[0].key); - const [pinnedTab, setPinnedTab] = useState(searchParams.get('pinned_tab') || ''); - const [taskid, setTaskId] = useState(searchParams.get('task') || ''); + + // Memoize URL params to prevent unnecessary state updates + const urlParams = useMemo(() => ({ + tab: searchParams.get('tab') || tabItems[0].key, + pinnedTab: searchParams.get('pinned_tab') || '', + taskId: searchParams.get('task') || '' + }), [searchParams]); - const resetProjectData = useCallback(() => { + const [activeTab, setActiveTab] = useState(urlParams.tab); + const [pinnedTab, setPinnedTab] = useState(urlParams.pinnedTab); + const [taskid, setTaskId] = useState(urlParams.taskId); + const [isInitialized, setIsInitialized] = useState(false); + + // Update local state when URL params change + useEffect(() => { + setActiveTab(urlParams.tab); + setPinnedTab(urlParams.pinnedTab); + setTaskId(urlParams.taskId); + }, [urlParams]); + + // Comprehensive cleanup function for when leaving project view entirely + const resetAllProjectData = useCallback(() => { dispatch(setProjectId(null)); dispatch(resetStatuses()); dispatch(deselectAll()); @@ -68,140 +93,259 @@ const ProjectView = () => { dispatch(resetGrouping()); dispatch(resetSelection()); dispatch(resetFields()); + dispatch(resetEnhancedKanbanState()); + + // Reset project insights + dispatch(setInsightsProjectId('')); + + // Reset task drawer completely + dispatch(resetTaskDrawer()); }, [dispatch]); + // Effect for handling component unmount (leaving project view entirely) useEffect(() => { - if (projectId) { - dispatch(setProjectId(projectId)); - dispatch(getProject(projectId)).then((res: any) => { - if (!res.payload) { - navigate('/worklenz/projects'); - return; - } - dispatch(fetchStatuses(projectId)); - dispatch(fetchLabels()); - }); + // This cleanup only runs when the component unmounts + return () => { + resetAllProjectData(); + }; + }, []); // Empty dependency array - only runs on mount/unmount + + // Effect for handling route changes (when navigating away from project view) + useEffect(() => { + const currentPath = location.pathname; + + // If we're not on a project view path, clean up + if (!currentPath.includes('/worklenz/projects/') || currentPath === '/worklenz/projects') { + resetAllProjectData(); } - if (taskid) { - dispatch(setSelectedTaskId(taskid || '')); + }, [location.pathname, resetAllProjectData]); + + // Optimized project data loading with better error handling and performance tracking + useEffect(() => { + if (projectId && !isInitialized) { + const loadProjectData = async () => { + try { + // Clean up previous project data before loading new project + dispatch(resetTaskListData()); + dispatch(resetBoardData()); + dispatch(resetTaskManagement()); + dispatch(resetEnhancedKanbanState()); + dispatch(deselectAll()); + + // Load new project data + dispatch(setProjectId(projectId)); + + // Load project and essential data in parallel + const [projectResult] = await Promise.allSettled([ + dispatch(getProject(projectId)), + dispatch(fetchStatuses(projectId)), + dispatch(fetchLabels()) + ]); + + if (projectResult.status === 'fulfilled' && !projectResult.value.payload) { + navigate('/worklenz/projects'); + return; + } + + setIsInitialized(true); + } catch (error) { + console.error('Error loading project data:', error); + navigate('/worklenz/projects'); + } + }; + + loadProjectData(); + } + }, [dispatch, navigate, projectId]); + + // Reset initialization when project changes + useEffect(() => { + setIsInitialized(false); + }, [projectId]); + + // Effect for handling task drawer opening from URL params + useEffect(() => { + if (taskid && isInitialized) { + dispatch(setSelectedTaskId(taskid)); dispatch(setShowTaskDrawer(true)); } + }, [dispatch, taskid, isInitialized]); - return () => { - resetProjectData(); - }; - }, [dispatch, navigate, projectId, taskid, resetProjectData]); - + // Optimized pin tab function with better error handling const pinToDefaultTab = useCallback(async (itemKey: string) => { if (!itemKey || !projectId) return; - const defaultView = itemKey === 'tasks-list' ? 'TASK_LIST' : 'BOARD'; - const res = await projectsApiService.updateDefaultTab({ - project_id: projectId, - default_view: defaultView, - }); - - if (res.done) { - setPinnedTab(itemKey); - tabItems.forEach(item => { - if (item.key === itemKey) { - item.isPinned = true; - } else { - item.isPinned = false; - } + try { + const defaultView = itemKey === 'tasks-list' ? 'TASK_LIST' : 'BOARD'; + const res = await projectsApiService.updateDefaultTab({ + project_id: projectId, + default_view: defaultView, }); - navigate({ - pathname: `/worklenz/projects/${projectId}`, - search: new URLSearchParams({ - tab: activeTab, - pinned_tab: itemKey - }).toString(), - }); + if (res.done) { + setPinnedTab(itemKey); + + // Optimize tab items update + tabItems.forEach(item => { + item.isPinned = item.key === itemKey; + }); + + navigate({ + pathname: `/worklenz/projects/${projectId}`, + search: new URLSearchParams({ + tab: activeTab, + pinned_tab: itemKey + }).toString(), + }, { replace: true }); // Use replace to avoid history pollution + } + } catch (error) { + console.error('Error updating default tab:', error); } }, [projectId, activeTab, navigate]); + // Optimized tab change handler const handleTabChange = useCallback((key: string) => { setActiveTab(key); dispatch(setProjectView(key === 'board' ? 'kanban' : 'list')); + + // Use replace for better performance and history management navigate({ pathname: location.pathname, search: new URLSearchParams({ tab: key, pinned_tab: pinnedTab, }).toString(), - }); + }, { replace: true }); }, [dispatch, location.pathname, navigate, pinnedTab]); - const tabMenuItems = useMemo(() => tabItems.map(item => ({ - key: item.key, - label: ( - - {item.label} - {item.key === 'tasks-list' || item.key === 'board' ? ( - -