feat: add vehicle health types and roster data hook (refs #197)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, MaintenanceSchedule[]>;
|
||||
documentsByVehicle: Map<string, DocumentRecord[]>;
|
||||
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<DashboardData> => {
|
||||
// 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<string, MaintenanceSchedule[]>();
|
||||
vehicles.forEach((vehicle, index) => {
|
||||
schedulesByVehicle.set(vehicle.id, allSchedulesArrays[index]);
|
||||
});
|
||||
|
||||
const flatSchedules = allSchedulesArrays.flat();
|
||||
const now = new Date();
|
||||
// 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
|
||||
}
|
||||
|
||||
// Calculate summary stats
|
||||
const thirtyDaysFromNow = new Date();
|
||||
thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30);
|
||||
// Group documents by vehicleId
|
||||
const documentsByVehicle = new Map<string, DocumentRecord[]>();
|
||||
for (const doc of expiryDocs) {
|
||||
const vehicleId = doc.vehicleId;
|
||||
if (!documentsByVehicle.has(vehicleId)) {
|
||||
documentsByVehicle.set(vehicleId, []);
|
||||
}
|
||||
documentsByVehicle.get(vehicleId)!.push(doc);
|
||||
}
|
||||
|
||||
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) {
|
||||
// Compute roster data per vehicle
|
||||
const roster: VehicleRosterData[] = vehicles.map(vehicle => {
|
||||
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;
|
||||
const documents = documentsByVehicle.get(vehicle.id) || [];
|
||||
const { health, attentionItems } = computeVehicleHealth(schedules, documents);
|
||||
return { vehicle, health, attentionItems };
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by priority (high -> medium -> low)
|
||||
const priorityOrder = { high: 0, medium: 1, low: 2 };
|
||||
vehiclesNeedingAttention.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);
|
||||
|
||||
// Build recent activity feed
|
||||
const vehicleMap = new Map(vehicles.map(v => [v.id, v]));
|
||||
|
||||
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,
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user