feat(performance): optimize resource loading and initialization

- Added resource hints in index.html for improved loading performance, including preconnect and dns-prefetch links.
- Implemented preload for critical JSON resources to enhance initial load times.
- Optimized Google Analytics and HubSpot script loading to defer execution and reduce blocking during initial render.
- Refactored app initialization in App.tsx to defer non-critical operations, improving perceived performance.
- Introduced lazy loading for chart components and TinyMCE editor to minimize initial bundle size and enhance user experience.
- Enhanced Vite configuration for optimized chunking strategy and improved caching with shorter hash lengths.
This commit is contained in:
chamikaJ
2025-07-07 12:41:23 +05:30
parent 26b47aac53
commit aa1fb1c6f5
7 changed files with 583 additions and 177 deletions

View File

@@ -5,43 +5,74 @@
<link rel="icon" href="./favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#2b2b2b" />
<!-- Resource hints for better loading performance -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />
<link rel="dns-prefetch" href="https://js.hs-scripts.com" />
<!-- Preload critical resources -->
<link rel="preload" href="/locales/en/common.json" as="fetch" type="application/json" crossorigin />
<link rel="preload" href="/locales/en/auth/login.json" as="fetch" type="application/json" crossorigin />
<link rel="preload" href="/locales/en/navbar.json" as="fetch" type="application/json" crossorigin />
<!-- Optimized font loading with font-display: swap -->
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap"
rel="stylesheet"
media="print"
onload="this.media='all'"
/>
<noscript>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap"
rel="stylesheet"
/>
</noscript>
<title>Worklenz</title>
<!-- Environment configuration -->
<script src="/env-config.js"></script>
<!-- Google Analytics -->
<!-- Optimized Google Analytics with reduced blocking -->
<script>
// Function to initialize Google Analytics
// Function to initialize Google Analytics asynchronously
function initGoogleAnalytics() {
// Load the Google Analytics script
const script = document.createElement('script');
script.async = true;
// Use requestIdleCallback to defer analytics loading
const loadAnalytics = () => {
// Determine which tracking ID to use based on the environment
const isProduction =
window.location.hostname === 'worklenz.com' ||
window.location.hostname === 'app.worklenz.com';
// Determine which tracking ID to use based on the environment
const isProduction =
window.location.hostname === 'worklenz.com' ||
window.location.hostname === 'app.worklenz.com';
const trackingId = isProduction ? 'G-XXXXXXXXXX' : 'G-3LM2HGWEXG'; // Open source tracking ID
const trackingId = isProduction ? 'G-XXXXXXXXXX' : 'G-3LM2HGWEXG'; // Open source tracking ID
// Load the Google Analytics script
const script = document.createElement('script');
script.async = true;
script.src = `https://www.googletagmanager.com/gtag/js?id=${trackingId}`;
document.head.appendChild(script);
script.src = `https://www.googletagmanager.com/gtag/js?id=${trackingId}`;
document.head.appendChild(script);
// Initialize Google Analytics
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag('js', new Date());
gtag('config', trackingId);
};
// Initialize Google Analytics
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
// Use requestIdleCallback if available, otherwise setTimeout
if ('requestIdleCallback' in window) {
requestIdleCallback(loadAnalytics, { timeout: 2000 });
} else {
setTimeout(loadAnalytics, 1000);
}
gtag('js', new Date());
gtag('config', trackingId);
}
// Initialize analytics
// Initialize analytics after a delay to not block initial render
initGoogleAnalytics();
// Function to show privacy notice
@@ -98,14 +129,24 @@
<div id="root"></div>
<script type="module" src="./src/index.tsx"></script>
<script type="text/javascript">
// Load HubSpot script asynchronously and only for production
if (window.location.hostname === 'app.worklenz.com') {
var hs = document.createElement('script');
hs.type = 'text/javascript';
hs.id = 'hs-script-loader';
hs.async = true;
hs.defer = true;
hs.src = '//js.hs-scripts.com/22348300.js';
document.body.appendChild(hs);
// Use requestIdleCallback to defer HubSpot loading
const loadHubSpot = () => {
var hs = document.createElement('script');
hs.type = 'text/javascript';
hs.id = 'hs-script-loader';
hs.async = true;
hs.defer = true;
hs.src = '//js.hs-scripts.com/22348300.js';
document.body.appendChild(hs);
};
if ('requestIdleCallback' in window) {
requestIdleCallback(loadHubSpot, { timeout: 3000 });
} else {
setTimeout(loadHubSpot, 2000);
}
}
</script>
</body>

View File

@@ -29,6 +29,7 @@ import { SuspenseFallback } from './components/suspense-fallback/suspense-fallba
* 4. Lazy loading - All route components loaded on demand
* 5. Suspense boundaries - Better loading states
* 6. Optimized guard components with memoization
* 7. Deferred initialization - Non-critical operations moved to background
*/
const App: React.FC = memo(() => {
const themeMode = useAppSelector(state => state.themeReducer.mode);
@@ -37,8 +38,22 @@ const App: React.FC = memo(() => {
// Memoize mixpanel initialization to prevent re-initialization
const mixpanelToken = useMemo(() => import.meta.env.VITE_MIXPANEL_TOKEN as string, []);
// Defer mixpanel initialization to not block initial render
useEffect(() => {
initMixpanel(mixpanelToken);
const initializeMixpanel = () => {
try {
initMixpanel(mixpanelToken);
} catch (error) {
logger.error('Failed to initialize Mixpanel:', error);
}
};
// Use requestIdleCallback to defer mixpanel initialization
if ('requestIdleCallback' in window) {
requestIdleCallback(initializeMixpanel, { timeout: 2000 });
} else {
setTimeout(initializeMixpanel, 1000);
}
}, [mixpanelToken]);
// Memoize language change handler
@@ -48,39 +63,54 @@ const App: React.FC = memo(() => {
});
}, []);
// Apply theme immediately to prevent flash
useEffect(() => {
document.documentElement.setAttribute('data-theme', themeMode);
}, [themeMode]);
// Handle language changes
useEffect(() => {
handleLanguageChange(language || Language.EN);
}, [language, handleLanguageChange]);
// Initialize CSRF token and translations on app startup
// Initialize critical app functionality
useEffect(() => {
let isMounted = true;
const initializeApp = async () => {
const initializeCriticalApp = async () => {
try {
// Initialize CSRF token
// Initialize CSRF token immediately as it's needed for API calls
await initializeCsrfToken();
// Note: Translation preloading is handled in i18n.ts initialization
// No need to call ensureTranslationsLoaded here to avoid duplicate requests
} catch (error) {
if (isMounted) {
logger.error('Failed to initialize app:', error);
logger.error('Failed to initialize critical app functionality:', error);
}
}
};
initializeApp();
// Initialize critical functionality immediately
initializeCriticalApp();
return () => {
isMounted = false;
};
}, []);
// Defer non-critical initialization
useEffect(() => {
const initializeNonCriticalApp = () => {
// Any non-critical initialization can go here
// For example: analytics, feature flags, etc.
};
// Defer non-critical initialization to not block initial render
if ('requestIdleCallback' in window) {
requestIdleCallback(initializeNonCriticalApp, { timeout: 3000 });
} else {
setTimeout(initializeNonCriticalApp, 1500);
}
}, []);
return (
<Suspense fallback={<SuspenseFallback />}>
<ThemeWrapper>

View File

@@ -0,0 +1,84 @@
import { lazy, Suspense } from 'react';
import { Spin } from 'antd';
// Lazy load Chart.js components
const LazyBarChart = lazy(() =>
import('react-chartjs-2').then(module => ({ default: module.Bar }))
);
const LazyLineChart = lazy(() =>
import('react-chartjs-2').then(module => ({ default: module.Line }))
);
const LazyPieChart = lazy(() =>
import('react-chartjs-2').then(module => ({ default: module.Pie }))
);
const LazyDoughnutChart = lazy(() =>
import('react-chartjs-2').then(module => ({ default: module.Doughnut }))
);
// Lazy load Gantt components
const LazyGanttChart = lazy(() =>
import('gantt-task-react').then(module => ({ default: module.Gantt }))
);
// Chart loading fallback
const ChartLoadingFallback = () => (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '300px',
background: '#fafafa',
borderRadius: '8px',
border: '1px solid #f0f0f0'
}}>
<Spin size="large" />
</div>
);
// Wrapped components with Suspense
export const BarChart = (props: any) => (
<Suspense fallback={<ChartLoadingFallback />}>
<LazyBarChart {...props} />
</Suspense>
);
export const LineChart = (props: any) => (
<Suspense fallback={<ChartLoadingFallback />}>
<LazyLineChart {...props} />
</Suspense>
);
export const PieChart = (props: any) => (
<Suspense fallback={<ChartLoadingFallback />}>
<LazyPieChart {...props} />
</Suspense>
);
export const DoughnutChart = (props: any) => (
<Suspense fallback={<ChartLoadingFallback />}>
<LazyDoughnutChart {...props} />
</Suspense>
);
export const GanttChart = (props: any) => (
<Suspense fallback={<ChartLoadingFallback />}>
<LazyGanttChart {...props} />
</Suspense>
);
// Hook to preload chart libraries when needed
export const usePreloadCharts = () => {
const preloadCharts = () => {
// Preload Chart.js
import('react-chartjs-2');
import('chart.js');
// Preload Gantt
import('gantt-task-react');
};
return { preloadCharts };
};

View File

@@ -1,10 +1,14 @@
import React, { useState, useRef, useEffect } from 'react';
import { Editor } from '@tinymce/tinymce-react';
import React, { useState, useRef, useEffect, lazy, Suspense } from 'react';
import DOMPurify from 'dompurify';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
// Lazy load TinyMCE editor to reduce initial bundle size
const LazyTinyMCEEditor = lazy(() =>
import('@tinymce/tinymce-react').then(module => ({ default: module.Editor }))
);
interface DescriptionEditorProps {
description: string | null;
taskId: string;
@@ -17,23 +21,39 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi
const [isEditorOpen, setIsEditorOpen] = useState<boolean>(false);
const [content, setContent] = useState<string>(description || '');
const [isEditorLoading, setIsEditorLoading] = useState<boolean>(false);
const [wordCount, setWordCount] = useState<number>(0); // State for word count
const [wordCount, setWordCount] = useState<number>(0);
const [isTinyMCELoaded, setIsTinyMCELoaded] = useState<boolean>(false);
const editorRef = useRef<any>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const themeMode = useAppSelector(state => state.themeReducer.mode);
// Preload TinyMCE script
useEffect(() => {
const preloadTinyMCE = () => {
const link = document.createElement('link');
link.rel = 'preload';
link.href = '/tinymce/tinymce.min.js';
link.as = 'script';
document.head.appendChild(link);
};
preloadTinyMCE();
}, []);
// Load TinyMCE script only when editor is opened
const loadTinyMCE = async () => {
if (isTinyMCELoaded) return;
setIsEditorLoading(true);
try {
// Load TinyMCE script dynamically
await new Promise<void>((resolve, reject) => {
if (window.tinymce) {
resolve();
return;
}
const script = document.createElement('script');
script.src = '/tinymce/tinymce.min.js';
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error('Failed to load TinyMCE'));
document.head.appendChild(script);
});
setIsTinyMCELoaded(true);
} catch (error) {
console.error('Failed to load TinyMCE:', error);
setIsEditorLoading(false);
}
};
const handleDescriptionChange = () => {
if (!taskId) return;
@@ -80,7 +100,6 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi
const handleEditorChange = (content: string) => {
const sanitizedContent = DOMPurify.sanitize(content);
setContent(sanitizedContent);
// Update word count when content changes
if (editorRef.current) {
const count = editorRef.current.plugins.wordcount.getCount();
setWordCount(count);
@@ -90,15 +109,14 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi
const handleInit = (evt: any, editor: any) => {
editorRef.current = editor;
editor.on('focus', () => setIsEditorOpen(true));
// Set initial word count on init
const initialCount = editor.plugins.wordcount.getCount();
setWordCount(initialCount);
setIsEditorLoading(false);
};
const handleOpenEditor = () => {
const handleOpenEditor = async () => {
setIsEditorOpen(true);
setIsEditorLoading(true);
await loadTinyMCE();
};
const darkModeStyles =
@@ -141,59 +159,63 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi
<div>Loading editor...</div>
</div>
)}
<Editor
tinymceScriptSrc="/tinymce/tinymce.min.js"
value={content}
onInit={handleInit}
licenseKey="gpl"
init={{
height: 200,
menubar: false,
branding: false,
plugins: [
'advlist',
'autolink',
'lists',
'link',
'charmap',
'preview',
'anchor',
'searchreplace',
'visualblocks',
'code',
'fullscreen',
'insertdatetime',
'media',
'table',
'code',
'wordcount', // Added wordcount
],
toolbar:
'blocks |' +
'bold italic underline strikethrough | ' +
'bullist numlist | link | removeformat | help',
content_style: `
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 14px;
}
${darkModeStyles}
`,
skin: themeMode === 'dark' ? 'oxide-dark' : 'oxide',
content_css: themeMode === 'dark' ? 'dark' : 'default',
skin_url: `/tinymce/skins/ui/${themeMode === 'dark' ? 'oxide-dark' : 'oxide'}`,
content_css_cors: true,
auto_focus: true,
init_instance_callback: editor => {
editor.dom.setStyle(
editor.getBody(),
'backgroundColor',
themeMode === 'dark' ? '#1e1e1e' : '#ffffff'
);
},
}}
onEditorChange={handleEditorChange}
/>
{isTinyMCELoaded && (
<Suspense fallback={<div>Loading editor...</div>}>
<LazyTinyMCEEditor
tinymceScriptSrc="/tinymce/tinymce.min.js"
value={content}
onInit={handleInit}
licenseKey="gpl"
init={{
height: 200,
menubar: false,
branding: false,
plugins: [
'advlist',
'autolink',
'lists',
'link',
'charmap',
'preview',
'anchor',
'searchreplace',
'visualblocks',
'code',
'fullscreen',
'insertdatetime',
'media',
'table',
'code',
'wordcount',
],
toolbar:
'blocks |' +
'bold italic underline strikethrough | ' +
'bullist numlist | link | removeformat | help',
content_style: `
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 14px;
}
${darkModeStyles}
`,
skin: themeMode === 'dark' ? 'oxide-dark' : 'oxide',
content_css: themeMode === 'dark' ? 'dark' : 'default',
skin_url: `/tinymce/skins/ui/${themeMode === 'dark' ? 'oxide-dark' : 'oxide'}`,
content_css_cors: true,
auto_focus: true,
init_instance_callback: editor => {
editor.dom.setStyle(
editor.getBody(),
'backgroundColor',
themeMode === 'dark' ? '#1e1e1e' : '#ffffff'
);
},
}}
onEditorChange={handleEditorChange}
/>
</Suspense>
)}
</div>
) : (
<div
@@ -201,24 +223,37 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
minHeight: '32px',
padding: '4px 11px',
border: `1px solid ${isHovered ? (themeMode === 'dark' ? '#177ddc' : '#40a9ff') : 'transparent'}`,
minHeight: '40px',
padding: '8px 12px',
border: `1px solid ${themeMode === 'dark' ? '#424242' : '#d9d9d9'}`,
borderRadius: '6px',
cursor: 'pointer',
backgroundColor: isHovered
? themeMode === 'dark'
? '#2a2a2a'
: '#fafafa'
: themeMode === 'dark'
? '#1e1e1e'
: '#ffffff',
color: themeMode === 'dark' ? '#ffffff' : '#000000',
transition: 'border-color 0.3s ease',
transition: 'all 0.2s ease',
}}
>
{content ? (
<div
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(content) }}
style={{ color: 'inherit' }}
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(content),
}}
/>
) : (
<span style={{ color: themeMode === 'dark' ? '#666666' : '#bfbfbf' }}>
Add a more detailed description...
</span>
<div
style={{
color: themeMode === 'dark' ? '#888888' : '#999999',
fontStyle: 'italic',
}}
>
Click to add description...
</div>
)}
</div>
)}

View File

@@ -6,16 +6,27 @@ import logger from './utils/errorLogger';
// Essential namespaces that should be preloaded to prevent Suspense
const ESSENTIAL_NAMESPACES = [
'common',
'auth/login',
'navbar',
];
// Secondary namespaces that can be loaded on demand
const SECONDARY_NAMESPACES = [
'tasks/task-table-bulk-actions',
'task-management',
'auth/login',
'settings',
'home',
'project-drawer',
];
// Cache to track loaded translations and prevent duplicate requests
const loadedTranslations = new Set<string>();
const loadingPromises = new Map<string, Promise<any>>();
// Background loading queue for non-essential translations
let backgroundLoadingQueue: Array<{ lang: string; ns: string }> = [];
let isBackgroundLoading = false;
i18n
.use(HttpApi)
.use(initReactI18next)
@@ -23,24 +34,34 @@ i18n
fallbackLng: 'en',
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
// Add request timeout to prevent hanging on slow connections
requestOptions: {
cache: 'default',
mode: 'cors',
credentials: 'same-origin',
},
},
defaultNS: 'common',
// Only load essential namespaces initially
ns: ESSENTIAL_NAMESPACES,
interpolation: {
escapeValue: false,
},
// Preload essential namespaces
preload: ['en', 'es', 'pt', 'alb', 'de'],
// Load all namespaces on initialization
// Only preload current language to reduce initial load
preload: [],
load: 'languageOnly',
// Cache translations
// Disable loading all namespaces on init
initImmediate: false,
// Cache translations with shorter expiration for better performance
cache: {
enabled: true,
expirationTime: 24 * 60 * 60 * 1000, // 24 hours
expirationTime: 12 * 60 * 60 * 1000, // 12 hours
},
// Reduce debug output in production
debug: process.env.NODE_ENV === 'development',
});
// Utility function to ensure translations are loaded
// Optimized function to ensure translations are loaded
export const ensureTranslationsLoaded = async (
namespaces: string[] = ESSENTIAL_NAMESPACES,
languages: string[] = [i18n.language || 'en']
@@ -65,7 +86,6 @@ export const ensureTranslationsLoaded = async (
// Create loading promise
const loadingPromise = new Promise<void>((resolve, reject) => {
// Switch to the target language temporarily if needed
const currentLang = i18n.language;
const shouldSwitchLang = currentLang !== lang;
@@ -77,7 +97,6 @@ export const ensureTranslationsLoaded = async (
await i18n.loadNamespaces(ns);
// Switch back to original language if we changed it
if (shouldSwitchLang && currentLang) {
await i18n.changeLanguage(currentLang);
}
@@ -100,7 +119,6 @@ export const ensureTranslationsLoaded = async (
}
}
// Wait for all loading promises to complete
await Promise.all(loadPromises);
return true;
} catch (error) {
@@ -109,13 +127,86 @@ export const ensureTranslationsLoaded = async (
}
};
// Preload essential translations for current language only on startup
const initializeTranslations = async () => {
const currentLang = i18n.language || 'en';
await ensureTranslationsLoaded(ESSENTIAL_NAMESPACES, [currentLang]);
// Background loading function for non-essential translations
const processBackgroundQueue = async () => {
if (isBackgroundLoading || backgroundLoadingQueue.length === 0) return;
isBackgroundLoading = true;
try {
// Process queue in batches to avoid overwhelming the network
const batchSize = 3;
while (backgroundLoadingQueue.length > 0) {
const batch = backgroundLoadingQueue.splice(0, batchSize);
const batchPromises = batch.map(({ lang, ns }) =>
ensureTranslationsLoaded([ns], [lang]).catch(error => {
logger.error(`Background loading failed for ${lang}:${ns}`, error);
})
);
await Promise.all(batchPromises);
// Add small delay between batches to prevent blocking
if (backgroundLoadingQueue.length > 0) {
await new Promise(resolve => setTimeout(resolve, 100));
}
}
} finally {
isBackgroundLoading = false;
}
};
// Initialize translations on app startup (only once)
// Queue secondary translations for background loading
const queueSecondaryTranslations = (language: string) => {
SECONDARY_NAMESPACES.forEach(ns => {
const key = `${language}:${ns}`;
if (!loadedTranslations.has(key)) {
backgroundLoadingQueue.push({ lang: language, ns });
}
});
// Start background loading with a delay to not interfere with initial render
setTimeout(processBackgroundQueue, 2000);
};
// Initialize only essential translations for current language
const initializeTranslations = async () => {
try {
const currentLang = i18n.language || 'en';
// Load only essential namespaces initially
await ensureTranslationsLoaded(ESSENTIAL_NAMESPACES, [currentLang]);
// Queue secondary translations for background loading
queueSecondaryTranslations(currentLang);
return true;
} catch (error) {
logger.error('Failed to initialize translations:', error);
return false;
}
};
// Language change handler that prioritizes essential namespaces
export const changeLanguageOptimized = async (language: string) => {
try {
// Change language first
await i18n.changeLanguage(language);
// Load essential namespaces immediately
await ensureTranslationsLoaded(ESSENTIAL_NAMESPACES, [language]);
// Queue secondary translations for background loading
queueSecondaryTranslations(language);
return true;
} catch (error) {
logger.error(`Failed to change language to ${language}:`, error);
return false;
}
};
// Initialize translations on app startup (only essential ones)
initializeTranslations();
export default i18n;

View File

@@ -1,5 +1,4 @@
import React, { useEffect, useState, forwardRef, useImperativeHandle } from 'react';
import { Bar } from 'react-chartjs-2';
import React, { useEffect, useState, forwardRef, useImperativeHandle, lazy, Suspense } from 'react';
import {
Chart as ChartJS,
CategoryScale,
@@ -20,7 +19,34 @@ import { IRPTTimeProject } from '@/types/reporting/reporting.types';
import { Empty, Spin } from 'antd';
import logger from '@/utils/errorLogger';
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ChartDataLabels);
// Lazy load the Bar chart component
const LazyBarChart = lazy(() =>
import('react-chartjs-2').then(module => ({ default: module.Bar }))
);
// Chart loading fallback
const ChartLoadingFallback = () => (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '400px',
background: '#fafafa',
borderRadius: '8px',
border: '1px solid #f0f0f0'
}}>
<Spin size="large" />
</div>
);
// Register Chart.js components only when needed
let isChartJSRegistered = false;
const registerChartJS = () => {
if (!isChartJSRegistered) {
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ChartDataLabels);
isChartJSRegistered = true;
}
};
const BAR_THICKNESS = 40;
const STROKE_WIDTH = 4;
@@ -36,6 +62,7 @@ const ProjectTimeSheetChart = forwardRef<ProjectTimeSheetChartRef>((_, ref) => {
const { t } = useTranslation('time-report');
const [jsonData, setJsonData] = useState<IRPTTimeProject[]>([]);
const [loading, setLoading] = useState(false);
const [chartReady, setChartReady] = useState(false);
const chartRef = React.useRef<ChartJS<'bar', string[], unknown>>(null);
const themeMode = useAppSelector(state => state.themeReducer.mode);
@@ -51,6 +78,21 @@ const ProjectTimeSheetChart = forwardRef<ProjectTimeSheetChartRef>((_, ref) => {
} = useAppSelector(state => state.timeReportsOverviewReducer);
const { duration, dateRange } = useAppSelector(state => state.reportingReducer);
// Initialize chart when component mounts
useEffect(() => {
const initChart = () => {
registerChartJS();
setChartReady(true);
};
// Use requestIdleCallback to defer chart initialization
if ('requestIdleCallback' in window) {
requestIdleCallback(initChart, { timeout: 1000 });
} else {
setTimeout(initChart, 500);
}
}, []);
const handleBarClick = (event: any, elements: any) => {
if (elements.length > 0) {
const elementIndex = elements[0].index;
@@ -158,7 +200,7 @@ const ProjectTimeSheetChart = forwardRef<ProjectTimeSheetChartRef>((_, ref) => {
};
useEffect(() => {
if (!loadingTeams && !loadingProjects && !loadingCategories) {
if (!loadingTeams && !loadingProjects && !loadingCategories && chartReady) {
setLoading(true);
fetchChartData().finally(() => {
setLoading(false);
@@ -175,6 +217,7 @@ const ProjectTimeSheetChart = forwardRef<ProjectTimeSheetChartRef>((_, ref) => {
loadingTeams,
loadingProjects,
loadingCategories,
chartReady,
]);
const exportChart = () => {
@@ -200,8 +243,8 @@ const ProjectTimeSheetChart = forwardRef<ProjectTimeSheetChartRef>((_, ref) => {
// Create download link
const link = document.createElement('a');
link.download = 'project-time-sheet.png';
link.href = tempCanvas.toDataURL('image/png');
link.download = 'project-time-sheet-chart.png';
link.href = tempCanvas.toDataURL();
link.click();
}
};
@@ -210,25 +253,35 @@ const ProjectTimeSheetChart = forwardRef<ProjectTimeSheetChartRef>((_, ref) => {
exportChart,
}));
// if (loading) {
// return (
// <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
// <Spin />
// </div>
// );
// }
if (loading) {
return (
<div style={{ minHeight: MIN_HEIGHT }}>
<Spin size="large" style={{ display: 'block', margin: '100px auto' }} />
</div>
);
}
if (!Array.isArray(jsonData) || jsonData.length === 0) {
return (
<div style={{ minHeight: MIN_HEIGHT }}>
<Empty description={t('noDataAvailable')} />
</div>
);
}
const chartHeight = jsonData.length * (BAR_THICKNESS + 10) + 100;
const containerHeight = Math.max(chartHeight, 400);
return (
<div>
<div
style={{
maxWidth: `calc(100vw - ${SIDEBAR_WIDTH}px)`,
minWidth: 'calc(100vw - 260px)',
minHeight: MIN_HEIGHT,
height: `${60 * data.labels.length}px`,
}}
>
<Bar data={data} options={options} ref={chartRef} />
<div style={{ minHeight: MIN_HEIGHT }}>
<div style={{ height: `${containerHeight}px`, width: '100%' }}>
{chartReady ? (
<Suspense fallback={<ChartLoadingFallback />}>
<LazyBarChart data={data} options={options} ref={chartRef} />
</Suspense>
) : (
<ChartLoadingFallback />
)}
</div>
<ProjectTimeLogDrawer />
</div>

View File

@@ -77,36 +77,90 @@ export default defineConfig(({ command, mode }) => {
// **Rollup Options**
rollupOptions: {
output: {
// **Simplified Chunking Strategy to avoid React context issues**
manualChunks: {
// Keep React and all React-dependent libraries together
'react-vendor': ['react', 'react-dom', 'react/jsx-runtime'],
// Separate chunk for router
'react-router': ['react-router-dom'],
// Keep Ant Design separate but ensure React is available
antd: ['antd', '@ant-design/icons'],
// **Optimized Chunking Strategy for better caching and loading**
manualChunks: (id) => {
// Core React libraries - most stable, rarely change
if (id.includes('react') || id.includes('react-dom') || id.includes('react/jsx-runtime')) {
return 'react-core';
}
// React Router - separate chunk as it's used throughout the app
if (id.includes('react-router') || id.includes('react-router-dom')) {
return 'react-router';
}
// Ant Design - large UI library, separate chunk
if (id.includes('antd') || id.includes('@ant-design')) {
return 'antd';
}
// Chart.js and related libraries - heavy visualization libs
if (id.includes('chart.js') || id.includes('react-chartjs') || id.includes('chartjs')) {
return 'charts';
}
// TinyMCE - heavy editor, separate chunk (lazy loaded)
if (id.includes('tinymce') || id.includes('@tinymce')) {
return 'tinymce';
}
// Gantt and scheduling libraries - heavy components
if (id.includes('gantt') || id.includes('scheduler')) {
return 'gantt';
}
// Date utilities - commonly used
if (id.includes('date-fns') || id.includes('moment')) {
return 'date-utils';
}
// Redux and state management
if (id.includes('@reduxjs') || id.includes('react-redux') || id.includes('redux')) {
return 'redux';
}
// Socket.io - real-time communication
if (id.includes('socket.io')) {
return 'socket';
}
// Utility libraries
if (id.includes('lodash') || id.includes('dompurify') || id.includes('nanoid')) {
return 'utils';
}
// i18n libraries
if (id.includes('i18next') || id.includes('react-i18next')) {
return 'i18n';
}
// Other node_modules dependencies
if (id.includes('node_modules')) {
return 'vendor';
}
// Return undefined for app code to be bundled together
return undefined;
},
// **File Naming Strategies**
chunkFileNames: chunkInfo => {
const facadeModuleId = chunkInfo.facadeModuleId
? chunkInfo.facadeModuleId.split('/').pop()
: 'chunk';
return `assets/js/[name]-[hash].js`;
chunkFileNames: (chunkInfo) => {
// Use shorter names for better caching
return `assets/js/[name]-[hash:8].js`;
},
entryFileNames: 'assets/js/[name]-[hash].js',
assetFileNames: assetInfo => {
if (!assetInfo.name) return 'assets/[name]-[hash].[ext]';
entryFileNames: 'assets/js/[name]-[hash:8].js',
assetFileNames: (assetInfo) => {
if (!assetInfo.name) return 'assets/[name]-[hash:8].[ext]';
const info = assetInfo.name.split('.');
let extType = info[info.length - 1];
if (/png|jpe?g|svg|gif|tiff|bmp|ico/i.test(extType)) {
if (/png|jpe?g|svg|gif|tiff|bmp|ico|webp/i.test(extType)) {
extType = 'img';
} else if (/woff2?|eot|ttf|otf/i.test(extType)) {
extType = 'fonts';
} else if (/css/i.test(extType)) {
extType = 'css';
}
return `assets/${extType}/[name]-[hash].[ext]`;
return `assets/${extType}/[name]-[hash:8].[ext]`;
},
},
@@ -126,17 +180,35 @@ export default defineConfig(({ command, mode }) => {
// **Optimization**
optimizeDeps: {
include: ['react', 'react-dom', 'react/jsx-runtime', 'antd', '@ant-design/icons'],
include: [
'react',
'react-dom',
'react/jsx-runtime',
'antd',
'@ant-design/icons',
'react-router-dom',
'i18next',
'react-i18next',
'date-fns',
'dompurify',
],
exclude: [
// Add any packages that should not be pre-bundled
// Exclude heavy libraries that should be lazy loaded
'@tinymce/tinymce-react',
'tinymce',
'chart.js',
'react-chartjs-2',
'gantt-task-react',
],
// Force pre-bundling to avoid runtime issues
force: true,
force: false, // Only force when needed to improve dev startup time
},
// **Define global constants**
define: {
__DEV__: !isProduction,
},
};
});