Merge pull request #169 from Worklenz/imp/task-list-performance-fixes

Imp/task list performance fixes
This commit is contained in:
Chamika J
2025-06-21 18:53:55 +05:30
committed by GitHub
21 changed files with 1735 additions and 452 deletions

View File

@@ -1,11 +1,10 @@
// Core dependencies
import React, { Suspense, useEffect } from 'react';
import React, { Suspense, useEffect, memo, useMemo, useCallback } from 'react';
import { RouterProvider } from 'react-router-dom';
import i18next from 'i18next';
// Components
import ThemeWrapper from './features/theme/ThemeWrapper';
import PreferenceSelector from './components/PreferenceSelector';
// Routes
import router from './app/routes';
@@ -20,36 +19,72 @@ import { Language } from './features/i18n/localesSlice';
import logger from './utils/errorLogger';
import { SuspenseFallback } from './components/suspense-fallback/suspense-fallback';
const App: React.FC<{ children: React.ReactNode }> = ({ children }) => {
/**
* Main App Component - Performance Optimized
*
* Performance optimizations applied:
* 1. React.memo() - Prevents unnecessary re-renders
* 2. useMemo() - Memoizes expensive computations
* 3. useCallback() - Memoizes event handlers
* 4. Lazy loading - All route components loaded on demand
* 5. Suspense boundaries - Better loading states
* 6. Optimized guard components with memoization
*/
const App: React.FC = memo(() => {
const themeMode = useAppSelector(state => state.themeReducer.mode);
const language = useAppSelector(state => state.localesReducer.lng);
initMixpanel(import.meta.env.VITE_MIXPANEL_TOKEN as string);
// Memoize mixpanel initialization to prevent re-initialization
const mixpanelToken = useMemo(() => import.meta.env.VITE_MIXPANEL_TOKEN as string, []);
useEffect(() => {
initMixpanel(mixpanelToken);
}, [mixpanelToken]);
// Memoize language change handler
const handleLanguageChange = useCallback((lng: string) => {
i18next.changeLanguage(lng, err => {
if (err) return logger.error('Error changing language', err);
});
}, []);
useEffect(() => {
document.documentElement.setAttribute('data-theme', themeMode);
}, [themeMode]);
useEffect(() => {
i18next.changeLanguage(language || Language.EN, err => {
if (err) return logger.error('Error changing language', err);
});
}, [language]);
handleLanguageChange(language || Language.EN);
}, [language, handleLanguageChange]);
// Initialize CSRF token on app startup
// Initialize CSRF token on app startup - memoize to prevent re-initialization
useEffect(() => {
let isMounted = true;
initializeCsrfToken().catch(error => {
logger.error('Failed to initialize CSRF token:', error);
if (isMounted) {
logger.error('Failed to initialize CSRF token:', error);
}
});
return () => {
isMounted = false;
};
}, []);
return (
<Suspense fallback={<SuspenseFallback />}>
<ThemeWrapper>
<RouterProvider router={router} future={{ v7_startTransition: true }} />
<RouterProvider
router={router}
future={{
v7_startTransition: true
}}
/>
</ThemeWrapper>
</Suspense>
);
};
});
App.displayName = 'App';
export default App;

View File

@@ -0,0 +1,122 @@
import { Middleware } from '@reduxjs/toolkit';
// Performance monitoring for Redux store
export interface PerformanceMetrics {
actionType: string;
duration: number;
timestamp: number;
stateSize: number;
}
class ReduxPerformanceMonitor {
private metrics: PerformanceMetrics[] = [];
private maxMetrics = 100; // Keep last 100 metrics
private slowActionThreshold = 50; // Log actions taking more than 50ms
logMetric(metric: PerformanceMetrics) {
this.metrics.push(metric);
// Keep only recent metrics
if (this.metrics.length > this.maxMetrics) {
this.metrics = this.metrics.slice(-this.maxMetrics);
}
// Log slow actions in development
if (process.env.NODE_ENV === 'development' && metric.duration > this.slowActionThreshold) {
console.warn(`Slow Redux action detected: ${metric.actionType} took ${metric.duration}ms`);
}
}
getMetrics() {
return [...this.metrics];
}
getSlowActions(threshold = this.slowActionThreshold) {
return this.metrics.filter(m => m.duration > threshold);
}
getAverageActionTime() {
if (this.metrics.length === 0) return 0;
const total = this.metrics.reduce((sum, m) => sum + m.duration, 0);
return total / this.metrics.length;
}
reset() {
this.metrics = [];
}
}
export const performanceMonitor = new ReduxPerformanceMonitor();
// Redux middleware for performance monitoring
export const performanceMiddleware: Middleware = (store) => (next) => (action: any) => {
const start = performance.now();
const result = next(action);
const end = performance.now();
const duration = end - start;
// Calculate approximate state size (in development only)
let stateSize = 0;
if (process.env.NODE_ENV === 'development') {
try {
stateSize = JSON.stringify(store.getState()).length;
} catch (e) {
stateSize = -1; // Indicates serialization error
}
}
performanceMonitor.logMetric({
actionType: action.type || 'unknown',
duration,
timestamp: Date.now(),
stateSize,
});
return result;
};
// Hook to access performance metrics in components
export function useReduxPerformance() {
return {
metrics: performanceMonitor.getMetrics(),
slowActions: performanceMonitor.getSlowActions(),
averageTime: performanceMonitor.getAverageActionTime(),
reset: () => performanceMonitor.reset(),
};
}
// Utility to detect potential performance issues
export function analyzeReduxPerformance() {
const metrics = performanceMonitor.getMetrics();
const analysis = {
totalActions: metrics.length,
slowActions: performanceMonitor.getSlowActions().length,
averageActionTime: performanceMonitor.getAverageActionTime(),
largestStateSize: Math.max(...metrics.map(m => m.stateSize)),
mostFrequentActions: {} as Record<string, number>,
recommendations: [] as string[],
};
// Count action frequencies
metrics.forEach(m => {
analysis.mostFrequentActions[m.actionType] =
(analysis.mostFrequentActions[m.actionType] || 0) + 1;
});
// Generate recommendations
if (analysis.slowActions > analysis.totalActions * 0.1) {
analysis.recommendations.push('Consider optimizing selectors with createSelector');
}
if (analysis.largestStateSize > 1000000) { // 1MB
analysis.recommendations.push('State size is large - consider normalizing data');
}
if (analysis.averageActionTime > 20) {
analysis.recommendations.push('Average action time is high - check for expensive reducers');
}
return analysis;
}

View File

@@ -1,20 +1,20 @@
import { lazy, Suspense } from 'react';
import AuthLayout from '@/layouts/AuthLayout';
import LoginPage from '@/pages/auth/login-page';
import SignupPage from '@/pages/auth/signup-page';
import ForgotPasswordPage from '@/pages/auth/forgot-password-page';
import LoggingOutPage from '@/pages/auth/logging-out';
import AuthenticatingPage from '@/pages/auth/authenticating';
import { Navigate } from 'react-router-dom';
import VerifyResetEmailPage from '@/pages/auth/verify-reset-email';
import { Suspense } from 'react';
import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback';
// Lazy load auth page components for better code splitting
const LoginPage = lazy(() => import('@/pages/auth/login-page'));
const SignupPage = lazy(() => import('@/pages/auth/signup-page'));
const ForgotPasswordPage = lazy(() => import('@/pages/auth/forgot-password-page'));
const LoggingOutPage = lazy(() => import('@/pages/auth/logging-out'));
const AuthenticatingPage = lazy(() => import('@/pages/auth/authenticating'));
const VerifyResetEmailPage = lazy(() => import('@/pages/auth/verify-reset-email'));
const authRoutes = [
{
path: '/auth',
element: (
<AuthLayout />
),
element: <AuthLayout />,
children: [
{
path: '',
@@ -22,27 +22,51 @@ const authRoutes = [
},
{
path: 'login',
element: <LoginPage />,
element: (
<Suspense fallback={<SuspenseFallback />}>
<LoginPage />
</Suspense>
),
},
{
path: 'signup',
element: <SignupPage />,
element: (
<Suspense fallback={<SuspenseFallback />}>
<SignupPage />
</Suspense>
),
},
{
path: 'forgot-password',
element: <ForgotPasswordPage />,
element: (
<Suspense fallback={<SuspenseFallback />}>
<ForgotPasswordPage />
</Suspense>
),
},
{
path: 'logging-out',
element: <LoggingOutPage />,
element: (
<Suspense fallback={<SuspenseFallback />}>
<LoggingOutPage />
</Suspense>
),
},
{
path: 'authenticating',
element: <AuthenticatingPage />,
element: (
<Suspense fallback={<SuspenseFallback />}>
<AuthenticatingPage />
</Suspense>
),
},
{
path: 'verify-reset-email/:user/:hash',
element: <VerifyResetEmailPage />,
element: (
<Suspense fallback={<SuspenseFallback />}>
<VerifyResetEmailPage />
</Suspense>
),
},
],
},

View File

@@ -1,4 +1,5 @@
import { createBrowserRouter, Navigate, RouteObject, useLocation } from 'react-router-dom';
import { lazy, Suspense, memo, useMemo } from 'react';
import rootRoutes from './root-routes';
import authRoutes from './auth-routes';
import mainRoutes, { licenseExpiredRoute } from './main-routes';
@@ -8,116 +9,195 @@ import reportingRoutes from './reporting-routes';
import { useAuthService } from '@/hooks/useAuth';
import { AuthenticatedLayout } from '@/layouts/AuthenticatedLayout';
import ErrorBoundary from '@/components/ErrorBoundary';
import NotFoundPage from '@/pages/404-page/404-page';
import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback';
import { ISUBSCRIPTION_TYPE } from '@/shared/constants';
import LicenseExpired from '@/pages/license-expired/license-expired';
// Lazy load the NotFoundPage component for better code splitting
const NotFoundPage = lazy(() => import('@/pages/404-page/404-page'));
interface GuardProps {
children: React.ReactNode;
}
export const AuthGuard = ({ children }: GuardProps) => {
const isAuthenticated = useAuthService().isAuthenticated();
const location = useLocation();
if (!isAuthenticated) {
return <Navigate to="/auth" state={{ from: location }} replace />;
}
return <>{children}</>;
// Route-based code splitting utility
const withCodeSplitting = (Component: React.LazyExoticComponent<React.ComponentType<any>>) => {
return memo(() => (
<Suspense fallback={<SuspenseFallback />}>
<Component />
</Suspense>
));
};
export const AdminGuard = ({ children }: GuardProps) => {
const isAuthenticated = useAuthService().isAuthenticated();
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
const currentSession = useAuthService().getCurrentSession();
const isFreePlan = currentSession?.subscription_type === ISUBSCRIPTION_TYPE.FREE;
// Memoized guard components with defensive programming
export const AuthGuard = memo(({ children }: GuardProps) => {
const authService = useAuthService();
const location = useLocation();
if (!isAuthenticated) {
return <Navigate to="/auth" state={{ from: location }} replace />;
}
if (!isOwnerOrAdmin || isFreePlan) {
return <Navigate to="/worklenz/unauthorized" replace />;
}
return <>{children}</>;
};
export const LicenseExpiryGuard = ({ children }: GuardProps) => {
const isAuthenticated = useAuthService().isAuthenticated();
const currentSession = useAuthService().getCurrentSession();
const location = useLocation();
const isAdminCenterRoute = location.pathname.includes('/worklenz/admin-center');
const isLicenseExpiredRoute = location.pathname === '/worklenz/license-expired';
// Don't check or redirect if we're already on the license-expired page
if (isLicenseExpiredRoute) {
return <>{children}</>;
}
// Check if trial is expired more than 7 days or if is_expired flag is set
const isLicenseExpiredMoreThan7Days = () => {
// Quick bail if no session data is available
if (!currentSession) {
return false;
}
// Check is_expired flag first
if (currentSession.is_expired) {
// If no trial_expire_date exists but is_expired is true, defer to backend check
if (!currentSession.trial_expire_date) {
return true;
const shouldRedirect = useMemo(() => {
try {
// Defensive check to ensure authService and its methods exist
if (!authService || typeof authService.isAuthenticated !== 'function') {
return false; // Don't redirect if auth service is not ready
}
// If there is a trial_expire_date, check if it's more than 7 days past
const today = new Date();
const expiryDate = new Date(currentSession.trial_expire_date);
const diffTime = today.getTime() - expiryDate.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
// Redirect if more than 7 days past expiration
return diffDays > 7;
return !authService.isAuthenticated();
} catch (error) {
console.error('Error in AuthGuard:', error);
return false; // Don't redirect on error, let the app handle it
}
// If not marked as expired but has trial_expire_date, do a date check
if (currentSession.subscription_type === ISUBSCRIPTION_TYPE.TRIAL && currentSession.trial_expire_date) {
const today = new Date();
const expiryDate = new Date(currentSession.trial_expire_date);
}, [authService]);
const diffTime = today.getTime() - expiryDate.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (shouldRedirect) {
return <Navigate to="/auth" state={{ from: location }} replace />;
}
return <>{children}</>;
});
AuthGuard.displayName = 'AuthGuard';
export const AdminGuard = memo(({ children }: GuardProps) => {
const authService = useAuthService();
const location = useLocation();
const guardResult = useMemo(() => {
try {
// Defensive checks to ensure authService and its methods exist
if (!authService ||
typeof authService.isAuthenticated !== 'function' ||
typeof authService.isOwnerOrAdmin !== 'function' ||
typeof authService.getCurrentSession !== 'function') {
return null; // Don't redirect if auth service is not ready
}
if (!authService.isAuthenticated()) {
return { redirect: '/auth', state: { from: location } };
}
const currentSession = authService.getCurrentSession();
const isFreePlan = currentSession?.subscription_type === ISUBSCRIPTION_TYPE.FREE;
// If expired more than 7 days, redirect
return diffDays > 7;
}
// No expiration data found
return false;
};
if (!authService.isOwnerOrAdmin() || isFreePlan) {
return { redirect: '/worklenz/unauthorized' };
}
return null;
} catch (error) {
console.error('Error in AdminGuard:', error);
return null; // Don't redirect on error
}
}, [authService, location]);
if (guardResult) {
return <Navigate to={guardResult.redirect} state={guardResult.state} replace />;
}
return <>{children}</>;
});
AdminGuard.displayName = 'AdminGuard';
export const LicenseExpiryGuard = memo(({ children }: GuardProps) => {
const authService = useAuthService();
const location = useLocation();
const shouldRedirect = useMemo(() => {
try {
// Defensive checks to ensure authService and its methods exist
if (!authService ||
typeof authService.isAuthenticated !== 'function' ||
typeof authService.getCurrentSession !== 'function') {
return false; // Don't redirect if auth service is not ready
}
if (!authService.isAuthenticated()) return false;
const isAdminCenterRoute = location.pathname.includes('/worklenz/admin-center');
const isLicenseExpiredRoute = location.pathname === '/worklenz/license-expired';
// Don't check or redirect if we're already on the license-expired page
if (isLicenseExpiredRoute) return false;
const currentSession = authService.getCurrentSession();
// Check if trial is expired more than 7 days or if is_expired flag is set
const isLicenseExpiredMoreThan7Days = () => {
// Quick bail if no session data is available
if (!currentSession) return false;
// Check is_expired flag first
if (currentSession.is_expired) {
// If no trial_expire_date exists but is_expired is true, defer to backend check
if (!currentSession.trial_expire_date) return true;
// If there is a trial_expire_date, check if it's more than 7 days past
const today = new Date();
const expiryDate = new Date(currentSession.trial_expire_date);
const diffTime = today.getTime() - expiryDate.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
// Redirect if more than 7 days past expiration
return diffDays > 7;
}
// If not marked as expired but has trial_expire_date, do a date check
if (currentSession.subscription_type === ISUBSCRIPTION_TYPE.TRIAL && currentSession.trial_expire_date) {
const today = new Date();
const expiryDate = new Date(currentSession.trial_expire_date);
const diffTime = today.getTime() - expiryDate.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
// If expired more than 7 days, redirect
return diffDays > 7;
}
// No expiration data found
return false;
};
return isLicenseExpiredMoreThan7Days() && !isAdminCenterRoute;
} catch (error) {
console.error('Error in LicenseExpiryGuard:', error);
return false; // Don't redirect on error
}
}, [authService, location.pathname]);
// Add this explicit check and log the result
const shouldRedirect = isAuthenticated && isLicenseExpiredMoreThan7Days() && !isAdminCenterRoute;
if (shouldRedirect) {
return <Navigate to="/worklenz/license-expired" replace />;
}
return <>{children}</>;
};
});
export const SetupGuard = ({ children }: GuardProps) => {
const isAuthenticated = useAuthService().isAuthenticated();
LicenseExpiryGuard.displayName = 'LicenseExpiryGuard';
export const SetupGuard = memo(({ children }: GuardProps) => {
const authService = useAuthService();
const location = useLocation();
if (!isAuthenticated) {
const shouldRedirect = useMemo(() => {
try {
// Defensive check to ensure authService and its methods exist
if (!authService || typeof authService.isAuthenticated !== 'function') {
return false; // Don't redirect if auth service is not ready
}
return !authService.isAuthenticated();
} catch (error) {
console.error('Error in SetupGuard:', error);
return false; // Don't redirect on error
}
}, [authService]);
if (shouldRedirect) {
return <Navigate to="/auth" state={{ from: location }} replace />;
}
return <>{children}</>;
};
});
// Helper to wrap routes with guards
SetupGuard.displayName = 'SetupGuard';
// Optimized route wrapping function with Suspense boundaries
const wrapRoutes = (
routes: RouteObject[],
Guard: React.ComponentType<{ children: React.ReactNode }>
@@ -125,7 +205,11 @@ const wrapRoutes = (
return routes.map(route => {
const wrappedRoute = {
...route,
element: <Guard>{route.element}</Guard>,
element: (
<Suspense fallback={<SuspenseFallback />}>
<Guard>{route.element}</Guard>
</Suspense>
),
};
if (route.children) {
@@ -140,9 +224,8 @@ const wrapRoutes = (
});
};
// Static license expired component that doesn't rely on translations or authentication
const StaticLicenseExpired = () => {
// Optimized static license expired component
const StaticLicenseExpired = memo(() => {
return (
<div style={{
marginTop: 65,
@@ -184,23 +267,31 @@ const StaticLicenseExpired = () => {
</div>
</div>
);
};
});
StaticLicenseExpired.displayName = 'StaticLicenseExpired';
// Create route arrays (moved outside of useMemo to avoid hook violations)
const publicRoutes = [
...rootRoutes,
...authRoutes,
notFoundRoute
];
const protectedMainRoutes = wrapRoutes(mainRoutes, AuthGuard);
const adminRoutes = wrapRoutes(reportingRoutes, AdminGuard);
const setupRoutes = wrapRoutes([accountSetupRoute], SetupGuard);
// Apply LicenseExpiryGuard to all protected routes
// License expiry check function
const withLicenseExpiryCheck = (routes: RouteObject[]): RouteObject[] => {
return routes.map(route => {
const wrappedRoute = {
...route,
element: <LicenseExpiryGuard>{route.element}</LicenseExpiryGuard>,
element: (
<Suspense fallback={<SuspenseFallback />}>
<LicenseExpiryGuard>{route.element}</LicenseExpiryGuard>
</Suspense>
),
};
if (route.children) {
@@ -213,10 +304,21 @@ const withLicenseExpiryCheck = (routes: RouteObject[]): RouteObject[] => {
const licenseCheckedMainRoutes = withLicenseExpiryCheck(protectedMainRoutes);
// Create optimized router with future flags for better performance
const router = createBrowserRouter([
{
element: <ErrorBoundary><AuthenticatedLayout /></ErrorBoundary>,
errorElement: <ErrorBoundary><NotFoundPage /></ErrorBoundary>,
element: (
<ErrorBoundary>
<AuthenticatedLayout />
</ErrorBoundary>
),
errorElement: (
<ErrorBoundary>
<Suspense fallback={<SuspenseFallback />}>
<NotFoundPage />
</Suspense>
</ErrorBoundary>
),
children: [
...licenseCheckedMainRoutes,
...adminRoutes,
@@ -225,6 +327,15 @@ const router = createBrowserRouter([
],
},
...publicRoutes,
]);
], {
// Enable React Router future features for better performance
future: {
v7_relativeSplatPath: true,
v7_fetcherPersist: true,
v7_normalizeFormMethod: true,
v7_partialHydration: true,
v7_skipActionErrorRevalidation: true
}
});
export default router;

View File

@@ -1,32 +1,49 @@
import { RouteObject } from 'react-router-dom';
import { lazy, Suspense } from 'react';
import MainLayout from '@/layouts/MainLayout';
import HomePage from '@/pages/home/home-page';
import ProjectList from '@/pages/projects/project-list';
import settingsRoutes from './settings-routes';
import adminCenterRoutes from './admin-center-routes';
import Schedule from '@/pages/schedule/schedule';
import ProjectTemplateEditView from '@/pages/settings/project-templates/projectTemplateEditView/ProjectTemplateEditView';
import LicenseExpired from '@/pages/license-expired/license-expired';
import ProjectView from '@/pages/projects/projectView/project-view';
import Unauthorized from '@/pages/unauthorized/unauthorized';
import { useAuthService } from '@/hooks/useAuth';
import { Navigate, useLocation } from 'react-router-dom';
import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback';
// Define AdminGuard component first
// Lazy load page components for better code splitting
const HomePage = lazy(() => import('@/pages/home/home-page'));
const ProjectList = lazy(() => import('@/pages/projects/project-list'));
const Schedule = lazy(() => import('@/pages/schedule/schedule'));
const ProjectTemplateEditView = lazy(() => import('@/pages/settings/project-templates/projectTemplateEditView/ProjectTemplateEditView'));
const LicenseExpired = lazy(() => import('@/pages/license-expired/license-expired'));
const ProjectView = lazy(() => import('@/pages/projects/projectView/project-view'));
const Unauthorized = lazy(() => import('@/pages/unauthorized/unauthorized'));
// Define AdminGuard component with defensive programming
const AdminGuard = ({ children }: { children: React.ReactNode }) => {
const isAuthenticated = useAuthService().isAuthenticated();
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
const authService = useAuthService();
const location = useLocation();
if (!isAuthenticated) {
return <Navigate to="/auth" state={{ from: location }} replace />;
}
try {
// Defensive checks to ensure authService and its methods exist
if (!authService ||
typeof authService.isAuthenticated !== 'function' ||
typeof authService.isOwnerOrAdmin !== 'function') {
// If auth service is not ready, render children (don't block)
return <>{children}</>;
}
if (!isOwnerOrAdmin) {
return <Navigate to="/worklenz/unauthorized" replace />;
}
if (!authService.isAuthenticated()) {
return <Navigate to="/auth" state={{ from: location }} replace />;
}
return <>{children}</>;
if (!authService.isOwnerOrAdmin()) {
return <Navigate to="/worklenz/unauthorized" replace />;
}
return <>{children}</>;
} catch (error) {
console.error('Error in AdminGuard (main-routes):', error);
// On error, render children to prevent complete blocking
return <>{children}</>;
}
};
const mainRoutes: RouteObject[] = [
@@ -35,18 +52,56 @@ const mainRoutes: RouteObject[] = [
element: <MainLayout />,
children: [
{ index: true, element: <Navigate to="home" replace /> },
{ path: 'home', element: <HomePage /> },
{ path: 'projects', element: <ProjectList /> },
{
path: 'home',
element: (
<Suspense fallback={<SuspenseFallback />}>
<HomePage />
</Suspense>
)
},
{
path: 'projects',
element: (
<Suspense fallback={<SuspenseFallback />}>
<ProjectList />
</Suspense>
)
},
{
path: 'schedule',
element: <AdminGuard><Schedule /></AdminGuard>
element: (
<Suspense fallback={<SuspenseFallback />}>
<AdminGuard>
<Schedule />
</AdminGuard>
</Suspense>
)
},
{
path: `projects/:projectId`,
element: (
<Suspense fallback={<SuspenseFallback />}>
<ProjectView />
</Suspense>
)
},
{ path: `projects/:projectId`, element: <ProjectView /> },
{
path: `settings/project-templates/edit/:templateId/:templateName`,
element: <ProjectTemplateEditView />,
element: (
<Suspense fallback={<SuspenseFallback />}>
<ProjectTemplateEditView />
</Suspense>
),
},
{
path: 'unauthorized',
element: (
<Suspense fallback={<SuspenseFallback />}>
<Unauthorized />
</Suspense>
)
},
{ path: 'unauthorized', element: <Unauthorized /> },
...settingsRoutes,
...adminCenterRoutes,
],
@@ -58,7 +113,14 @@ export const licenseExpiredRoute: RouteObject = {
path: '/worklenz',
element: <MainLayout />,
children: [
{ path: 'license-expired', element: <LicenseExpired /> }
{
path: 'license-expired',
element: (
<Suspense fallback={<SuspenseFallback />}>
<LicenseExpired />
</Suspense>
)
}
]
};

View File

@@ -0,0 +1,81 @@
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from './store';
// Memoized selectors for better performance
// These prevent unnecessary re-renders when state hasn't actually changed
// Auth selectors
export const selectAuth = (state: RootState) => state.auth;
export const selectUser = (state: RootState) => state.userReducer;
export const selectIsAuthenticated = createSelector(
[selectAuth],
(auth) => !!auth.user
);
// Project selectors
export const selectProjects = (state: RootState) => state.projectsReducer;
export const selectCurrentProject = (state: RootState) => state.projectReducer;
export const selectProjectMembers = (state: RootState) => state.projectMemberReducer;
// Task selectors
export const selectTasks = (state: RootState) => state.taskReducer;
export const selectTaskManagement = (state: RootState) => state.taskManagement;
export const selectTaskSelection = (state: RootState) => state.taskManagementSelection;
// UI State selectors
export const selectTheme = (state: RootState) => state.themeReducer;
export const selectLocale = (state: RootState) => state.localesReducer;
export const selectAlerts = (state: RootState) => state.alertsReducer;
// Board and Project View selectors
export const selectBoard = (state: RootState) => state.boardReducer;
export const selectProjectView = (state: RootState) => state.projectViewReducer;
export const selectProjectDrawer = (state: RootState) => state.projectDrawerReducer;
// Task attributes selectors
export const selectTaskPriorities = (state: RootState) => state.priorityReducer;
export const selectTaskLabels = (state: RootState) => state.taskLabelsReducer;
export const selectTaskStatuses = (state: RootState) => state.taskStatusReducer;
export const selectTaskDrawer = (state: RootState) => state.taskDrawerReducer;
// Settings selectors
export const selectMembers = (state: RootState) => state.memberReducer;
export const selectClients = (state: RootState) => state.clientReducer;
export const selectJobs = (state: RootState) => state.jobReducer;
export const selectTeams = (state: RootState) => state.teamReducer;
export const selectCategories = (state: RootState) => state.categoriesReducer;
export const selectLabels = (state: RootState) => state.labelReducer;
// Reporting selectors
export const selectReporting = (state: RootState) => state.reportingReducer;
export const selectProjectReports = (state: RootState) => state.projectReportsReducer;
export const selectMemberReports = (state: RootState) => state.membersReportsReducer;
export const selectTimeReports = (state: RootState) => state.timeReportsOverviewReducer;
// Admin and billing selectors
export const selectAdminCenter = (state: RootState) => state.adminCenterReducer;
export const selectBilling = (state: RootState) => state.billingReducer;
// Schedule and date selectors
export const selectSchedule = (state: RootState) => state.scheduleReducer;
export const selectDate = (state: RootState) => state.dateReducer;
// Feature-specific selectors
export const selectHomePage = (state: RootState) => state.homePageReducer;
export const selectAccountSetup = (state: RootState) => state.accountSetupReducer;
export const selectRoadmap = (state: RootState) => state.roadmapReducer;
export const selectGroupByFilter = (state: RootState) => state.groupByFilterDropdownReducer;
// Memoized computed selectors for common use cases
export const selectHasActiveProject = createSelector(
[selectCurrentProject],
(project) => !!project && Object.keys(project).length > 0
);
export const selectIsLoading = createSelector(
[selectTasks, selectProjects],
(tasks, projects) => {
// Check if any major feature is loading
return (tasks as any)?.loading || (projects as any)?.loading;
}
);

View File

@@ -1,46 +1,50 @@
import React, { memo } from 'react';
import { colors } from '@/styles/colors';
import { getInitialTheme } from '@/utils/get-initial-theme';
import { ConfigProvider, theme, Layout, Spin } from 'antd';
// Loading component with theme awareness
export const SuspenseFallback = () => {
// Memoized loading component with theme awareness
export const SuspenseFallback = memo(() => {
const currentTheme = getInitialTheme();
const isDark = currentTheme === 'dark';
// Memoize theme configuration to prevent unnecessary re-renders
const themeConfig = React.useMemo(() => ({
algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
components: {
Layout: {
colorBgLayout: isDark ? colors.darkGray : '#fafafa',
},
Spin: {
colorPrimary: isDark ? '#fff' : '#1890ff',
},
},
}), [isDark]);
// Memoize layout style to prevent object recreation
const layoutStyle = React.useMemo(() => ({
position: 'fixed' as const,
width: '100vw',
height: '100vh',
background: 'transparent',
transition: 'none',
}), []);
// Memoize spin style to prevent object recreation
const spinStyle = React.useMemo(() => ({
position: 'absolute' as const,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}), []);
return (
<ConfigProvider
theme={{
algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
components: {
Layout: {
colorBgLayout: isDark ? colors.darkGray : '#fafafa',
},
Spin: {
colorPrimary: isDark ? '#fff' : '#1890ff',
},
},
}}
>
<Layout
className="app-loading-container"
style={{
position: 'fixed',
width: '100vw',
height: '100vh',
background: 'transparent',
transition: 'none',
}}
>
<Spin
size="large"
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}}
/>
<ConfigProvider theme={themeConfig}>
<Layout className="app-loading-container" style={layoutStyle}>
<Spin size="large" style={spinStyle} />
</Layout>
</ConfigProvider>
);
};
});
SuspenseFallback.displayName = 'SuspenseFallback';

View File

@@ -1,174 +0,0 @@
import React from 'react';
import { Card, Button, Space, Typography, Dropdown, Menu, Popconfirm, message } from 'antd';
import {
DeleteOutlined,
EditOutlined,
TagOutlined,
UserOutlined,
CheckOutlined,
CloseOutlined,
MoreOutlined,
} from '@ant-design/icons';
import { useDispatch, useSelector } from 'react-redux';
import { IGroupBy, bulkUpdateTasks, bulkDeleteTasks } from '@/features/tasks/tasks.slice';
import { AppDispatch, RootState } from '@/app/store';
const { Text } = Typography;
interface BulkActionBarProps {
selectedTaskIds: string[];
totalSelected: number;
currentGrouping: IGroupBy;
projectId: string;
onClearSelection?: () => void;
}
const BulkActionBar: React.FC<BulkActionBarProps> = ({
selectedTaskIds,
totalSelected,
currentGrouping,
projectId,
onClearSelection,
}) => {
const dispatch = useDispatch<AppDispatch>();
const { statuses, priorities } = useSelector((state: RootState) => state.taskReducer);
const handleBulkStatusChange = (statusId: string) => {
// dispatch(bulkUpdateTasks({ ids: selectedTaskIds, changes: { status: statusId } }));
message.success(`Updated ${totalSelected} tasks`);
onClearSelection?.();
};
const handleBulkPriorityChange = (priority: string) => {
// dispatch(bulkUpdateTasks({ ids: selectedTaskIds, changes: { priority } }));
message.success(`Updated ${totalSelected} tasks`);
onClearSelection?.();
};
const handleBulkDelete = () => {
// dispatch(bulkDeleteTasks(selectedTaskIds));
message.success(`Deleted ${totalSelected} tasks`);
onClearSelection?.();
};
const statusMenu = (
<Menu
onClick={({ key }) => handleBulkStatusChange(key)}
items={statuses.map(status => ({
key: status.id!,
label: (
<div className="flex items-center space-x-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: status.color_code }}
/>
<span>{status.name}</span>
</div>
),
}))}
/>
);
const priorityMenu = (
<Menu
onClick={({ key }) => handleBulkPriorityChange(key)}
items={[
{ key: 'critical', label: 'Critical', icon: <div className="w-2 h-2 rounded-full bg-red-500" /> },
{ key: 'high', label: 'High', icon: <div className="w-2 h-2 rounded-full bg-orange-500" /> },
{ key: 'medium', label: 'Medium', icon: <div className="w-2 h-2 rounded-full bg-yellow-500" /> },
{ key: 'low', label: 'Low', icon: <div className="w-2 h-2 rounded-full bg-green-500" /> },
]}
/>
);
const moreActionsMenu = (
<Menu
items={[
{
key: 'assign',
label: 'Assign to member',
icon: <UserOutlined />,
},
{
key: 'labels',
label: 'Add labels',
icon: <TagOutlined />,
},
{
key: 'archive',
label: 'Archive tasks',
icon: <EditOutlined />,
},
]}
/>
);
return (
<Card
size="small"
className="mb-4 bg-blue-50 border-blue-200"
styles={{ body: { padding: '8px 16px' } }}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Text strong className="text-blue-700">
{totalSelected} task{totalSelected > 1 ? 's' : ''} selected
</Text>
</div>
<Space>
{/* Status Change */}
{currentGrouping !== 'status' && (
<Dropdown overlay={statusMenu} trigger={['click']}>
<Button size="small" icon={<CheckOutlined />}>
Change Status
</Button>
</Dropdown>
)}
{/* Priority Change */}
{currentGrouping !== 'priority' && (
<Dropdown overlay={priorityMenu} trigger={['click']}>
<Button size="small" icon={<EditOutlined />}>
Set Priority
</Button>
</Dropdown>
)}
{/* More Actions */}
<Dropdown overlay={moreActionsMenu} trigger={['click']}>
<Button size="small" icon={<MoreOutlined />}>
More Actions
</Button>
</Dropdown>
{/* Delete */}
<Popconfirm
title={`Delete ${totalSelected} task${totalSelected > 1 ? 's' : ''}?`}
description="This action cannot be undone."
onConfirm={handleBulkDelete}
okText="Delete"
cancelText="Cancel"
okType="danger"
>
<Button size="small" danger icon={<DeleteOutlined />}>
Delete
</Button>
</Popconfirm>
{/* Clear Selection */}
<Button
size="small"
icon={<CloseOutlined />}
onClick={onClearSelection}
title="Clear selection"
>
Clear
</Button>
</Space>
</div>
</Card>
);
};
export default BulkActionBar;

View File

@@ -0,0 +1,590 @@
import React, { useEffect, useRef, useState } from 'react';
import { Button, Typography, Dropdown, Menu, Popconfirm, message, Tooltip, Badge, CheckboxChangeEvent, InputRef } from 'antd';
import {
DeleteOutlined,
EditOutlined,
TagOutlined,
UserOutlined,
CheckOutlined,
CloseOutlined,
MoreOutlined,
RetweetOutlined,
UserAddOutlined,
InboxOutlined,
TagsOutlined,
UsergroupAddOutlined,
} from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { IGroupBy, fetchTaskGroups } from '@/features/tasks/tasks.slice';
import { AppDispatch, RootState } from '@/app/store';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
import { taskListBulkActionsApiService } from '@/api/tasks/task-list-bulk-actions.api.service';
import {
evt_project_task_list_bulk_archive,
evt_project_task_list_bulk_assign_me,
evt_project_task_list_bulk_assign_members,
evt_project_task_list_bulk_change_phase,
evt_project_task_list_bulk_change_priority,
evt_project_task_list_bulk_change_status,
evt_project_task_list_bulk_delete,
evt_project_task_list_bulk_update_labels,
} from '@/shared/worklenz-analytics-events';
import {
IBulkTasksLabelsRequest,
IBulkTasksPhaseChangeRequest,
IBulkTasksPriorityChangeRequest,
IBulkTasksStatusChangeRequest,
} from '@/types/tasks/bulk-action-bar.types';
import { ITaskStatus } from '@/types/tasks/taskStatus.types';
import { ITaskPriority } from '@/types/tasks/taskPriority.types';
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types';
import { ITaskAssignee } from '@/types/tasks/task.types';
import { createPortal } from 'react-dom';
import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
import AssigneesDropdown from '@/components/taskListCommon/task-list-bulk-actions-bar/components/AssigneesDropdown';
import LabelsDropdown from '@/components/taskListCommon/task-list-bulk-actions-bar/components/LabelsDropdown';
import { sortTeamMembers } from '@/utils/sort-team-members';
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
import { useAuthService } from '@/hooks/useAuth';
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
import alertService from '@/services/alerts/alertService';
import logger from '@/utils/errorLogger';
const { Text } = Typography;
interface BulkActionBarProps {
selectedTaskIds: string[];
totalSelected: number;
currentGrouping: IGroupBy;
projectId: string;
onClearSelection?: () => void;
}
const BulkActionBarContent: React.FC<BulkActionBarProps> = ({
selectedTaskIds,
totalSelected,
currentGrouping,
projectId,
onClearSelection,
}) => {
const dispatch = useAppDispatch();
const { t } = useTranslation('tasks/task-table-bulk-actions');
const { trackMixpanelEvent } = useMixpanelTracking();
// Add permission hooks
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
// loading state
const [loading, setLoading] = useState(false);
const [updatingLabels, setUpdatingLabels] = useState(false);
const [updatingAssignToMe, setUpdatingAssignToMe] = useState(false);
const [updatingAssignees, setUpdatingAssignees] = useState(false);
const [updatingArchive, setUpdatingArchive] = useState(false);
const [updatingDelete, setUpdatingDelete] = useState(false);
// Selectors
const { selectedTaskIdsList } = useAppSelector(state => state.bulkActionReducer);
const statusList = useAppSelector(state => state.taskStatusReducer.status);
const priorityList = useAppSelector(state => state.priorityReducer.priorities);
const phaseList = useAppSelector(state => state.phaseReducer.phaseList);
const labelsList = useAppSelector(state => state.taskLabelsReducer.labels);
const members = useAppSelector(state => state.teamMembersReducer.teamMembers);
const archived = useAppSelector(state => state.taskReducer.archived);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const labelsInputRef = useRef<InputRef>(null);
const [createLabelText, setCreateLabelText] = useState<string>('');
const [teamMembersSorted, setTeamMembersSorted] = useState<ITeamMembersViewModel>({
data: [],
total: 0,
});
const [assigneeDropdownOpen, setAssigneeDropdownOpen] = useState(false);
const [showDrawer, setShowDrawer] = useState(false);
const [selectedLabels, setSelectedLabels] = useState<ITaskLabel[]>([]);
// Handlers
const handleChangeStatus = async (status: ITaskStatus) => {
if (!status.id || !projectId) return;
try {
setLoading(true);
const body: IBulkTasksStatusChangeRequest = {
tasks: selectedTaskIds,
status_id: status.id,
};
const res = await taskListBulkActionsApiService.changeStatus(body, projectId);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_change_status);
dispatch(deselectAll());
dispatch(fetchTaskGroups(projectId));
onClearSelection?.();
}
for (const it of selectedTaskIds) {
const canContinue = await checkTaskDependencyStatus(it, status.id);
if (!canContinue) {
if (selectedTaskIds.length > 1) {
alertService.warning(
'Incomplete Dependencies!',
'Some tasks were not updated. Please ensure all dependent tasks are completed before proceeding.'
);
} else {
alertService.error(
'Task is not completed',
'Please complete the task dependencies before proceeding'
);
}
return;
}
}
} catch (error) {
logger.error('Error changing status:', error);
} finally {
setLoading(false);
}
};
const handleChangePriority = async (priority: ITaskPriority) => {
if (!priority.id || !projectId) return;
try {
setLoading(true);
const body: IBulkTasksPriorityChangeRequest = {
tasks: selectedTaskIds,
priority_id: priority.id,
};
const res = await taskListBulkActionsApiService.changePriority(body, projectId);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_change_priority);
dispatch(deselectAll());
dispatch(fetchTaskGroups(projectId));
onClearSelection?.();
}
} catch (error) {
logger.error('Error changing priority:', error);
} finally {
setLoading(false);
}
};
const handleChangePhase = async (phase: ITaskPhase) => {
if (!phase.id || !projectId) return;
try {
setLoading(true);
const body: IBulkTasksPhaseChangeRequest = {
tasks: selectedTaskIds,
phase_id: phase.id,
};
const res = await taskListBulkActionsApiService.changePhase(body, projectId);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_change_phase);
dispatch(deselectAll());
dispatch(fetchTaskGroups(projectId));
onClearSelection?.();
}
} catch (error) {
logger.error('Error changing phase:', error);
} finally {
setLoading(false);
}
};
const handleAssignToMe = async () => {
if (!projectId) return;
try {
setUpdatingAssignToMe(true);
const body = {
tasks: selectedTaskIds,
project_id: projectId,
};
const res = await taskListBulkActionsApiService.assignToMe(body);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_assign_me);
dispatch(deselectAll());
dispatch(fetchTaskGroups(projectId));
onClearSelection?.();
}
} catch (error) {
logger.error('Error assigning to me:', error);
} finally {
setUpdatingAssignToMe(false);
}
};
const handleArchive = async () => {
if (!projectId) return;
try {
setUpdatingArchive(true);
const body = {
tasks: selectedTaskIds,
project_id: projectId,
};
const res = await taskListBulkActionsApiService.archiveTasks(body, archived);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_archive);
dispatch(deselectAll());
dispatch(fetchTaskGroups(projectId));
onClearSelection?.();
}
} catch (error) {
logger.error('Error archiving tasks:', error);
} finally {
setUpdatingArchive(false);
}
};
const handleChangeAssignees = async (selectedAssignees: ITeamMemberViewModel[]) => {
if (!projectId) return;
try {
setUpdatingAssignees(true);
const body = {
tasks: selectedTaskIds,
project_id: projectId,
members: selectedAssignees.map(member => ({
id: member.id,
name: member.name || member.email || 'Unknown', // Fix: Ensure name is always a string
email: member.email || '',
avatar_url: member.avatar_url,
team_member_id: member.id,
project_member_id: member.id,
})) as ITaskAssignee[],
};
const res = await taskListBulkActionsApiService.assignTasks(body);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_assign_members);
dispatch(deselectAll());
dispatch(fetchTaskGroups(projectId));
onClearSelection?.();
}
} catch (error) {
logger.error('Error assigning tasks:', error);
} finally {
setUpdatingAssignees(false);
}
};
const handleDelete = async () => {
if (!projectId) return;
try {
setUpdatingDelete(true);
const body = {
tasks: selectedTaskIds,
project_id: projectId,
};
const res = await taskListBulkActionsApiService.deleteTasks(body, projectId);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_delete);
dispatch(deselectAll());
dispatch(fetchTaskGroups(projectId));
onClearSelection?.();
}
} catch (error) {
logger.error('Error deleting tasks:', error);
} finally {
setUpdatingDelete(false);
}
};
// Menu Generators
const getChangeOptionsMenu = () => [
{
key: '1',
label: t('status'),
children: statusList.map(status => ({
key: status.id,
onClick: () => handleChangeStatus(status),
label: <Badge color={status.color_code} text={status.name} />,
})),
},
{
key: '2',
label: t('priority'),
children: priorityList.map(priority => ({
key: priority.id,
onClick: () => handleChangePriority(priority),
label: <Badge color={priority.color_code} text={priority.name} />,
})),
},
{
key: '3',
label: t('phase'),
children: phaseList.map(phase => ({
key: phase.id,
onClick: () => handleChangePhase(phase),
label: <Badge color={phase.color_code} text={phase.name} />,
})),
},
];
useEffect(() => {
if (members?.data && assigneeDropdownOpen) {
let sortedMembers = sortTeamMembers(members.data);
setTeamMembersSorted({ data: sortedMembers, total: members.total });
}
}, [assigneeDropdownOpen, members?.data]);
const getAssigneesMenu = () => {
return (
<AssigneesDropdown
members={teamMembersSorted?.data || []}
themeMode={themeMode}
onApply={handleChangeAssignees}
onClose={() => setAssigneeDropdownOpen(false)}
t={t}
/>
);
};
const handleLabelChange = (e: CheckboxChangeEvent, label: ITaskLabel) => {
if (e.target.checked) {
setSelectedLabels(prev => [...prev, label]);
} else {
setSelectedLabels(prev => prev.filter(l => l.id !== label.id));
}
};
const applyLabels = async () => {
if (!projectId) return;
try {
setUpdatingLabels(true);
const body: IBulkTasksLabelsRequest = {
tasks: selectedTaskIds,
labels: selectedLabels,
text:
selectedLabels.length > 0
? null
: createLabelText.trim() !== ''
? createLabelText.trim()
: null,
};
const res = await taskListBulkActionsApiService.assignLabels(body, projectId);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_update_labels);
dispatch(deselectAll());
dispatch(fetchTaskGroups(projectId));
dispatch(fetchLabels()); // Fallback: refetch all labels
setCreateLabelText('');
setSelectedLabels([]);
onClearSelection?.();
}
} catch (error) {
logger.error('Error updating labels:', error);
} finally {
setUpdatingLabels(false);
}
};
const labelsDropdownContent = (
<LabelsDropdown
labelsList={labelsList}
themeMode={themeMode}
createLabelText={createLabelText}
selectedLabels={selectedLabels}
labelsInputRef={labelsInputRef as React.RefObject<InputRef>}
onLabelChange={handleLabelChange}
onCreateLabelTextChange={value => setCreateLabelText(value)}
onApply={applyLabels}
t={t}
loading={updatingLabels}
/>
);
const onAssigneeDropdownOpenChange = (open: boolean) => {
setAssigneeDropdownOpen(open);
};
const buttonStyle = {
background: 'transparent',
color: '#fff',
border: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '4px 8px',
height: '32px',
fontSize: '16px',
};
return (
<div
style={{
position: 'fixed',
bottom: '30px',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 1000,
background: '#252628',
borderRadius: '25px',
padding: '8px 16px',
boxShadow: '0 0 0 1px #434343, 0 4px 12px 0 rgba(0, 0, 0, 0.15)',
display: 'flex',
alignItems: 'center',
gap: '8px',
minWidth: 'fit-content',
}}
>
<Text style={{ color: '#fff', fontSize: '14px', fontWeight: 500, marginRight: '8px' }}>
{totalSelected} task{totalSelected > 1 ? 's' : ''} selected
</Text>
{/* Status/Priority/Phase Change */}
<Tooltip title="Change Status/Priority/Phase">
<Dropdown menu={{ items: getChangeOptionsMenu() }} trigger={['click']}>
<Button
icon={<RetweetOutlined />}
style={buttonStyle}
size="small"
loading={loading}
/>
</Dropdown>
</Tooltip>
{/* Labels */}
<Tooltip title="Add Labels">
<Dropdown
dropdownRender={() => labelsDropdownContent}
placement="top"
arrow
trigger={['click']}
onOpenChange={value => {
if (!value) {
setSelectedLabels([]);
}
}}
>
<Button
icon={<TagsOutlined />}
style={buttonStyle}
size="small"
loading={updatingLabels}
/>
</Dropdown>
</Tooltip>
{/* Assign to Me */}
<Tooltip title="Assign to Me">
<Button
icon={<UserAddOutlined />}
style={buttonStyle}
size="small"
onClick={handleAssignToMe}
loading={updatingAssignToMe}
/>
</Tooltip>
{/* Assign Members */}
<Tooltip title="Assign Members">
<Dropdown
dropdownRender={getAssigneesMenu}
open={assigneeDropdownOpen}
onOpenChange={onAssigneeDropdownOpenChange}
placement="top"
arrow
trigger={['click']}
>
<Button
icon={<UsergroupAddOutlined />}
style={buttonStyle}
size="small"
loading={updatingAssignees}
/>
</Dropdown>
</Tooltip>
{/* Archive */}
<Tooltip title={archived ? 'Unarchive' : 'Archive'}>
<Button
icon={<InboxOutlined />}
style={buttonStyle}
size="small"
onClick={handleArchive}
loading={updatingArchive}
/>
</Tooltip>
{/* Delete */}
<Tooltip title="Delete">
<Popconfirm
title={`Delete ${totalSelected} task${totalSelected > 1 ? 's' : ''}?`}
description="This action cannot be undone."
onConfirm={handleDelete}
okText="Delete"
cancelText="Cancel"
okType="danger"
>
<Button
icon={<DeleteOutlined />}
style={buttonStyle}
size="small"
loading={updatingDelete}
/>
</Popconfirm>
</Tooltip>
{/* More Actions - Only for Owner/Admin */}
{isOwnerOrAdmin && (
<Tooltip title="More Actions">
<Dropdown
trigger={['click']}
menu={{
items: [
{
key: 'createTemplate',
label: 'Create task template',
onClick: () => setShowDrawer(true),
},
],
}}
>
<Button
icon={<MoreOutlined />}
style={buttonStyle}
size="small"
/>
</Dropdown>
</Tooltip>
)}
{/* Clear Selection */}
<Tooltip title="Clear Selection">
<Button
icon={<CloseOutlined />}
onClick={onClearSelection}
style={buttonStyle}
size="small"
/>
</Tooltip>
{/* Task Template Drawer */}
{createPortal(
<TaskTemplateDrawer
showDrawer={showDrawer}
selectedTemplateId={null}
onClose={() => {
setShowDrawer(false);
dispatch(deselectAll());
onClearSelection?.();
}}
/>,
document.body,
'create-task-template'
)}
</div>
);
};
const BulkActionBar: React.FC<BulkActionBarProps> = (props) => {
// Render the bulk action bar through a portal to avoid suspense issues
return createPortal(
<BulkActionBarContent {...props} />,
document.body,
'bulk-action-bar'
);
};
export default BulkActionBar;

View File

@@ -8,7 +8,7 @@ import { ITaskListGroup } from '@/types/tasks/taskList.types';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { IGroupBy, COLUMN_KEYS } from '@/features/tasks/tasks.slice';
import { RootState } from '@/app/store';
import TaskRow from './TaskRow';
import TaskRow from './task-row';
import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row';
const { Text } = Typography;
@@ -126,7 +126,10 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
{/* Column Headers */}
{!isCollapsed && totalTasks > 0 && (
<div className="task-group-column-headers">
<div
className="task-group-column-headers"
style={{ borderLeft: `4px solid ${getGroupColor()}` }}
>
<div className="task-group-column-headers-row">
<div className="task-table-fixed-columns">
<div
@@ -182,7 +185,10 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
{/* Tasks List */}
{!isCollapsed && (
<div className="task-group-body">
<div
className="task-group-body"
style={{ borderLeft: `4px solid ${getGroupColor()}` }}
>
{group.tasks.length === 0 ? (
<div className="task-group-empty">
<div className="task-table-fixed-columns">
@@ -262,7 +268,7 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
display: inline-flex;
align-items: center;
padding: 8px 12px;
border-radius: 6px;
border-radius: 6px 6px 0 0;
background-color: #f0f0f0;
color: white;
font-weight: 500;

View File

@@ -24,9 +24,9 @@ import {
reorderTasks,
} from '@/features/tasks/tasks.slice';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import TaskGroup from './TaskGroup';
import TaskRow from './TaskRow';
import BulkActionBar from './BulkActionBar';
import TaskGroup from './task-group';
import TaskRow from './task-row';
import BulkActionBar from './bulk-action-bar';
import { AppDispatch } from '@/app/store';
// Import the TaskListFilters component
@@ -242,6 +242,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
totalSelected={selectedTaskIds.length}
currentGrouping={groupBy}
projectId={projectId}
onClearSelection={() => setSelectedTaskIds([])}
/>
)}

View File

@@ -247,7 +247,7 @@ const TaskListBulkActionsBar = () => {
project_id: projectId,
members: selectedAssignees.map(member => ({
id: member.id,
name: member.name,
name: member.name || '',
email: member.email,
avatar_url: member.avatar_url,
})) as ITaskAssignee[],
@@ -437,7 +437,6 @@ const TaskListBulkActionsBar = () => {
placement="top"
arrow
trigger={['click']}
destroyOnHidden
onOpenChange={value => {
if (!value) {
setSelectedLabels([]);

View File

@@ -1,5 +1,5 @@
import { ConfigProvider, theme } from 'antd';
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useRef, memo, useMemo, useCallback } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { initializeTheme } from './themeSlice';
@@ -9,12 +9,45 @@ type ChildrenProp = {
children: React.ReactNode;
};
const ThemeWrapper = ({ children }: ChildrenProp) => {
const ThemeWrapper = memo(({ children }: ChildrenProp) => {
const dispatch = useAppDispatch();
const themeMode = useAppSelector(state => state.themeReducer.mode);
const isInitialized = useAppSelector(state => state.themeReducer.isInitialized);
const configRef = useRef<HTMLDivElement>(null);
// Memoize theme configuration to prevent unnecessary re-renders
const themeConfig = useMemo(() => ({
algorithm: themeMode === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm,
components: {
Layout: {
colorBgLayout: themeMode === 'dark' ? colors.darkGray : colors.white,
headerBg: themeMode === 'dark' ? colors.darkGray : colors.white,
},
Menu: {
colorBgContainer: colors.transparent,
},
Table: {
rowHoverBg: themeMode === 'dark' ? '#000' : '#edebf0',
},
Select: {
controlHeight: 32,
},
},
token: {
borderRadius: 4,
},
}), [themeMode]);
// Memoize the theme class name
const themeClassName = useMemo(() => `theme-${themeMode}`, [themeMode]);
// Memoize the media query change handler
const handleMediaQueryChange = useCallback((e: MediaQueryListEvent) => {
if (!localStorage.getItem('theme')) {
dispatch(initializeTheme());
}
}, [dispatch]);
// Initialize theme after mount
useEffect(() => {
if (!isInitialized) {
@@ -26,15 +59,9 @@ const ThemeWrapper = ({ children }: ChildrenProp) => {
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
if (!localStorage.getItem('theme')) {
dispatch(initializeTheme());
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [dispatch]);
mediaQuery.addEventListener('change', handleMediaQueryChange);
return () => mediaQuery.removeEventListener('change', handleMediaQueryChange);
}, [handleMediaQueryChange]);
// Add CSS transition classes to prevent flash
useEffect(() => {
@@ -44,34 +71,14 @@ const ThemeWrapper = ({ children }: ChildrenProp) => {
}, []);
return (
<div ref={configRef} className={`theme-${themeMode}`}>
<ConfigProvider
theme={{
algorithm: themeMode === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm,
components: {
Layout: {
colorBgLayout: themeMode === 'dark' ? colors.darkGray : colors.white,
headerBg: themeMode === 'dark' ? colors.darkGray : colors.white,
},
Menu: {
colorBgContainer: colors.transparent,
},
Table: {
rowHoverBg: themeMode === 'dark' ? '#000' : '#edebf0',
},
Select: {
controlHeight: 32,
},
},
token: {
borderRadius: 4,
},
}}
>
<div ref={configRef} className={themeClassName}>
<ConfigProvider theme={themeConfig}>
{children}
</ConfigProvider>
</div>
);
};
});
ThemeWrapper.displayName = 'ThemeWrapper';
export default ThemeWrapper;

View File

@@ -1,60 +1,74 @@
import { Col, ConfigProvider, Layout } from 'antd';
import { Outlet, useNavigate } from 'react-router-dom';
import { useEffect, memo, useMemo, useCallback } from 'react';
import { useMediaQuery } from 'react-responsive';
import Navbar from '../features/navbar/navbar';
import { useAppSelector } from '../hooks/useAppSelector';
import { useMediaQuery } from 'react-responsive';
import { useAppDispatch } from '../hooks/useAppDispatch';
import { colors } from '../styles/colors';
import { verifyAuthentication } from '@/features/auth/authSlice';
import { useEffect } from 'react';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useRenderPerformance } from '@/utils/performance';
import HubSpot from '@/components/HubSpot';
const MainLayout = () => {
const MainLayout = memo(() => {
const themeMode = useAppSelector(state => state.themeReducer.mode);
const isDesktop = useMediaQuery({ query: '(min-width: 1024px)' });
const dispatch = useAppDispatch();
const navigate = useNavigate();
const verifyAuthStatus = async () => {
// Performance monitoring in development
useRenderPerformance('MainLayout');
// Memoize auth verification function
const verifyAuthStatus = useCallback(async () => {
const session = await dispatch(verifyAuthentication()).unwrap();
if (!session.user.setup_completed) {
navigate('/worklenz/setup');
}
};
}, [dispatch, navigate]);
useEffect(() => {
void verifyAuthStatus();
}, [dispatch, navigate]);
}, [verifyAuthStatus]);
const headerStyles = {
// Memoize styles to prevent object recreation on every render
const headerStyles = useMemo(() => ({
zIndex: 999,
position: 'fixed',
position: 'fixed' as const,
width: '100%',
display: 'flex',
alignItems: 'center',
padding: 0,
borderBottom: themeMode === 'dark' ? '1px solid #303030' : 'none',
} as const;
}), [themeMode]);
const contentStyles = {
const contentStyles = useMemo(() => ({
paddingInline: isDesktop ? 64 : 24,
overflowX: 'hidden',
} as const;
overflowX: 'hidden' as const,
}), [isDesktop]);
// Memoize theme configuration
const themeConfig = useMemo(() => ({
components: {
Layout: {
colorBgLayout: themeMode === 'dark' ? colors.darkGray : colors.white,
headerBg: themeMode === 'dark' ? colors.darkGray : colors.white,
},
},
}), [themeMode]);
// Memoize header className
const headerClassName = useMemo(() =>
`shadow-md ${themeMode === 'dark' ? '' : 'shadow-[#18181811]'}`,
[themeMode]
);
return (
<ConfigProvider
theme={{
components: {
Layout: {
colorBgLayout: themeMode === 'dark' ? colors.darkGray : colors.white,
headerBg: themeMode === 'dark' ? colors.darkGray : colors.white,
},
},
}}
>
<ConfigProvider theme={themeConfig}>
<Layout style={{ minHeight: '100vh' }}>
<Layout.Header
className={`shadow-md ${themeMode === 'dark' ? '' : 'shadow-[#18181811]'}`}
className={headerClassName}
style={headerStyles}
>
<Navbar />
@@ -71,6 +85,8 @@ const MainLayout = () => {
</Layout>
</ConfigProvider>
);
};
});
MainLayout.displayName = 'MainLayout';
export default MainLayout;

View File

@@ -1,7 +1,7 @@
import React, { useEffect } from 'react';
import { Layout, Typography, Card, Space, Alert } from 'antd';
import { useDispatch } from 'react-redux';
import TaskListBoard from '@/components/task-management/TaskListBoard';
import TaskListBoard from '@/components/task-management/task-list-board';
import { AppDispatch } from '@/app/store';
const { Header, Content } = Layout;

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, memo, useMemo, useCallback } from 'react';
import { useMediaQuery } from 'react-responsive';
import Col from 'antd/es/col';
import Flex from 'antd/es/flex';
@@ -19,48 +19,72 @@ import { fetchProjectCategories } from '@/features/projects/lookups/projectCateg
import { fetchProjectHealth } from '@/features/projects/lookups/projectHealth/projectHealthSlice';
import { fetchProjects } from '@/features/home-page/home-page.slice';
import { createPortal } from 'react-dom';
import React from 'react';
import React, { Suspense } from 'react';
const DESKTOP_MIN_WIDTH = 1024;
const TASK_LIST_MIN_WIDTH = 500;
const SIDEBAR_MAX_WIDTH = 400;
// Lazy load heavy components
const TaskDrawer = React.lazy(() => import('@components/task-drawer/task-drawer'));
const HomePage = () => {
const HomePage = memo(() => {
const dispatch = useAppDispatch();
const isDesktop = useMediaQuery({ query: `(min-width: ${DESKTOP_MIN_WIDTH}px)` });
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
useDocumentTitle('Home');
useEffect(() => {
const fetchLookups = async () => {
const fetchPromises = [
dispatch(fetchProjectHealth()),
dispatch(fetchProjectCategories()),
dispatch(fetchProjectStatuses()),
dispatch(fetchProjects()),
].filter(Boolean);
// Memoize fetch function to prevent recreation on every render
const fetchLookups = useCallback(async () => {
const fetchPromises = [
dispatch(fetchProjectHealth()),
dispatch(fetchProjectCategories()),
dispatch(fetchProjectStatuses()),
dispatch(fetchProjects()),
].filter(Boolean);
await Promise.all(fetchPromises);
};
fetchLookups();
await Promise.all(fetchPromises);
}, [dispatch]);
const CreateProjectButtonComponent = () =>
isDesktop ? (
useEffect(() => {
fetchLookups();
}, [fetchLookups]);
// Memoize project drawer close handler
const handleProjectDrawerClose = useCallback(() => {}, []);
// Memoize desktop flex styles to prevent object recreation
const desktopFlexStyle = useMemo(() => ({
minWidth: TASK_LIST_MIN_WIDTH,
width: '100%'
}), []);
const sidebarFlexStyle = useMemo(() => ({
width: '100%',
maxWidth: SIDEBAR_MAX_WIDTH
}), []);
// Memoize components to prevent unnecessary re-renders
const CreateProjectButtonComponent = useMemo(() => {
if (!isOwnerOrAdmin) return null;
return isDesktop ? (
<div className="absolute right-0 top-1/2 -translate-y-1/2">
{isOwnerOrAdmin && <CreateProjectButton />}
<CreateProjectButton />
</div>
) : (
isOwnerOrAdmin && <CreateProjectButton />
<CreateProjectButton />
);
}, [isDesktop, isOwnerOrAdmin]);
const MainContent = () =>
isDesktop ? (
const MainContent = useMemo(() => {
return isDesktop ? (
<Flex gap={24} align="flex-start" className="w-full mt-12">
<Flex style={{ minWidth: TASK_LIST_MIN_WIDTH, width: '100%' }}>
<Flex style={desktopFlexStyle}>
<TasksList />
</Flex>
<Flex vertical gap={24} style={{ width: '100%', maxWidth: SIDEBAR_MAX_WIDTH }}>
<Flex vertical gap={24} style={sidebarFlexStyle}>
<TodoList />
<RecentAndFavouriteProjectList />
</Flex>
@@ -72,19 +96,31 @@ const HomePage = () => {
<RecentAndFavouriteProjectList />
</Flex>
);
}, [isDesktop, desktopFlexStyle, sidebarFlexStyle]);
return (
<div className="my-24 min-h-[90vh]">
<Col className="flex flex-col gap-6">
<GreetingWithTime />
<CreateProjectButtonComponent />
{CreateProjectButtonComponent}
</Col>
<MainContent />
{createPortal(<TaskDrawer />, document.body, 'home-task-drawer')}
{createPortal(<ProjectDrawer onClose={() => {}} />, document.body, 'project-drawer')}
{MainContent}
{/* Use Suspense for lazy-loaded components */}
<Suspense fallback={null}>
{createPortal(<TaskDrawer />, document.body, 'home-task-drawer')}
</Suspense>
{createPortal(
<ProjectDrawer onClose={handleProjectDrawerClose} />,
document.body,
'project-drawer'
)}
</div>
);
};
});
HomePage.displayName = 'HomePage';
export default HomePage;

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import TaskListBoard from '@/components/task-management/TaskListBoard';
import TaskListBoard from '@/components/task-management/task-list-board';
const ProjectViewEnhancedTasks: React.FC = () => {
const { project } = useAppSelector(state => state.projectReducer);

View File

@@ -0,0 +1,182 @@
import React from 'react';
/**
* Performance monitoring utilities for development
*/
const isProduction = import.meta.env.PROD;
const isDevelopment = !isProduction;
interface PerformanceEntry {
name: string;
startTime: number;
duration?: number;
}
class PerformanceMonitor {
private timers: Map<string, number> = new Map();
private entries: PerformanceEntry[] = [];
/**
* Start timing a performance measurement
*/
public startTimer(name: string): void {
if (isProduction) return;
this.timers.set(name, performance.now());
}
/**
* End timing and log the result
*/
public endTimer(name: string): number | null {
if (isProduction) return null;
const startTime = this.timers.get(name);
if (!startTime) {
console.warn(`Performance timer "${name}" was not started`);
return null;
}
const endTime = performance.now();
const duration = endTime - startTime;
this.timers.delete(name);
this.entries.push({ name, startTime, duration });
if (isDevelopment) {
const color = duration > 100 ? '#ff4d4f' : duration > 50 ? '#faad14' : '#52c41a';
console.log(
`%c⏱ ${name}: ${duration.toFixed(2)}ms`,
`color: ${color}; font-weight: bold;`
);
}
return duration;
}
/**
* Measure the performance of a function
*/
public measure<T>(name: string, fn: () => T): T {
if (isProduction) return fn();
this.startTimer(name);
const result = fn();
this.endTimer(name);
return result;
}
/**
* Measure the performance of an async function
*/
public async measureAsync<T>(name: string, fn: () => Promise<T>): Promise<T> {
if (isProduction) return fn();
this.startTimer(name);
const result = await fn();
this.endTimer(name);
return result;
}
/**
* Get all performance entries
*/
public getEntries(): PerformanceEntry[] {
return [...this.entries];
}
/**
* Clear all entries
*/
public clearEntries(): void {
this.entries = [];
}
/**
* Log a summary of all performance entries
*/
public logSummary(): void {
if (isProduction || this.entries.length === 0) return;
console.group('📊 Performance Summary');
const sortedEntries = this.entries
.filter(entry => entry.duration !== undefined)
.sort((a, b) => (b.duration || 0) - (a.duration || 0));
console.table(
sortedEntries.map(entry => ({
Name: entry.name,
Duration: `${(entry.duration || 0).toFixed(2)}ms`,
'Start Time': `${entry.startTime.toFixed(2)}ms`
}))
);
const totalTime = sortedEntries.reduce((sum, entry) => sum + (entry.duration || 0), 0);
console.log(`%cTotal measured time: ${totalTime.toFixed(2)}ms`, 'font-weight: bold;');
console.groupEnd();
}
}
// Create default instance
export const performanceMonitor = new PerformanceMonitor();
/**
* Higher-order component to measure component render performance
*/
export function withPerformanceMonitoring<P extends object>(
Component: React.ComponentType<P>,
componentName?: string
): React.ComponentType<P> {
if (isProduction) return Component;
const name = componentName || Component.displayName || Component.name || 'Unknown';
const WrappedComponent = (props: P) => {
React.useEffect(() => {
performanceMonitor.startTimer(`${name} mount`);
return () => {
performanceMonitor.endTimer(`${name} mount`);
};
}, []);
React.useEffect(() => {
performanceMonitor.endTimer(`${name} render`);
});
performanceMonitor.startTimer(`${name} render`);
return React.createElement(Component, props);
};
WrappedComponent.displayName = `withPerformanceMonitoring(${name})`;
return WrappedComponent;
}
/**
* Hook to measure render performance
*/
export function useRenderPerformance(componentName: string): void {
if (isProduction) return;
const renderCount = React.useRef(0);
const startTime = React.useRef<number>(0);
React.useEffect(() => {
renderCount.current += 1;
const endTime = performance.now();
const duration = endTime - startTime.current;
if (renderCount.current > 1) {
console.log(
`%c🔄 ${componentName} render #${renderCount.current}: ${duration.toFixed(2)}ms`,
'color: #1890ff; font-size: 11px;'
);
}
});
startTime.current = performance.now();
}
export default performanceMonitor;

View File

@@ -0,0 +1,181 @@
import React from 'react';
/**
* Route preloader utility to prefetch components and improve navigation performance
*/
interface PreloadableRoute {
path: string;
loader: () => Promise<any>;
priority: 'high' | 'medium' | 'low';
}
class RoutePreloader {
private preloadedRoutes = new Set<string>();
private preloadQueue: PreloadableRoute[] = [];
private isPreloading = false;
/**
* Register a route for preloading
*/
public registerRoute(path: string, loader: () => Promise<any>, priority: 'high' | 'medium' | 'low' = 'medium'): void {
if (this.preloadedRoutes.has(path)) return;
this.preloadQueue.push({ path, loader, priority });
this.sortQueue();
}
/**
* Preload a specific route immediately
*/
public async preloadRoute(path: string, loader: () => Promise<any>): Promise<void> {
if (this.preloadedRoutes.has(path)) return;
try {
await loader();
this.preloadedRoutes.add(path);
} catch (error) {
console.warn(`Failed to preload route: ${path}`, error);
}
}
/**
* Start preloading routes in the queue
*/
public async startPreloading(): Promise<void> {
if (this.isPreloading || this.preloadQueue.length === 0) return;
this.isPreloading = true;
// Use requestIdleCallback if available, otherwise setTimeout
const scheduleWork = (callback: () => void) => {
if ('requestIdleCallback' in window) {
requestIdleCallback(callback, { timeout: 1000 });
} else {
setTimeout(callback, 0);
}
};
const processQueue = async () => {
while (this.preloadQueue.length > 0) {
const route = this.preloadQueue.shift();
if (!route) break;
if (this.preloadedRoutes.has(route.path)) continue;
try {
await route.loader();
this.preloadedRoutes.add(route.path);
} catch (error) {
console.warn(`Failed to preload route: ${route.path}`, error);
}
// Yield control back to the browser
await new Promise<void>(resolve => scheduleWork(() => resolve()));
}
this.isPreloading = false;
};
scheduleWork(processQueue);
}
/**
* Preload routes on user interaction (hover, focus)
*/
public preloadOnInteraction(element: HTMLElement, path: string, loader: () => Promise<any>): void {
if (this.preloadedRoutes.has(path)) return;
let preloadTriggered = false;
const handleInteraction = () => {
if (preloadTriggered) return;
preloadTriggered = true;
this.preloadRoute(path, loader);
// Clean up listeners
element.removeEventListener('mouseenter', handleInteraction);
element.removeEventListener('focus', handleInteraction);
element.removeEventListener('touchstart', handleInteraction);
};
element.addEventListener('mouseenter', handleInteraction, { passive: true });
element.addEventListener('focus', handleInteraction, { passive: true });
element.addEventListener('touchstart', handleInteraction, { passive: true });
}
/**
* Preload routes when the browser is idle
*/
public preloadOnIdle(): void {
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
this.startPreloading();
}, { timeout: 2000 });
} else {
setTimeout(() => {
this.startPreloading();
}, 1000);
}
}
/**
* Check if a route is already preloaded
*/
public isRoutePreloaded(path: string): boolean {
return this.preloadedRoutes.has(path);
}
/**
* Clear all preloaded routes
*/
public clearPreloaded(): void {
this.preloadedRoutes.clear();
this.preloadQueue = [];
}
private sortQueue(): void {
const priorityOrder = { high: 0, medium: 1, low: 2 };
this.preloadQueue.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);
}
}
// Create default instance
export const routePreloader = new RoutePreloader();
/**
* React hook to preload routes on component mount
*/
export function useRoutePreloader(routes: Array<{ path: string; loader: () => Promise<any>; priority?: 'high' | 'medium' | 'low' }>): void {
React.useEffect(() => {
routes.forEach(route => {
routePreloader.registerRoute(route.path, route.loader, route.priority);
});
// Start preloading after a short delay to not interfere with initial render
const timer = setTimeout(() => {
routePreloader.preloadOnIdle();
}, 100);
return () => clearTimeout(timer);
}, [routes]);
}
/**
* React hook to preload a route on element interaction
*/
export function usePreloadOnHover(path: string, loader: () => Promise<any>) {
const elementRef = React.useRef<HTMLElement>(null);
React.useEffect(() => {
const element = elementRef.current;
if (!element) return;
routePreloader.preloadOnInteraction(element, path, loader);
}, [path, loader]);
return elementRef;
}
export default routePreloader;