From f6684e72c0ae7379efbf8c78585da536a4e19d5d Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 15 Feb 2026 11:03:52 -0600 Subject: [PATCH] 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) }; +}