test: add dashboard redesign tests (refs #201)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m22s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 51s
Deploy to Staging / Verify Staging (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m22s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 51s
Deploy to Staging / Verify Staging (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ const config: Config = {
|
|||||||
},
|
},
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'^@/(.*)$': '<rootDir>/src/$1',
|
'^@/(.*)$': '<rootDir>/src/$1',
|
||||||
|
'(.*/core/api/client)$': '<rootDir>/src/core/api/__mocks__/client.ts',
|
||||||
'\\.(css|less|scss|sass)$': '<rootDir>/test/__mocks__/styleMock.js',
|
'\\.(css|less|scss|sass)$': '<rootDir>/test/__mocks__/styleMock.js',
|
||||||
'\\.(svg|png|jpg|jpeg|gif)$': '<rootDir>/test/__mocks__/fileMock.js',
|
'\\.(svg|png|jpg|jpeg|gif)$': '<rootDir>/test/__mocks__/fileMock.js',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
// Jest setup for React Testing Library
|
// Jest setup for React Testing Library
|
||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
|
// Polyfill TextEncoder/TextDecoder for jsdom (required by Auth0 SDK)
|
||||||
|
import { TextEncoder, TextDecoder } from 'util';
|
||||||
|
Object.assign(global, { TextEncoder, TextDecoder });
|
||||||
|
|
||||||
|
|||||||
15
frontend/src/core/api/__mocks__/client.ts
Normal file
15
frontend/src/core/api/__mocks__/client.ts
Normal file
@@ -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() },
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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(<ActionBar onAddVehicle={onAddVehicle} onLogFuel={onLogFuel} />);
|
||||||
|
|
||||||
|
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(<ActionBar onAddVehicle={onAddVehicle} onLogFuel={onLogFuel} />);
|
||||||
|
|
||||||
|
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(<ActionBar onAddVehicle={onAddVehicle} onLogFuel={onLogFuel} />);
|
||||||
|
|
||||||
|
const logFuelButton = screen.getByText('Log Fuel');
|
||||||
|
fireEvent.click(logFuelButton);
|
||||||
|
|
||||||
|
expect(onLogFuel).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: () => <div data-testid="vehicle-image" />,
|
||||||
|
}));
|
||||||
|
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<typeof useVehicleRoster>;
|
||||||
|
|
||||||
|
const makeVehicle = (overrides: Partial<Vehicle> = {}): 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(
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
{ui}
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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(<DashboardScreen />);
|
||||||
|
|
||||||
|
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(<DashboardScreen />);
|
||||||
|
|
||||||
|
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(<DashboardScreen />);
|
||||||
|
|
||||||
|
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(<DashboardScreen />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Your Fleet')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: () => <div data-testid="vehicle-image" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const makeVehicle = (overrides: Partial<Vehicle> = {}): 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> = {}): 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(<VehicleRosterCard data={data} onClick={onClick} />);
|
||||||
|
|
||||||
|
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(<VehicleRosterCard data={data} onClick={onClick} />);
|
||||||
|
|
||||||
|
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(<VehicleRosterCard data={data} onClick={onClick} />);
|
||||||
|
|
||||||
|
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(<VehicleRosterCard data={data} onClick={onClick} />);
|
||||||
|
|
||||||
|
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(<VehicleRosterCard data={data} onClick={onClick} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Oil Change - OVERDUE')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders odometer with formatting', () => {
|
||||||
|
const data = makeRosterData();
|
||||||
|
const onClick = jest.fn();
|
||||||
|
|
||||||
|
render(<VehicleRosterCard data={data} onClick={onClick} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('87,412 mi')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClick with vehicle ID when clicked', () => {
|
||||||
|
const data = makeRosterData();
|
||||||
|
const onClick = jest.fn();
|
||||||
|
|
||||||
|
render(<VehicleRosterCard data={data} onClick={onClick} />);
|
||||||
|
|
||||||
|
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(<VehicleRosterCard data={data} onClick={onClick} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('All clear')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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> = {}): 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> = {}): 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,7 +12,10 @@ import { documentsApi } from '../../documents/api/documents.api';
|
|||||||
import { MaintenanceSchedule } from '../../maintenance/types/maintenance.types';
|
import { MaintenanceSchedule } from '../../maintenance/types/maintenance.types';
|
||||||
import { DocumentRecord } from '../../documents/types/documents.types';
|
import { DocumentRecord } from '../../documents/types/documents.types';
|
||||||
import { Vehicle } from '../../vehicles/types/vehicles.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 {
|
interface DashboardData {
|
||||||
vehicles: Vehicle[];
|
vehicles: Vehicle[];
|
||||||
@@ -21,70 +24,6 @@ interface DashboardData {
|
|||||||
roster: VehicleRosterData[];
|
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.
|
* Unified hook that fetches all dashboard data in a single query.
|
||||||
* Fetches vehicles, maintenance schedules, and document expiry data.
|
* Fetches vehicles, maintenance schedules, and document expiry data.
|
||||||
|
|||||||
@@ -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) };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user