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[]; }