From a195fa9231093fa1ae94e443cddd419693912edf Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sat, 14 Feb 2026 21:55:24 -0600 Subject: [PATCH 01/15] fix: allow callback route to complete Auth0 code exchange (refs #189) Co-Authored-By: Claude Opus 4.6 --- frontend/src/App.tsx | 39 +++++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0aa66e0..3a161dd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -557,18 +557,37 @@ function App() { ); } - // Callback route requires authentication - handled by CallbackPage component - if (isCallbackRoute && isAuthenticated) { + if (isCallbackRoute) { + if (isAuthenticated) { + return ( + + +
Processing login...
+ + }> + {mobileMode ? : } +
+ +
+ ); + } + if (mobileMode) { + return ( + + +
+
Processing login...
+
+
+
+ ); + } return ( - -
Processing login...
- - }> - {mobileMode ? : } -
- +
+
Processing login...
+
); } From 6e493e9bc77e7c72e8c37ac13cfdd3b4f937d593 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sat, 14 Feb 2026 21:55:54 -0600 Subject: [PATCH 02/15] fix: detect and clear stale IndexedDB auth tokens (refs #190) Co-Authored-By: Claude Opus 4.6 --- frontend/src/core/auth/Auth0Provider.tsx | 28 +++++++++++++++++++- frontend/src/core/utils/indexeddb-storage.ts | 17 ++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/frontend/src/core/auth/Auth0Provider.tsx b/frontend/src/core/auth/Auth0Provider.tsx index 7678046..a1025dc 100644 --- a/frontend/src/core/auth/Auth0Provider.tsx +++ b/frontend/src/core/auth/Auth0Provider.tsx @@ -57,8 +57,9 @@ export const Auth0Provider: React.FC = ({ children }) => { // Component to inject token into API client with mobile support const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const { getAccessTokenSilently, isAuthenticated, isLoading, user } = useAuth0(); + const { getAccessTokenSilently, isAuthenticated, isLoading, user, logout } = useAuth0(); const [retryCount, setRetryCount] = React.useState(0); + const validatingRef = React.useRef(false); // Basic component loading debug console.log('[TokenInjector] Component loaded'); @@ -116,6 +117,31 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) => return null; }; + // Prevent stale session state when cached token is no longer valid + React.useEffect(() => { + if (!isAuthenticated || isLoading || validatingRef.current) return; + + const validateToken = async () => { + validatingRef.current = true; + try { + await getAccessTokenSilently({ cacheMode: 'off', timeoutInSeconds: 10 }); + } catch (error: any) { + const errorType = error?.error || error?.message || ''; + if (errorType.includes('login_required') || errorType.includes('consent_required') || + errorType.includes('invalid_grant')) { + console.warn('[Auth] Stale token detected, clearing auth state'); + const { indexedDBStorage } = await import('../utils/indexeddb-storage'); + await indexedDBStorage.clearAll(); + logout({ openUrl: false }); + return; + } + } + validatingRef.current = false; + }; + + validateToken(); + }, [isAuthenticated, isLoading]); + // Force authentication check for devices when user seems logged in but isAuthenticated is false React.useEffect(() => { const isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); diff --git a/frontend/src/core/utils/indexeddb-storage.ts b/frontend/src/core/utils/indexeddb-storage.ts index 6ce8dd6..3014e50 100644 --- a/frontend/src/core/utils/indexeddb-storage.ts +++ b/frontend/src/core/utils/indexeddb-storage.ts @@ -157,6 +157,23 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache { } } + async clearAll(): Promise { + await this.initPromise; + if (!this.db) { + this.memoryCache.clear(); + return; + } + const tx = this.db.transaction(this.storeName, 'readwrite'); + tx.objectStore(this.storeName).clear(); + await new Promise((resolve, reject) => { + tx.oncomplete = () => { + this.memoryCache.clear(); + resolve(); + }; + tx.onerror = () => reject(tx.error); + }); + } + key(index: number): string | null { const keys = Array.from(this.memoryCache.keys()); return keys[index] || null; From 723e25e1a745a6f949064d62220b3390fbaa5c65 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sat, 14 Feb 2026 21:56:24 -0600 Subject: [PATCH 03/15] fix: add pre-auth session clear mechanism on HomePage (refs #192) Co-Authored-By: Claude Opus 4.6 --- frontend/src/pages/HomePage.tsx | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 5091dac..ff0492a 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -6,9 +6,10 @@ import { FeaturesGrid } from './HomePage/FeaturesGrid'; import { motion } from 'framer-motion'; export const HomePage = () => { - const { loginWithRedirect, isAuthenticated } = useAuth0(); + const { loginWithRedirect, isAuthenticated, logout } = useAuth0(); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [isScrolled, setIsScrolled] = useState(false); + const [sessionCleared, setSessionCleared] = useState(false); const navigate = useNavigate(); useEffect(() => { @@ -41,6 +42,22 @@ export const HomePage = () => { navigate('/signup'); }; + const handleClearSession = async () => { + try { + const { indexedDBStorage } = await import('../core/utils/indexeddb-storage'); + await indexedDBStorage.clearAll(); + Object.keys(localStorage).forEach(key => { + if (key.startsWith('@@auth0')) localStorage.removeItem(key); + }); + logout({ openUrl: false }); + setSessionCleared(true); + setTimeout(() => setSessionCleared(false), 3000); + } catch (error) { + console.error('[HomePage] Failed to clear session:', error); + window.location.reload(); + } + }; + return (
{/* Navigation Bar */} @@ -84,6 +101,12 @@ export const HomePage = () => { > Login +
{/* Mobile Menu Button */} @@ -149,6 +172,12 @@ export const HomePage = () => { > Login + )} From 15128bfd50ed0bbf680eaa66698d938528abdb7b Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sat, 14 Feb 2026 21:57:28 -0600 Subject: [PATCH 04/15] fix: add missing hook dependencies for stale token effect (refs #190) Co-Authored-By: Claude Opus 4.6 --- frontend/src/core/auth/Auth0Provider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/core/auth/Auth0Provider.tsx b/frontend/src/core/auth/Auth0Provider.tsx index a1025dc..c7c743e 100644 --- a/frontend/src/core/auth/Auth0Provider.tsx +++ b/frontend/src/core/auth/Auth0Provider.tsx @@ -140,7 +140,7 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) => }; validateToken(); - }, [isAuthenticated, isLoading]); + }, [isAuthenticated, isLoading, getAccessTokenSilently, logout]); // Force authentication check for devices when user seems logged in but isAuthenticated is false React.useEffect(() => { From db127eb24c9478d7671b64bf802412207dde6980 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sat, 14 Feb 2026 21:59:31 -0600 Subject: [PATCH 05/15] fix: address QR review findings for token validation and clearAll reliability (refs #190) Co-Authored-By: Claude Opus 4.6 --- frontend/src/core/auth/Auth0Provider.tsx | 4 ++-- frontend/src/core/utils/indexeddb-storage.ts | 9 ++++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/frontend/src/core/auth/Auth0Provider.tsx b/frontend/src/core/auth/Auth0Provider.tsx index c7c743e..3c2926b 100644 --- a/frontend/src/core/auth/Auth0Provider.tsx +++ b/frontend/src/core/auth/Auth0Provider.tsx @@ -133,10 +133,10 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) => const { indexedDBStorage } = await import('../utils/indexeddb-storage'); await indexedDBStorage.clearAll(); logout({ openUrl: false }); - return; } + } finally { + validatingRef.current = false; } - validatingRef.current = false; }; validateToken(); diff --git a/frontend/src/core/utils/indexeddb-storage.ts b/frontend/src/core/utils/indexeddb-storage.ts index 3014e50..24f0d60 100644 --- a/frontend/src/core/utils/indexeddb-storage.ts +++ b/frontend/src/core/utils/indexeddb-storage.ts @@ -170,7 +170,14 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache { this.memoryCache.clear(); resolve(); }; - tx.onerror = () => reject(tx.error); + tx.onerror = () => { + this.memoryCache.clear(); + reject(tx.error); + }; + tx.onabort = () => { + this.memoryCache.clear(); + reject(new Error('Transaction aborted')); + }; }); } From 38debaad5dd13cb9a5659893a22571e5126535e0 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sat, 14 Feb 2026 22:09:09 -0600 Subject: [PATCH 06/15] fix: skip stale token validation during callback code exchange (refs #190) Co-Authored-By: Claude Opus 4.6 --- frontend/src/core/auth/Auth0Provider.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/core/auth/Auth0Provider.tsx b/frontend/src/core/auth/Auth0Provider.tsx index 3c2926b..9ee6477 100644 --- a/frontend/src/core/auth/Auth0Provider.tsx +++ b/frontend/src/core/auth/Auth0Provider.tsx @@ -120,6 +120,7 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) => // Prevent stale session state when cached token is no longer valid React.useEffect(() => { if (!isAuthenticated || isLoading || validatingRef.current) return; + if (window.location.pathname === '/callback') return; const validateToken = async () => { validatingRef.current = true; From da59168d7b0bb81fe1377915aa6bcdc163e33cf1 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sat, 14 Feb 2026 22:20:34 -0600 Subject: [PATCH 07/15] fix: IndexedDB cache broken on page reload - root cause of mobile login failure (refs #190) loadCacheFromDB used store.getAll() which returns raw values, not key-value pairs. The item.key check always failed, so memoryCache was empty after every page reload. Auth0 SDK state stored before redirect was lost on mobile Safari (no bfcache). Also fixed set()/remove() to await IDB persistence so Auth0 state is fully written before loginWithRedirect() navigates away. Added 10s timeout on callback loading state as safety net. Co-Authored-By: Claude Opus 4.6 --- frontend/src/App.tsx | 13 ++++++++ frontend/src/core/utils/indexeddb-storage.ts | 31 ++++++++++++-------- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3a161dd..64be162 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -496,6 +496,16 @@ function App() { const isAuthRoute = isSignupRoute || isVerifyEmailRoute || isOnboardingRoute; const shouldShowHomePage = !isGarageRoute && !isCallbackRoute && !isAuthRoute; + const [callbackTimedOut, setCallbackTimedOut] = useState(false); + useEffect(() => { + if (isCallbackRoute && !isAuthenticated && !isLoading) { + const timer = setTimeout(() => setCallbackTimedOut(true), 10000); + return () => clearTimeout(timer); + } + setCallbackTimedOut(false); + return undefined; + }, [isCallbackRoute, isAuthenticated, isLoading]); + // Enhanced navigation handlers for mobile const handleVehicleSelect = useCallback((vehicle: Vehicle) => { setSelectedVehicle(vehicle); @@ -572,6 +582,9 @@ function App() { ); } + if (callbackTimedOut) { + return ; + } if (mobileMode) { return ( diff --git a/frontend/src/core/utils/indexeddb-storage.ts b/frontend/src/core/utils/indexeddb-storage.ts index 24f0d60..2dc10d0 100644 --- a/frontend/src/core/utils/indexeddb-storage.ts +++ b/frontend/src/core/utils/indexeddb-storage.ts @@ -71,25 +71,27 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache { return new Promise((resolve) => { const transaction = this.db!.transaction([this.storeName], 'readonly'); const store = transaction.objectStore(this.storeName); - const request = store.getAll(); + const request = store.openCursor(); + this.memoryCache.clear(); request.onsuccess = () => { - const results = request.result; - this.memoryCache.clear(); - - for (const item of results) { - if (item.key && typeof item.value === 'string') { - this.memoryCache.set(item.key, item.value); + const cursor = request.result; + if (cursor) { + const key = cursor.key as string; + const value = cursor.value; + if (typeof key === 'string' && typeof value === 'string') { + this.memoryCache.set(key, value); } + cursor.continue(); + } else { + console.log(`[IndexedDB] Loaded ${this.memoryCache.size} items into cache`); + resolve(); } - - console.log(`[IndexedDB] Loaded ${this.memoryCache.size} items into cache`); - resolve(); }; request.onerror = () => { console.warn('[IndexedDB] Failed to load cache from DB:', request.error); - resolve(); // Don't fail initialization + resolve(); }; }); } @@ -199,12 +201,15 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache { async set(key: string, value: any): Promise { await this.initPromise; - this.setItem(key, JSON.stringify(value)); + const stringValue = JSON.stringify(value); + this.memoryCache.set(key, stringValue); + await this.persistToDB(key, stringValue); } async remove(key: string): Promise { await this.initPromise; - this.removeItem(key); + this.memoryCache.delete(key); + await this.persistToDB(key, null); } // Additional methods for enhanced functionality From b5b82db532caa07aca59f4f59239bcfc7deddfc4 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 15 Feb 2026 09:06:40 -0600 Subject: [PATCH 08/15] fix: resolve auth callback failure from IndexedDB cache issues (refs #188) Add allKeys() to IndexedDBStorage to eliminate Auth0 CacheKeyManifest fallback, revert set()/remove() to non-blocking persist, add auth error display on callback route, remove leaky force-auth-check interceptor, and migrate debug console calls to centralized logger. Co-Authored-By: Claude Opus 4.6 --- frontend/src/App.tsx | 23 ++++- frontend/src/core/auth/Auth0Provider.tsx | 102 +++---------------- frontend/src/core/utils/indexeddb-storage.ts | 35 +++++-- 3 files changed, 58 insertions(+), 102 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 64be162..fb95ed8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,7 +4,7 @@ import React, { useState, useEffect, useTransition, useCallback, lazy } from 'react'; import { useQueryClient } from '@tanstack/react-query'; -import { Routes, Route, Navigate, useLocation } from 'react-router-dom'; +import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom'; import { useAuth0 } from '@auth0/auth0-react'; import { useIsAuthInitialized } from './core/auth/auth-gate'; import { motion, AnimatePresence } from 'framer-motion'; @@ -310,11 +310,11 @@ const EditVehicleScreen: React.FC = ({ vehicle, onBack, }; function App() { - const { isLoading, isAuthenticated, user } = useAuth0(); + const { isLoading, isAuthenticated, user, error: authError } = useAuth0(); const location = useLocation(); + const navigate = useNavigate(); const isAuthGateReady = useIsAuthInitialized(); const [_isPending, startTransition] = useTransition(); - console.log('[DEBUG App] Render check - isLoading:', isLoading, 'isAuthenticated:', isAuthenticated, 'isAuthGateReady:', isAuthGateReady); // Initialize data synchronization const { prefetchForNavigation } = useDataSync(); @@ -486,7 +486,6 @@ function App() { } }, [navigateToScreen, navigateToVehicleSubScreen]); - console.log('MotoVaultPro status:', { isLoading, isAuthenticated, mobileMode, activeScreen, vehicleSubScreen, userAgent: navigator.userAgent }); const isGarageRoute = location.pathname === '/garage' || location.pathname.startsWith('/garage/'); const isCallbackRoute = location.pathname === '/callback'; @@ -568,6 +567,21 @@ function App() { } if (isCallbackRoute) { + if (authError) { + return ( + +
+
Login failed: {authError.message}
+ +
+
+ ); + } if (isAuthenticated) { return ( @@ -669,7 +683,6 @@ function App() { // Wait for auth gate to be ready before rendering protected routes // This prevents a race condition where the page renders before the auth token is ready if (!isAuthGateReady) { - console.log('[DEBUG App] Auth gate not ready yet, showing loading state'); if (mobileMode) { return ( diff --git a/frontend/src/core/auth/Auth0Provider.tsx b/frontend/src/core/auth/Auth0Provider.tsx index 9ee6477..746f5ad 100644 --- a/frontend/src/core/auth/Auth0Provider.tsx +++ b/frontend/src/core/auth/Auth0Provider.tsx @@ -8,6 +8,7 @@ import { useNavigate } from 'react-router-dom'; import { apiClient, setAuthReady } from '../api/client'; import { createIndexedDBAdapter } from '../utils/indexeddb-storage'; import { setAuthInitialized } from './auth-gate'; +import logger from '../../utils/logger'; interface Auth0ProviderProps { children: React.ReactNode; @@ -20,12 +21,8 @@ export const Auth0Provider: React.FC = ({ children }) => { const clientId = import.meta.env.VITE_AUTH0_CLIENT_ID; const audience = import.meta.env.VITE_AUTH0_AUDIENCE; - // Basic component loading debug - console.log('[Auth0Provider] Component loaded', { domain, clientId, audience }); - - const onRedirectCallback = (appState?: { returnTo?: string }) => { - console.log('[Auth0Provider] Redirect callback triggered', { appState, returnTo: appState?.returnTo }); + logger.debug('Auth0 redirect callback triggered', { returnTo: appState?.returnTo }); // Route to callback page which will check user status and redirect appropriately // Pass the intended destination as state for after status check navigate('/callback', { @@ -57,19 +54,10 @@ export const Auth0Provider: React.FC = ({ children }) => { // Component to inject token into API client with mobile support const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const { getAccessTokenSilently, isAuthenticated, isLoading, user, logout } = useAuth0(); + const { getAccessTokenSilently, isAuthenticated, isLoading, logout } = useAuth0(); const [retryCount, setRetryCount] = React.useState(0); const validatingRef = React.useRef(false); - // Basic component loading debug - console.log('[TokenInjector] Component loaded'); - - // Debug mobile authentication state - React.useEffect(() => { - const isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); - console.log(`[Auth Debug] Mobile: ${isMobile}, Loading: ${isLoading}, Authenticated: ${isAuthenticated}, User: ${user ? 'present' : 'null'}`); - }, [isAuthenticated, isLoading, user]); - // Helper function to get token with enhanced retry logic for mobile devices const getTokenWithRetry = async (maxRetries = 5, delayMs = 300): Promise => { for (let attempt = 0; attempt < maxRetries; attempt++) { @@ -94,26 +82,21 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) => } const token = await getAccessTokenSilently(tokenOptions); - console.log(`[Mobile Auth] Token acquired successfully on attempt ${attempt + 1}`, { - cacheMode: tokenOptions.cacheMode, - timeout: tokenOptions.timeoutInSeconds - }); + logger.debug(`Token acquired on attempt ${attempt + 1}`); return token; } catch (error: any) { - console.warn(`[Mobile Auth] Attempt ${attempt + 1}/${maxRetries} failed:`, { - error: error.message || error, - cacheMode: attempt <= 2 ? 'on' : 'off' + logger.warn(`Token attempt ${attempt + 1}/${maxRetries} failed`, { + error: error.message || String(error), }); // Mobile-specific: longer delays and more attempts if (attempt < maxRetries - 1) { - const delay = delayMs * Math.pow(1.5, attempt); // Gentler exponential backoff - console.log(`[Mobile Auth] Waiting ${Math.round(delay)}ms before retry...`); + const delay = delayMs * Math.pow(1.5, attempt); await new Promise(resolve => setTimeout(resolve, delay)); } } } - console.error('[Mobile Auth] All token acquisition attempts failed - authentication may be broken'); + logger.error('All token acquisition attempts failed'); return null; }; @@ -130,7 +113,7 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) => const errorType = error?.error || error?.message || ''; if (errorType.includes('login_required') || errorType.includes('consent_required') || errorType.includes('invalid_grant')) { - console.warn('[Auth] Stale token detected, clearing auth state'); + logger.warn('Stale token detected, clearing auth state'); const { indexedDBStorage } = await import('../utils/indexeddb-storage'); await indexedDBStorage.clearAll(); logout({ openUrl: false }); @@ -143,55 +126,6 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) => validateToken(); }, [isAuthenticated, isLoading, getAccessTokenSilently, logout]); - // Force authentication check for devices when user seems logged in but isAuthenticated is false - React.useEffect(() => { - const isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); - - // Debug current state - console.log('[Auth Debug] State check:', { - isMobile, - isLoading, - isAuthenticated, - pathname: window.location.pathname, - userAgent: navigator.userAgent.substring(0, 50) + '...' - }); - - // Trigger for mobile devices OR any device on protected route without authentication - if (!isLoading && !isAuthenticated && window.location.pathname !== '/') { - console.log('[Auth Debug] User on protected route but not authenticated, forcing token check...'); - - // Aggressive token check - const forceAuthCheck = async () => { - try { - // Try multiple approaches to get token - const token = await getAccessTokenSilently({ - cacheMode: 'off' as const, - timeoutInSeconds: 10 - }); - console.log('[Auth Debug] Force auth successful, token acquired'); - - // Manually add to API client since isAuthenticated might still be false - if (token) { - console.log('[Auth Debug] Manually adding token to API client'); - // Force add the token to subsequent requests - apiClient.interceptors.request.use((config) => { - if (!config.headers.Authorization) { - config.headers.Authorization = `Bearer ${token}`; - console.log('[Auth Debug] Token manually added to request'); - } - return config; - }); - setAuthReady(true); - } - } catch (error: any) { - console.log('[Auth Debug] Force auth failed:', error.message); - } - }; - - forceAuthCheck(); - } - }, [isLoading, isAuthenticated, getAccessTokenSilently]); - React.useEffect(() => { let interceptorId: number | undefined; @@ -202,34 +136,30 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) => try { const { indexedDBStorage } = await import('../utils/indexeddb-storage'); await indexedDBStorage.waitForReady(); - console.log('[Auth] IndexedDB storage is ready'); + logger.debug('IndexedDB storage is ready'); } catch (error) { - console.warn('[Auth] IndexedDB not ready, proceeding anyway:', error); + logger.warn('IndexedDB not ready, proceeding anyway', { error: String(error) }); } // Minimal delay only for mobile devices (desktop needs no delay since IndexedDB is already ready) const isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); if (isMobile) { - // Small delay for mobile browsers to settle after IndexedDB init - console.log('[Mobile Auth] Initializing token cache (mobile: true, delay: 50ms)'); await new Promise(resolve => setTimeout(resolve, 50)); - } else { - console.log('[Auth] Initializing token cache (desktop, no delay)'); } try { const token = await getTokenWithRetry(); if (token) { - console.log('[Mobile Auth] Token pre-warming successful'); + logger.debug('Token pre-warming successful'); setRetryCount(0); setAuthReady(true); setAuthInitialized(true); // Signal that auth is fully ready } else { - console.error('[Mobile Auth] Failed to acquire token after retries - will retry on API calls'); + logger.error('Failed to acquire token after retries'); setRetryCount(prev => prev + 1); } } catch (error) { - console.error('[Mobile Auth] Token initialization failed:', error); + logger.error('Token initialization failed', { error: String(error) }); setRetryCount(prev => prev + 1); } }; @@ -248,11 +178,11 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) => config.headers.Authorization = `Bearer ${token}`; setAuthReady(true); } else { - console.error('No token available for request to:', config.url); + logger.error('No token available for request', { url: 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); + logger.error('Failed to get access token for request', { error: error.message || String(error) }); // Allow request to proceed - backend will return 401 if needed } return config; diff --git a/frontend/src/core/utils/indexeddb-storage.ts b/frontend/src/core/utils/indexeddb-storage.ts index 2dc10d0..417a67e 100644 --- a/frontend/src/core/utils/indexeddb-storage.ts +++ b/frontend/src/core/utils/indexeddb-storage.ts @@ -3,6 +3,8 @@ * @ai-context Replaces localStorage with IndexedDB for mobile browser compatibility */ +import logger from '../../utils/logger'; + interface StorageAdapter { getItem(key: string): string | null; setItem(key: string, value: string): void; @@ -36,9 +38,9 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache { this.db = await this.openDatabase(); await this.loadCacheFromDB(); this.isReady = true; - console.log('[IndexedDB] Storage initialized successfully'); + logger.debug('IndexedDB storage initialized successfully'); } catch (error) { - console.error('[IndexedDB] Initialization failed, using memory only:', error); + logger.error('IndexedDB initialization failed, using memory only', { error: String(error) }); this.isReady = false; } } @@ -48,7 +50,7 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache { const request = indexedDB.open(this.dbName, this.dbVersion); request.onerror = () => { - console.error(`IndexedDB open failed: ${request.error?.message}`); + logger.error(`IndexedDB open failed: ${request.error?.message}`); resolve(null as any); // Fallback to memory-only mode }; @@ -84,13 +86,13 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache { } cursor.continue(); } else { - console.log(`[IndexedDB] Loaded ${this.memoryCache.size} items into cache`); + logger.debug(`IndexedDB loaded ${this.memoryCache.size} items into cache`); resolve(); } }; request.onerror = () => { - console.warn('[IndexedDB] Failed to load cache from DB:', request.error); + logger.warn('IndexedDB failed to load cache from DB', { error: String(request.error) }); resolve(); }; }); @@ -107,14 +109,14 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache { const request = store.delete(key); request.onsuccess = () => resolve(); request.onerror = () => { - console.warn(`[IndexedDB] Failed to delete ${key}:`, request.error); + logger.warn(`IndexedDB failed to delete ${key}`, { error: String(request.error) }); resolve(); }; } else { const request = store.put(value, key); request.onsuccess = () => resolve(); request.onerror = () => { - console.warn(`[IndexedDB] Failed to persist ${key}:`, request.error); + logger.warn(`IndexedDB failed to persist ${key}`, { error: String(request.error) }); resolve(); }; } @@ -132,7 +134,7 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache { // Async persist to IndexedDB (non-blocking) if (this.isReady) { this.persistToDB(key, value).catch(error => { - console.warn(`[IndexedDB] Background persist failed for ${key}:`, error); + logger.warn(`IndexedDB background persist failed for ${key}`, { error: String(error) }); }); } } @@ -143,7 +145,7 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache { // Async remove from IndexedDB (non-blocking) if (this.isReady) { this.persistToDB(key, null).catch(error => { - console.warn(`[IndexedDB] Background removal failed for ${key}:`, error); + logger.warn(`IndexedDB background removal failed for ${key}`, { error: String(error) }); }); } } @@ -193,6 +195,11 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache { } // Auth0 Cache interface implementation + // allKeys() eliminates Auth0 SDK's CacheKeyManifest fallback (auth0-spa-js line 2319) + allKeys(): string[] { + return Array.from(this.memoryCache.keys()); + } + async get(key: string): Promise { await this.initPromise; const value = this.getItem(key); @@ -203,13 +210,19 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache { await this.initPromise; const stringValue = JSON.stringify(value); this.memoryCache.set(key, stringValue); - await this.persistToDB(key, stringValue); + // Fire-and-forget: persist to IndexedDB for page reload survival + this.persistToDB(key, stringValue).catch(error => { + logger.warn(`IndexedDB background persist failed for ${key}`, { error: String(error) }); + }); } async remove(key: string): Promise { await this.initPromise; this.memoryCache.delete(key); - await this.persistToDB(key, null); + // Fire-and-forget: remove from IndexedDB + this.persistToDB(key, null).catch(error => { + logger.warn(`IndexedDB background removal failed for ${key}`, { error: String(error) }); + }); } // Additional methods for enhanced functionality From 850f713310132dd26acadbec67434d36bfe23124 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 15 Feb 2026 09:24:56 -0600 Subject: [PATCH 09/15] fix: prevent URL sync effects from stripping Auth0 callback params (refs #188) Root cause: React fires child effects before parent effects. App's URL sync effect called history.replaceState() on /callback, stripping the ?code= and &state= query params before Auth0Provider's useEffect could read them via hasAuthParams(). The SDK fell through to checkSession() instead of handleRedirectCallback(), silently failing with no error. Guard both URL sync effects to skip on /callback, /signup, /verify-email. Co-Authored-By: Claude Opus 4.6 --- frontend/src/App.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fb95ed8..fbbd914 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -365,17 +365,24 @@ function App() { const [showAddVehicle, setShowAddVehicle] = useState(false); // Sync browser URL to Zustand screen state on mount (enables direct URL navigation on mobile) + // Skip on auth routes -- their query params must survive until Auth0 SDK processes them useEffect(() => { - const screen = routeToScreen[window.location.pathname]; + const path = window.location.pathname; + if (path === '/callback' || path === '/signup' || path === '/verify-email') return; + const screen = routeToScreen[path]; if (screen && screen !== activeScreen) { navigateToScreen(screen, { source: 'url-sync' }); } }, []); // eslint-disable-line react-hooks/exhaustive-deps -- intentionally runs once on mount // Sync Zustand screen changes back to browser URL (enables bookmarks and URL sharing) + // Skip on auth routes -- replaceState would strip ?code= and &state= params that + // Auth0 SDK needs for handleRedirectCallback (child effects fire before parent effects) useEffect(() => { + const path = window.location.pathname; + if (path === '/callback' || path === '/signup' || path === '/verify-email') return; const targetPath = screenToRoute[activeScreen]; - if (targetPath && window.location.pathname !== targetPath) { + if (targetPath && path !== targetPath) { window.history.replaceState(null, '', targetPath); } }, [activeScreen]); From 7140c7e8d48088948ea0e7bf842b6b45bcc80626 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 15 Feb 2026 10:01:33 -0600 Subject: [PATCH 10/15] fix: wire up Add Maintenance button on vehicle detail page (refs #194) Rename "Schedule Maintenance" to "Add Maintenance", match contained button style to "Add Fuel Log", and open inline MaintenanceRecordForm dialog on click. Applied to both desktop and mobile views. Co-Authored-By: Claude Opus 4.6 --- .../components/MaintenanceRecordForm.tsx | 4 +- .../vehicles/mobile/VehicleDetailMobile.tsx | 37 +++++++++++++++++-- .../vehicles/pages/VehicleDetailPage.tsx | 35 ++++++++++++++++-- 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/frontend/src/features/maintenance/components/MaintenanceRecordForm.tsx b/frontend/src/features/maintenance/components/MaintenanceRecordForm.tsx index f20aae7..0aff345 100644 --- a/frontend/src/features/maintenance/components/MaintenanceRecordForm.tsx +++ b/frontend/src/features/maintenance/components/MaintenanceRecordForm.tsx @@ -65,9 +65,10 @@ type FormData = z.infer; interface MaintenanceRecordFormProps { vehicleId?: string; + onSuccess?: () => void; } -export const MaintenanceRecordForm: React.FC = ({ vehicleId }) => { +export const MaintenanceRecordForm: React.FC = ({ vehicleId, onSuccess }) => { const { data: vehicles, isLoading: isLoadingVehicles } = useVehicles(); const { createRecord, isRecordMutating } = useMaintenanceRecords(); const [selectedCategory, setSelectedCategory] = useState(null); @@ -211,6 +212,7 @@ export const MaintenanceRecordForm: React.FC = ({ ve await createRecord(payload); toast.success('Maintenance record added successfully'); + onSuccess?.(); // Reset form reset({ diff --git a/frontend/src/features/vehicles/mobile/VehicleDetailMobile.tsx b/frontend/src/features/vehicles/mobile/VehicleDetailMobile.tsx index b1504f8..8dbc6c3 100644 --- a/frontend/src/features/vehicles/mobile/VehicleDetailMobile.tsx +++ b/frontend/src/features/vehicles/mobile/VehicleDetailMobile.tsx @@ -3,13 +3,14 @@ */ import React, { useMemo, useState } from 'react'; -import { Box, Typography, Button, Card, CardContent, Divider, FormControl, InputLabel, Select, MenuItem, List, ListItem, ListItemButton } from '@mui/material'; +import { Box, Typography, Button, Card, CardContent, Divider, FormControl, InputLabel, Select, MenuItem, List, ListItem, ListItemButton, Dialog, DialogTitle, DialogContent } from '@mui/material'; import { useQueryClient } from '@tanstack/react-query'; import { Vehicle } from '../types/vehicles.types'; import { useFuelLogs } from '../../fuel-logs/hooks/useFuelLogs'; import { FuelLogResponse, UpdateFuelLogRequest } from '../../fuel-logs/types/fuel-logs.types'; import { FuelLogEditDialog } from '../../fuel-logs/components/FuelLogEditDialog'; import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api'; +import { MaintenanceRecordForm } from '../../maintenance/components/MaintenanceRecordForm'; import { VehicleImage } from '../components/VehicleImage'; import { OwnershipCostsList } from '../../ownership-costs'; import { getVehicleLabel } from '@/core/utils/vehicleDisplay'; @@ -46,6 +47,7 @@ export const VehicleDetailMobile: React.FC = ({ const { fuelLogs, isLoading: isFuelLoading } = useFuelLogs(vehicle.id); const queryClient = useQueryClient(); const [editingLog, setEditingLog] = useState(null); + const [showMaintenanceDialog, setShowMaintenanceDialog] = useState(false); // Unit conversions are now handled by the backend type VehicleRecord = { @@ -184,8 +186,11 @@ export const VehicleDetailMobile: React.FC = ({ > Add Fuel - @@ -300,6 +305,32 @@ export const VehicleDetailMobile: React.FC = ({ onClose={handleCloseEdit} onSave={handleSaveEdit} /> + + {/* Add Maintenance Dialog */} + setShowMaintenanceDialog(false)} + maxWidth="md" + fullWidth + fullScreen + PaperProps={{ + sx: { maxHeight: '90vh' } + }} + > + Add Maintenance + + + { + setShowMaintenanceDialog(false); + queryClient.invalidateQueries({ queryKey: ['maintenanceRecords', vehicle.id] }); + queryClient.invalidateQueries({ queryKey: ['maintenanceRecords'] }); + }} + /> + + + ); }; diff --git a/frontend/src/features/vehicles/pages/VehicleDetailPage.tsx b/frontend/src/features/vehicles/pages/VehicleDetailPage.tsx index da384cc..a50f37d 100644 --- a/frontend/src/features/vehicles/pages/VehicleDetailPage.tsx +++ b/frontend/src/features/vehicles/pages/VehicleDetailPage.tsx @@ -21,6 +21,7 @@ import { useFuelLogs } from '../../fuel-logs/hooks/useFuelLogs'; import { FuelLogResponse, UpdateFuelLogRequest } from '../../fuel-logs/types/fuel-logs.types'; import { FuelLogEditDialog } from '../../fuel-logs/components/FuelLogEditDialog'; import { FuelLogForm } from '../../fuel-logs/components/FuelLogForm'; +import { MaintenanceRecordForm } from '../../maintenance/components/MaintenanceRecordForm'; // Unit conversions now handled by backend import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api'; import { OwnershipCostsList } from '../../ownership-costs'; @@ -63,6 +64,7 @@ export const VehicleDetailPage: React.FC = () => { const queryClient = useQueryClient(); const [editingLog, setEditingLog] = useState(null); const [showAddDialog, setShowAddDialog] = useState(false); + const [showMaintenanceDialog, setShowMaintenanceDialog] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [documentToDelete, setDocumentToDelete] = useState(null); const isSmallScreen = useMediaQuery('(max-width:600px)'); @@ -354,12 +356,13 @@ export const VehicleDetailPage: React.FC = () => { > Add Fuel Log - } sx={{ borderRadius: '999px' }} + onClick={() => setShowMaintenanceDialog(true)} > - Schedule Maintenance + Add Maintenance @@ -545,6 +548,32 @@ export const VehicleDetailPage: React.FC = () => { + {/* Add Maintenance Dialog */} + setShowMaintenanceDialog(false)} + maxWidth="md" + fullWidth + fullScreen={isSmallScreen} + PaperProps={{ + sx: { maxHeight: '90vh' } + }} + > + Add Maintenance + + + { + setShowMaintenanceDialog(false); + queryClient.invalidateQueries({ queryKey: ['maintenanceRecords', vehicle?.id] }); + queryClient.invalidateQueries({ queryKey: ['maintenanceRecords'] }); + }} + /> + + + + {/* Delete Document Confirmation Dialog */} Date: Sun, 15 Feb 2026 10:48:37 -0600 Subject: [PATCH 11/15] feat: add vehicle health types and roster data hook (refs #197) Co-Authored-By: Claude Opus 4.6 --- .../dashboard/hooks/useDashboardData.ts | 258 ++++++++---------- .../src/features/dashboard/types/index.ts | 32 +-- 2 files changed, 124 insertions(+), 166 deletions(-) diff --git a/frontend/src/features/dashboard/hooks/useDashboardData.ts b/frontend/src/features/dashboard/hooks/useDashboardData.ts index 28dc63a..91fc7d7 100644 --- a/frontend/src/features/dashboard/hooks/useDashboardData.ts +++ b/frontend/src/features/dashboard/hooks/useDashboardData.ts @@ -1,29 +1,93 @@ /** * @ai-summary React Query hooks for dashboard data - * @ai-context Unified data fetching to prevent duplicate API calls + * @ai-context Fetches vehicles, maintenance schedules, and document expiry data + * to compute per-vehicle health indicators for the fleet roster. */ import { useQuery } from '@tanstack/react-query'; import { useAuth0 } from '@auth0/auth0-react'; import { vehiclesApi } from '../../vehicles/api/vehicles.api'; -import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api'; import { maintenanceApi } from '../../maintenance/api/maintenance.api'; -import { DashboardSummary, VehicleNeedingAttention, RecentActivityItem } from '../types'; +import { documentsApi } from '../../documents/api/documents.api'; import { MaintenanceSchedule } from '../../maintenance/types/maintenance.types'; -import { getVehicleLabel } from '@/core/utils/vehicleDisplay'; +import { DocumentRecord } from '../../documents/types/documents.types'; +import { Vehicle } from '../../vehicles/types/vehicles.types'; +import { VehicleHealth, AttentionItem, VehicleRosterData } from '../types'; -/** - * Combined dashboard data structure - */ interface DashboardData { - summary: DashboardSummary; - vehiclesNeedingAttention: VehicleNeedingAttention[]; - recentActivity: RecentActivityItem[]; + vehicles: Vehicle[]; + schedulesByVehicle: Map; + documentsByVehicle: Map; + roster: VehicleRosterData[]; } /** - * Unified hook that fetches all dashboard data in a single query - * Prevents duplicate API calls for vehicles and maintenance schedules + * Compute health status and attention items for a single vehicle. + * Pure function -- no React dependencies, easily unit-testable. + */ +export function computeVehicleHealth( + schedules: MaintenanceSchedule[], + documents: DocumentRecord[], +): { health: VehicleHealth; attentionItems: AttentionItem[] } { + const now = new Date(); + const items: AttentionItem[] = []; + + // Maintenance schedule attention items + for (const schedule of schedules) { + if (!schedule.nextDueDate || !schedule.isActive) continue; + const dueDate = new Date(schedule.nextDueDate); + const daysUntil = Math.floor((dueDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); + const label = schedule.subtypes.length > 0 + ? schedule.subtypes[0] + : schedule.category.replace(/_/g, ' '); + + if (daysUntil < 0) { + items.push({ label, urgency: 'overdue', daysUntilDue: daysUntil, source: 'maintenance' }); + } else if (daysUntil <= 14) { + items.push({ label, urgency: 'due-soon', daysUntilDue: daysUntil, source: 'maintenance' }); + } else if (daysUntil <= 30) { + items.push({ label, urgency: 'upcoming', daysUntilDue: daysUntil, source: 'maintenance' }); + } + } + + // Document expiry attention items (insurance, registration) + for (const doc of documents) { + if (!doc.expirationDate) continue; + const expiryDate = new Date(doc.expirationDate); + const daysUntil = Math.floor((expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); + const label = doc.documentType === 'insurance' ? 'Insurance' : 'Registration'; + + if (daysUntil < 0) { + items.push({ label, urgency: 'overdue', daysUntilDue: daysUntil, source: 'document' }); + } else if (daysUntil <= 14) { + items.push({ label, urgency: 'due-soon', daysUntilDue: daysUntil, source: 'document' }); + } else if (daysUntil <= 30) { + items.push({ label, urgency: 'upcoming', daysUntilDue: daysUntil, source: 'document' }); + } + } + + // Sort: overdue first (most overdue at top), then due-soon by proximity, then upcoming + const urgencyOrder = { overdue: 0, 'due-soon': 1, upcoming: 2 }; + items.sort((a, b) => { + const urgencyDiff = urgencyOrder[a.urgency] - urgencyOrder[b.urgency]; + if (urgencyDiff !== 0) return urgencyDiff; + return a.daysUntilDue - b.daysUntilDue; + }); + + // Determine health color + const hasOverdue = items.some(i => i.urgency === 'overdue'); + const hasDueSoon = items.some(i => i.urgency === 'due-soon'); + + let health: VehicleHealth = 'green'; + if (hasOverdue) health = 'red'; + else if (hasDueSoon) health = 'yellow'; + + return { health, attentionItems: items.slice(0, 3) }; +} + +/** + * Unified hook that fetches all dashboard data in a single query. + * Fetches vehicles, maintenance schedules, and document expiry data. */ export const useDashboardData = () => { const { isAuthenticated, isLoading: authLoading } = useAuth0(); @@ -31,123 +95,57 @@ export const useDashboardData = () => { return useQuery({ queryKey: ['dashboard', 'all'], queryFn: async (): Promise => { - // Fetch vehicles and fuel logs in parallel - const [vehicles, fuelLogs] = await Promise.all([ - vehiclesApi.getAll(), - fuelLogsApi.getUserFuelLogs(), - ]); + // Fetch vehicles first (need IDs for schedule queries) + const vehicles = await vehiclesApi.getAll(); - // Fetch all maintenance schedules in parallel (not sequential!) + // Fetch maintenance schedules per vehicle in parallel const allSchedulesArrays = await Promise.all( vehicles.map(v => maintenanceApi.getSchedulesByVehicle(v.id)) ); - // Create a map of vehicle ID to schedules for efficient lookup const schedulesByVehicle = new Map(); vehicles.forEach((vehicle, index) => { schedulesByVehicle.set(vehicle.id, allSchedulesArrays[index]); }); - const flatSchedules = allSchedulesArrays.flat(); - const now = new Date(); - - // Calculate summary stats - const thirtyDaysFromNow = new Date(); - thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30); - - const upcomingMaintenance = flatSchedules.filter(schedule => { - if (!schedule.nextDueDate) return false; - const dueDate = new Date(schedule.nextDueDate); - return dueDate >= now && dueDate <= thirtyDaysFromNow; - }); - - const sevenDaysAgo = new Date(); - sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); - - const recentFuelLogs = fuelLogs.filter(log => { - const logDate = new Date(log.dateTime); - return logDate >= sevenDaysAgo; - }); - - const summary: DashboardSummary = { - totalVehicles: vehicles.length, - upcomingMaintenanceCount: upcomingMaintenance.length, - recentFuelLogsCount: recentFuelLogs.length, - }; - - // Calculate vehicles needing attention (using already-fetched schedules) - const vehiclesNeedingAttention: VehicleNeedingAttention[] = []; - - for (const vehicle of vehicles) { - const schedules = schedulesByVehicle.get(vehicle.id) || []; - - const overdueSchedules = schedules.filter(schedule => { - if (!schedule.nextDueDate) return false; - const dueDate = new Date(schedule.nextDueDate); - return dueDate < now; - }); - - if (overdueSchedules.length > 0) { - const mostOverdue = overdueSchedules.reduce((oldest, current) => { - const oldestDate = new Date(oldest.nextDueDate!); - const currentDate = new Date(current.nextDueDate!); - return currentDate < oldestDate ? current : oldest; - }); - - const daysOverdue = Math.floor( - (now.getTime() - new Date(mostOverdue.nextDueDate!).getTime()) / (1000 * 60 * 60 * 24) - ); - - let priority: 'high' | 'medium' | 'low' = 'low'; - if (daysOverdue > 30) { - priority = 'high'; - } else if (daysOverdue > 14) { - priority = 'medium'; - } - - vehiclesNeedingAttention.push({ - ...vehicle, - reason: `${overdueSchedules.length} overdue maintenance ${overdueSchedules.length === 1 ? 'item' : 'items'}`, - priority, - }); - } + // Fetch document expiry data (insurance + registration) with graceful degradation + let expiryDocs: DocumentRecord[] = []; + try { + const [insuranceDocs, registrationDocs] = await Promise.all([ + documentsApi.list({ type: 'insurance' }), + documentsApi.list({ type: 'registration' }), + ]); + expiryDocs = [...insuranceDocs, ...registrationDocs] + .filter(d => d.expirationDate != null); + } catch { + // Gracefully degrade: dashboard still works with maintenance-only health data } - // Sort by priority (high -> medium -> low) - const priorityOrder = { high: 0, medium: 1, low: 2 }; - vehiclesNeedingAttention.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]); + // Group documents by vehicleId + const documentsByVehicle = new Map(); + for (const doc of expiryDocs) { + const vehicleId = doc.vehicleId; + if (!documentsByVehicle.has(vehicleId)) { + documentsByVehicle.set(vehicleId, []); + } + documentsByVehicle.get(vehicleId)!.push(doc); + } - // Build recent activity feed - const vehicleMap = new Map(vehicles.map(v => [v.id, v])); + // Compute roster data per vehicle + const roster: VehicleRosterData[] = vehicles.map(vehicle => { + const schedules = schedulesByVehicle.get(vehicle.id) || []; + const documents = documentsByVehicle.get(vehicle.id) || []; + const { health, attentionItems } = computeVehicleHealth(schedules, documents); + return { vehicle, health, attentionItems }; + }); - const fuelActivity: RecentActivityItem[] = recentFuelLogs.map(log => ({ - type: 'fuel' as const, - vehicleId: log.vehicleId, - vehicleName: getVehicleLabel(vehicleMap.get(log.vehicleId)), - description: `Filled ${log.fuelUnits.toFixed(1)} gal at $${log.costPerUnit.toFixed(2)}/gal`, - timestamp: log.dateTime, - })); - - const maintenanceActivity: RecentActivityItem[] = upcomingMaintenance.map(schedule => ({ - type: 'maintenance' as const, - vehicleId: schedule.vehicleId, - vehicleName: getVehicleLabel(vehicleMap.get(schedule.vehicleId)), - description: `${schedule.category.replace(/_/g, ' ')} due${schedule.nextDueDate ? ` ${new Date(schedule.nextDueDate).toLocaleDateString()}` : ''}`, - timestamp: schedule.nextDueDate || now.toISOString(), - })); - - const recentActivity = [...fuelActivity, ...maintenanceActivity] - .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) - .slice(0, 7); - - return { summary, vehiclesNeedingAttention, recentActivity }; + return { vehicles, schedulesByVehicle, documentsByVehicle, roster }; }, enabled: isAuthenticated && !authLoading, - staleTime: 2 * 60 * 1000, // 2 minutes - gcTime: 5 * 60 * 1000, // 5 minutes cache time + staleTime: 2 * 60 * 1000, + gcTime: 5 * 60 * 1000, retry: (failureCount, error: any) => { if (error?.response?.status === 401 && failureCount < 3) { - console.log(`[Mobile Auth] Dashboard retry ${failureCount + 1}/3 for 401 error`); return true; } return false; @@ -157,44 +155,14 @@ export const useDashboardData = () => { }; /** - * Hook to fetch dashboard summary stats - * Derives from unified dashboard data query + * Derived hook returning vehicle roster data for the dashboard grid. */ -export const useDashboardSummary = () => { +export const useVehicleRoster = () => { const { data, isLoading, error, refetch } = useDashboardData(); return { - data: data?.summary, - isLoading, - error, - refetch, - }; -}; - -/** - * Hook to fetch recent activity feed - * Derives from unified dashboard data query - */ -export const useRecentActivity = () => { - const { data, isLoading, error, refetch } = useDashboardData(); - - return { - data: data?.recentActivity, - isLoading, - error, - refetch, - }; -}; - -/** - * Hook to fetch vehicles needing attention (overdue maintenance) - * Derives from unified dashboard data query - */ -export const useVehiclesNeedingAttention = () => { - const { data, isLoading, error, refetch } = useDashboardData(); - - return { - data: data?.vehiclesNeedingAttention, + data: data?.roster, + vehicles: data?.vehicles, isLoading, error, refetch, diff --git a/frontend/src/features/dashboard/types/index.ts b/frontend/src/features/dashboard/types/index.ts index 0992627..11dcc87 100644 --- a/frontend/src/features/dashboard/types/index.ts +++ b/frontend/src/features/dashboard/types/index.ts @@ -4,27 +4,17 @@ import { Vehicle } from '../../vehicles/types/vehicles.types'; -export interface DashboardSummary { - totalVehicles: number; - upcomingMaintenanceCount: number; - recentFuelLogsCount: number; +export type VehicleHealth = 'green' | 'yellow' | 'red'; + +export interface AttentionItem { + label: string; + urgency: 'overdue' | 'due-soon' | 'upcoming'; + daysUntilDue: number; + source: 'maintenance' | 'document'; } -export interface VehicleNeedingAttention extends Vehicle { - reason: string; - priority: 'high' | 'medium' | 'low'; -} - -export interface RecentActivityItem { - type: 'fuel' | 'maintenance'; - vehicleId: string; - vehicleName: string; - description: string; - timestamp: string; -} - -export interface DashboardData { - summary: DashboardSummary; - vehiclesNeedingAttention: VehicleNeedingAttention[]; - recentActivity: RecentActivityItem[]; +export interface VehicleRosterData { + vehicle: Vehicle; + health: VehicleHealth; + attentionItems: AttentionItem[]; } From 505ab8262c22b7856f3e079c2508ccc58cabe7c0 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 15 Feb 2026 10:50:24 -0600 Subject: [PATCH 12/15] feat: add VehicleRosterCard component (refs #198) Co-Authored-By: Claude Opus 4.6 --- .../components/VehicleRosterCard.tsx | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 frontend/src/features/dashboard/components/VehicleRosterCard.tsx diff --git a/frontend/src/features/dashboard/components/VehicleRosterCard.tsx b/frontend/src/features/dashboard/components/VehicleRosterCard.tsx new file mode 100644 index 0000000..41bf831 --- /dev/null +++ b/frontend/src/features/dashboard/components/VehicleRosterCard.tsx @@ -0,0 +1,103 @@ +/** + * @ai-summary Vehicle roster card component for dashboard fleet grid + * Displays vehicle image, health status, attention items, and odometer + */ + +import React from 'react'; +import { clsx } from 'clsx'; +import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard'; +import { VehicleImage } from '../../vehicles/components/VehicleImage'; +import { getVehicleLabel } from '../../../core/utils/vehicleDisplay'; +import { VehicleRosterData, AttentionItem } from '../types'; + +interface VehicleRosterCardProps { + data: VehicleRosterData; + onClick: (vehicleId: string) => void; +} + +const getHealthBadgeClass = (health: VehicleRosterData['health']): string => { + switch (health) { + case 'green': + return 'bg-emerald-500'; + case 'yellow': + return 'bg-amber-500'; + case 'red': + return 'bg-red-500'; + } +}; + +const getAttentionItemClass = (urgency: AttentionItem['urgency']): string => { + switch (urgency) { + case 'overdue': + return 'text-red-600 dark:text-red-400'; + case 'due-soon': + return 'text-amber-600 dark:text-amber-400'; + case 'upcoming': + return 'text-slate-500 dark:text-titanio'; + } +}; + +const formatAttentionStatus = (item: AttentionItem): string => { + if (item.urgency === 'overdue') { + return 'OVERDUE'; + } + return `${item.daysUntilDue} days`; +}; + +export const VehicleRosterCard: React.FC = ({ + data, + onClick, +}) => { + const { vehicle, health, attentionItems } = data; + const displayedItems = attentionItems.slice(0, 3); + + return ( + onClick(vehicle.id)}> + {/* Top row: Image, Label, Health Badge */} +
+ {/* Vehicle image container - clips the built-in mb-2 margin */} +
+ +
+ + {/* Vehicle label */} +
+
+ {getVehicleLabel(vehicle)} +
+
+ + {/* Health badge */} +
+
+ + {/* Attention items */} +
+ {displayedItems.length === 0 ? ( +
+ All clear +
+ ) : ( + displayedItems.map((item, index) => ( +
+ {item.label} - {formatAttentionStatus(item)} +
+ )) + )} +
+ + {/* Odometer */} +
+ {vehicle.odometerReading.toLocaleString()} mi +
+ + ); +}; From 767df9e9f2dde7a92435fcdae112bd97c121dd0e Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 15 Feb 2026 10:50:29 -0600 Subject: [PATCH 13/15] feat: add dashboard ActionBar component (refs #199) Co-Authored-By: Claude Opus 4.6 --- .../dashboard/components/ActionBar.tsx | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 frontend/src/features/dashboard/components/ActionBar.tsx diff --git a/frontend/src/features/dashboard/components/ActionBar.tsx b/frontend/src/features/dashboard/components/ActionBar.tsx new file mode 100644 index 0000000..3464d8b --- /dev/null +++ b/frontend/src/features/dashboard/components/ActionBar.tsx @@ -0,0 +1,38 @@ +/** + * @ai-summary Compact action bar for dashboard with Add Vehicle and Log Fuel buttons + */ + +import React from 'react'; +import Button from '@mui/material/Button'; +import Add from '@mui/icons-material/Add'; +import LocalGasStation from '@mui/icons-material/LocalGasStation'; + +interface ActionBarProps { + onAddVehicle: () => void; + onLogFuel: () => void; +} + +export const ActionBar: React.FC = ({ onAddVehicle, onLogFuel }) => { + return ( +
+ + +
+ ); +}; From 654a7f0fc33b622991faeb73e5010c2af42324dd Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 15 Feb 2026 10:53:35 -0600 Subject: [PATCH 14/15] feat: rewire DashboardScreen with vehicle roster layout (refs #200) Co-Authored-By: Claude Opus 4.6 --- .../dashboard/components/DashboardScreen.tsx | 111 +++++++----- .../dashboard/components/QuickActions.tsx | 167 ------------------ .../dashboard/components/RecentActivity.tsx | 118 ------------- .../dashboard/components/SummaryCards.tsx | 134 -------------- .../dashboard/components/VehicleAttention.tsx | 162 ----------------- .../dashboard/hooks/useDashboardData.ts | 5 +- frontend/src/features/dashboard/index.ts | 10 +- .../dashboard/pages/DashboardPage.tsx | 5 +- 8 files changed, 73 insertions(+), 639 deletions(-) delete mode 100644 frontend/src/features/dashboard/components/QuickActions.tsx delete mode 100644 frontend/src/features/dashboard/components/RecentActivity.tsx delete mode 100644 frontend/src/features/dashboard/components/SummaryCards.tsx delete mode 100644 frontend/src/features/dashboard/components/VehicleAttention.tsx diff --git a/frontend/src/features/dashboard/components/DashboardScreen.tsx b/frontend/src/features/dashboard/components/DashboardScreen.tsx index 01b76aa..ee0fc7c 100644 --- a/frontend/src/features/dashboard/components/DashboardScreen.tsx +++ b/frontend/src/features/dashboard/components/DashboardScreen.tsx @@ -1,47 +1,72 @@ /** - * @ai-summary Main dashboard screen component showing fleet overview + * @ai-summary Main dashboard screen showing vehicle fleet roster with health indicators */ import React, { useState } from 'react'; -import { Box, Dialog, DialogTitle, DialogContent, IconButton, useMediaQuery, useTheme } from '@mui/material'; +import { Box, Dialog, DialogTitle, DialogContent, IconButton, Skeleton, Typography, useMediaQuery, useTheme } from '@mui/material'; import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded'; import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded'; import CloseIcon from '@mui/icons-material/Close'; -import { SummaryCards, SummaryCardsSkeleton } from './SummaryCards'; -import { VehicleAttention, VehicleAttentionSkeleton } from './VehicleAttention'; -import { QuickActions, QuickActionsSkeleton } from './QuickActions'; -import { RecentActivity, RecentActivitySkeleton } from './RecentActivity'; -import { useDashboardSummary, useVehiclesNeedingAttention, useRecentActivity } from '../hooks/useDashboardData'; +import { VehicleRosterCard } from './VehicleRosterCard'; +import { ActionBar } from './ActionBar'; +import { useVehicleRoster } from '../hooks/useDashboardData'; import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard'; import { Button } from '../../../shared-minimal/components/Button'; import { PendingAssociationBanner } from '../../email-ingestion/components/PendingAssociationBanner'; import { PendingAssociationList } from '../../email-ingestion/components/PendingAssociationList'; - import { MobileScreen } from '../../../core/store'; import { Vehicle } from '../../vehicles/types/vehicles.types'; interface DashboardScreenProps { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches navigation store type signature onNavigate?: (screen: MobileScreen, metadata?: Record) => void; onVehicleClick?: (vehicle: Vehicle) => void; onViewMaintenance?: () => void; onAddVehicle?: () => void; } +const RosterSkeleton: React.FC = () => ( +
+ {[0, 1, 2, 3].map(i => ( + +
+ +
+ +
+ +
+
+ + +
+ +
+ ))} +
+); + export const DashboardScreen: React.FC = ({ onNavigate, onVehicleClick, - onViewMaintenance, - onAddVehicle + onAddVehicle, }) => { const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down('sm')); const [showPendingReceipts, setShowPendingReceipts] = useState(false); - const { data: summary, isLoading: summaryLoading, error: summaryError } = useDashboardSummary(); - const { data: vehiclesNeedingAttention, isLoading: attentionLoading, error: attentionError } = useVehiclesNeedingAttention(); - const { data: recentActivity } = useRecentActivity(); + const { data: roster, vehicles, isLoading, error } = useVehicleRoster(); + + const handleAddVehicle = onAddVehicle ?? (() => onNavigate?.('Vehicles')); + const handleLogFuel = () => onNavigate?.('Log Fuel'); + const handleVehicleClick = (vehicleId: string) => { + const vehicle = vehicles?.find(v => v.id === vehicleId); + if (vehicle && onVehicleClick) { + onVehicleClick(vehicle); + } + }; // Error state - if (summaryError || attentionError) { + if (error) { return (
@@ -69,19 +94,21 @@ export const DashboardScreen: React.FC = ({ } // Loading state - if (summaryLoading || attentionLoading || !summary || !vehiclesNeedingAttention) { + if (isLoading || !roster) { return (
- - - - +
+ + Your Fleet + +
+
); } // Empty state - no vehicles - if (summary.totalVehicles === 0) { + if (roster.length === 0) { return (
@@ -98,7 +125,7 @@ export const DashboardScreen: React.FC = ({ @@ -114,32 +141,24 @@ export const DashboardScreen: React.FC = ({ {/* Pending Receipts Banner */} setShowPendingReceipts(true)} /> - {/* Summary Cards */} - + {/* Heading + Action Bar */} +
+ + Your Fleet + + +
- {/* Vehicles Needing Attention */} - {vehiclesNeedingAttention && vehiclesNeedingAttention.length > 0 && ( - { - const vehicle = vehiclesNeedingAttention.find(v => v.id === vehicleId); - if (vehicle && onVehicleClick) { - onVehicleClick(vehicle); - } - }} - /> - )} - - {/* Recent Activity */} - {recentActivity && } - - {/* Quick Actions */} - onNavigate?.('Vehicles'))} - onLogFuel={() => onNavigate?.('Log Fuel')} - onViewMaintenance={onViewMaintenance ?? (() => onNavigate?.('Vehicles'))} - onViewVehicles={() => onNavigate?.('Vehicles')} - /> + {/* Vehicle Roster Grid */} +
+ {roster.map(rosterData => ( + + ))} +
{/* Pending Receipts Dialog */} ; - onClick: () => void; -} - -interface QuickActionsProps { - onAddVehicle: () => void; - onLogFuel: () => void; - onViewMaintenance: () => void; - onViewVehicles: () => void; -} - -export const QuickActions: React.FC = ({ - onAddVehicle, - onLogFuel, - onViewMaintenance, - onViewVehicles, -}) => { - const actions: QuickAction[] = [ - { - id: 'add-vehicle', - title: 'Add Vehicle', - description: 'Register a new vehicle', - icon: DirectionsCarRoundedIcon, - onClick: onAddVehicle, - }, - { - id: 'log-fuel', - title: 'Log Fuel', - description: 'Record a fuel purchase', - icon: LocalGasStationRoundedIcon, - onClick: onLogFuel, - }, - { - id: 'view-maintenance', - title: 'Maintenance', - description: 'View maintenance records', - icon: BuildRoundedIcon, - onClick: onViewMaintenance, - }, - { - id: 'view-vehicles', - title: 'My Vehicles', - description: 'View all vehicles', - icon: FormatListBulletedRoundedIcon, - onClick: onViewVehicles, - }, - ]; - - return ( - -
-

- Quick Actions -

-

- Common tasks and navigation -

-
- -
- {actions.map((action) => { - const IconComponent = action.icon; - return ( - - - - - - - {action.title} - - - {action.description} - - - - ); - })} -
-
- ); -}; - -export const QuickActionsSkeleton: React.FC = () => { - return ( - -
-
-
-
-
- {[1, 2, 3, 4].map((i) => ( -
-
-
-
-
- ))} -
- - ); -}; diff --git a/frontend/src/features/dashboard/components/RecentActivity.tsx b/frontend/src/features/dashboard/components/RecentActivity.tsx deleted file mode 100644 index 51e6b9f..0000000 --- a/frontend/src/features/dashboard/components/RecentActivity.tsx +++ /dev/null @@ -1,118 +0,0 @@ -/** - * @ai-summary Recent activity feed showing latest fuel logs and maintenance events - */ - -import React from 'react'; -import { Box } from '@mui/material'; -import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded'; -import BuildRoundedIcon from '@mui/icons-material/BuildRounded'; -import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard'; -import { RecentActivityItem } from '../types'; - -interface RecentActivityProps { - items: RecentActivityItem[]; -} - -const formatRelativeTime = (timestamp: string): string => { - const now = new Date(); - const date = new Date(timestamp); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMins / 60); - const diffDays = Math.floor(diffHours / 24); - - if (diffDays < 0) { - // Future date (upcoming maintenance) - const absDays = Math.abs(diffDays); - if (absDays === 0) return 'Today'; - if (absDays === 1) return 'Tomorrow'; - return `In ${absDays} days`; - } - if (diffDays === 0) { - if (diffHours === 0) return diffMins <= 1 ? 'Just now' : `${diffMins}m ago`; - return `${diffHours}h ago`; - } - if (diffDays === 1) return 'Yesterday'; - if (diffDays < 7) return `${diffDays}d ago`; - return date.toLocaleDateString(); -}; - -export const RecentActivity: React.FC = ({ items }) => { - if (items.length === 0) { - return ( - -

- Recent Activity -

-

- No recent activity. Start by logging fuel or scheduling maintenance. -

-
- ); - } - - return ( - -

- Recent Activity -

-
- {items.map((item, index) => ( -
- - {item.type === 'fuel' ? ( - - ) : ( - - )} - -
-

- {item.vehicleName} -

-

- {item.description} -

-
- - {formatRelativeTime(item.timestamp)} - -
- ))} -
-
- ); -}; - -export const RecentActivitySkeleton: React.FC = () => { - return ( - -
-
- {[1, 2, 3].map((i) => ( -
-
-
-
-
-
-
- ))} -
- - ); -}; diff --git a/frontend/src/features/dashboard/components/SummaryCards.tsx b/frontend/src/features/dashboard/components/SummaryCards.tsx deleted file mode 100644 index 9b14463..0000000 --- a/frontend/src/features/dashboard/components/SummaryCards.tsx +++ /dev/null @@ -1,134 +0,0 @@ -/** - * @ai-summary Summary cards showing key dashboard metrics - */ - -import React from 'react'; -import { Box } from '@mui/material'; -import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded'; -import BuildRoundedIcon from '@mui/icons-material/BuildRounded'; -import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded'; -import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard'; -import { DashboardSummary } from '../types'; -import { MobileScreen } from '../../../core/store'; - -interface SummaryCardsProps { - summary: DashboardSummary; - onNavigate?: (screen: MobileScreen) => void; -} - -export const SummaryCards: React.FC = ({ summary, onNavigate }) => { - const cards = [ - { - title: 'Total Vehicles', - value: summary.totalVehicles, - icon: DirectionsCarRoundedIcon, - color: 'primary.main', - ctaText: 'Add a vehicle', - ctaScreen: 'Vehicles' as MobileScreen, - }, - { - title: 'Upcoming Maintenance', - value: summary.upcomingMaintenanceCount, - subtitle: 'Next 30 days', - icon: BuildRoundedIcon, - color: 'primary.main', - ctaText: 'Schedule maintenance', - ctaScreen: 'Maintenance' as MobileScreen, - }, - { - title: 'Recent Fuel Logs', - value: summary.recentFuelLogsCount, - subtitle: 'Last 7 days', - icon: LocalGasStationRoundedIcon, - color: 'primary.main', - ctaText: 'Log your first fill-up', - ctaScreen: 'Log Fuel' as MobileScreen, - }, - ]; - - return ( -
- {cards.map((card) => { - const IconComponent = card.icon; - return ( - -
- - - -
-

- {card.title} -

- - {card.value} - - {card.value === 0 && card.ctaText ? ( - onNavigate?.(card.ctaScreen)} - sx={{ - background: 'none', - border: 'none', - padding: 0, - cursor: 'pointer', - color: 'primary.main', - fontSize: '0.75rem', - fontWeight: 500, - mt: 0.5, - '&:hover': { textDecoration: 'underline' }, - }} - > - {card.ctaText} - - ) : card.subtitle ? ( -

- {card.subtitle} -

- ) : null} -
-
-
- ); - })} -
- ); -}; - -export const SummaryCardsSkeleton: React.FC = () => { - return ( -
- {[1, 2, 3].map((i) => ( - -
-
-
-
-
-
-
-
- - ))} -
- ); -}; diff --git a/frontend/src/features/dashboard/components/VehicleAttention.tsx b/frontend/src/features/dashboard/components/VehicleAttention.tsx deleted file mode 100644 index 89fbabc..0000000 --- a/frontend/src/features/dashboard/components/VehicleAttention.tsx +++ /dev/null @@ -1,162 +0,0 @@ -/** - * @ai-summary List of vehicles needing attention (overdue maintenance) - */ - -import React from 'react'; -import { Box, SvgIconProps } from '@mui/material'; -import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded'; -import ErrorRoundedIcon from '@mui/icons-material/ErrorRounded'; -import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded'; -import ScheduleRoundedIcon from '@mui/icons-material/ScheduleRounded'; -import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard'; -import { getVehicleLabel } from '@/core/utils/vehicleDisplay'; -import { VehicleNeedingAttention } from '../types'; - -interface VehicleAttentionProps { - vehicles: VehicleNeedingAttention[]; - onVehicleClick?: (vehicleId: string) => void; -} - -export const VehicleAttention: React.FC = ({ vehicles, onVehicleClick }) => { - if (vehicles.length === 0) { - return ( - -
- - - -

- All Caught Up! -

-

- No vehicles need immediate attention -

-
-
- ); - } - - const priorityConfig: Record }> = { - high: { - color: 'error.main', - icon: ErrorRoundedIcon, - }, - medium: { - color: 'warning.main', - icon: WarningAmberRoundedIcon, - }, - low: { - color: 'info.main', - icon: ScheduleRoundedIcon, - }, - }; - - return ( - -
-

- Needs Attention -

-

- Vehicles with overdue maintenance -

-
- -
- {vehicles.map((vehicle) => { - const config = priorityConfig[vehicle.priority]; - const IconComponent = config.icon; - return ( - onVehicleClick?.(vehicle.id)} - role={onVehicleClick ? 'button' : undefined} - tabIndex={onVehicleClick ? 0 : undefined} - onKeyDown={(e: React.KeyboardEvent) => { - if (onVehicleClick && (e.key === 'Enter' || e.key === ' ')) { - e.preventDefault(); - onVehicleClick(vehicle.id); - } - }} - sx={{ - p: 2, - borderRadius: 3, - bgcolor: 'action.hover', - border: '1px solid', - borderColor: 'divider', - cursor: onVehicleClick ? 'pointer' : 'default', - transition: 'all 0.2s', - '&:hover': onVehicleClick ? { - bgcolor: 'action.selected', - } : {}, - }} - > -
- - - -
- - {getVehicleLabel(vehicle)} - -

- {vehicle.reason} -

- - {vehicle.priority.toUpperCase()} PRIORITY - -
-
-
- ); - })} -
-
- ); -}; - -export const VehicleAttentionSkeleton: React.FC = () => { - return ( - -
-
-
-
-
- {[1, 2].map((i) => ( -
-
-
-
-
-
-
-
-
-
- ))} -
- - ); -}; diff --git a/frontend/src/features/dashboard/hooks/useDashboardData.ts b/frontend/src/features/dashboard/hooks/useDashboardData.ts index 91fc7d7..e9ec5b0 100644 --- a/frontend/src/features/dashboard/hooks/useDashboardData.ts +++ b/frontend/src/features/dashboard/hooks/useDashboardData.ts @@ -144,8 +144,9 @@ export const useDashboardData = () => { enabled: isAuthenticated && !authLoading, staleTime: 2 * 60 * 1000, gcTime: 5 * 60 * 1000, - retry: (failureCount, error: any) => { - if (error?.response?.status === 401 && failureCount < 3) { + retry: (failureCount, error: unknown) => { + const status = (error as { response?: { status?: number } })?.response?.status; + if (status === 401 && failureCount < 3) { return true; } return false; diff --git a/frontend/src/features/dashboard/index.ts b/frontend/src/features/dashboard/index.ts index cac9ef7..cc7963a 100644 --- a/frontend/src/features/dashboard/index.ts +++ b/frontend/src/features/dashboard/index.ts @@ -4,9 +4,7 @@ export { DashboardScreen } from './components/DashboardScreen'; export { DashboardPage } from './pages/DashboardPage'; -export { SummaryCards, SummaryCardsSkeleton } from './components/SummaryCards'; -export { VehicleAttention, VehicleAttentionSkeleton } from './components/VehicleAttention'; -export { QuickActions, QuickActionsSkeleton } from './components/QuickActions'; -export { RecentActivity, RecentActivitySkeleton } from './components/RecentActivity'; -export { useDashboardSummary, useVehiclesNeedingAttention, useRecentActivity } from './hooks/useDashboardData'; -export type { DashboardSummary, VehicleNeedingAttention, RecentActivityItem, DashboardData } from './types'; +export { VehicleRosterCard } from './components/VehicleRosterCard'; +export { ActionBar } from './components/ActionBar'; +export { useVehicleRoster } from './hooks/useDashboardData'; +export type { VehicleHealth, AttentionItem, VehicleRosterData } from './types'; diff --git a/frontend/src/features/dashboard/pages/DashboardPage.tsx b/frontend/src/features/dashboard/pages/DashboardPage.tsx index 39bd4d7..68f7084 100644 --- a/frontend/src/features/dashboard/pages/DashboardPage.tsx +++ b/frontend/src/features/dashboard/pages/DashboardPage.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; -import { Box, Typography } from '@mui/material'; +import { Box } from '@mui/material'; import { DashboardScreen } from '../components/DashboardScreen'; import { MobileScreen } from '../../../core/store'; import { Vehicle } from '../../vehicles/types/vehicles.types'; @@ -49,9 +49,6 @@ export const DashboardPage: React.FC = () => { return ( - - Dashboard - Date: Sun, 15 Feb 2026 11:03:52 -0600 Subject: [PATCH 15/15] test: add dashboard redesign tests (refs #201) Co-Authored-By: Claude Opus 4.6 --- frontend/jest.config.ts | 1 + frontend/setupTests.ts | 4 + frontend/src/core/api/__mocks__/client.ts | 15 + .../components/__tests__/ActionBar.test.tsx | 38 ++ .../__tests__/DashboardScreen.test.tsx | 125 ++++++ .../__tests__/VehicleRosterCard.test.tsx | 117 ++++++ .../hooks/__tests__/useDashboardData.test.ts | 373 ++++++++++++++++++ .../dashboard/hooks/useDashboardData.ts | 69 +--- .../dashboard/utils/computeVehicleHealth.ts | 71 ++++ 9 files changed, 748 insertions(+), 65 deletions(-) create mode 100644 frontend/src/core/api/__mocks__/client.ts create mode 100644 frontend/src/features/dashboard/components/__tests__/ActionBar.test.tsx create mode 100644 frontend/src/features/dashboard/components/__tests__/DashboardScreen.test.tsx create mode 100644 frontend/src/features/dashboard/components/__tests__/VehicleRosterCard.test.tsx create mode 100644 frontend/src/features/dashboard/hooks/__tests__/useDashboardData.test.ts create mode 100644 frontend/src/features/dashboard/utils/computeVehicleHealth.ts diff --git a/frontend/jest.config.ts b/frontend/jest.config.ts index 6ef7f47..4946922 100644 --- a/frontend/jest.config.ts +++ b/frontend/jest.config.ts @@ -17,6 +17,7 @@ const config: Config = { }, moduleNameMapper: { '^@/(.*)$': '/src/$1', + '(.*/core/api/client)$': '/src/core/api/__mocks__/client.ts', '\\.(css|less|scss|sass)$': '/test/__mocks__/styleMock.js', '\\.(svg|png|jpg|jpeg|gif)$': '/test/__mocks__/fileMock.js', }, diff --git a/frontend/setupTests.ts b/frontend/setupTests.ts index d1e03bc..f277bf3 100644 --- a/frontend/setupTests.ts +++ b/frontend/setupTests.ts @@ -1,3 +1,7 @@ // Jest setup for React Testing Library import '@testing-library/jest-dom'; +// Polyfill TextEncoder/TextDecoder for jsdom (required by Auth0 SDK) +import { TextEncoder, TextDecoder } from 'util'; +Object.assign(global, { TextEncoder, TextDecoder }); + diff --git a/frontend/src/core/api/__mocks__/client.ts b/frontend/src/core/api/__mocks__/client.ts new file mode 100644 index 0000000..c4a20ed --- /dev/null +++ b/frontend/src/core/api/__mocks__/client.ts @@ -0,0 +1,15 @@ +/** + * @ai-summary Manual mock for API client used in Jest tests + * Prevents import.meta.env errors in jsdom environment + */ + +export const apiClient = { + get: jest.fn().mockResolvedValue({ data: [] }), + post: jest.fn().mockResolvedValue({ data: {} }), + put: jest.fn().mockResolvedValue({ data: {} }), + delete: jest.fn().mockResolvedValue({}), + interceptors: { + request: { use: jest.fn() }, + response: { use: jest.fn() }, + }, +}; diff --git a/frontend/src/features/dashboard/components/__tests__/ActionBar.test.tsx b/frontend/src/features/dashboard/components/__tests__/ActionBar.test.tsx new file mode 100644 index 0000000..a9f11fd --- /dev/null +++ b/frontend/src/features/dashboard/components/__tests__/ActionBar.test.tsx @@ -0,0 +1,38 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { ActionBar } from '../ActionBar'; + +describe('ActionBar', () => { + it('renders both buttons with correct text', () => { + const onAddVehicle = jest.fn(); + const onLogFuel = jest.fn(); + + render(); + + expect(screen.getByText('Add Vehicle')).toBeInTheDocument(); + expect(screen.getByText('Log Fuel')).toBeInTheDocument(); + }); + + it('calls onAddVehicle when Add Vehicle button clicked', () => { + const onAddVehicle = jest.fn(); + const onLogFuel = jest.fn(); + + render(); + + const addVehicleButton = screen.getByText('Add Vehicle'); + fireEvent.click(addVehicleButton); + + expect(onAddVehicle).toHaveBeenCalledTimes(1); + }); + + it('calls onLogFuel when Log Fuel button clicked', () => { + const onAddVehicle = jest.fn(); + const onLogFuel = jest.fn(); + + render(); + + const logFuelButton = screen.getByText('Log Fuel'); + fireEvent.click(logFuelButton); + + expect(onLogFuel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/features/dashboard/components/__tests__/DashboardScreen.test.tsx b/frontend/src/features/dashboard/components/__tests__/DashboardScreen.test.tsx new file mode 100644 index 0000000..09b8ee8 --- /dev/null +++ b/frontend/src/features/dashboard/components/__tests__/DashboardScreen.test.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { ThemeProvider, createTheme } from '@mui/material'; +import { DashboardScreen } from '../DashboardScreen'; +import { Vehicle } from '../../../vehicles/types/vehicles.types'; +import { VehicleRosterData } from '../../types'; +import { useVehicleRoster } from '../../hooks/useDashboardData'; + +jest.mock('@auth0/auth0-react'); +jest.mock('../../../../core/api/client'); +jest.mock('../../../vehicles/api/vehicles.api'); +jest.mock('../../../maintenance/api/maintenance.api'); +jest.mock('../../../documents/api/documents.api'); +jest.mock('../../../vehicles/components/VehicleImage', () => ({ + VehicleImage: () =>
, +})); +jest.mock('../../../email-ingestion/components/PendingAssociationBanner', () => ({ + PendingAssociationBanner: () => null, +})); +jest.mock('../../../email-ingestion/components/PendingAssociationList', () => ({ + PendingAssociationList: () => null, +})); +jest.mock('../../hooks/useDashboardData'); + +const mockUseVehicleRoster = useVehicleRoster as jest.MockedFunction; + +const makeVehicle = (overrides: Partial = {}): Vehicle => ({ + id: 'vehicle-1', + userId: 'user-1', + vin: '1HGBH41JXMN109186', + year: 2019, + make: 'Ford', + model: 'F-150', + odometerReading: 87412, + isActive: true, + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', + ...overrides, +}); + +const makeRosterData = (vehicle?: Vehicle): VehicleRosterData => ({ + vehicle: vehicle ?? makeVehicle(), + health: 'green' as const, + attentionItems: [], +}); + +const theme = createTheme(); + +const renderWithProviders = (ui: React.ReactElement) => { + return render( + + {ui} + + ); +}; + +describe('DashboardScreen', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders vehicle roster cards', () => { + const vehicle1 = makeVehicle({ id: 'v1', make: 'Ford', model: 'F-150', year: 2019 }); + const vehicle2 = makeVehicle({ id: 'v2', make: 'Honda', model: 'Civic', year: 2020 }); + const roster = [makeRosterData(vehicle1), makeRosterData(vehicle2)]; + + mockUseVehicleRoster.mockReturnValue({ + data: roster, + vehicles: [vehicle1, vehicle2], + isLoading: false, + error: null, + refetch: jest.fn(), + }); + + renderWithProviders(); + + expect(screen.getByText('2019 Ford F-150')).toBeInTheDocument(); + expect(screen.getByText('2020 Honda Civic')).toBeInTheDocument(); + }); + + it('renders empty state when 0 vehicles', () => { + mockUseVehicleRoster.mockReturnValue({ + data: [], + vehicles: [], + isLoading: false, + error: null, + refetch: jest.fn(), + }); + + renderWithProviders(); + + expect(screen.getByText('Welcome to MotoVaultPro')).toBeInTheDocument(); + }); + + it('renders loading skeletons when loading', () => { + mockUseVehicleRoster.mockReturnValue({ + data: undefined, + vehicles: undefined, + isLoading: true, + error: null, + refetch: jest.fn(), + }); + + renderWithProviders(); + + expect(screen.getByText('Your Fleet')).toBeInTheDocument(); + }); + + it('renders "Your Fleet" heading', () => { + const vehicle = makeVehicle(); + const roster = [makeRosterData(vehicle)]; + + mockUseVehicleRoster.mockReturnValue({ + data: roster, + vehicles: [vehicle], + isLoading: false, + error: null, + refetch: jest.fn(), + }); + + renderWithProviders(); + + expect(screen.getByText('Your Fleet')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/features/dashboard/components/__tests__/VehicleRosterCard.test.tsx b/frontend/src/features/dashboard/components/__tests__/VehicleRosterCard.test.tsx new file mode 100644 index 0000000..5020df4 --- /dev/null +++ b/frontend/src/features/dashboard/components/__tests__/VehicleRosterCard.test.tsx @@ -0,0 +1,117 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { VehicleRosterCard } from '../VehicleRosterCard'; +import { Vehicle } from '../../../vehicles/types/vehicles.types'; +import { VehicleRosterData, AttentionItem } from '../../types'; + +jest.mock('@auth0/auth0-react'); +jest.mock('../../../vehicles/components/VehicleImage', () => ({ + VehicleImage: () =>
, +})); + +const makeVehicle = (overrides: Partial = {}): Vehicle => ({ + id: 'vehicle-1', + userId: 'user-1', + vin: '1HGBH41JXMN109186', + year: 2019, + make: 'Ford', + model: 'F-150', + odometerReading: 87412, + isActive: true, + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', + ...overrides, +}); + +const makeRosterData = (overrides: Partial = {}): VehicleRosterData => ({ + vehicle: makeVehicle(), + health: 'green', + attentionItems: [], + ...overrides, +}); + +describe('VehicleRosterCard', () => { + it('renders vehicle label with year make model', () => { + const data = makeRosterData(); + const onClick = jest.fn(); + + render(); + + expect(screen.getByText('2019 Ford F-150')).toBeInTheDocument(); + }); + + it('renders health badge with correct color class for green health', () => { + const data = makeRosterData({ health: 'green' }); + const onClick = jest.fn(); + + const { container } = render(); + + const badge = container.querySelector('.bg-emerald-500'); + expect(badge).toBeInTheDocument(); + }); + + it('renders health badge with correct color class for yellow health', () => { + const data = makeRosterData({ health: 'yellow' }); + const onClick = jest.fn(); + + const { container } = render(); + + const badge = container.querySelector('.bg-amber-500'); + expect(badge).toBeInTheDocument(); + }); + + it('renders health badge with correct color class for red health', () => { + const data = makeRosterData({ health: 'red' }); + const onClick = jest.fn(); + + const { container } = render(); + + const badge = container.querySelector('.bg-red-500'); + expect(badge).toBeInTheDocument(); + }); + + it('renders attention items text', () => { + const attentionItems: AttentionItem[] = [ + { + label: 'Oil Change', + urgency: 'overdue', + daysUntilDue: -5, + source: 'maintenance', + }, + ]; + const data = makeRosterData({ attentionItems }); + const onClick = jest.fn(); + + render(); + + expect(screen.getByText('Oil Change - OVERDUE')).toBeInTheDocument(); + }); + + it('renders odometer with formatting', () => { + const data = makeRosterData(); + const onClick = jest.fn(); + + render(); + + expect(screen.getByText('87,412 mi')).toBeInTheDocument(); + }); + + it('calls onClick with vehicle ID when clicked', () => { + const data = makeRosterData(); + const onClick = jest.fn(); + + render(); + + fireEvent.click(screen.getByText('2019 Ford F-150')); + + expect(onClick).toHaveBeenCalledWith('vehicle-1'); + }); + + it('renders All clear when no attention items', () => { + const data = makeRosterData({ attentionItems: [] }); + const onClick = jest.fn(); + + render(); + + expect(screen.getByText('All clear')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/features/dashboard/hooks/__tests__/useDashboardData.test.ts b/frontend/src/features/dashboard/hooks/__tests__/useDashboardData.test.ts new file mode 100644 index 0000000..c3b14d9 --- /dev/null +++ b/frontend/src/features/dashboard/hooks/__tests__/useDashboardData.test.ts @@ -0,0 +1,373 @@ +/** + * @ai-summary Unit tests for computeVehicleHealth pure function + * @ai-context Tests health calculation logic from maintenance schedules and document expiry + */ + +import { computeVehicleHealth } from '../../utils/computeVehicleHealth'; +import { MaintenanceSchedule } from '../../../maintenance/types/maintenance.types'; +import { DocumentRecord } from '../../../documents/types/documents.types'; + +// Helper factory functions for test data +const makeSchedule = (overrides: Partial = {}): MaintenanceSchedule => ({ + id: 'sched-1', + userId: 'user-1', + vehicleId: 'vehicle-1', + category: 'routine_maintenance', + subtypes: ['Engine Oil'], + scheduleType: 'interval', + isActive: true, + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', + ...overrides, +}); + +const makeDocument = (overrides: Partial = {}): DocumentRecord => ({ + id: 'doc-1', + userId: 'user-1', + vehicleId: 'vehicle-1', + documentType: 'insurance', + title: 'Insurance Policy', + sharedVehicleIds: [], + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', + ...overrides, +}); + +describe('computeVehicleHealth', () => { + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-02-15T00:00:00Z')); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + describe('Green health', () => { + it('should return green health with no schedules and no documents', () => { + const { health, attentionItems } = computeVehicleHealth([], []); + + expect(health).toBe('green'); + expect(attentionItems).toEqual([]); + }); + + it('should return green health with schedule due in 20 days and 1 upcoming attention item', () => { + const schedules = [ + makeSchedule({ + nextDueDate: '2026-03-07T00:00:00Z', // 20 days from now + subtypes: ['Engine Oil'], + }), + ]; + + const { health, attentionItems } = computeVehicleHealth(schedules, []); + + expect(health).toBe('green'); + expect(attentionItems).toHaveLength(1); + expect(attentionItems[0]).toEqual({ + label: 'Engine Oil', + urgency: 'upcoming', + daysUntilDue: 20, + source: 'maintenance', + }); + }); + }); + + describe('Yellow health', () => { + it('should return yellow health with schedule due in 10 days, no overdue', () => { + const schedules = [ + makeSchedule({ + nextDueDate: '2026-02-25T00:00:00Z', // 10 days from now + subtypes: ['Air Filter Element'], + }), + ]; + + const { health, attentionItems } = computeVehicleHealth(schedules, []); + + expect(health).toBe('yellow'); + expect(attentionItems).toHaveLength(1); + expect(attentionItems[0]).toEqual({ + label: 'Air Filter Element', + urgency: 'due-soon', + daysUntilDue: 10, + source: 'maintenance', + }); + }); + + it('should return yellow health with registration expiring in 7 days', () => { + const documents = [ + makeDocument({ + documentType: 'registration', + expirationDate: '2026-02-22T00:00:00Z', // 7 days from now + }), + ]; + + const { health, attentionItems } = computeVehicleHealth([], documents); + + expect(health).toBe('yellow'); + expect(attentionItems).toHaveLength(1); + expect(attentionItems[0]).toEqual({ + label: 'Registration', + urgency: 'due-soon', + daysUntilDue: 7, + source: 'document', + }); + }); + }); + + describe('Red health', () => { + it('should return red health with maintenance overdue by 5 days', () => { + const schedules = [ + makeSchedule({ + nextDueDate: '2026-02-10T00:00:00Z', // 5 days ago + subtypes: ['Brakes and Traction Control'], + }), + ]; + + const { health, attentionItems } = computeVehicleHealth(schedules, []); + + expect(health).toBe('red'); + expect(attentionItems).toHaveLength(1); + expect(attentionItems[0]).toEqual({ + label: 'Brakes and Traction Control', + urgency: 'overdue', + daysUntilDue: -5, + source: 'maintenance', + }); + }); + + it('should return red health with insurance expired 3 days ago', () => { + const documents = [ + makeDocument({ + documentType: 'insurance', + expirationDate: '2026-02-12T00:00:00Z', // 3 days ago + }), + ]; + + const { health, attentionItems } = computeVehicleHealth([], documents); + + expect(health).toBe('red'); + expect(attentionItems).toHaveLength(1); + expect(attentionItems[0]).toEqual({ + label: 'Insurance', + urgency: 'overdue', + daysUntilDue: -3, + source: 'document', + }); + }); + + it('should return red health with one overdue maintenance and one due-soon document', () => { + const schedules = [ + makeSchedule({ + nextDueDate: '2026-02-10T00:00:00Z', // 5 days ago + subtypes: ['Coolant'], + }), + ]; + + const documents = [ + makeDocument({ + documentType: 'registration', + expirationDate: '2026-02-20T00:00:00Z', // 5 days from now + }), + ]; + + const { health, attentionItems } = computeVehicleHealth(schedules, documents); + + expect(health).toBe('red'); + expect(attentionItems).toHaveLength(2); + expect(attentionItems[0]).toEqual({ + label: 'Coolant', + urgency: 'overdue', + daysUntilDue: -5, + source: 'maintenance', + }); + expect(attentionItems[1]).toEqual({ + label: 'Registration', + urgency: 'due-soon', + daysUntilDue: 5, + source: 'document', + }); + }); + }); + + describe('Attention items sorting', () => { + it('should sort attention items with overdue first by most overdue, then due-soon by proximity', () => { + const schedules = [ + makeSchedule({ + id: 'sched-1', + nextDueDate: '2026-02-13T00:00:00Z', // 2 days ago (overdue, less urgent) + subtypes: ['Cabin Air Filter / Purifier'], + }), + makeSchedule({ + id: 'sched-2', + nextDueDate: '2026-02-05T00:00:00Z', // 10 days ago (overdue, more urgent) + subtypes: ['Engine Oil'], + }), + makeSchedule({ + id: 'sched-3', + nextDueDate: '2026-02-20T00:00:00Z', // 5 days from now (due-soon) + subtypes: ['Wiper Blade'], + }), + makeSchedule({ + id: 'sched-4', + nextDueDate: '2026-02-17T00:00:00Z', // 2 days from now (due-soon, more urgent) + subtypes: ['Brakes and Traction Control'], + }), + ]; + + const { attentionItems } = computeVehicleHealth(schedules, []); + + expect(attentionItems).toHaveLength(3); // Max 3 items + expect(attentionItems[0]).toEqual({ + label: 'Engine Oil', + urgency: 'overdue', + daysUntilDue: -10, + source: 'maintenance', + }); + expect(attentionItems[1]).toEqual({ + label: 'Cabin Air Filter / Purifier', + urgency: 'overdue', + daysUntilDue: -2, + source: 'maintenance', + }); + expect(attentionItems[2]).toEqual({ + label: 'Brakes and Traction Control', + urgency: 'due-soon', + daysUntilDue: 2, + source: 'maintenance', + }); + }); + }); + + describe('Max 3 attention items enforcement', () => { + it('should enforce max 3 attention items when 5 items are present', () => { + const schedules = [ + makeSchedule({ + id: 'sched-1', + nextDueDate: '2026-02-05T00:00:00Z', // 10 days ago + subtypes: ['Item 1'], + }), + makeSchedule({ + id: 'sched-2', + nextDueDate: '2026-02-08T00:00:00Z', // 7 days ago + subtypes: ['Item 2'], + }), + makeSchedule({ + id: 'sched-3', + nextDueDate: '2026-02-10T00:00:00Z', // 5 days ago + subtypes: ['Item 3'], + }), + makeSchedule({ + id: 'sched-4', + nextDueDate: '2026-02-12T00:00:00Z', // 3 days ago + subtypes: ['Item 4'], + }), + makeSchedule({ + id: 'sched-5', + nextDueDate: '2026-02-14T00:00:00Z', // 1 day ago + subtypes: ['Item 5'], + }), + ]; + + const { attentionItems } = computeVehicleHealth(schedules, []); + + expect(attentionItems).toHaveLength(3); + expect(attentionItems[0].label).toBe('Item 1'); // Most overdue + expect(attentionItems[1].label).toBe('Item 2'); + expect(attentionItems[2].label).toBe('Item 3'); + }); + }); + + describe('Inactive schedule handling', () => { + it('should ignore inactive schedules (isActive: false)', () => { + const schedules = [ + makeSchedule({ + nextDueDate: '2026-02-10T00:00:00Z', // 5 days ago (overdue) + subtypes: ['Ignored Item'], + isActive: false, + }), + makeSchedule({ + nextDueDate: '2026-02-20T00:00:00Z', // 5 days from now + subtypes: ['Active Item'], + isActive: true, + }), + ]; + + const { health, attentionItems } = computeVehicleHealth(schedules, []); + + expect(health).toBe('yellow'); + expect(attentionItems).toHaveLength(1); + expect(attentionItems[0].label).toBe('Active Item'); + }); + }); + + describe('Missing date handling', () => { + it('should ignore schedules without nextDueDate', () => { + const schedules = [ + makeSchedule({ + nextDueDate: undefined, + subtypes: ['No Due Date'], + isActive: true, + }), + makeSchedule({ + nextDueDate: '2026-02-20T00:00:00Z', // 5 days from now + subtypes: ['With Due Date'], + isActive: true, + }), + ]; + + const { health, attentionItems } = computeVehicleHealth(schedules, []); + + expect(health).toBe('yellow'); + expect(attentionItems).toHaveLength(1); + expect(attentionItems[0].label).toBe('With Due Date'); + }); + + it('should ignore documents without expirationDate', () => { + const documents = [ + makeDocument({ + documentType: 'manual', + expirationDate: null, + }), + makeDocument({ + documentType: 'insurance', + expirationDate: '2026-02-20T00:00:00Z', // 5 days from now + }), + ]; + + const { health, attentionItems } = computeVehicleHealth([], documents); + + expect(health).toBe('yellow'); + expect(attentionItems).toHaveLength(1); + expect(attentionItems[0].label).toBe('Insurance'); + }); + }); + + describe('Label extraction', () => { + it('should use first subtype as label when subtypes array is not empty', () => { + const schedules = [ + makeSchedule({ + nextDueDate: '2026-02-20T00:00:00Z', + subtypes: ['Air Filter Element', 'Engine Oil'], + }), + ]; + + const { attentionItems } = computeVehicleHealth(schedules, []); + + expect(attentionItems[0].label).toBe('Air Filter Element'); + }); + + it('should use formatted category as label when subtypes array is empty', () => { + const schedules = [ + makeSchedule({ + nextDueDate: '2026-02-20T00:00:00Z', + category: 'routine_maintenance', + subtypes: [], + }), + ]; + + const { attentionItems } = computeVehicleHealth(schedules, []); + + expect(attentionItems[0].label).toBe('routine maintenance'); + }); + }); +}); diff --git a/frontend/src/features/dashboard/hooks/useDashboardData.ts b/frontend/src/features/dashboard/hooks/useDashboardData.ts index e9ec5b0..9983861 100644 --- a/frontend/src/features/dashboard/hooks/useDashboardData.ts +++ b/frontend/src/features/dashboard/hooks/useDashboardData.ts @@ -12,7 +12,10 @@ import { documentsApi } from '../../documents/api/documents.api'; import { MaintenanceSchedule } from '../../maintenance/types/maintenance.types'; import { DocumentRecord } from '../../documents/types/documents.types'; import { Vehicle } from '../../vehicles/types/vehicles.types'; -import { VehicleHealth, AttentionItem, VehicleRosterData } from '../types'; +import { VehicleRosterData } from '../types'; +import { computeVehicleHealth } from '../utils/computeVehicleHealth'; + +export { computeVehicleHealth }; interface DashboardData { vehicles: Vehicle[]; @@ -21,70 +24,6 @@ interface DashboardData { roster: VehicleRosterData[]; } -/** - * Compute health status and attention items for a single vehicle. - * Pure function -- no React dependencies, easily unit-testable. - */ -export function computeVehicleHealth( - schedules: MaintenanceSchedule[], - documents: DocumentRecord[], -): { health: VehicleHealth; attentionItems: AttentionItem[] } { - const now = new Date(); - const items: AttentionItem[] = []; - - // Maintenance schedule attention items - for (const schedule of schedules) { - if (!schedule.nextDueDate || !schedule.isActive) continue; - const dueDate = new Date(schedule.nextDueDate); - const daysUntil = Math.floor((dueDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); - const label = schedule.subtypes.length > 0 - ? schedule.subtypes[0] - : schedule.category.replace(/_/g, ' '); - - if (daysUntil < 0) { - items.push({ label, urgency: 'overdue', daysUntilDue: daysUntil, source: 'maintenance' }); - } else if (daysUntil <= 14) { - items.push({ label, urgency: 'due-soon', daysUntilDue: daysUntil, source: 'maintenance' }); - } else if (daysUntil <= 30) { - items.push({ label, urgency: 'upcoming', daysUntilDue: daysUntil, source: 'maintenance' }); - } - } - - // Document expiry attention items (insurance, registration) - for (const doc of documents) { - if (!doc.expirationDate) continue; - const expiryDate = new Date(doc.expirationDate); - const daysUntil = Math.floor((expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); - const label = doc.documentType === 'insurance' ? 'Insurance' : 'Registration'; - - if (daysUntil < 0) { - items.push({ label, urgency: 'overdue', daysUntilDue: daysUntil, source: 'document' }); - } else if (daysUntil <= 14) { - items.push({ label, urgency: 'due-soon', daysUntilDue: daysUntil, source: 'document' }); - } else if (daysUntil <= 30) { - items.push({ label, urgency: 'upcoming', daysUntilDue: daysUntil, source: 'document' }); - } - } - - // Sort: overdue first (most overdue at top), then due-soon by proximity, then upcoming - const urgencyOrder = { overdue: 0, 'due-soon': 1, upcoming: 2 }; - items.sort((a, b) => { - const urgencyDiff = urgencyOrder[a.urgency] - urgencyOrder[b.urgency]; - if (urgencyDiff !== 0) return urgencyDiff; - return a.daysUntilDue - b.daysUntilDue; - }); - - // Determine health color - const hasOverdue = items.some(i => i.urgency === 'overdue'); - const hasDueSoon = items.some(i => i.urgency === 'due-soon'); - - let health: VehicleHealth = 'green'; - if (hasOverdue) health = 'red'; - else if (hasDueSoon) health = 'yellow'; - - return { health, attentionItems: items.slice(0, 3) }; -} - /** * Unified hook that fetches all dashboard data in a single query. * Fetches vehicles, maintenance schedules, and document expiry data. diff --git a/frontend/src/features/dashboard/utils/computeVehicleHealth.ts b/frontend/src/features/dashboard/utils/computeVehicleHealth.ts new file mode 100644 index 0000000..4dc7f0d --- /dev/null +++ b/frontend/src/features/dashboard/utils/computeVehicleHealth.ts @@ -0,0 +1,71 @@ +/** + * @ai-summary Pure function to compute per-vehicle health status from maintenance and document data + */ + +import { MaintenanceSchedule } from '../../maintenance/types/maintenance.types'; +import { DocumentRecord } from '../../documents/types/documents.types'; +import { VehicleHealth, AttentionItem } from '../types'; + +/** + * Compute health status and attention items for a single vehicle. + * Pure function -- no React dependencies, easily unit-testable. + */ +export function computeVehicleHealth( + schedules: MaintenanceSchedule[], + documents: DocumentRecord[], +): { health: VehicleHealth; attentionItems: AttentionItem[] } { + const now = new Date(); + const items: AttentionItem[] = []; + + // Maintenance schedule attention items + for (const schedule of schedules) { + if (!schedule.nextDueDate || !schedule.isActive) continue; + const dueDate = new Date(schedule.nextDueDate); + const daysUntil = Math.floor((dueDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); + const label = schedule.subtypes.length > 0 + ? schedule.subtypes[0] + : schedule.category.replace(/_/g, ' '); + + if (daysUntil < 0) { + items.push({ label, urgency: 'overdue', daysUntilDue: daysUntil, source: 'maintenance' }); + } else if (daysUntil <= 14) { + items.push({ label, urgency: 'due-soon', daysUntilDue: daysUntil, source: 'maintenance' }); + } else if (daysUntil <= 30) { + items.push({ label, urgency: 'upcoming', daysUntilDue: daysUntil, source: 'maintenance' }); + } + } + + // Document expiry attention items (insurance, registration) + for (const doc of documents) { + if (!doc.expirationDate) continue; + const expiryDate = new Date(doc.expirationDate); + const daysUntil = Math.floor((expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); + const label = doc.documentType === 'insurance' ? 'Insurance' : 'Registration'; + + if (daysUntil < 0) { + items.push({ label, urgency: 'overdue', daysUntilDue: daysUntil, source: 'document' }); + } else if (daysUntil <= 14) { + items.push({ label, urgency: 'due-soon', daysUntilDue: daysUntil, source: 'document' }); + } else if (daysUntil <= 30) { + items.push({ label, urgency: 'upcoming', daysUntilDue: daysUntil, source: 'document' }); + } + } + + // Sort: overdue first (most overdue at top), then due-soon by proximity, then upcoming + const urgencyOrder = { overdue: 0, 'due-soon': 1, upcoming: 2 }; + items.sort((a, b) => { + const urgencyDiff = urgencyOrder[a.urgency] - urgencyOrder[b.urgency]; + if (urgencyDiff !== 0) return urgencyDiff; + return a.daysUntilDue - b.daysUntilDue; + }); + + // Determine health color + const hasOverdue = items.some(i => i.urgency === 'overdue'); + const hasDueSoon = items.some(i => i.urgency === 'due-soon'); + + let health: VehicleHealth = 'green'; + if (hasOverdue) health = 'red'; + else if (hasDueSoon) health = 'yellow'; + + return { health, attentionItems: items.slice(0, 3) }; +}