feat(pwa): implement service worker and PWA enhancements

- Added service worker (sw.js) for offline functionality, caching strategies, and performance improvements.
- Registered service worker in App component to manage updates and offline readiness.
- Introduced ServiceWorkerStatus component to display connection status and provide cache management controls.
- Created manifest.json for PWA configuration, including app name, icons, and display settings.
- Updated index.html with PWA meta tags and links to support mobile web app capabilities.
- Refactored authentication guards to utilize useAuthStatus hook for improved user state management.
- Removed deprecated unregister-sw.js file to streamline service worker management.
This commit is contained in:
chamiakJ
2025-07-10 14:07:03 +05:30
parent bb8e6ee60f
commit bcfa18b1e8
16 changed files with 1238 additions and 187 deletions

View File

@@ -6,6 +6,27 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#2b2b2b" />
<!-- PWA Meta Tags -->
<meta name="application-name" content="Worklenz" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Worklenz" />
<meta name="description" content="A comprehensive project management application for teams" />
<meta name="format-detection" content="telephone=no" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="msapplication-config" content="/browserconfig.xml" />
<meta name="msapplication-TileColor" content="#2b2b2b" />
<meta name="msapplication-tap-highlight" content="no" />
<!-- Apple Touch Icons -->
<link rel="apple-touch-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="152x152" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="167x167" href="/favicon.ico" />
<!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json" />
<!-- Resource hints for better loading performance -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

View File

@@ -0,0 +1,78 @@
{
"name": "Worklenz - Project Management",
"short_name": "Worklenz",
"description": "A comprehensive project management application for teams",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#2b2b2b",
"orientation": "portrait-primary",
"categories": ["productivity", "business"],
"lang": "en",
"scope": "/",
"icons": [
{
"src": "/favicon.ico",
"sizes": "16x16 32x32 48x48",
"type": "image/x-icon",
"purpose": "any maskable"
},
{
"src": "/assets/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/assets/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"shortcuts": [
{
"name": "Dashboard",
"short_name": "Dashboard",
"description": "View your project dashboard",
"url": "/worklenz",
"icons": [
{
"src": "/favicon.ico",
"sizes": "16x16 32x32 48x48"
}
]
},
{
"name": "Tasks",
"short_name": "Tasks",
"description": "Manage your tasks",
"url": "/worklenz/tasks",
"icons": [
{
"src": "/favicon.ico",
"sizes": "16x16 32x32 48x48"
}
]
}
],
"screenshots": [
{
"src": "/assets/images/screenshot-desktop.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
},
{
"src": "/assets/images/screenshot-mobile.png",
"sizes": "480x854",
"type": "image/png",
"form_factor": "narrow"
}
],
"prefer_related_applications": false,
"related_applications": [],
"launch_handler": {
"client_mode": "focus-existing"
}
}

View File

@@ -0,0 +1,345 @@
// Worklenz Service Worker
// Provides offline functionality, caching, and performance improvements
const CACHE_VERSION = 'v1.0.0';
const CACHE_NAMES = {
STATIC: `worklenz-static-${CACHE_VERSION}`,
DYNAMIC: `worklenz-dynamic-${CACHE_VERSION}`,
API: `worklenz-api-${CACHE_VERSION}`,
IMAGES: `worklenz-images-${CACHE_VERSION}`
};
// Resources to cache immediately on install
const STATIC_CACHE_URLS = [
'/',
'/index.html',
'/favicon.ico',
'/env-config.js',
'/manifest.json',
// Ant Design and other critical CSS/JS will be cached as they're requested
];
// API endpoints that can be cached
const CACHEABLE_API_PATTERNS = [
/\/api\/project-categories/,
/\/api\/project-statuses/,
/\/api\/task-priorities/,
/\/api\/task-statuses/,
/\/api\/job-titles/,
/\/api\/teams\/\d+\/members/,
/\/api\/auth\/user/, // Cache user info for offline access
];
// Resources that should never be cached
const NEVER_CACHE_PATTERNS = [
/\/api\/auth\/login/,
/\/api\/auth\/logout/,
/\/api\/notifications/,
/\/socket\.io/,
/\.hot-update\./,
/sw\.js$/,
/chrome-extension/,
/moz-extension/,
];
// Install event - Cache static resources
self.addEventListener('install', event => {
console.log('Service Worker: Installing...');
event.waitUntil(
(async () => {
try {
const cache = await caches.open(CACHE_NAMES.STATIC);
await cache.addAll(STATIC_CACHE_URLS);
console.log('Service Worker: Static resources cached');
// Skip waiting to activate immediately
await self.skipWaiting();
} catch (error) {
console.error('Service Worker: Installation failed', error);
}
})()
);
});
// Activate event - Clean up old caches
self.addEventListener('activate', event => {
console.log('Service Worker: Activating...');
event.waitUntil(
(async () => {
try {
// Clean up old caches
const cacheNames = await caches.keys();
const oldCaches = cacheNames.filter(name =>
Object.values(CACHE_NAMES).every(currentCache => currentCache !== name)
);
await Promise.all(
oldCaches.map(cacheName => caches.delete(cacheName))
);
console.log('Service Worker: Old caches cleaned up');
// Take control of all pages
await self.clients.claim();
} catch (error) {
console.error('Service Worker: Activation failed', error);
}
})()
);
});
// Fetch event - Handle all network requests
self.addEventListener('fetch', event => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests and browser extensions
if (request.method !== 'GET' || NEVER_CACHE_PATTERNS.some(pattern => pattern.test(url.href))) {
return;
}
event.respondWith(handleFetchRequest(request));
});
// Main fetch handler with different strategies based on resource type
async function handleFetchRequest(request) {
const url = new URL(request.url);
try {
// Static assets - Cache First strategy
if (isStaticAsset(url)) {
return await cacheFirstStrategy(request, CACHE_NAMES.STATIC);
}
// Images - Cache First with long-term storage
if (isImageRequest(url)) {
return await cacheFirstStrategy(request, CACHE_NAMES.IMAGES);
}
// API requests - Network First with fallback
if (isAPIRequest(url)) {
return await networkFirstStrategy(request, CACHE_NAMES.API);
}
// HTML pages - Stale While Revalidate
if (isHTMLRequest(request)) {
return await staleWhileRevalidateStrategy(request, CACHE_NAMES.DYNAMIC);
}
// Everything else - Network First
return await networkFirstStrategy(request, CACHE_NAMES.DYNAMIC);
} catch (error) {
console.error('Service Worker: Fetch failed', error);
return createOfflineResponse(request);
}
}
// Cache First Strategy - Try cache first, fallback to network
async function cacheFirstStrategy(request, cacheName) {
const cache = await caches.open(cacheName);
const cachedResponse = await cache.match(request);
if (cachedResponse) {
return cachedResponse;
}
try {
const networkResponse = await fetch(request);
if (networkResponse.status === 200) {
// Clone before caching as response can only be used once
const responseClone = networkResponse.clone();
await cache.put(request, responseClone);
}
return networkResponse;
} catch (error) {
console.error('Cache First: Network failed', error);
throw error;
}
}
// Network First Strategy - Try network first, fallback to cache
async function networkFirstStrategy(request, cacheName) {
const cache = await caches.open(cacheName);
try {
const networkResponse = await fetch(request);
if (networkResponse.status === 200) {
// Cache successful responses
const responseClone = networkResponse.clone();
await cache.put(request, responseClone);
}
return networkResponse;
} catch (error) {
console.warn('Network First: Network failed, trying cache', error);
const cachedResponse = await cache.match(request);
if (cachedResponse) {
return cachedResponse;
}
throw error;
}
}
// Stale While Revalidate Strategy - Return cached version while updating in background
async function staleWhileRevalidateStrategy(request, cacheName) {
const cache = await caches.open(cacheName);
const cachedResponse = await cache.match(request);
// Fetch from network in background
const networkResponsePromise = fetch(request).then(async networkResponse => {
if (networkResponse.status === 200) {
const responseClone = networkResponse.clone();
await cache.put(request, responseClone);
}
return networkResponse;
}).catch(error => {
console.warn('Stale While Revalidate: Background update failed', error);
});
// Return cached version immediately if available
if (cachedResponse) {
return cachedResponse;
}
// If no cached version, wait for network
return await networkResponsePromise;
}
// Helper functions to identify resource types
function isStaticAsset(url) {
return /\.(js|css|woff2?|ttf|eot)$/.test(url.pathname) ||
url.pathname.includes('/assets/') ||
url.pathname === '/' ||
url.pathname === '/index.html' ||
url.pathname === '/favicon.ico' ||
url.pathname === '/env-config.js';
}
function isImageRequest(url) {
return /\.(png|jpg|jpeg|gif|svg|webp|ico)$/.test(url.pathname) ||
url.pathname.includes('/file-types/');
}
function isAPIRequest(url) {
return url.pathname.startsWith('/api/') ||
CACHEABLE_API_PATTERNS.some(pattern => pattern.test(url.pathname));
}
function isHTMLRequest(request) {
return request.headers.get('accept')?.includes('text/html');
}
// Create offline fallback response
function createOfflineResponse(request) {
if (isImageRequest(new URL(request.url))) {
// Return a simple SVG placeholder for images
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="200" height="150" viewBox="0 0 200 150">
<rect width="200" height="150" fill="#f0f0f0"/>
<text x="100" y="75" text-anchor="middle" fill="#999" font-family="Arial, sans-serif" font-size="14">
Offline
</text>
</svg>`;
return new Response(svg, {
headers: { 'Content-Type': 'image/svg+xml' }
});
}
if (isAPIRequest(new URL(request.url))) {
// Return empty array or error for API requests
return new Response(JSON.stringify({
error: 'Offline',
message: 'This feature requires an internet connection'
}), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
}
// For HTML requests, try to return cached index.html
return caches.match('/') || new Response('Offline', { status: 503 });
}
// Handle background sync events (for future implementation)
self.addEventListener('sync', event => {
console.log('Service Worker: Background sync', event.tag);
if (event.tag === 'background-sync') {
event.waitUntil(handleBackgroundSync());
}
});
async function handleBackgroundSync() {
// This is where you would handle queued actions when coming back online
console.log('Service Worker: Handling background sync');
// Example: Send queued task updates, sync offline changes, etc.
// Implementation would depend on your app's specific needs
}
// Handle push notification events (for future implementation)
self.addEventListener('push', event => {
if (!event.data) return;
const options = {
body: event.data.text(),
icon: '/favicon.ico',
badge: '/favicon.ico',
vibrate: [200, 100, 200],
data: {
dateOfArrival: Date.now(),
primaryKey: 1
}
};
event.waitUntil(
self.registration.showNotification('Worklenz', options)
);
});
// Handle notification click events
self.addEventListener('notificationclick', event => {
event.notification.close();
event.waitUntil(
self.clients.openWindow('/')
);
});
// Message handling for communication with main thread
self.addEventListener('message', event => {
const { type, payload } = event.data;
switch (type) {
case 'SKIP_WAITING':
self.skipWaiting();
break;
case 'GET_VERSION':
event.ports[0].postMessage({ version: CACHE_VERSION });
break;
case 'CLEAR_CACHE':
clearAllCaches().then(() => {
event.ports[0].postMessage({ success: true });
});
break;
default:
console.log('Service Worker: Unknown message type', type);
}
});
async function clearAllCaches() {
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map(name => caches.delete(name)));
console.log('Service Worker: All caches cleared');
}
console.log('Service Worker: Loaded successfully');

View File

@@ -1,23 +0,0 @@
if ('serviceWorker' in navigator) {
// Check if we've already attempted to unregister in this session
if (!sessionStorage.getItem('swUnregisterAttempted')) {
navigator.serviceWorker.getRegistrations().then(function (registrations) {
const ngswWorker = registrations.find(reg => reg.active?.scriptURL.includes('ngsw-worker'));
if (ngswWorker) {
// Mark that we've attempted to unregister
sessionStorage.setItem('swUnregisterAttempted', 'true');
// Unregister the ngsw-worker
ngswWorker.unregister().then(() => {
// Reload the page after unregistering
window.location.reload(true);
});
} else {
// If no ngsw-worker is found, unregister any other service workers
for (let registration of registrations) {
registration.unregister();
}
}
});
}
}

View File

@@ -19,6 +19,9 @@ import { Language } from './features/i18n/localesSlice';
import logger from './utils/errorLogger';
import { SuspenseFallback } from './components/suspense-fallback/suspense-fallback';
// Service Worker
import { registerSW } from './utils/serviceWorkerRegistration';
/**
* Main App Component - Performance Optimized
*
@@ -96,6 +99,25 @@ const App: React.FC = memo(() => {
};
}, []);
// Register service worker
useEffect(() => {
registerSW({
onSuccess: (registration) => {
console.log('Service Worker registered successfully', registration);
},
onUpdate: (registration) => {
console.log('New content is available and will be used when all tabs for this page are closed.');
// You could show a toast notification here for user to refresh
},
onOfflineReady: () => {
console.log('This web app has been cached for offline use.');
},
onError: (error) => {
logger.error('Service Worker registration failed:', error);
}
});
}, []);
// Defer non-critical initialization
useEffect(() => {
const initializeNonCriticalApp = () => {

View File

@@ -29,24 +29,12 @@ const withCodeSplitting = (Component: React.LazyExoticComponent<React.ComponentT
};
// Memoized guard components with defensive programming
import { useAuthStatus } from '@/hooks/useAuthStatus';
export const AuthGuard = memo(({ children }: GuardProps) => {
const authService = useAuthService();
const location = useLocation();
const { isAuthenticated, location } = useAuthStatus();
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 AuthGuard:', error);
return false; // Don't redirect on error, let the app handle it
}
}, [authService]);
if (shouldRedirect) {
if (!isAuthenticated) {
return <Navigate to="/auth" state={{ from: location }} replace />;
}
@@ -56,41 +44,14 @@ export const AuthGuard = memo(({ children }: GuardProps) => {
AuthGuard.displayName = 'AuthGuard';
export const AdminGuard = memo(({ children }: GuardProps) => {
const authService = useAuthService();
const location = useLocation();
const { isAuthenticated, isAdmin, location } = useAuthStatus();
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 (!isAuthenticated) {
return <Navigate to="/auth" state={{ from: location }} replace />;
}
if (!authService.isAuthenticated()) {
return { redirect: '/auth', state: { from: location } };
}
const currentSession = authService.getCurrentSession();
const isFreePlan = currentSession?.subscription_type === ISUBSCRIPTION_TYPE.FREE;
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 />;
if (!isAdmin) {
return <Navigate to="/worklenz/unauthorized" />;
}
return <>{children}</>;
@@ -99,77 +60,12 @@ export const AdminGuard = memo(({ children }: GuardProps) => {
AdminGuard.displayName = 'AdminGuard';
export const LicenseExpiryGuard = memo(({ children }: GuardProps) => {
const authService = useAuthService();
const location = useLocation();
const { isLicenseExpired, location } = useAuthStatus();
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
}
const isAdminCenterRoute = location.pathname.includes('/worklenz/admin-center');
const isLicenseExpiredRoute = location.pathname === '/worklenz/license-expired';
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]);
if (shouldRedirect) {
if (isLicenseExpired && !isAdminCenterRoute && !isLicenseExpiredRoute) {
return <Navigate to="/worklenz/license-expired" replace />;
}
@@ -179,26 +75,16 @@ export const LicenseExpiryGuard = memo(({ children }: GuardProps) => {
LicenseExpiryGuard.displayName = 'LicenseExpiryGuard';
export const SetupGuard = memo(({ children }: GuardProps) => {
const authService = useAuthService();
const location = useLocation();
const { isAuthenticated, isSetupComplete, location } = useAuthStatus();
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) {
if (!isAuthenticated) {
return <Navigate to="/auth" state={{ from: location }} replace />;
}
if (!isSetupComplete) {
return <Navigate to="/worklenz/setup" />;
}
return <>{children}</>;
});

View File

@@ -0,0 +1,17 @@
import { redirect } from 'react-router-dom';
import { store } from '../store';
import { verifyAuthentication } from '@/features/auth/authSlice';
export const authLoader = async () => {
const session = await store.dispatch(verifyAuthentication()).unwrap();
if (!session.user) {
return redirect('/auth/login');
}
if (session.user.is_expired) {
return redirect('/worklenz/license-expired');
}
return session;
};

View File

@@ -0,0 +1,140 @@
// Service Worker Status Component
// Shows offline status and provides cache management controls
import React from 'react';
import { Badge, Button, Space, Tooltip, message } from 'antd';
import { WifiOutlined, DisconnectOutlined, ReloadOutlined, DeleteOutlined } from '@ant-design/icons';
import { useServiceWorker } from '../../utils/serviceWorkerRegistration';
interface ServiceWorkerStatusProps {
minimal?: boolean; // Show only basic offline indicator
showControls?: boolean; // Show cache management controls
}
const ServiceWorkerStatus: React.FC<ServiceWorkerStatusProps> = ({
minimal = false,
showControls = false
}) => {
const { isOffline, swManager, clearCache, forceUpdate, getVersion } = useServiceWorker();
const [swVersion, setSwVersion] = React.useState<string>('');
const [isClearing, setIsClearing] = React.useState(false);
const [isUpdating, setIsUpdating] = React.useState(false);
// Get service worker version on mount
React.useEffect(() => {
if (getVersion) {
const versionPromise = getVersion();
if (versionPromise) {
versionPromise.then(version => {
setSwVersion(version);
}).catch(() => {
// Ignore errors when getting version
});
}
}
}, [getVersion]);
const handleClearCache = async () => {
if (!clearCache) return;
setIsClearing(true);
try {
const success = await clearCache();
if (success) {
message.success('Cache cleared successfully');
} else {
message.error('Failed to clear cache');
}
} catch (error) {
message.error('Error clearing cache');
} finally {
setIsClearing(false);
}
};
const handleForceUpdate = async () => {
if (!forceUpdate) return;
setIsUpdating(true);
try {
await forceUpdate();
message.success('Application will reload with updates');
} catch (error) {
message.error('Failed to update application');
setIsUpdating(false);
}
};
// Minimal version - just show offline status
if (minimal) {
return (
<Tooltip title={isOffline ? 'You are offline' : 'You are online'}>
<Badge
status={isOffline ? 'error' : 'success'}
text={isOffline ? 'Offline' : 'Online'}
/>
</Tooltip>
);
}
return (
<div style={{ padding: '8px' }}>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
{/* Connection Status */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{isOffline ? (
<DisconnectOutlined style={{ color: '#ff4d4f' }} />
) : (
<WifiOutlined style={{ color: '#52c41a' }} />
)}
<span style={{ fontSize: '14px' }}>
{isOffline ? 'Offline Mode' : 'Online'}
</span>
{swVersion && (
<span style={{ fontSize: '12px', color: '#8c8c8c' }}>
v{swVersion}
</span>
)}
</div>
{/* Information */}
<div style={{ fontSize: '12px', color: '#8c8c8c' }}>
{isOffline ? (
'App is running from cache. Changes will sync when online.'
) : (
'App is cached for offline use. Ready to work anywhere!'
)}
</div>
{/* Controls */}
{showControls && swManager && (
<Space size="small">
<Tooltip title="Clear all cached data">
<Button
size="small"
icon={<DeleteOutlined />}
loading={isClearing}
onClick={handleClearCache}
>
Clear Cache
</Button>
</Tooltip>
<Tooltip title="Check for updates and reload">
<Button
size="small"
icon={<ReloadOutlined />}
loading={isUpdating}
onClick={handleForceUpdate}
>
Update App
</Button>
</Tooltip>
</Space>
)}
</Space>
</div>
);
};
export default ServiceWorkerStatus;

View File

@@ -0,0 +1,52 @@
import { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { useAuthService } from '@/hooks/useAuth';
import { ISUBSCRIPTION_TYPE } from '@/shared/constants';
export const useAuthStatus = () => {
const authService = useAuthService();
const location = useLocation();
const status = useMemo(() => {
try {
if (!authService || typeof authService.isAuthenticated !== 'function') {
return { isAuthenticated: false, isLicenseExpired: false, isAdmin: false, isSetupComplete: false };
}
const isAuthenticated = authService.isAuthenticated();
if (!isAuthenticated) {
return { isAuthenticated: false, isLicenseExpired: false, isAdmin: false, isSetupComplete: false };
}
const currentSession = authService.getCurrentSession();
const isFreePlan = currentSession?.subscription_type === ISUBSCRIPTION_TYPE.FREE;
const isAdmin = authService.isOwnerOrAdmin() && !isFreePlan;
const isSetupComplete = currentSession?.setup_completed ?? false;
const isLicenseExpired = () => {
if (!currentSession) return false;
if (currentSession.is_expired) return true;
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));
return diffDays > 7;
}
return false;
};
return { isAuthenticated, isLicenseExpired: isLicenseExpired(), isAdmin, isSetupComplete };
} catch (error) {
console.error('Error in useAuthStatus:', error);
return { isAuthenticated: false, isLicenseExpired: false, isAdmin: false, isSetupComplete: false };
}
}, [authService]);
return { ...status, location };
};

View File

@@ -1,36 +1,25 @@
import { Col, ConfigProvider, Layout } from 'antd';
import { Outlet, useNavigate } from 'react-router-dom';
import { useEffect, memo, useMemo, useCallback } from 'react';
import { memo, useMemo } from 'react';
import { useMediaQuery } from 'react-responsive';
import Navbar from '../features/navbar/navbar';
import { useAppSelector } from '../hooks/useAppSelector';
import { useAppDispatch } from '../hooks/useAppDispatch';
import { colors } from '../styles/colors';
import { verifyAuthentication } from '@/features/auth/authSlice';
import { useRenderPerformance } from '@/utils/performance';
import HubSpot from '@/components/HubSpot';
const MainLayout = memo(() => {
const themeMode = useAppSelector(state => state.themeReducer.mode);
const isDesktop = useMediaQuery({ query: '(min-width: 1024px)' });
const dispatch = useAppDispatch();
const navigate = useNavigate();
// 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();
}, [verifyAuthStatus]);
// Memoize styles to prevent object recreation on every render
const headerStyles = useMemo(

View File

@@ -22,11 +22,7 @@ const ReportingLayout = () => {
const currentSession = getCurrentSession();
const navigate = useNavigate();
useEffect(() => {
if (currentSession?.is_expired) {
navigate('/worklenz/license-expired');
}
}, [currentSession, navigate]);
// function to handle collapse
const handleCollapsedToggler = () => {

View File

@@ -11,11 +11,7 @@ const SettingsLayout = () => {
const currentSession = getCurrentSession();
const navigate = useNavigate();
useEffect(() => {
if (currentSession?.is_expired) {
navigate('/worklenz/license-expired');
}
}, [currentSession, navigate]);
return (
<div style={{ marginBlock: 96, minHeight: '90vh' }}>

View File

@@ -1,10 +1,9 @@
import { Flex, Typography } from 'antd';
import React, { useEffect } from 'react';
import React from 'react';
import { Outlet } from 'react-router-dom';
import { useMediaQuery } from 'react-responsive';
import AdminCenterSidebar from '@/pages/admin-center/sidebar/sidebar';
import { useTranslation } from 'react-i18next';
import { verifyAuthentication } from '@/features/auth/authSlice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
const AdminCenterLayout: React.FC = () => {
@@ -13,9 +12,7 @@ const AdminCenterLayout: React.FC = () => {
const isMarginAvailable = useMediaQuery({ query: '(min-width: 1000px)' });
const { t } = useTranslation('admin-center/sidebar');
useEffect(() => {
void dispatch(verifyAuthentication());
}, [dispatch]);
return (
<div

View File

@@ -0,0 +1,259 @@
# Service Worker Implementation
This directory contains the service worker implementation for Worklenz, providing offline functionality, caching, and performance improvements.
## Files Overview
- **`sw.js`** (in `/public/`) - The main service worker file
- **`serviceWorkerRegistration.ts`** - Registration and management utilities
- **`ServiceWorkerStatus.tsx`** (in `/components/`) - React component for SW status
## Features
### 🔄 Caching Strategies
1. **Cache First** - Static assets (JS, CSS, images)
- Serves from cache first, falls back to network
- Perfect for unchanging resources
2. **Network First** - API requests
- Tries network first, falls back to cache
- Ensures fresh data when online
3. **Stale While Revalidate** - HTML pages
- Serves cached version immediately
- Updates cache in background
### 📱 PWA Features
- **Offline Support** - App works without internet
- **Installable** - Can be installed on devices
- **Background Sync** - Sync data when online (framework ready)
- **Push Notifications** - Real-time notifications (framework ready)
## Usage
### Basic Integration
The service worker is automatically registered in `App.tsx`:
```tsx
import { registerSW } from './utils/serviceWorkerRegistration';
useEffect(() => {
registerSW({
onSuccess: (registration) => {
console.log('SW registered successfully');
},
onUpdate: (registration) => {
// Show update notification to user
},
onOfflineReady: () => {
console.log('App ready for offline use');
}
});
}, []);
```
### Using the Hook
```tsx
import { useServiceWorker } from '../utils/serviceWorkerRegistration';
const MyComponent = () => {
const { isOffline, swManager, clearCache, forceUpdate } = useServiceWorker();
return (
<div>
<p>Status: {isOffline ? 'Offline' : 'Online'}</p>
<button onClick={clearCache}>Clear Cache</button>
<button onClick={forceUpdate}>Update App</button>
</div>
);
};
```
### Status Component
```tsx
import ServiceWorkerStatus from '../components/service-worker-status/ServiceWorkerStatus';
// Minimal offline indicator
<ServiceWorkerStatus minimal />
// Full status with controls
<ServiceWorkerStatus showControls />
```
## Configuration
### Cacheable Resources
Edit the patterns in `sw.js`:
```javascript
// API endpoints that can be cached
const CACHEABLE_API_PATTERNS = [
/\/api\/project-categories/,
/\/api\/task-statuses/,
// Add more patterns...
];
// Resources that should never be cached
const NEVER_CACHE_PATTERNS = [
/\/api\/auth\/login/,
/\/socket\.io/,
// Add more patterns...
];
```
### Cache Names
Update version to force cache refresh:
```javascript
const CACHE_VERSION = 'v1.0.1'; // Increment when deploying
```
## Development
### Testing Offline
1. Open DevTools → Application → Service Workers
2. Check "Offline" to simulate offline mode
3. Verify app still functions
### Debugging
```javascript
// Check service worker status
navigator.serviceWorker.ready.then(registration => {
console.log('SW ready:', registration);
});
// Check cache contents
caches.keys().then(names => {
console.log('Cache names:', names);
});
```
### Cache Management
```javascript
// Clear all caches
caches.keys().then(names =>
Promise.all(names.map(name => caches.delete(name)))
);
// Clear specific cache
caches.delete('worklenz-api-v1.0.0');
```
## Best Practices
### 1. Cache Strategy Selection
- **Static Assets**: Cache First (fast loading)
- **API Data**: Network First (fresh data)
- **User Content**: Network Only (always fresh)
- **App Shell**: Cache First (instant loading)
### 2. Cache Invalidation
- Increment `CACHE_VERSION` when deploying
- Use versioned URLs for assets
- Set appropriate cache headers
### 3. Offline UX
- Show offline indicators
- Queue actions for later sync
- Provide meaningful offline messages
- Cache critical user data
### 4. Performance
- Cache only necessary resources
- Set cache size limits
- Clean up old caches regularly
- Monitor cache usage
## Monitoring
### Storage Usage
```javascript
// Check storage quota
navigator.storage.estimate().then(estimate => {
console.log('Used:', estimate.usage);
console.log('Quota:', estimate.quota);
});
```
### Cache Hit Rate
Monitor in DevTools → Network:
- Look for "from ServiceWorker" requests
- Check cache effectiveness
## Troubleshooting
### Common Issues
1. **SW not updating**
- Hard refresh (Ctrl+Shift+R)
- Clear browser cache
- Check CACHE_VERSION
2. **Resources not caching**
- Verify URL patterns
- Check NEVER_CACHE_PATTERNS
- Ensure HTTPS in production
3. **Offline features not working**
- Verify SW registration
- Check browser support
- Test cache strategies
### Reset Service Worker
```javascript
// Unregister and reload
navigator.serviceWorker.getRegistrations().then(registrations => {
registrations.forEach(registration => registration.unregister());
window.location.reload();
});
```
## Browser Support
- ✅ Chrome 45+
- ✅ Firefox 44+
- ✅ Safari 11.1+
- ✅ Edge 17+
- ❌ Internet Explorer
## Future Enhancements
1. **Background Sync**
- Queue offline actions
- Sync when online
2. **Push Notifications**
- Task assignments
- Project updates
- Deadline reminders
3. **Advanced Caching**
- Intelligent prefetching
- ML-based cache eviction
- Compression
4. **Offline Analytics**
- Track offline usage
- Cache hit rates
- Performance metrics
---
*Last updated: January 2025*

View File

@@ -0,0 +1,273 @@
// Service Worker Registration Utility
// Handles registration, updates, and error handling
import React from 'react';
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
window.location.hostname === '[::1]' ||
window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/)
);
type Config = {
onSuccess?: (registration: ServiceWorkerRegistration) => void;
onUpdate?: (registration: ServiceWorkerRegistration) => void;
onOfflineReady?: () => void;
onError?: (error: Error) => void;
};
export function registerSW(config?: Config) {
if ('serviceWorker' in navigator) {
// Only register in production or when explicitly testing
const swUrl = '/sw.js';
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://bit.ly/CRA-PWA'
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
} else {
console.log('Service workers are not supported in this browser.');
}
}
function registerValidSW(swUrl: string, config?: Config) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
console.log('Service Worker registered successfully:', registration);
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all ' +
'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
);
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
if (config && config.onOfflineReady) {
config.onOfflineReady();
}
}
}
};
};
})
.catch(error => {
console.error('Error during service worker registration:', error);
if (config && config.onError) {
config.onError(error);
}
});
}
function checkValidServiceWorker(swUrl: string, config?: Config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl, {
headers: { 'Service-Worker': 'script' },
})
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type');
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.'
);
});
}
export function unregisterSW() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready
.then(registration => {
registration.unregister();
console.log('Service Worker unregistered successfully');
})
.catch(error => {
console.error('Error during service worker unregistration:', error);
});
}
}
// Utility to communicate with service worker
export class ServiceWorkerManager {
private registration: ServiceWorkerRegistration | null = null;
constructor(registration?: ServiceWorkerRegistration) {
this.registration = registration || null;
}
// Send message to service worker
async sendMessage(type: string, payload?: any): Promise<any> {
if (!this.registration || !this.registration.active) {
throw new Error('Service Worker not available');
}
return new Promise((resolve, reject) => {
const messageChannel = new MessageChannel();
messageChannel.port1.onmessage = (event) => {
if (event.data.error) {
reject(event.data.error);
} else {
resolve(event.data);
}
};
this.registration!.active!.postMessage(
{ type, payload },
[messageChannel.port2]
);
// Timeout after 5 seconds
setTimeout(() => {
reject(new Error('Service Worker message timeout'));
}, 5000);
});
}
// Get service worker version
async getVersion(): Promise<string> {
try {
const response = await this.sendMessage('GET_VERSION');
return response.version;
} catch (error) {
console.error('Failed to get service worker version:', error);
return 'unknown';
}
}
// Clear all caches
async clearCache(): Promise<boolean> {
try {
await this.sendMessage('CLEAR_CACHE');
return true;
} catch (error) {
console.error('Failed to clear cache:', error);
return false;
}
}
// Force update service worker
async forceUpdate(): Promise<void> {
if (!this.registration) return;
try {
await this.registration.update();
await this.sendMessage('SKIP_WAITING');
window.location.reload();
} catch (error) {
console.error('Failed to force update service worker:', error);
throw error;
}
}
// Check if app is running offline
isOffline(): boolean {
return !navigator.onLine;
}
// Get cache storage estimate
async getCacheSize(): Promise<StorageEstimate | null> {
if ('storage' in navigator && 'estimate' in navigator.storage) {
try {
return await navigator.storage.estimate();
} catch (error) {
console.error('Failed to get storage estimate:', error);
}
}
return null;
}
}
// Hook to use service worker in React components
export function useServiceWorker() {
const [isOffline, setIsOffline] = React.useState(!navigator.onLine);
const [swManager, setSWManager] = React.useState<ServiceWorkerManager | null>(null);
React.useEffect(() => {
const handleOnline = () => setIsOffline(false);
const handleOffline = () => setIsOffline(true);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
// Register service worker
registerSW({
onSuccess: (registration) => {
setSWManager(new ServiceWorkerManager(registration));
},
onUpdate: (registration) => {
// You could show a toast here asking user to refresh
console.log('New version available');
setSWManager(new ServiceWorkerManager(registration));
},
onOfflineReady: () => {
console.log('App ready for offline use');
}
});
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return {
isOffline,
swManager,
clearCache: () => swManager?.clearCache(),
forceUpdate: () => swManager?.forceUpdate(),
getVersion: () => swManager?.getVersion(),
};
}

View File

@@ -138,5 +138,8 @@ export default defineConfig(({ command, mode }) => {
define: {
__DEV__: !isProduction,
},
// **Public Directory** - sw.js will be automatically copied from public/ to build/
publicDir: 'public',
};
});