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:
Eric Gullickson
2026-02-15 10:48:37 -06:00
parent 963c17014c
commit b57b835eb3
2 changed files with 124 additions and 166 deletions

View File

@@ -1,29 +1,93 @@
/** /**
* @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 * @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 { useQuery } from '@tanstack/react-query';
import { useAuth0 } from '@auth0/auth0-react'; import { useAuth0 } from '@auth0/auth0-react';
import { vehiclesApi } from '../../vehicles/api/vehicles.api'; import { vehiclesApi } from '../../vehicles/api/vehicles.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, RecentActivityItem } from '../types'; import { documentsApi } from '../../documents/api/documents.api';
import { MaintenanceSchedule } from '../../maintenance/types/maintenance.types'; 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 { interface DashboardData {
summary: DashboardSummary; vehicles: Vehicle[];
vehiclesNeedingAttention: VehicleNeedingAttention[]; schedulesByVehicle: Map<string, MaintenanceSchedule[]>;
recentActivity: RecentActivityItem[]; documentsByVehicle: Map<string, DocumentRecord[]>;
roster: VehicleRosterData[];
} }
/** /**
* Unified hook that fetches all dashboard data in a single query * Compute health status and attention items for a single vehicle.
* Prevents duplicate API calls for vehicles and maintenance schedules * 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 = () => { export const useDashboardData = () => {
const { isAuthenticated, isLoading: authLoading } = useAuth0(); const { isAuthenticated, isLoading: authLoading } = useAuth0();
@@ -31,123 +95,57 @@ export const useDashboardData = () => {
return useQuery({ return useQuery({
queryKey: ['dashboard', 'all'], queryKey: ['dashboard', 'all'],
queryFn: async (): Promise<DashboardData> => { queryFn: async (): Promise<DashboardData> => {
// Fetch vehicles and fuel logs in parallel // Fetch vehicles first (need IDs for schedule queries)
const [vehicles, fuelLogs] = await Promise.all([ const vehicles = await vehiclesApi.getAll();
vehiclesApi.getAll(),
fuelLogsApi.getUserFuelLogs(),
]);
// Fetch all maintenance schedules in parallel (not sequential!) // Fetch maintenance schedules per vehicle in parallel
const allSchedulesArrays = await Promise.all( const allSchedulesArrays = await Promise.all(
vehicles.map(v => maintenanceApi.getSchedulesByVehicle(v.id)) vehicles.map(v => maintenanceApi.getSchedulesByVehicle(v.id))
); );
// Create a map of vehicle ID to schedules for efficient lookup
const schedulesByVehicle = new Map<string, MaintenanceSchedule[]>(); const schedulesByVehicle = new Map<string, MaintenanceSchedule[]>();
vehicles.forEach((vehicle, index) => { vehicles.forEach((vehicle, index) => {
schedulesByVehicle.set(vehicle.id, allSchedulesArrays[index]); schedulesByVehicle.set(vehicle.id, allSchedulesArrays[index]);
}); });
const flatSchedules = allSchedulesArrays.flat(); // Fetch document expiry data (insurance + registration) with graceful degradation
const now = new Date(); 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 // Group documents by vehicleId
const thirtyDaysFromNow = new Date(); const documentsByVehicle = new Map<string, DocumentRecord[]>();
thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30); 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 => { // Compute roster data per vehicle
if (!schedule.nextDueDate) return false; const roster: VehicleRosterData[] = vehicles.map(vehicle => {
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 schedules = schedulesByVehicle.get(vehicle.id) || [];
const documents = documentsByVehicle.get(vehicle.id) || [];
const overdueSchedules = schedules.filter(schedule => { const { health, attentionItems } = computeVehicleHealth(schedules, documents);
if (!schedule.nextDueDate) return false; return { vehicle, health, attentionItems };
const dueDate = new Date(schedule.nextDueDate);
return dueDate < now;
}); });
if (overdueSchedules.length > 0) { return { vehicles, schedulesByVehicle, documentsByVehicle, roster };
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 };
}, },
enabled: isAuthenticated && !authLoading, enabled: isAuthenticated && !authLoading,
staleTime: 2 * 60 * 1000, // 2 minutes staleTime: 2 * 60 * 1000,
gcTime: 5 * 60 * 1000, // 5 minutes cache time gcTime: 5 * 60 * 1000,
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] Dashboard retry ${failureCount + 1}/3 for 401 error`);
return true; return true;
} }
return false; return false;
@@ -157,44 +155,14 @@ export const useDashboardData = () => {
}; };
/** /**
* Hook to fetch dashboard summary stats * Derived hook returning vehicle roster data for the dashboard grid.
* Derives from unified dashboard data query
*/ */
export const useDashboardSummary = () => { export const useVehicleRoster = () => {
const { data, isLoading, error, refetch } = useDashboardData(); const { data, isLoading, error, refetch } = useDashboardData();
return { return {
data: data?.summary, data: data?.roster,
isLoading, vehicles: data?.vehicles,
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,
isLoading, isLoading,
error, error,
refetch, refetch,

View File

@@ -4,27 +4,17 @@
import { Vehicle } from '../../vehicles/types/vehicles.types'; import { Vehicle } from '../../vehicles/types/vehicles.types';
export interface DashboardSummary { export type VehicleHealth = 'green' | 'yellow' | 'red';
totalVehicles: number;
upcomingMaintenanceCount: number; export interface AttentionItem {
recentFuelLogsCount: number; label: string;
urgency: 'overdue' | 'due-soon' | 'upcoming';
daysUntilDue: number;
source: 'maintenance' | 'document';
} }
export interface VehicleNeedingAttention extends Vehicle { export interface VehicleRosterData {
reason: string; vehicle: Vehicle;
priority: 'high' | 'medium' | 'low'; health: VehicleHealth;
} attentionItems: AttentionItem[];
export interface RecentActivityItem {
type: 'fuel' | 'maintenance';
vehicleId: string;
vehicleName: string;
description: string;
timestamp: string;
}
export interface DashboardData {
summary: DashboardSummary;
vehiclesNeedingAttention: VehicleNeedingAttention[];
recentActivity: RecentActivityItem[];
} }