Initial Commit

This commit is contained in:
Eric Gullickson
2025-09-17 16:09:15 -05:00
parent 0cdb9803de
commit a052040e3a
373 changed files with 437090 additions and 6773 deletions

View File

@@ -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);
}
);

View File

@@ -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}</>;
};

View 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}</>;
};

View 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>
);
};

View 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;
}
}

View 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);
},
};
};

View 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,
};
};

View 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(),
});
}
},
};

View 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 }),
}));

View File

@@ -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

View 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,
}),
}
)
);

View 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
}),
}
)
);

View 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);
}
}

View 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;
};

View 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;
}

View 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';
}