Initial Commit
This commit is contained in:
@@ -16,10 +16,20 @@ export const apiClient: AxiosInstance = axios.create({
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor for auth token
|
||||
// Request interceptor for auth token with mobile debugging
|
||||
apiClient.interceptors.request.use(
|
||||
async (config: InternalAxiosRequestConfig) => {
|
||||
// Token will be added by Auth0 wrapper
|
||||
// Log mobile requests for debugging
|
||||
if (import.meta.env.MODE === 'development') {
|
||||
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
if (isMobile && config.url?.includes('/vehicles')) {
|
||||
console.log('Mobile API request:', config.method?.toUpperCase(), config.url, {
|
||||
hasAuth: !!config.headers.Authorization,
|
||||
authPreview: config.headers.Authorization?.toString().substring(0, 20) + '...'
|
||||
});
|
||||
}
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
@@ -27,19 +37,37 @@ apiClient.interceptors.request.use(
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor for error handling
|
||||
// Response interceptor for error handling with mobile-specific logic
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
// Handle unauthorized - Auth0 will redirect to login
|
||||
toast.error('Session expired. Please login again.');
|
||||
// Enhanced 401 handling for mobile token issues
|
||||
const errorMessage = error.response?.data?.message || '';
|
||||
const isTokenIssue = errorMessage.includes('token') || errorMessage.includes('JWT') || errorMessage.includes('Unauthorized');
|
||||
|
||||
if (isMobile && isTokenIssue) {
|
||||
// Mobile devices sometimes have token timing issues
|
||||
// Show a more helpful message that doesn't sound like a permanent error
|
||||
toast.error('Refreshing your session...', {
|
||||
duration: 3000,
|
||||
id: 'mobile-auth-refresh' // Prevent duplicate toasts
|
||||
});
|
||||
} else {
|
||||
// Standard session expiry message
|
||||
toast.error('Session expired. Please login again.');
|
||||
}
|
||||
} else if (error.response?.status === 403) {
|
||||
toast.error('You do not have permission to perform this action.');
|
||||
} else if (error.response?.status >= 500) {
|
||||
toast.error('Server error. Please try again later.');
|
||||
} else if (error.code === 'NETWORK_ERROR' && isMobile) {
|
||||
// Mobile-specific network error handling
|
||||
toast.error('Network error. Please check your connection and try again.');
|
||||
}
|
||||
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -28,35 +28,102 @@ export const Auth0Provider: React.FC<Auth0ProviderProps> = ({ children }) => {
|
||||
domain={domain}
|
||||
clientId={clientId}
|
||||
authorizationParams={{
|
||||
redirect_uri: window.location.origin,
|
||||
redirect_uri: window.location.hostname === "admin.motovaultpro.com" ? "https://admin.motovaultpro.com/callback" : window.location.origin + "/callback",
|
||||
audience: audience,
|
||||
}}
|
||||
onRedirectCallback={onRedirectCallback}
|
||||
cacheLocation="localstorage"
|
||||
useRefreshTokens={true}
|
||||
>
|
||||
<TokenInjector>{children}</TokenInjector>
|
||||
</BaseAuth0Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Component to inject token into API client
|
||||
// Component to inject token into API client with mobile support
|
||||
const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { getAccessTokenSilently, isAuthenticated } = useAuth0();
|
||||
const [retryCount, setRetryCount] = React.useState(0);
|
||||
|
||||
// Helper function to get token with retry logic for mobile devices
|
||||
const getTokenWithRetry = async (maxRetries = 3, delayMs = 500): Promise<string | null> => {
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
// Progressive fallback strategy for mobile compatibility
|
||||
let tokenOptions;
|
||||
if (attempt === 0) {
|
||||
// First attempt: try cache first
|
||||
tokenOptions = { timeoutInSeconds: 15, cacheMode: 'on' as const };
|
||||
} else if (attempt === 1) {
|
||||
// Second attempt: force refresh
|
||||
tokenOptions = { timeoutInSeconds: 20, cacheMode: 'off' as const };
|
||||
} else {
|
||||
// Final attempt: default behavior with longer timeout
|
||||
tokenOptions = { timeoutInSeconds: 30 };
|
||||
}
|
||||
|
||||
const token = await getAccessTokenSilently(tokenOptions);
|
||||
console.log(`Token acquired successfully on attempt ${attempt + 1}`);
|
||||
return token;
|
||||
} catch (error: any) {
|
||||
console.warn(`Token acquisition attempt ${attempt + 1} failed:`, error.message || error);
|
||||
|
||||
// On mobile, Auth0 might need more time - wait and retry
|
||||
if (attempt < maxRetries - 1) {
|
||||
const delay = delayMs * Math.pow(2, attempt); // Exponential backoff
|
||||
console.log(`Waiting ${delay}ms before retry...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
console.error('All token acquisition attempts failed');
|
||||
return null;
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
let interceptorId: number | undefined;
|
||||
|
||||
if (isAuthenticated) {
|
||||
// Add token to all API requests
|
||||
// Pre-warm token cache for mobile devices with delay
|
||||
const initializeToken = async () => {
|
||||
// Give Auth0 a moment to fully initialize on mobile
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
try {
|
||||
const token = await getTokenWithRetry();
|
||||
if (token) {
|
||||
console.log('Token pre-warming successful');
|
||||
setRetryCount(0);
|
||||
} else {
|
||||
console.error('Failed to acquire token after retries - will retry on API calls');
|
||||
setRetryCount(prev => prev + 1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Token initialization failed:', error);
|
||||
setRetryCount(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
initializeToken();
|
||||
|
||||
// Add token to all API requests with enhanced error handling
|
||||
interceptorId = apiClient.interceptors.request.use(async (config) => {
|
||||
try {
|
||||
const token = await getAccessTokenSilently();
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
} catch (error) {
|
||||
console.error('Failed to get access token:', error);
|
||||
const token = await getTokenWithRetry();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
} else {
|
||||
console.error('No token available for request to:', config.url);
|
||||
// Allow request to proceed - backend will return 401 if needed
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to get access token for request:', error.message || error);
|
||||
// Allow request to proceed - backend will return 401 if needed
|
||||
}
|
||||
return config;
|
||||
});
|
||||
} else {
|
||||
setRetryCount(0);
|
||||
}
|
||||
|
||||
// Cleanup function to remove interceptor
|
||||
@@ -65,7 +132,7 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
||||
apiClient.interceptors.request.eject(interceptorId);
|
||||
}
|
||||
};
|
||||
}, [isAuthenticated, getAccessTokenSilently]);
|
||||
}, [isAuthenticated, getAccessTokenSilently, retryCount]);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
138
frontend/src/core/auth/Auth0Provider.tsx.backup
Normal file
138
frontend/src/core/auth/Auth0Provider.tsx.backup
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @ai-summary Auth0 provider wrapper with API token injection
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Auth0Provider as BaseAuth0Provider, useAuth0 } from '@auth0/auth0-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { apiClient } from '../api/client';
|
||||
|
||||
interface Auth0ProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Auth0Provider: React.FC<Auth0ProviderProps> = ({ children }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const domain = import.meta.env.VITE_AUTH0_DOMAIN;
|
||||
const clientId = import.meta.env.VITE_AUTH0_CLIENT_ID;
|
||||
const audience = import.meta.env.VITE_AUTH0_AUDIENCE;
|
||||
|
||||
|
||||
const onRedirectCallback = (appState?: { returnTo?: string }) => {
|
||||
navigate(appState?.returnTo || '/dashboard');
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseAuth0Provider
|
||||
domain={domain}
|
||||
clientId={clientId}
|
||||
authorizationParams={{
|
||||
redirect_uri: window.location.origin,
|
||||
audience: audience,
|
||||
}}
|
||||
onRedirectCallback={onRedirectCallback}
|
||||
cacheLocation="localstorage"
|
||||
useRefreshTokens={true}
|
||||
>
|
||||
<TokenInjector>{children}</TokenInjector>
|
||||
</BaseAuth0Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Component to inject token into API client with mobile support
|
||||
const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { getAccessTokenSilently, isAuthenticated } = useAuth0();
|
||||
const [retryCount, setRetryCount] = React.useState(0);
|
||||
|
||||
// Helper function to get token with retry logic for mobile devices
|
||||
const getTokenWithRetry = async (maxRetries = 3, delayMs = 500): Promise<string | null> => {
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
// Progressive fallback strategy for mobile compatibility
|
||||
let tokenOptions;
|
||||
if (attempt === 0) {
|
||||
// First attempt: try cache first
|
||||
tokenOptions = { timeoutInSeconds: 15, cacheMode: 'on' as const };
|
||||
} else if (attempt === 1) {
|
||||
// Second attempt: force refresh
|
||||
tokenOptions = { timeoutInSeconds: 20, cacheMode: 'off' as const };
|
||||
} else {
|
||||
// Final attempt: default behavior with longer timeout
|
||||
tokenOptions = { timeoutInSeconds: 30 };
|
||||
}
|
||||
|
||||
const token = await getAccessTokenSilently(tokenOptions);
|
||||
console.log(`Token acquired successfully on attempt ${attempt + 1}`);
|
||||
return token;
|
||||
} catch (error: any) {
|
||||
console.warn(`Token acquisition attempt ${attempt + 1} failed:`, error.message || error);
|
||||
|
||||
// On mobile, Auth0 might need more time - wait and retry
|
||||
if (attempt < maxRetries - 1) {
|
||||
const delay = delayMs * Math.pow(2, attempt); // Exponential backoff
|
||||
console.log(`Waiting ${delay}ms before retry...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
console.error('All token acquisition attempts failed');
|
||||
return null;
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
let interceptorId: number | undefined;
|
||||
|
||||
if (isAuthenticated) {
|
||||
// Pre-warm token cache for mobile devices with delay
|
||||
const initializeToken = async () => {
|
||||
// Give Auth0 a moment to fully initialize on mobile
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
try {
|
||||
const token = await getTokenWithRetry();
|
||||
if (token) {
|
||||
console.log('Token pre-warming successful');
|
||||
setRetryCount(0);
|
||||
} else {
|
||||
console.error('Failed to acquire token after retries - will retry on API calls');
|
||||
setRetryCount(prev => prev + 1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Token initialization failed:', error);
|
||||
setRetryCount(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
initializeToken();
|
||||
|
||||
// Add token to all API requests with enhanced error handling
|
||||
interceptorId = apiClient.interceptors.request.use(async (config) => {
|
||||
try {
|
||||
const token = await getTokenWithRetry();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
} else {
|
||||
console.error('No token available for request to:', config.url);
|
||||
// Allow request to proceed - backend will return 401 if needed
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to get access token for request:', error.message || error);
|
||||
// Allow request to proceed - backend will return 401 if needed
|
||||
}
|
||||
return config;
|
||||
});
|
||||
} else {
|
||||
setRetryCount(0);
|
||||
}
|
||||
|
||||
// Cleanup function to remove interceptor
|
||||
return () => {
|
||||
if (interceptorId !== undefined) {
|
||||
apiClient.interceptors.request.eject(interceptorId);
|
||||
}
|
||||
};
|
||||
}, [isAuthenticated, getAccessTokenSilently, retryCount]);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
258
frontend/src/core/debug/MobileDebugPanel.tsx
Normal file
258
frontend/src/core/debug/MobileDebugPanel.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* @ai-summary Enhanced debugging panel for mobile token flow and performance monitoring
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigationStore, useUserStore } from '../store';
|
||||
|
||||
interface DebugInfo {
|
||||
timestamp: string;
|
||||
type: 'auth' | 'query' | 'navigation' | 'network';
|
||||
message: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export const MobileDebugPanel: React.FC<{ visible: boolean }> = ({ visible }) => {
|
||||
const { isAuthenticated, getAccessTokenSilently } = useAuth0();
|
||||
const queryClient = useQueryClient();
|
||||
const navigationStore = useNavigationStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const [debugLogs, setDebugLogs] = useState<DebugInfo[]>([]);
|
||||
const [tokenInfo, setTokenInfo] = useState<{
|
||||
hasToken: boolean;
|
||||
tokenPreview?: string;
|
||||
lastRefresh?: string;
|
||||
cacheMode?: string;
|
||||
}>({
|
||||
hasToken: false,
|
||||
});
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Monitor token status
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
const checkToken = async () => {
|
||||
try {
|
||||
const token = await getAccessTokenSilently({ cacheMode: 'cache-only' });
|
||||
setTokenInfo({
|
||||
hasToken: !!token,
|
||||
tokenPreview: token ? token.substring(0, 20) + '...' : undefined,
|
||||
lastRefresh: new Date().toLocaleTimeString(),
|
||||
cacheMode: 'cache-only',
|
||||
});
|
||||
|
||||
addDebugLog('auth', 'Token check successful', {
|
||||
hasToken: !!token,
|
||||
cacheMode: 'cache-only',
|
||||
});
|
||||
} catch (error) {
|
||||
addDebugLog('auth', 'Token check failed', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
setTokenInfo({ hasToken: false });
|
||||
}
|
||||
};
|
||||
|
||||
checkToken();
|
||||
const interval = setInterval(checkToken, 10000); // Check every 10 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isAuthenticated, getAccessTokenSilently]);
|
||||
|
||||
const addDebugLog = (type: DebugInfo['type'], message: string, data?: any) => {
|
||||
const logEntry: DebugInfo = {
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
type,
|
||||
message,
|
||||
data,
|
||||
};
|
||||
|
||||
setDebugLogs(prev => [...prev.slice(-19), logEntry]); // Keep last 20 entries
|
||||
};
|
||||
|
||||
// Monitor navigation changes
|
||||
useEffect(() => {
|
||||
addDebugLog('navigation', `Navigated to ${navigationStore.activeScreen}`, {
|
||||
screen: navigationStore.activeScreen,
|
||||
subScreen: navigationStore.vehicleSubScreen,
|
||||
selectedVehicleId: navigationStore.selectedVehicleId,
|
||||
isNavigating: navigationStore.isNavigating,
|
||||
historyLength: navigationStore.navigationHistory.length,
|
||||
});
|
||||
}, [navigationStore.activeScreen, navigationStore.vehicleSubScreen, navigationStore.selectedVehicleId, navigationStore.isNavigating]);
|
||||
|
||||
// Monitor navigation errors
|
||||
useEffect(() => {
|
||||
if (navigationStore.navigationError) {
|
||||
addDebugLog('navigation', `Navigation Error: ${navigationStore.navigationError}`, {
|
||||
error: navigationStore.navigationError,
|
||||
screen: navigationStore.activeScreen,
|
||||
});
|
||||
}
|
||||
}, [navigationStore.navigationError]);
|
||||
|
||||
// Monitor network status
|
||||
useEffect(() => {
|
||||
const handleOnline = () => addDebugLog('network', 'Network: Online');
|
||||
const handleOffline = () => addDebugLog('network', 'Network: Offline');
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getQueryCacheStats = () => {
|
||||
const cache = queryClient.getQueryCache();
|
||||
const queries = cache.getAll();
|
||||
|
||||
return {
|
||||
total: queries.length,
|
||||
stale: queries.filter(q => q.isStale()).length,
|
||||
loading: queries.filter(q => q.state.status === 'pending').length,
|
||||
error: queries.filter(q => q.state.status === 'error').length,
|
||||
};
|
||||
};
|
||||
|
||||
const testTokenRefresh = async () => {
|
||||
try {
|
||||
addDebugLog('auth', 'Testing token refresh...');
|
||||
const token = await getAccessTokenSilently({ cacheMode: 'off' });
|
||||
addDebugLog('auth', 'Token refresh successful', {
|
||||
hasToken: !!token,
|
||||
length: token?.length,
|
||||
});
|
||||
setTokenInfo(prev => ({
|
||||
...prev,
|
||||
hasToken: !!token,
|
||||
tokenPreview: token ? token.substring(0, 20) + '...' : undefined,
|
||||
lastRefresh: new Date().toLocaleTimeString(),
|
||||
cacheMode: 'off',
|
||||
}));
|
||||
} catch (error) {
|
||||
addDebugLog('auth', 'Token refresh failed', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const cacheStats = getQueryCacheStats();
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
|
||||
return (
|
||||
<div className={`fixed ${expanded ? 'inset-4' : 'bottom-4 right-4'} bg-black/90 text-white text-xs font-mono rounded-lg transition-all duration-300 z-50`}>
|
||||
<div className="flex items-center justify-between p-2 border-b border-white/20">
|
||||
<span className="font-semibold">Debug Panel</span>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-white/70 hover:text-white"
|
||||
>
|
||||
{expanded ? '−' : '+'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded ? (
|
||||
<div className="p-3 max-h-[80vh] overflow-y-auto">
|
||||
{/* System Info */}
|
||||
<div className="mb-4">
|
||||
<div className="text-yellow-400 font-semibold mb-1">System Status</div>
|
||||
<div>Mode: {isMobile ? 'Mobile' : 'Desktop'} | Auth: {isAuthenticated ? 'Yes' : 'No'}</div>
|
||||
<div>Screen: {navigationStore.activeScreen} | Sub: {navigationStore.vehicleSubScreen}</div>
|
||||
<div>Online: {userStore.isOnline ? 'Yes' : 'No'} | Width: {window.innerWidth}px</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Info */}
|
||||
<div className="mb-4">
|
||||
<div className="text-cyan-400 font-semibold mb-1">Navigation State</div>
|
||||
<div>Current: {navigationStore.activeScreen} → {navigationStore.vehicleSubScreen}</div>
|
||||
<div>Navigating: {navigationStore.isNavigating ? 'Yes' : 'No'}</div>
|
||||
<div>History: {navigationStore.navigationHistory.length} entries</div>
|
||||
<div>Selected Vehicle: {navigationStore.selectedVehicleId || 'None'}</div>
|
||||
{navigationStore.navigationError && (
|
||||
<div className="text-red-300">Error: {navigationStore.navigationError}</div>
|
||||
)}
|
||||
<div className="mt-2 flex gap-1 flex-wrap">
|
||||
{['Dashboard', 'Vehicles', 'Log Fuel', 'Settings'].map((screen) => (
|
||||
<button
|
||||
key={screen}
|
||||
onClick={() => {
|
||||
addDebugLog('navigation', `Debug navigation to ${screen}`);
|
||||
navigationStore.navigateToScreen(screen as any);
|
||||
}}
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
navigationStore.activeScreen === screen
|
||||
? 'bg-cyan-600 text-white'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{screen.slice(0, 3)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token Info */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-green-400 font-semibold">Token Status</span>
|
||||
<button
|
||||
onClick={testTokenRefresh}
|
||||
className="text-xs bg-blue-600 px-2 py-1 rounded hover:bg-blue-700"
|
||||
>
|
||||
Test Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div>Has Token: {tokenInfo.hasToken ? 'Yes' : 'No'}</div>
|
||||
{tokenInfo.tokenPreview && <div>Preview: {tokenInfo.tokenPreview}</div>}
|
||||
{tokenInfo.lastRefresh && <div>Last Refresh: {tokenInfo.lastRefresh}</div>}
|
||||
{tokenInfo.cacheMode && <div>Cache Mode: {tokenInfo.cacheMode}</div>}
|
||||
</div>
|
||||
|
||||
{/* Query Cache Stats */}
|
||||
<div className="mb-4">
|
||||
<div className="text-blue-400 font-semibold mb-1">Query Cache</div>
|
||||
<div>Total: {cacheStats.total} | Stale: {cacheStats.stale}</div>
|
||||
<div>Loading: {cacheStats.loading} | Error: {cacheStats.error}</div>
|
||||
</div>
|
||||
|
||||
{/* Debug Logs */}
|
||||
<div>
|
||||
<div className="text-purple-400 font-semibold mb-1">Recent Events</div>
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto">
|
||||
{debugLogs.slice(-10).reverse().map((log, index) => (
|
||||
<div key={index} className="text-xs">
|
||||
<span className="text-white/50">[{log.timestamp}]</span>{' '}
|
||||
<span className={
|
||||
log.type === 'auth' ? 'text-green-300' :
|
||||
log.type === 'query' ? 'text-blue-300' :
|
||||
log.type === 'navigation' ? 'text-yellow-300' :
|
||||
'text-purple-300'
|
||||
}>
|
||||
{log.type.toUpperCase()}
|
||||
</span>:{' '}
|
||||
<span>{log.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2">
|
||||
<div>
|
||||
{isMobile ? 'M' : 'D'} | {isAuthenticated ? '🔐' : '🔓'} |
|
||||
{navigationStore.activeScreen.substring(0, 3)} |
|
||||
T:{tokenInfo.hasToken ? '✅' : '❌'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
124
frontend/src/core/error-boundaries/MobileErrorBoundary.tsx
Normal file
124
frontend/src/core/error-boundaries/MobileErrorBoundary.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @ai-summary Error boundary component specifically designed for mobile screens
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { GlassCard } from '../../shared-minimal/components/mobile/GlassCard';
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: React.ErrorInfo | null;
|
||||
}
|
||||
|
||||
interface MobileErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
screenName: string;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export class MobileErrorBoundary extends React.Component<MobileErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: MobileErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null, errorInfo: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
override componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
this.setState({
|
||||
error,
|
||||
errorInfo
|
||||
});
|
||||
|
||||
// Log error for debugging
|
||||
console.error(`Mobile screen error in ${this.props.screenName}:`, error, errorInfo);
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
this.setState({ hasError: false, error: null, errorInfo: null });
|
||||
this.props.onRetry?.();
|
||||
};
|
||||
|
||||
override render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<GlassCard>
|
||||
<div className="text-center py-8">
|
||||
<div className="mb-4">
|
||||
<div className="mx-auto w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg
|
||||
className="w-8 h-8 text-red-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-2">
|
||||
Oops! Something went wrong
|
||||
</h3>
|
||||
<p className="text-slate-600 mb-4">
|
||||
There was an error loading the {this.props.screenName.toLowerCase()} screen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={this.handleRetry}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="w-full px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
Refresh App
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
<details className="mt-6 text-left">
|
||||
<summary className="text-sm text-slate-500 cursor-pointer">
|
||||
Error Details (Development)
|
||||
</summary>
|
||||
<div className="mt-2 p-3 bg-red-50 rounded text-xs text-red-800 overflow-auto">
|
||||
<div className="font-semibold mb-1">Error:</div>
|
||||
<div className="mb-2">{this.state.error.message}</div>
|
||||
|
||||
<div className="font-semibold mb-1">Stack Trace:</div>
|
||||
<pre className="whitespace-pre-wrap text-xs">
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
|
||||
{this.state.errorInfo && (
|
||||
<>
|
||||
<div className="font-semibold mb-1 mt-2">Component Stack:</div>
|
||||
<pre className="whitespace-pre-wrap text-xs">
|
||||
{this.state.errorInfo.componentStack}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
44
frontend/src/core/hooks/useDataSync.ts
Normal file
44
frontend/src/core/hooks/useDataSync.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @ai-summary React hook for data synchronization management
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { DataSyncManager } from '../sync/data-sync';
|
||||
import { useNavigationStore } from '../store/navigation';
|
||||
|
||||
export const useDataSync = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const syncManagerRef = useRef<DataSyncManager | null>(null);
|
||||
const navigationStore = useNavigationStore();
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize data sync manager
|
||||
syncManagerRef.current = new DataSyncManager(queryClient, {
|
||||
enableCrossTabs: true,
|
||||
enableOptimisticUpdates: true,
|
||||
enableBackgroundSync: true,
|
||||
syncInterval: 30000,
|
||||
});
|
||||
|
||||
return () => {
|
||||
syncManagerRef.current?.cleanup();
|
||||
};
|
||||
}, [queryClient]);
|
||||
|
||||
// Listen for navigation changes and trigger prefetching
|
||||
useEffect(() => {
|
||||
if (syncManagerRef.current) {
|
||||
syncManagerRef.current.prefetchForNavigation(navigationStore.activeScreen);
|
||||
}
|
||||
}, [navigationStore.activeScreen]);
|
||||
|
||||
return {
|
||||
optimisticVehicleUpdate: (vehicleId: string, updates: any) => {
|
||||
syncManagerRef.current?.optimisticVehicleUpdate(vehicleId, updates);
|
||||
},
|
||||
prefetchForNavigation: (screen: string) => {
|
||||
syncManagerRef.current?.prefetchForNavigation(screen);
|
||||
},
|
||||
};
|
||||
};
|
||||
175
frontend/src/core/hooks/useFormState.ts
Normal file
175
frontend/src/core/hooks/useFormState.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavigationStore } from '../store/navigation';
|
||||
|
||||
export interface UseFormStateOptions<T> {
|
||||
formId: string;
|
||||
defaultValues: T;
|
||||
autoSave?: boolean;
|
||||
saveDelay?: number;
|
||||
onRestore?: (data: T) => void;
|
||||
onSave?: (data: T) => void;
|
||||
validate?: (data: T) => Record<string, string> | null;
|
||||
}
|
||||
|
||||
export interface FormStateReturn<T> {
|
||||
formData: T;
|
||||
updateFormData: (updates: Partial<T>) => void;
|
||||
setFormData: (data: T) => void;
|
||||
resetForm: () => void;
|
||||
submitForm: () => Promise<void>;
|
||||
hasChanges: boolean;
|
||||
isRestored: boolean;
|
||||
isSaving: boolean;
|
||||
errors: Record<string, string>;
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
export const useFormState = <T extends Record<string, any>>({
|
||||
formId,
|
||||
defaultValues,
|
||||
autoSave = true,
|
||||
saveDelay = 1000,
|
||||
onRestore,
|
||||
onSave,
|
||||
validate,
|
||||
}: UseFormStateOptions<T>): FormStateReturn<T> => {
|
||||
const { saveFormState, restoreFormState, clearFormState } = useNavigationStore();
|
||||
const [formData, setFormDataState] = useState<T>(defaultValues);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [isRestored, setIsRestored] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const initialDataRef = useRef<T>(defaultValues);
|
||||
const formDataRef = useRef<T>(formData);
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Update ref when formData changes
|
||||
useEffect(() => {
|
||||
formDataRef.current = formData;
|
||||
}, [formData]);
|
||||
|
||||
// Validation
|
||||
const validateForm = useCallback((data: T) => {
|
||||
if (!validate) return {};
|
||||
|
||||
const validationErrors = validate(data);
|
||||
return validationErrors || {};
|
||||
}, [validate]);
|
||||
|
||||
// Restore form state on mount
|
||||
useEffect(() => {
|
||||
const restoredState = restoreFormState(formId);
|
||||
if (restoredState && !isRestored) {
|
||||
const restoredData = { ...defaultValues, ...restoredState.data };
|
||||
setFormDataState(restoredData);
|
||||
setHasChanges(restoredState.isDirty);
|
||||
setIsRestored(true);
|
||||
|
||||
if (onRestore) {
|
||||
onRestore(restoredData);
|
||||
}
|
||||
}
|
||||
}, [formId, restoreFormState, defaultValues, isRestored, onRestore]);
|
||||
|
||||
// Auto-save with debounce
|
||||
useEffect(() => {
|
||||
if (!autoSave || !hasChanges) return;
|
||||
|
||||
// Clear existing timeout
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Set new timeout
|
||||
saveTimeoutRef.current = setTimeout(async () => {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
saveFormState(formId, formDataRef.current, hasChanges);
|
||||
|
||||
if (onSave) {
|
||||
await onSave(formDataRef.current);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Form auto-save failed:', error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, saveDelay);
|
||||
|
||||
// Cleanup timeout
|
||||
return () => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [formData, hasChanges, autoSave, saveDelay, formId, saveFormState, onSave]);
|
||||
|
||||
// Validate when form data changes
|
||||
useEffect(() => {
|
||||
if (hasChanges) {
|
||||
const validationErrors = validateForm(formData);
|
||||
setErrors(validationErrors);
|
||||
}
|
||||
}, [formData, hasChanges, validateForm]);
|
||||
|
||||
const updateFormData = useCallback((updates: Partial<T>) => {
|
||||
setFormDataState((current) => {
|
||||
const updated = { ...current, ...updates };
|
||||
const hasActualChanges = JSON.stringify(updated) !== JSON.stringify(initialDataRef.current);
|
||||
setHasChanges(hasActualChanges);
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setFormData = useCallback((data: T) => {
|
||||
setFormDataState(data);
|
||||
const hasActualChanges = JSON.stringify(data) !== JSON.stringify(initialDataRef.current);
|
||||
setHasChanges(hasActualChanges);
|
||||
}, []);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setFormDataState(defaultValues);
|
||||
setHasChanges(false);
|
||||
setErrors({});
|
||||
clearFormState(formId);
|
||||
initialDataRef.current = { ...defaultValues };
|
||||
}, [defaultValues, formId, clearFormState]);
|
||||
|
||||
const submitForm = useCallback(async () => {
|
||||
const validationErrors = validateForm(formDataRef.current);
|
||||
setErrors(validationErrors);
|
||||
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
throw new Error('Form validation failed');
|
||||
}
|
||||
|
||||
try {
|
||||
setHasChanges(false);
|
||||
clearFormState(formId);
|
||||
initialDataRef.current = { ...formDataRef.current };
|
||||
|
||||
if (onSave) {
|
||||
await onSave(formDataRef.current);
|
||||
}
|
||||
} catch (error) {
|
||||
setHasChanges(true); // Restore changes state on error
|
||||
throw error;
|
||||
}
|
||||
}, [validateForm, formId, clearFormState, onSave]);
|
||||
|
||||
const isValid = Object.keys(errors).length === 0;
|
||||
|
||||
return {
|
||||
formData,
|
||||
updateFormData,
|
||||
setFormData,
|
||||
resetForm,
|
||||
submitForm,
|
||||
hasChanges,
|
||||
isRestored,
|
||||
isSaving,
|
||||
errors,
|
||||
isValid,
|
||||
};
|
||||
};
|
||||
148
frontend/src/core/query/query-config.ts
Normal file
148
frontend/src/core/query/query-config.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* @ai-summary Enhanced Query Client configuration with mobile optimization
|
||||
*/
|
||||
|
||||
import { QueryClient, QueryCache, MutationCache } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
// Mobile detection utility
|
||||
const isMobileDevice = (): boolean => {
|
||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
|
||||
window.innerWidth <= 768;
|
||||
};
|
||||
|
||||
// Enhanced error handler for mobile devices
|
||||
const handleQueryError = (error: any) => {
|
||||
const isMobile = isMobileDevice();
|
||||
|
||||
if (error?.response?.status === 401) {
|
||||
// Token refresh handled by Auth0Provider
|
||||
if (isMobile) {
|
||||
toast.error('Refreshing session...', {
|
||||
duration: 2000,
|
||||
id: 'mobile-auth-refresh'
|
||||
});
|
||||
}
|
||||
} else if (error?.response?.status >= 500) {
|
||||
toast.error(isMobile ? 'Server issue, retrying...' : 'Server error occurred', {
|
||||
duration: isMobile ? 3000 : 4000,
|
||||
});
|
||||
} else if (error?.code === 'NETWORK_ERROR') {
|
||||
if (isMobile) {
|
||||
toast.error('Check connection and try again', {
|
||||
duration: 4000,
|
||||
id: 'mobile-network-error'
|
||||
});
|
||||
} else {
|
||||
toast.error('Network error occurred');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Create enhanced query client with mobile-optimized settings
|
||||
export const createEnhancedQueryClient = (): QueryClient => {
|
||||
const isMobile = isMobileDevice();
|
||||
|
||||
return new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError: handleQueryError,
|
||||
}),
|
||||
mutationCache: new MutationCache({
|
||||
onError: handleQueryError,
|
||||
}),
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// Mobile-optimized retry strategy
|
||||
retry: (failureCount, error: any) => {
|
||||
// Don't retry 4xx errors except 401 (auth issues)
|
||||
if (error?.response?.status >= 400 && error?.response?.status < 500) {
|
||||
return error?.response?.status === 401 && failureCount < 2;
|
||||
}
|
||||
|
||||
// Mobile devices get more aggressive retry for network issues
|
||||
if (isMobile) {
|
||||
return failureCount < 3;
|
||||
}
|
||||
|
||||
return failureCount < 2;
|
||||
},
|
||||
|
||||
// Mobile-optimized timing
|
||||
retryDelay: (attemptIndex) => {
|
||||
const baseDelay = isMobile ? 1000 : 500;
|
||||
return Math.min(baseDelay * (2 ** attemptIndex), 30000);
|
||||
},
|
||||
|
||||
// Stale time optimization for mobile
|
||||
staleTime: isMobile ? 1000 * 60 * 2 : 1000 * 60 * 5, // 2 min mobile, 5 min desktop
|
||||
|
||||
// GC time optimization
|
||||
gcTime: isMobile ? 1000 * 60 * 10 : 1000 * 60 * 30, // 10 min mobile, 30 min desktop
|
||||
|
||||
// Refetch behavior
|
||||
refetchOnWindowFocus: !isMobile, // Disable on mobile to save data
|
||||
refetchOnReconnect: true,
|
||||
refetchOnMount: true,
|
||||
|
||||
// Network mode for offline capability
|
||||
networkMode: 'offlineFirst',
|
||||
},
|
||||
mutations: {
|
||||
// Mutation retry strategy
|
||||
retry: (failureCount, error: any) => {
|
||||
if (error?.response?.status >= 400 && error?.response?.status < 500) {
|
||||
return false; // Don't retry 4xx errors for mutations
|
||||
}
|
||||
|
||||
return failureCount < (isMobile ? 2 : 1);
|
||||
},
|
||||
|
||||
retryDelay: (attemptIndex) => {
|
||||
const baseDelay = isMobile ? 2000 : 1000;
|
||||
return Math.min(baseDelay * (2 ** attemptIndex), 30000);
|
||||
},
|
||||
|
||||
networkMode: 'offlineFirst',
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Query key factories for consistent cache management
|
||||
export const queryKeys = {
|
||||
all: ['motovaultpro'] as const,
|
||||
users: () => [...queryKeys.all, 'users'] as const,
|
||||
user: (id: string) => [...queryKeys.users(), id] as const,
|
||||
vehicles: () => [...queryKeys.all, 'vehicles'] as const,
|
||||
vehicle: (id: string) => [...queryKeys.vehicles(), id] as const,
|
||||
vehiclesByUser: (userId: string) => [...queryKeys.vehicles(), 'user', userId] as const,
|
||||
fuelLogs: () => [...queryKeys.all, 'fuel-logs'] as const,
|
||||
fuelLog: (id: string) => [...queryKeys.fuelLogs(), id] as const,
|
||||
fuelLogsByVehicle: (vehicleId: string) => [...queryKeys.fuelLogs(), 'vehicle', vehicleId] as const,
|
||||
settings: () => [...queryKeys.all, 'settings'] as const,
|
||||
userSettings: (userId: string) => [...queryKeys.settings(), 'user', userId] as const,
|
||||
} as const;
|
||||
|
||||
// Performance monitoring utilities
|
||||
export const queryPerformanceMonitor = {
|
||||
logSlowQuery: (queryKey: readonly unknown[], duration: number) => {
|
||||
if (duration > 5000) { // Log queries taking more than 5 seconds
|
||||
console.warn('Slow query detected:', {
|
||||
queryKey,
|
||||
duration: `${duration}ms`,
|
||||
isMobile: isMobileDevice(),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
logCacheHit: (queryKey: readonly unknown[], fromCache: boolean) => {
|
||||
if (import.meta.env.MODE === 'development') {
|
||||
console.log('Query cache:', {
|
||||
queryKey,
|
||||
fromCache,
|
||||
isMobile: isMobileDevice(),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
24
frontend/src/core/store/app.ts
Normal file
24
frontend/src/core/store/app.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { create } from 'zustand';
|
||||
import { Vehicle } from '../../features/vehicles/types/vehicles.types';
|
||||
|
||||
interface AppState {
|
||||
// UI state
|
||||
sidebarOpen: boolean;
|
||||
selectedVehicle: Vehicle | null;
|
||||
|
||||
// Actions
|
||||
toggleSidebar: () => void;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
setSelectedVehicle: (vehicle: Vehicle | null) => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
// Initial state
|
||||
sidebarOpen: false,
|
||||
selectedVehicle: null,
|
||||
|
||||
// Actions
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
setSidebarOpen: (open: boolean) => set({ sidebarOpen: open }),
|
||||
setSelectedVehicle: (vehicle: Vehicle | null) => set({ selectedVehicle: vehicle }),
|
||||
}));
|
||||
@@ -1,54 +1,12 @@
|
||||
/**
|
||||
* @ai-summary Global state management with Zustand
|
||||
* @ai-context Minimal global state, features manage their own state
|
||||
*/
|
||||
// Export navigation store
|
||||
export { useNavigationStore } from './navigation';
|
||||
export type { MobileScreen, VehicleSubScreen } from './navigation';
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
// Export user store
|
||||
export { useUserStore } from './user';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
}
|
||||
// Export app store (compatibility)
|
||||
export { useAppStore } from './app';
|
||||
|
||||
interface AppState {
|
||||
// User state
|
||||
user: User | null;
|
||||
setUser: (user: User | null) => void;
|
||||
|
||||
// UI state
|
||||
sidebarOpen: boolean;
|
||||
toggleSidebar: () => void;
|
||||
|
||||
// Selected vehicle (for context)
|
||||
selectedVehicleId: string | null;
|
||||
setSelectedVehicle: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set) => ({
|
||||
// User state
|
||||
user: null,
|
||||
setUser: (user) => set({ user }),
|
||||
|
||||
// UI state
|
||||
sidebarOpen: true,
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
|
||||
// Selected vehicle
|
||||
selectedVehicleId: null,
|
||||
setSelectedVehicle: (vehicleId) => set({ selectedVehicleId: vehicleId }),
|
||||
}),
|
||||
{
|
||||
name: 'motovaultpro-storage',
|
||||
partialize: (state) => ({
|
||||
selectedVehicleId: state.selectedVehicleId,
|
||||
sidebarOpen: state.sidebarOpen,
|
||||
}),
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
// Note: This replaces any existing store exports and provides
|
||||
// centralized access to all Zustand stores in the application
|
||||
205
frontend/src/core/store/navigation.ts
Normal file
205
frontend/src/core/store/navigation.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
|
||||
export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Settings';
|
||||
export type VehicleSubScreen = 'list' | 'detail' | 'add' | 'edit';
|
||||
|
||||
interface NavigationHistory {
|
||||
screen: MobileScreen;
|
||||
vehicleSubScreen?: VehicleSubScreen;
|
||||
selectedVehicleId?: string | null;
|
||||
timestamp: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
data: Record<string, any>;
|
||||
timestamp: number;
|
||||
isDirty: boolean;
|
||||
}
|
||||
|
||||
interface NavigationState {
|
||||
// Current navigation state
|
||||
activeScreen: MobileScreen;
|
||||
vehicleSubScreen: VehicleSubScreen;
|
||||
selectedVehicleId: string | null;
|
||||
|
||||
// Navigation history for back button
|
||||
navigationHistory: NavigationHistory[];
|
||||
|
||||
// Form state preservation
|
||||
formStates: Record<string, FormState>;
|
||||
|
||||
// Loading and error states
|
||||
isNavigating: boolean;
|
||||
navigationError: string | null;
|
||||
|
||||
// Actions
|
||||
navigateToScreen: (screen: MobileScreen, metadata?: Record<string, any>) => void;
|
||||
navigateToVehicleSubScreen: (subScreen: VehicleSubScreen, vehicleId?: string, metadata?: Record<string, any>) => void;
|
||||
goBack: () => boolean;
|
||||
canGoBack: () => boolean;
|
||||
saveFormState: (formId: string, data: any, isDirty?: boolean) => void;
|
||||
restoreFormState: (formId: string) => FormState | null;
|
||||
clearFormState: (formId: string) => void;
|
||||
clearAllFormStates: () => void;
|
||||
setNavigationError: (error: string | null) => void;
|
||||
}
|
||||
|
||||
export const useNavigationStore = create<NavigationState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// Initial state
|
||||
activeScreen: 'Vehicles',
|
||||
vehicleSubScreen: 'list',
|
||||
selectedVehicleId: null,
|
||||
navigationHistory: [],
|
||||
formStates: {},
|
||||
isNavigating: false,
|
||||
navigationError: null,
|
||||
|
||||
// Navigation actions
|
||||
navigateToScreen: (screen, metadata = {}) => {
|
||||
const currentState = get();
|
||||
|
||||
// Skip navigation if already on the same screen
|
||||
if (currentState.activeScreen === screen && !currentState.isNavigating) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const historyEntry: NavigationHistory = {
|
||||
screen: currentState.activeScreen,
|
||||
vehicleSubScreen: currentState.vehicleSubScreen,
|
||||
selectedVehicleId: currentState.selectedVehicleId,
|
||||
timestamp: Date.now(),
|
||||
metadata,
|
||||
};
|
||||
|
||||
// Update state atomically to prevent blank screens
|
||||
set({
|
||||
activeScreen: screen,
|
||||
vehicleSubScreen: screen === 'Vehicles' ? currentState.vehicleSubScreen : 'list',
|
||||
selectedVehicleId: screen === 'Vehicles' ? currentState.selectedVehicleId : null,
|
||||
navigationHistory: [...currentState.navigationHistory, historyEntry].slice(-10),
|
||||
isNavigating: false,
|
||||
navigationError: null,
|
||||
});
|
||||
} catch (error) {
|
||||
set({
|
||||
navigationError: error instanceof Error ? error.message : 'Navigation failed',
|
||||
isNavigating: false
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
navigateToVehicleSubScreen: (subScreen, vehicleId, metadata = {}) => {
|
||||
const currentState = get();
|
||||
|
||||
set({ isNavigating: true, navigationError: null });
|
||||
|
||||
try {
|
||||
const historyEntry: NavigationHistory = {
|
||||
screen: currentState.activeScreen,
|
||||
vehicleSubScreen: currentState.vehicleSubScreen,
|
||||
selectedVehicleId: currentState.selectedVehicleId,
|
||||
timestamp: Date.now(),
|
||||
metadata,
|
||||
};
|
||||
|
||||
set({
|
||||
vehicleSubScreen: subScreen,
|
||||
selectedVehicleId: vehicleId !== null ? vehicleId : currentState.selectedVehicleId,
|
||||
navigationHistory: [...currentState.navigationHistory, historyEntry].slice(-10),
|
||||
isNavigating: false,
|
||||
});
|
||||
} catch (error) {
|
||||
set({
|
||||
navigationError: error instanceof Error ? error.message : 'Navigation failed',
|
||||
isNavigating: false
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
goBack: () => {
|
||||
const currentState = get();
|
||||
const lastEntry = currentState.navigationHistory[currentState.navigationHistory.length - 1];
|
||||
|
||||
if (lastEntry) {
|
||||
set({
|
||||
activeScreen: lastEntry.screen,
|
||||
vehicleSubScreen: lastEntry.vehicleSubScreen || 'list',
|
||||
selectedVehicleId: lastEntry.selectedVehicleId,
|
||||
navigationHistory: currentState.navigationHistory.slice(0, -1),
|
||||
isNavigating: false,
|
||||
navigationError: null,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
canGoBack: () => {
|
||||
return get().navigationHistory.length > 0;
|
||||
},
|
||||
|
||||
// Form state management
|
||||
saveFormState: (formId, data, isDirty = true) => {
|
||||
const currentState = get();
|
||||
const formState: FormState = {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
isDirty,
|
||||
};
|
||||
|
||||
set({
|
||||
formStates: {
|
||||
...currentState.formStates,
|
||||
[formId]: formState,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
restoreFormState: (formId) => {
|
||||
const state = get().formStates[formId];
|
||||
const maxAge = 2 * 60 * 60 * 1000; // 2 hours
|
||||
|
||||
if (state && Date.now() - state.timestamp < maxAge) {
|
||||
return state;
|
||||
}
|
||||
|
||||
// Clean up old state
|
||||
if (state) {
|
||||
get().clearFormState(formId);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
clearFormState: (formId) => {
|
||||
const currentState = get();
|
||||
const newFormStates = { ...currentState.formStates };
|
||||
delete newFormStates[formId];
|
||||
set({ formStates: newFormStates });
|
||||
},
|
||||
|
||||
clearAllFormStates: () => {
|
||||
set({ formStates: {} });
|
||||
},
|
||||
|
||||
setNavigationError: (error) => {
|
||||
set({ navigationError: error });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'motovaultpro-mobile-navigation',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
activeScreen: state.activeScreen,
|
||||
vehicleSubScreen: state.vehicleSubScreen,
|
||||
selectedVehicleId: state.selectedVehicleId,
|
||||
formStates: state.formStates,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
101
frontend/src/core/store/user.ts
Normal file
101
frontend/src/core/store/user.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
|
||||
interface UserPreferences {
|
||||
unitSystem: 'imperial' | 'metric';
|
||||
darkMode: boolean;
|
||||
notifications: {
|
||||
email: boolean;
|
||||
push: boolean;
|
||||
maintenance: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface UserState {
|
||||
// User data (persisted subset)
|
||||
userProfile: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
picture: string;
|
||||
} | null;
|
||||
|
||||
preferences: UserPreferences;
|
||||
|
||||
// Session data (not persisted)
|
||||
isOnline: boolean;
|
||||
lastSyncTimestamp: number;
|
||||
|
||||
// Actions
|
||||
setUserProfile: (profile: any) => void;
|
||||
updatePreferences: (preferences: Partial<UserPreferences>) => void;
|
||||
setOnlineStatus: (isOnline: boolean) => void;
|
||||
updateLastSync: () => void;
|
||||
clearUserData: () => void;
|
||||
}
|
||||
|
||||
export const useUserStore = create<UserState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
// Initial state
|
||||
userProfile: null,
|
||||
preferences: {
|
||||
unitSystem: 'imperial',
|
||||
darkMode: false,
|
||||
notifications: {
|
||||
email: true,
|
||||
push: true,
|
||||
maintenance: true,
|
||||
},
|
||||
},
|
||||
isOnline: true,
|
||||
lastSyncTimestamp: 0,
|
||||
|
||||
// Actions
|
||||
setUserProfile: (profile) => {
|
||||
if (profile) {
|
||||
set({
|
||||
userProfile: {
|
||||
id: profile.sub,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
picture: profile.picture,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
updatePreferences: (newPreferences) => {
|
||||
set((state) => ({
|
||||
preferences: { ...state.preferences, ...newPreferences },
|
||||
}));
|
||||
},
|
||||
|
||||
setOnlineStatus: (isOnline) => set({ isOnline }),
|
||||
|
||||
updateLastSync: () => set({ lastSyncTimestamp: Date.now() }),
|
||||
|
||||
clearUserData: () => set({
|
||||
userProfile: null,
|
||||
preferences: {
|
||||
unitSystem: 'imperial',
|
||||
darkMode: false,
|
||||
notifications: {
|
||||
email: true,
|
||||
push: true,
|
||||
maintenance: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'motovaultpro-user-context',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
userProfile: state.userProfile,
|
||||
preferences: state.preferences,
|
||||
// Don't persist session data
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
254
frontend/src/core/sync/data-sync.ts
Normal file
254
frontend/src/core/sync/data-sync.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* @ai-summary Data synchronization layer integrating React Query with Zustand stores
|
||||
*/
|
||||
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { useNavigationStore } from '../store/navigation';
|
||||
import { useUserStore } from '../store/user';
|
||||
import { Vehicle } from '../../features/vehicles/types/vehicles.types';
|
||||
|
||||
interface SyncConfig {
|
||||
enableCrossTabs: boolean;
|
||||
enableOptimisticUpdates: boolean;
|
||||
enableBackgroundSync: boolean;
|
||||
syncInterval: number;
|
||||
}
|
||||
|
||||
export class DataSyncManager {
|
||||
private queryClient: QueryClient;
|
||||
private config: SyncConfig;
|
||||
private syncInterval?: NodeJS.Timeout;
|
||||
private isOnline: boolean = navigator.onLine;
|
||||
|
||||
constructor(queryClient: QueryClient, config: Partial<SyncConfig> = {}) {
|
||||
this.queryClient = queryClient;
|
||||
this.config = {
|
||||
enableCrossTabs: true,
|
||||
enableOptimisticUpdates: true,
|
||||
enableBackgroundSync: true,
|
||||
syncInterval: 30000, // 30 seconds
|
||||
...config,
|
||||
};
|
||||
|
||||
this.initializeSync();
|
||||
}
|
||||
|
||||
private initializeSync() {
|
||||
// Listen to online/offline events
|
||||
window.addEventListener('online', this.handleOnline.bind(this));
|
||||
window.addEventListener('offline', this.handleOffline.bind(this));
|
||||
|
||||
// Cross-tab synchronization
|
||||
if (this.config.enableCrossTabs) {
|
||||
this.initializeCrossTabSync();
|
||||
}
|
||||
|
||||
// Background sync
|
||||
if (this.config.enableBackgroundSync) {
|
||||
this.startBackgroundSync();
|
||||
}
|
||||
}
|
||||
|
||||
private handleOnline() {
|
||||
this.isOnline = true;
|
||||
useUserStore.getState().setOnlineStatus(true);
|
||||
|
||||
// Trigger cache revalidation when coming back online
|
||||
this.queryClient.invalidateQueries();
|
||||
console.log('DataSync: Back online, revalidating cache');
|
||||
}
|
||||
|
||||
private handleOffline() {
|
||||
this.isOnline = false;
|
||||
useUserStore.getState().setOnlineStatus(false);
|
||||
console.log('DataSync: Offline mode enabled');
|
||||
}
|
||||
|
||||
private initializeCrossTabSync() {
|
||||
// Listen for storage changes from other tabs
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key?.startsWith('motovaultpro-')) {
|
||||
// Another tab updated store data
|
||||
if (event.key.includes('user-context')) {
|
||||
// User data changed in another tab - sync React Query cache
|
||||
this.syncUserDataFromStorage();
|
||||
} else if (event.key.includes('mobile-navigation')) {
|
||||
// Navigation state changed - could affect cache keys
|
||||
this.syncNavigationFromStorage();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async syncUserDataFromStorage() {
|
||||
try {
|
||||
const userData = useUserStore.getState().userProfile;
|
||||
if (userData) {
|
||||
// Update query cache with latest user data
|
||||
this.queryClient.setQueryData(['user', userData.id], userData);
|
||||
console.log('DataSync: User data synchronized from another tab');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('DataSync: Failed to sync user data from storage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async syncNavigationFromStorage() {
|
||||
try {
|
||||
const navigationState = useNavigationStore.getState();
|
||||
|
||||
// If the selected vehicle changed in another tab, preload its data
|
||||
if (navigationState.selectedVehicleId) {
|
||||
await this.queryClient.prefetchQuery({
|
||||
queryKey: ['vehicles', navigationState.selectedVehicleId],
|
||||
queryFn: () => this.fetchVehicleById(navigationState.selectedVehicleId!),
|
||||
});
|
||||
console.log('DataSync: Vehicle data preloaded from navigation sync');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('DataSync: Failed to sync navigation from storage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private startBackgroundSync() {
|
||||
this.syncInterval = setInterval(() => {
|
||||
if (this.isOnline) {
|
||||
this.performBackgroundSync();
|
||||
}
|
||||
}, this.config.syncInterval);
|
||||
}
|
||||
|
||||
private async performBackgroundSync() {
|
||||
try {
|
||||
// Update last sync timestamp
|
||||
useUserStore.getState().updateLastSync();
|
||||
|
||||
// Strategically refresh critical data
|
||||
const navigationState = useNavigationStore.getState();
|
||||
|
||||
// If on vehicles screen, refresh vehicles data
|
||||
if (navigationState.activeScreen === 'Vehicles') {
|
||||
await this.queryClient.invalidateQueries({ queryKey: ['vehicles'] });
|
||||
}
|
||||
|
||||
// If viewing specific vehicle, refresh its data
|
||||
if (navigationState.selectedVehicleId) {
|
||||
await this.queryClient.invalidateQueries({
|
||||
queryKey: ['vehicles', navigationState.selectedVehicleId]
|
||||
});
|
||||
}
|
||||
|
||||
console.log('DataSync: Background sync completed');
|
||||
} catch (error) {
|
||||
console.warn('DataSync: Background sync failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to fetch vehicle by ID (would normally import from vehicles API)
|
||||
private async fetchVehicleById(id: string): Promise<Vehicle | null> {
|
||||
try {
|
||||
const response = await fetch(`/api/vehicles/${id}`, {
|
||||
headers: {
|
||||
'Authorization': this.getAuthHeader(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.warn(`Failed to fetch vehicle ${id}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private getAuthHeader(): string {
|
||||
// This would integrate with Auth0 token from interceptor
|
||||
// For now, return empty string as token is handled by axios interceptor
|
||||
return '';
|
||||
}
|
||||
|
||||
// Public methods for optimistic updates
|
||||
public async optimisticVehicleUpdate(vehicleId: string, updates: Partial<Vehicle>) {
|
||||
if (!this.config.enableOptimisticUpdates) return;
|
||||
|
||||
try {
|
||||
// Optimistically update query cache
|
||||
this.queryClient.setQueryData(['vehicles', vehicleId], (old: Vehicle | undefined) => {
|
||||
if (!old) return old;
|
||||
return { ...old, ...updates };
|
||||
});
|
||||
|
||||
// Also update the vehicles list cache
|
||||
this.queryClient.setQueryData(['vehicles'], (old: Vehicle[] | undefined) => {
|
||||
if (!old) return old;
|
||||
return old.map(vehicle =>
|
||||
vehicle.id === vehicleId ? { ...vehicle, ...updates } : vehicle
|
||||
);
|
||||
});
|
||||
|
||||
console.log('DataSync: Optimistic vehicle update applied');
|
||||
} catch (error) {
|
||||
console.warn('DataSync: Optimistic update failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
public async prefetchForNavigation(targetScreen: string) {
|
||||
try {
|
||||
switch (targetScreen) {
|
||||
case 'Vehicles':
|
||||
// Prefetch vehicles list if not already cached
|
||||
await this.queryClient.prefetchQuery({
|
||||
queryKey: ['vehicles'],
|
||||
queryFn: () => this.fetchVehicles(),
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
break;
|
||||
|
||||
case 'Log Fuel':
|
||||
// Prefetch vehicles for fuel logging dropdown
|
||||
await this.queryClient.prefetchQuery({
|
||||
queryKey: ['vehicles'],
|
||||
queryFn: () => this.fetchVehicles(),
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
// No specific prefetching needed
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('DataSync: Prefetch failed for', targetScreen, error);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchVehicles(): Promise<Vehicle[]> {
|
||||
try {
|
||||
const response = await fetch('/api/vehicles', {
|
||||
headers: {
|
||||
'Authorization': this.getAuthHeader(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch vehicles:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public cleanup() {
|
||||
if (this.syncInterval) {
|
||||
clearInterval(this.syncInterval);
|
||||
}
|
||||
|
||||
window.removeEventListener('online', this.handleOnline);
|
||||
window.removeEventListener('offline', this.handleOffline);
|
||||
}
|
||||
}
|
||||
117
frontend/src/core/units/UnitsContext.tsx
Normal file
117
frontend/src/core/units/UnitsContext.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* @ai-summary React context for unit system preferences
|
||||
* @ai-context Provides unit preferences and conversion functions throughout the app
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { UnitSystem, UnitPreferences } from './units.types';
|
||||
import {
|
||||
formatDistanceBySystem,
|
||||
formatVolumeBySystem,
|
||||
formatFuelEfficiencyBySystem,
|
||||
formatPriceBySystem,
|
||||
convertDistanceBySystem,
|
||||
convertVolumeBySystem,
|
||||
convertFuelEfficiencyBySystem,
|
||||
getDistanceUnit,
|
||||
getVolumeUnit,
|
||||
getFuelEfficiencyUnit
|
||||
} from './units.utils';
|
||||
|
||||
interface UnitsContextType {
|
||||
unitSystem: UnitSystem;
|
||||
setUnitSystem: (system: UnitSystem) => void;
|
||||
preferences: UnitPreferences;
|
||||
|
||||
// Conversion functions
|
||||
convertDistance: (miles: number) => number;
|
||||
convertVolume: (gallons: number) => number;
|
||||
convertFuelEfficiency: (mpg: number) => number;
|
||||
|
||||
// Formatting functions
|
||||
formatDistance: (miles: number, precision?: number) => string;
|
||||
formatVolume: (gallons: number, precision?: number) => string;
|
||||
formatFuelEfficiency: (mpg: number, precision?: number) => string;
|
||||
formatPrice: (pricePerGallon: number, currency?: string, precision?: number) => string;
|
||||
}
|
||||
|
||||
const UnitsContext = createContext<UnitsContextType | undefined>(undefined);
|
||||
|
||||
interface UnitsProviderProps {
|
||||
children: ReactNode;
|
||||
initialSystem?: UnitSystem;
|
||||
}
|
||||
|
||||
export const UnitsProvider: React.FC<UnitsProviderProps> = ({
|
||||
children,
|
||||
initialSystem = 'imperial'
|
||||
}) => {
|
||||
const [unitSystem, setUnitSystem] = useState<UnitSystem>(initialSystem);
|
||||
|
||||
// Load unit preference from localStorage on mount
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('motovaultpro-unit-system');
|
||||
if (stored === 'imperial' || stored === 'metric') {
|
||||
setUnitSystem(stored);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save unit preference to localStorage when changed
|
||||
const handleSetUnitSystem = (system: UnitSystem) => {
|
||||
setUnitSystem(system);
|
||||
localStorage.setItem('motovaultpro-unit-system', system);
|
||||
};
|
||||
|
||||
// Generate preferences object based on current system
|
||||
const preferences: UnitPreferences = {
|
||||
system: unitSystem,
|
||||
distance: getDistanceUnit(unitSystem),
|
||||
volume: getVolumeUnit(unitSystem),
|
||||
fuelEfficiency: getFuelEfficiencyUnit(unitSystem),
|
||||
};
|
||||
|
||||
// Conversion functions using current unit system
|
||||
const convertDistance = (miles: number) => convertDistanceBySystem(miles, unitSystem);
|
||||
const convertVolume = (gallons: number) => convertVolumeBySystem(gallons, unitSystem);
|
||||
const convertFuelEfficiency = (mpg: number) => convertFuelEfficiencyBySystem(mpg, unitSystem);
|
||||
|
||||
// Formatting functions using current unit system
|
||||
const formatDistance = (miles: number, precision?: number) =>
|
||||
formatDistanceBySystem(miles, unitSystem, precision);
|
||||
|
||||
const formatVolume = (gallons: number, precision?: number) =>
|
||||
formatVolumeBySystem(gallons, unitSystem, precision);
|
||||
|
||||
const formatFuelEfficiency = (mpg: number, precision?: number) =>
|
||||
formatFuelEfficiencyBySystem(mpg, unitSystem, precision);
|
||||
|
||||
const formatPrice = (pricePerGallon: number, currency?: string, precision?: number) =>
|
||||
formatPriceBySystem(pricePerGallon, unitSystem, currency, precision);
|
||||
|
||||
const value: UnitsContextType = {
|
||||
unitSystem,
|
||||
setUnitSystem: handleSetUnitSystem,
|
||||
preferences,
|
||||
convertDistance,
|
||||
convertVolume,
|
||||
convertFuelEfficiency,
|
||||
formatDistance,
|
||||
formatVolume,
|
||||
formatFuelEfficiency,
|
||||
formatPrice,
|
||||
};
|
||||
|
||||
return (
|
||||
<UnitsContext.Provider value={value}>
|
||||
{children}
|
||||
</UnitsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useUnits = (): UnitsContextType => {
|
||||
const context = useContext(UnitsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useUnits must be used within a UnitsProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
24
frontend/src/core/units/units.types.ts
Normal file
24
frontend/src/core/units/units.types.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @ai-summary Type definitions for unit system support
|
||||
* @ai-context Frontend types for Imperial/Metric unit preferences
|
||||
*/
|
||||
|
||||
export type UnitSystem = 'imperial' | 'metric';
|
||||
export type DistanceUnit = 'miles' | 'km';
|
||||
export type VolumeUnit = 'gallons' | 'liters';
|
||||
export type FuelEfficiencyUnit = 'mpg' | 'l100km';
|
||||
|
||||
export interface UnitPreferences {
|
||||
system: UnitSystem;
|
||||
distance: DistanceUnit;
|
||||
volume: VolumeUnit;
|
||||
fuelEfficiency: FuelEfficiencyUnit;
|
||||
}
|
||||
|
||||
export interface UserPreferences {
|
||||
id: string;
|
||||
userId: string;
|
||||
unitSystem: UnitSystem;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
194
frontend/src/core/units/units.utils.ts
Normal file
194
frontend/src/core/units/units.utils.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* @ai-summary Frontend unit conversion utilities
|
||||
* @ai-context Mirror of backend unit conversion functions for frontend use
|
||||
*/
|
||||
|
||||
import { UnitSystem, DistanceUnit, VolumeUnit, FuelEfficiencyUnit } from './units.types';
|
||||
|
||||
// Conversion constants
|
||||
const MILES_TO_KM = 1.60934;
|
||||
const KM_TO_MILES = 0.621371;
|
||||
const GALLONS_TO_LITERS = 3.78541;
|
||||
const LITERS_TO_GALLONS = 0.264172;
|
||||
const MPG_TO_L100KM_FACTOR = 235.214;
|
||||
|
||||
// Distance Conversions
|
||||
export function convertDistance(value: number, fromUnit: DistanceUnit, toUnit: DistanceUnit): number {
|
||||
if (fromUnit === toUnit) return value;
|
||||
|
||||
if (fromUnit === 'miles' && toUnit === 'km') {
|
||||
return value * MILES_TO_KM;
|
||||
}
|
||||
|
||||
if (fromUnit === 'km' && toUnit === 'miles') {
|
||||
return value * KM_TO_MILES;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function convertDistanceBySystem(miles: number, toSystem: UnitSystem): number {
|
||||
if (toSystem === 'metric') {
|
||||
return convertDistance(miles, 'miles', 'km');
|
||||
}
|
||||
return miles;
|
||||
}
|
||||
|
||||
// Volume Conversions
|
||||
export function convertVolume(value: number, fromUnit: VolumeUnit, toUnit: VolumeUnit): number {
|
||||
if (fromUnit === toUnit) return value;
|
||||
|
||||
if (fromUnit === 'gallons' && toUnit === 'liters') {
|
||||
return value * GALLONS_TO_LITERS;
|
||||
}
|
||||
|
||||
if (fromUnit === 'liters' && toUnit === 'gallons') {
|
||||
return value * LITERS_TO_GALLONS;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function convertVolumeBySystem(gallons: number, toSystem: UnitSystem): number {
|
||||
if (toSystem === 'metric') {
|
||||
return convertVolume(gallons, 'gallons', 'liters');
|
||||
}
|
||||
return gallons;
|
||||
}
|
||||
|
||||
// Fuel Efficiency Conversions
|
||||
export function convertFuelEfficiency(value: number, fromUnit: FuelEfficiencyUnit, toUnit: FuelEfficiencyUnit): number {
|
||||
if (fromUnit === toUnit) return value;
|
||||
|
||||
if (fromUnit === 'mpg' && toUnit === 'l100km') {
|
||||
return value === 0 ? 0 : MPG_TO_L100KM_FACTOR / value;
|
||||
}
|
||||
|
||||
if (fromUnit === 'l100km' && toUnit === 'mpg') {
|
||||
return value === 0 ? 0 : MPG_TO_L100KM_FACTOR / value;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function convertFuelEfficiencyBySystem(mpg: number, toSystem: UnitSystem): number {
|
||||
if (toSystem === 'metric') {
|
||||
return convertFuelEfficiency(mpg, 'mpg', 'l100km');
|
||||
}
|
||||
return mpg;
|
||||
}
|
||||
|
||||
// Display Formatting Functions
|
||||
export function formatDistance(value: number, unit: DistanceUnit, precision = 1): string {
|
||||
if (typeof value !== 'number' || isNaN(value)) {
|
||||
return unit === 'miles' ? '0 miles' : '0 km';
|
||||
}
|
||||
|
||||
const rounded = parseFloat(value.toFixed(precision));
|
||||
|
||||
if (unit === 'miles') {
|
||||
return `${rounded.toLocaleString()} miles`;
|
||||
} else {
|
||||
return `${rounded.toLocaleString()} km`;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatVolume(value: number, unit: VolumeUnit, precision = 2): string {
|
||||
if (typeof value !== 'number' || isNaN(value)) {
|
||||
return unit === 'gallons' ? '0 gal' : '0 L';
|
||||
}
|
||||
|
||||
const rounded = parseFloat(value.toFixed(precision));
|
||||
|
||||
if (unit === 'gallons') {
|
||||
return `${rounded} gal`;
|
||||
} else {
|
||||
return `${rounded} L`;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatFuelEfficiency(value: number, unit: FuelEfficiencyUnit, precision = 1): string {
|
||||
if (typeof value !== 'number' || isNaN(value)) {
|
||||
return unit === 'mpg' ? '0 MPG' : '0 L/100km';
|
||||
}
|
||||
|
||||
const rounded = parseFloat(value.toFixed(precision));
|
||||
|
||||
if (unit === 'mpg') {
|
||||
return `${rounded} MPG`;
|
||||
} else {
|
||||
return `${rounded} L/100km`;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatPrice(value: number, unit: VolumeUnit, currency = 'USD', precision = 3): string {
|
||||
if (typeof value !== 'number' || isNaN(value)) {
|
||||
const formatter = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: precision,
|
||||
maximumFractionDigits: precision,
|
||||
});
|
||||
return unit === 'gallons' ? `${formatter.format(0)}/gal` : `${formatter.format(0)}/L`;
|
||||
}
|
||||
|
||||
const rounded = parseFloat(value.toFixed(precision));
|
||||
const formatter = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: precision,
|
||||
maximumFractionDigits: precision,
|
||||
});
|
||||
|
||||
if (unit === 'gallons') {
|
||||
return `${formatter.format(rounded)}/gal`;
|
||||
} else {
|
||||
return `${formatter.format(rounded)}/L`;
|
||||
}
|
||||
}
|
||||
|
||||
// System-based formatting (convenience functions)
|
||||
export function formatDistanceBySystem(miles: number, system: UnitSystem, precision = 1): string {
|
||||
if (system === 'metric') {
|
||||
const km = convertDistanceBySystem(miles, system);
|
||||
return formatDistance(km, 'km', precision);
|
||||
}
|
||||
return formatDistance(miles, 'miles', precision);
|
||||
}
|
||||
|
||||
export function formatVolumeBySystem(gallons: number, system: UnitSystem, precision = 2): string {
|
||||
if (system === 'metric') {
|
||||
const liters = convertVolumeBySystem(gallons, system);
|
||||
return formatVolume(liters, 'liters', precision);
|
||||
}
|
||||
return formatVolume(gallons, 'gallons', precision);
|
||||
}
|
||||
|
||||
export function formatFuelEfficiencyBySystem(mpg: number, system: UnitSystem, precision = 1): string {
|
||||
if (system === 'metric') {
|
||||
const l100km = convertFuelEfficiencyBySystem(mpg, system);
|
||||
return formatFuelEfficiency(l100km, 'l100km', precision);
|
||||
}
|
||||
return formatFuelEfficiency(mpg, 'mpg', precision);
|
||||
}
|
||||
|
||||
export function formatPriceBySystem(pricePerGallon: number, system: UnitSystem, currency = 'USD', precision = 3): string {
|
||||
if (system === 'metric') {
|
||||
const pricePerLiter = pricePerGallon * LITERS_TO_GALLONS;
|
||||
return formatPrice(pricePerLiter, 'liters', currency, precision);
|
||||
}
|
||||
return formatPrice(pricePerGallon, 'gallons', currency, precision);
|
||||
}
|
||||
|
||||
// Unit system helpers
|
||||
export function getDistanceUnit(system: UnitSystem): DistanceUnit {
|
||||
return system === 'metric' ? 'km' : 'miles';
|
||||
}
|
||||
|
||||
export function getVolumeUnit(system: UnitSystem): VolumeUnit {
|
||||
return system === 'metric' ? 'liters' : 'gallons';
|
||||
}
|
||||
|
||||
export function getFuelEfficiencyUnit(system: UnitSystem): FuelEfficiencyUnit {
|
||||
return system === 'metric' ? 'l100km' : 'mpg';
|
||||
}
|
||||
Reference in New Issue
Block a user