Merge pull request 'perf: fix dashboard load performance (#45)' (#46) from issue-45-dashboard-performance into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 25s
Deploy to Staging / Deploy to Staging (push) Successful in 28s
Deploy to Staging / Verify Staging (push) Successful in 7s
Deploy to Staging / Notify Staging Ready (push) Successful in 6s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
All checks were successful
Deploy to Staging / Build Images (push) Successful in 25s
Deploy to Staging / Deploy to Staging (push) Successful in 28s
Deploy to Staging / Verify Staging (push) Successful in 7s
Deploy to Staging / Notify Staging Ready (push) Successful in 6s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Reviewed-on: #46
This commit was merged in pull request #46.
This commit is contained in:
@@ -180,12 +180,15 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
console.warn('[Auth] IndexedDB not ready, proceeding anyway:', error);
|
console.warn('[Auth] IndexedDB not ready, proceeding anyway:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Give Auth0 more time to fully initialize on mobile devices
|
// 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);
|
const isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||||
const initDelay = isMobile ? 500 : 100; // Longer delay for mobile
|
if (isMobile) {
|
||||||
|
// Small delay for mobile browsers to settle after IndexedDB init
|
||||||
console.log(`[Mobile Auth] Initializing token cache (mobile: ${isMobile}, delay: ${initDelay}ms)`);
|
console.log('[Mobile Auth] Initializing token cache (mobile: true, delay: 50ms)');
|
||||||
await new Promise(resolve => setTimeout(resolve, initDelay));
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
} else {
|
||||||
|
console.log('[Auth] Initializing token cache (desktop, no delay)');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = await getTokenWithRetry();
|
const token = await getTokenWithRetry();
|
||||||
|
|||||||
@@ -10,6 +10,21 @@ let authInitialized = false;
|
|||||||
let authInitPromise: Promise<void> | null = null;
|
let authInitPromise: Promise<void> | null = null;
|
||||||
let resolveAuthInit: (() => void) | null = null;
|
let resolveAuthInit: (() => void) | null = null;
|
||||||
|
|
||||||
|
// Subscription-based state change notification (replaces polling)
|
||||||
|
type AuthStateListener = (initialized: boolean) => void;
|
||||||
|
const listeners: Set<AuthStateListener> = new Set();
|
||||||
|
|
||||||
|
const notifyListeners = (initialized: boolean) => {
|
||||||
|
listeners.forEach(listener => listener(initialized));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const subscribeToAuthState = (listener: AuthStateListener): (() => void) => {
|
||||||
|
listeners.add(listener);
|
||||||
|
// Immediately notify of current state
|
||||||
|
listener(authInitialized);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
};
|
||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
console.log('[Auth Gate] Module loaded, authInitialized:', authInitialized);
|
console.log('[Auth Gate] Module loaded, authInitialized:', authInitialized);
|
||||||
|
|
||||||
@@ -41,6 +56,9 @@ export const setAuthInitialized = (initialized: boolean) => {
|
|||||||
console.log('[DEBUG] setAuthInitialized called with:', initialized, '(was:', authInitialized, ')');
|
console.log('[DEBUG] setAuthInitialized called with:', initialized, '(was:', authInitialized, ')');
|
||||||
authInitialized = initialized;
|
authInitialized = initialized;
|
||||||
|
|
||||||
|
// Notify all subscribers of state change
|
||||||
|
notifyListeners(initialized);
|
||||||
|
|
||||||
if (initialized) {
|
if (initialized) {
|
||||||
console.log('[DEBUG Auth Gate] Authentication fully initialized');
|
console.log('[DEBUG Auth Gate] Authentication fully initialized');
|
||||||
|
|
||||||
@@ -107,48 +125,21 @@ const processRequestQueue = async () => {
|
|||||||
/**
|
/**
|
||||||
* React hook to track auth initialization state
|
* React hook to track auth initialization state
|
||||||
* Returns true once auth is fully initialized with token
|
* Returns true once auth is fully initialized with token
|
||||||
* Uses polling with exponential backoff to detect state changes
|
* Uses subscription-based notification for immediate state changes
|
||||||
*/
|
*/
|
||||||
export const useIsAuthInitialized = () => {
|
export const useIsAuthInitialized = () => {
|
||||||
const [initialized, setInitialized] = useState(isAuthInitialized());
|
const [initialized, setInitialized] = useState(isAuthInitialized());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If already initialized, no need to wait
|
// Subscribe to auth state changes
|
||||||
if (isAuthInitialized()) {
|
const unsubscribe = subscribeToAuthState((isInit) => {
|
||||||
console.log('[useIsAuthInitialized] Already initialized');
|
|
||||||
setInitialized(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Poll for initialization with exponential backoff
|
|
||||||
console.log('[useIsAuthInitialized] Starting poll for auth init');
|
|
||||||
let pollCount = 0;
|
|
||||||
const maxPolls = 50; // 5 seconds with exponential backoff
|
|
||||||
|
|
||||||
const pollAuthInit = () => {
|
|
||||||
pollCount++;
|
|
||||||
const isInit = isAuthInitialized();
|
|
||||||
console.log(`[useIsAuthInitialized] Poll #${pollCount}: initialized=${isInit}`);
|
|
||||||
|
|
||||||
if (isInit) {
|
if (isInit) {
|
||||||
console.log('[useIsAuthInitialized] Auth initialized via poll!');
|
console.log('[useIsAuthInitialized] Auth initialized via subscription');
|
||||||
setInitialized(true);
|
setInitialized(true);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (pollCount >= maxPolls) {
|
return unsubscribe;
|
||||||
console.warn('[useIsAuthInitialized] Max polls reached, assuming initialized');
|
|
||||||
setInitialized(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exponential backoff: 50ms, 100ms, 200ms, 400ms, etc.
|
|
||||||
const delay = Math.min(50 * Math.pow(1.5, pollCount - 1), 2000);
|
|
||||||
setTimeout(pollAuthInit, delay);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Start polling after a small delay to let TokenInjector run
|
|
||||||
setTimeout(pollAuthInit, 100);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return initialized;
|
return initialized;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @ai-summary React Query hooks for dashboard data
|
* @ai-summary React Query hooks for dashboard data
|
||||||
|
* @ai-context Unified data fetching to prevent duplicate API calls
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
@@ -8,40 +9,56 @@ import { vehiclesApi } from '../../vehicles/api/vehicles.api';
|
|||||||
import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api';
|
import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api';
|
||||||
import { maintenanceApi } from '../../maintenance/api/maintenance.api';
|
import { maintenanceApi } from '../../maintenance/api/maintenance.api';
|
||||||
import { DashboardSummary, VehicleNeedingAttention } from '../types';
|
import { DashboardSummary, VehicleNeedingAttention } from '../types';
|
||||||
|
import { MaintenanceSchedule } from '../../maintenance/types/maintenance.types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to fetch dashboard summary stats
|
* Combined dashboard data structure
|
||||||
*/
|
*/
|
||||||
export const useDashboardSummary = () => {
|
interface DashboardData {
|
||||||
|
summary: DashboardSummary;
|
||||||
|
vehiclesNeedingAttention: VehicleNeedingAttention[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified hook that fetches all dashboard data in a single query
|
||||||
|
* Prevents duplicate API calls for vehicles and maintenance schedules
|
||||||
|
*/
|
||||||
|
export const useDashboardData = () => {
|
||||||
const { isAuthenticated, isLoading: authLoading } = useAuth0();
|
const { isAuthenticated, isLoading: authLoading } = useAuth0();
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['dashboard', 'summary'],
|
queryKey: ['dashboard', 'all'],
|
||||||
queryFn: async (): Promise<DashboardSummary> => {
|
queryFn: async (): Promise<DashboardData> => {
|
||||||
// Fetch all required data in parallel
|
// Fetch vehicles and fuel logs in parallel
|
||||||
const [vehicles, fuelLogs] = await Promise.all([
|
const [vehicles, fuelLogs] = await Promise.all([
|
||||||
vehiclesApi.getAll(),
|
vehiclesApi.getAll(),
|
||||||
fuelLogsApi.getUserFuelLogs(),
|
fuelLogsApi.getUserFuelLogs(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Fetch schedules for all vehicles to count upcoming maintenance
|
// Fetch all maintenance schedules in parallel (not sequential!)
|
||||||
const allSchedules = await Promise.all(
|
const allSchedulesArrays = await Promise.all(
|
||||||
vehicles.map(v => maintenanceApi.getSchedulesByVehicle(v.id))
|
vehicles.map(v => maintenanceApi.getSchedulesByVehicle(v.id))
|
||||||
);
|
);
|
||||||
const flatSchedules = allSchedules.flat();
|
|
||||||
|
|
||||||
// Calculate upcoming maintenance (next 30 days)
|
// Create a map of vehicle ID to schedules for efficient lookup
|
||||||
|
const schedulesByVehicle = new Map<string, MaintenanceSchedule[]>();
|
||||||
|
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();
|
const thirtyDaysFromNow = new Date();
|
||||||
thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30);
|
thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30);
|
||||||
|
|
||||||
const upcomingMaintenance = flatSchedules.filter(schedule => {
|
const upcomingMaintenance = flatSchedules.filter(schedule => {
|
||||||
// Count schedules as upcoming if they have a next due date within 30 days
|
|
||||||
if (!schedule.nextDueDate) return false;
|
if (!schedule.nextDueDate) return false;
|
||||||
const dueDate = new Date(schedule.nextDueDate);
|
const dueDate = new Date(schedule.nextDueDate);
|
||||||
return dueDate >= new Date() && dueDate <= thirtyDaysFromNow;
|
return dueDate >= now && dueDate <= thirtyDaysFromNow;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate recent fuel logs (last 7 days)
|
|
||||||
const sevenDaysAgo = new Date();
|
const sevenDaysAgo = new Date();
|
||||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||||
|
|
||||||
@@ -50,47 +67,18 @@ export const useDashboardSummary = () => {
|
|||||||
return logDate >= sevenDaysAgo;
|
return logDate >= sevenDaysAgo;
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
const summary: DashboardSummary = {
|
||||||
totalVehicles: vehicles.length,
|
totalVehicles: vehicles.length,
|
||||||
upcomingMaintenanceCount: upcomingMaintenance.length,
|
upcomingMaintenanceCount: upcomingMaintenance.length,
|
||||||
recentFuelLogsCount: recentFuelLogs.length,
|
recentFuelLogsCount: recentFuelLogs.length,
|
||||||
};
|
};
|
||||||
},
|
|
||||||
enabled: isAuthenticated && !authLoading,
|
|
||||||
staleTime: 2 * 60 * 1000, // 2 minutes - fresher than other queries for dashboard
|
|
||||||
gcTime: 5 * 60 * 1000, // 5 minutes cache time
|
|
||||||
retry: (failureCount, error: any) => {
|
|
||||||
// Retry 401 errors up to 3 times for mobile auth timing issues
|
|
||||||
if (error?.response?.status === 401 && failureCount < 3) {
|
|
||||||
console.log(`[Mobile Auth] Dashboard retry ${failureCount + 1}/3 for 401 error`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to fetch vehicles needing attention (overdue maintenance)
|
|
||||||
*/
|
|
||||||
export const useVehiclesNeedingAttention = () => {
|
|
||||||
const { isAuthenticated, isLoading: authLoading } = useAuth0();
|
|
||||||
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['dashboard', 'vehiclesNeedingAttention'],
|
|
||||||
queryFn: async (): Promise<VehicleNeedingAttention[]> => {
|
|
||||||
// Fetch vehicles
|
|
||||||
const vehicles = await vehiclesApi.getAll();
|
|
||||||
|
|
||||||
|
// Calculate vehicles needing attention (using already-fetched schedules)
|
||||||
const vehiclesNeedingAttention: VehicleNeedingAttention[] = [];
|
const vehiclesNeedingAttention: VehicleNeedingAttention[] = [];
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
// Check each vehicle for overdue maintenance
|
|
||||||
for (const vehicle of vehicles) {
|
for (const vehicle of vehicles) {
|
||||||
const schedules = await maintenanceApi.getSchedulesByVehicle(vehicle.id);
|
const schedules = schedulesByVehicle.get(vehicle.id) || [];
|
||||||
|
|
||||||
// Find overdue schedules
|
|
||||||
const overdueSchedules = schedules.filter(schedule => {
|
const overdueSchedules = schedules.filter(schedule => {
|
||||||
if (!schedule.nextDueDate) return false;
|
if (!schedule.nextDueDate) return false;
|
||||||
const dueDate = new Date(schedule.nextDueDate);
|
const dueDate = new Date(schedule.nextDueDate);
|
||||||
@@ -98,14 +86,15 @@ export const useVehiclesNeedingAttention = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (overdueSchedules.length > 0) {
|
if (overdueSchedules.length > 0) {
|
||||||
// Calculate priority based on how overdue the maintenance is
|
|
||||||
const mostOverdue = overdueSchedules.reduce((oldest, current) => {
|
const mostOverdue = overdueSchedules.reduce((oldest, current) => {
|
||||||
const oldestDate = new Date(oldest.nextDueDate!);
|
const oldestDate = new Date(oldest.nextDueDate!);
|
||||||
const currentDate = new Date(current.nextDueDate!);
|
const currentDate = new Date(current.nextDueDate!);
|
||||||
return currentDate < oldestDate ? current : oldest;
|
return currentDate < oldestDate ? current : oldest;
|
||||||
});
|
});
|
||||||
|
|
||||||
const daysOverdue = Math.floor((now.getTime() - new Date(mostOverdue.nextDueDate!).getTime()) / (1000 * 60 * 60 * 24));
|
const daysOverdue = Math.floor(
|
||||||
|
(now.getTime() - new Date(mostOverdue.nextDueDate!).getTime()) / (1000 * 60 * 60 * 24)
|
||||||
|
);
|
||||||
|
|
||||||
let priority: 'high' | 'medium' | 'low' = 'low';
|
let priority: 'high' | 'medium' | 'low' = 'low';
|
||||||
if (daysOverdue > 30) {
|
if (daysOverdue > 30) {
|
||||||
@@ -124,14 +113,16 @@ export const useVehiclesNeedingAttention = () => {
|
|||||||
|
|
||||||
// Sort by priority (high -> medium -> low)
|
// Sort by priority (high -> medium -> low)
|
||||||
const priorityOrder = { high: 0, medium: 1, low: 2 };
|
const priorityOrder = { high: 0, medium: 1, low: 2 };
|
||||||
return vehiclesNeedingAttention.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);
|
vehiclesNeedingAttention.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);
|
||||||
|
|
||||||
|
return { summary, vehiclesNeedingAttention };
|
||||||
},
|
},
|
||||||
enabled: isAuthenticated && !authLoading,
|
enabled: isAuthenticated && !authLoading,
|
||||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||||
gcTime: 5 * 60 * 1000, // 5 minutes cache time
|
gcTime: 5 * 60 * 1000, // 5 minutes cache time
|
||||||
retry: (failureCount, error: any) => {
|
retry: (failureCount, error: any) => {
|
||||||
if (error?.response?.status === 401 && failureCount < 3) {
|
if (error?.response?.status === 401 && failureCount < 3) {
|
||||||
console.log(`[Mobile Auth] Vehicles attention retry ${failureCount + 1}/3 for 401 error`);
|
console.log(`[Mobile Auth] Dashboard retry ${failureCount + 1}/3 for 401 error`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -139,3 +130,33 @@ export const useVehiclesNeedingAttention = () => {
|
|||||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch dashboard summary stats
|
||||||
|
* Derives from unified dashboard data query
|
||||||
|
*/
|
||||||
|
export const useDashboardSummary = () => {
|
||||||
|
const { data, isLoading, error, refetch } = useDashboardData();
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: data?.summary,
|
||||||
|
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,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user