Compare commits

...

5 Commits

Author SHA1 Message Date
Eric Gullickson
f6684e72c0 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
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 11:03:52 -06:00
Eric Gullickson
654a7f0fc3 feat: rewire DashboardScreen with vehicle roster layout (refs #200)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 10:53:35 -06:00
Eric Gullickson
767df9e9f2 feat: add dashboard ActionBar component (refs #199)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 10:50:29 -06:00
Eric Gullickson
505ab8262c feat: add VehicleRosterCard component (refs #198)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 10:50:24 -06:00
Eric Gullickson
b57b835eb3 feat: add vehicle health types and roster data hook (refs #197)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 10:48:37 -06:00
19 changed files with 1021 additions and 805 deletions

View File

@@ -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',
}, },

View File

@@ -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 });

View 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() },
},
};

View File

@@ -0,0 +1,38 @@
/**
* @ai-summary Compact action bar for dashboard with Add Vehicle and Log Fuel buttons
*/
import React from 'react';
import Button from '@mui/material/Button';
import Add from '@mui/icons-material/Add';
import LocalGasStation from '@mui/icons-material/LocalGasStation';
interface ActionBarProps {
onAddVehicle: () => void;
onLogFuel: () => void;
}
export const ActionBar: React.FC<ActionBarProps> = ({ onAddVehicle, onLogFuel }) => {
return (
<div className="flex flex-row gap-2 items-center">
<Button
variant="contained"
size="small"
startIcon={<Add />}
onClick={onAddVehicle}
sx={{ minHeight: 44 }}
>
Add Vehicle
</Button>
<Button
variant="outlined"
size="small"
startIcon={<LocalGasStation />}
onClick={onLogFuel}
sx={{ minHeight: 44 }}
>
Log Fuel
</Button>
</div>
);
};

View File

@@ -1,47 +1,72 @@
/** /**
* @ai-summary Main dashboard screen component showing fleet overview * @ai-summary Main dashboard screen showing vehicle fleet roster with health indicators
*/ */
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Box, Dialog, DialogTitle, DialogContent, IconButton, useMediaQuery, useTheme } from '@mui/material'; import { Box, Dialog, DialogTitle, DialogContent, IconButton, Skeleton, Typography, useMediaQuery, useTheme } from '@mui/material';
import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded'; import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded';
import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded'; import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
import { SummaryCards, SummaryCardsSkeleton } from './SummaryCards'; import { VehicleRosterCard } from './VehicleRosterCard';
import { VehicleAttention, VehicleAttentionSkeleton } from './VehicleAttention'; import { ActionBar } from './ActionBar';
import { QuickActions, QuickActionsSkeleton } from './QuickActions'; import { useVehicleRoster } from '../hooks/useDashboardData';
import { RecentActivity, RecentActivitySkeleton } from './RecentActivity';
import { useDashboardSummary, useVehiclesNeedingAttention, useRecentActivity } from '../hooks/useDashboardData';
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard'; import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
import { Button } from '../../../shared-minimal/components/Button'; import { Button } from '../../../shared-minimal/components/Button';
import { PendingAssociationBanner } from '../../email-ingestion/components/PendingAssociationBanner'; import { PendingAssociationBanner } from '../../email-ingestion/components/PendingAssociationBanner';
import { PendingAssociationList } from '../../email-ingestion/components/PendingAssociationList'; import { PendingAssociationList } from '../../email-ingestion/components/PendingAssociationList';
import { MobileScreen } from '../../../core/store'; import { MobileScreen } from '../../../core/store';
import { Vehicle } from '../../vehicles/types/vehicles.types'; import { Vehicle } from '../../vehicles/types/vehicles.types';
interface DashboardScreenProps { interface DashboardScreenProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches navigation store type signature
onNavigate?: (screen: MobileScreen, metadata?: Record<string, any>) => void; onNavigate?: (screen: MobileScreen, metadata?: Record<string, any>) => void;
onVehicleClick?: (vehicle: Vehicle) => void; onVehicleClick?: (vehicle: Vehicle) => void;
onViewMaintenance?: () => void; onViewMaintenance?: () => void;
onAddVehicle?: () => void; onAddVehicle?: () => void;
} }
const RosterSkeleton: React.FC = () => (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{[0, 1, 2, 3].map(i => (
<GlassCard key={i}>
<div className="flex items-center gap-3 mb-3">
<Skeleton variant="circular" width={48} height={48} />
<div className="flex-1">
<Skeleton variant="text" width="60%" />
</div>
<Skeleton variant="circular" width={12} height={12} />
</div>
<div className="mb-3 space-y-1">
<Skeleton variant="text" width="80%" />
<Skeleton variant="text" width="80%" />
</div>
<Skeleton variant="text" width="30%" />
</GlassCard>
))}
</div>
);
export const DashboardScreen: React.FC<DashboardScreenProps> = ({ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
onNavigate, onNavigate,
onVehicleClick, onVehicleClick,
onViewMaintenance, onAddVehicle,
onAddVehicle
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down('sm')); const isSmall = useMediaQuery(theme.breakpoints.down('sm'));
const [showPendingReceipts, setShowPendingReceipts] = useState(false); const [showPendingReceipts, setShowPendingReceipts] = useState(false);
const { data: summary, isLoading: summaryLoading, error: summaryError } = useDashboardSummary(); const { data: roster, vehicles, isLoading, error } = useVehicleRoster();
const { data: vehiclesNeedingAttention, isLoading: attentionLoading, error: attentionError } = useVehiclesNeedingAttention();
const { data: recentActivity } = useRecentActivity(); const handleAddVehicle = onAddVehicle ?? (() => onNavigate?.('Vehicles'));
const handleLogFuel = () => onNavigate?.('Log Fuel');
const handleVehicleClick = (vehicleId: string) => {
const vehicle = vehicles?.find(v => v.id === vehicleId);
if (vehicle && onVehicleClick) {
onVehicleClick(vehicle);
}
};
// Error state // Error state
if (summaryError || attentionError) { if (error) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<GlassCard> <GlassCard>
@@ -69,19 +94,21 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
} }
// Loading state // Loading state
if (summaryLoading || attentionLoading || !summary || !vehiclesNeedingAttention) { if (isLoading || !roster) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<SummaryCardsSkeleton /> <div className="flex items-center justify-between">
<VehicleAttentionSkeleton /> <Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary' }}>
<RecentActivitySkeleton /> Your Fleet
<QuickActionsSkeleton /> </Typography>
</div>
<RosterSkeleton />
</div> </div>
); );
} }
// Empty state - no vehicles // Empty state - no vehicles
if (summary.totalVehicles === 0) { if (roster.length === 0) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<GlassCard> <GlassCard>
@@ -98,7 +125,7 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
<Button <Button
variant="primary" variant="primary"
size="lg" size="lg"
onClick={onAddVehicle ?? (() => onNavigate?.('Vehicles'))} onClick={handleAddVehicle}
> >
Add Your First Vehicle Add Your First Vehicle
</Button> </Button>
@@ -114,32 +141,24 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
{/* Pending Receipts Banner */} {/* Pending Receipts Banner */}
<PendingAssociationBanner onViewPending={() => setShowPendingReceipts(true)} /> <PendingAssociationBanner onViewPending={() => setShowPendingReceipts(true)} />
{/* Summary Cards */} {/* Heading + Action Bar */}
<SummaryCards summary={summary} onNavigate={onNavigate} /> <div className="flex items-center justify-between">
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary' }}>
Your Fleet
</Typography>
<ActionBar onAddVehicle={handleAddVehicle} onLogFuel={handleLogFuel} />
</div>
{/* Vehicles Needing Attention */} {/* Vehicle Roster Grid */}
{vehiclesNeedingAttention && vehiclesNeedingAttention.length > 0 && ( <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<VehicleAttention {roster.map(rosterData => (
vehicles={vehiclesNeedingAttention} <VehicleRosterCard
onVehicleClick={(vehicleId) => { key={rosterData.vehicle.id}
const vehicle = vehiclesNeedingAttention.find(v => v.id === vehicleId); data={rosterData}
if (vehicle && onVehicleClick) { onClick={handleVehicleClick}
onVehicleClick(vehicle); />
} ))}
}} </div>
/>
)}
{/* Recent Activity */}
{recentActivity && <RecentActivity items={recentActivity} />}
{/* Quick Actions */}
<QuickActions
onAddVehicle={onAddVehicle ?? (() => onNavigate?.('Vehicles'))}
onLogFuel={() => onNavigate?.('Log Fuel')}
onViewMaintenance={onViewMaintenance ?? (() => onNavigate?.('Vehicles'))}
onViewVehicles={() => onNavigate?.('Vehicles')}
/>
{/* Pending Receipts Dialog */} {/* Pending Receipts Dialog */}
<Dialog <Dialog

View File

@@ -1,167 +0,0 @@
/**
* @ai-summary Quick action buttons for common tasks
*/
import React from 'react';
import { Box, SvgIconProps } from '@mui/material';
import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
import BuildRoundedIcon from '@mui/icons-material/BuildRounded';
import FormatListBulletedRoundedIcon from '@mui/icons-material/FormatListBulletedRounded';
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
interface QuickAction {
id: string;
title: string;
description: string;
icon: React.ComponentType<SvgIconProps>;
onClick: () => void;
}
interface QuickActionsProps {
onAddVehicle: () => void;
onLogFuel: () => void;
onViewMaintenance: () => void;
onViewVehicles: () => void;
}
export const QuickActions: React.FC<QuickActionsProps> = ({
onAddVehicle,
onLogFuel,
onViewMaintenance,
onViewVehicles,
}) => {
const actions: QuickAction[] = [
{
id: 'add-vehicle',
title: 'Add Vehicle',
description: 'Register a new vehicle',
icon: DirectionsCarRoundedIcon,
onClick: onAddVehicle,
},
{
id: 'log-fuel',
title: 'Log Fuel',
description: 'Record a fuel purchase',
icon: LocalGasStationRoundedIcon,
onClick: onLogFuel,
},
{
id: 'view-maintenance',
title: 'Maintenance',
description: 'View maintenance records',
icon: BuildRoundedIcon,
onClick: onViewMaintenance,
},
{
id: 'view-vehicles',
title: 'My Vehicles',
description: 'View all vehicles',
icon: FormatListBulletedRoundedIcon,
onClick: onViewVehicles,
},
];
return (
<GlassCard padding="md">
<div className="mb-4">
<h3 className="text-lg font-semibold text-slate-800 dark:text-avus">
Quick Actions
</h3>
<p className="text-sm text-slate-500 dark:text-titanio">
Common tasks and navigation
</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{actions.map((action) => {
const IconComponent = action.icon;
return (
<Box
key={action.id}
component="button"
onClick={action.onClick}
sx={{
p: 2,
borderRadius: 1.5,
bgcolor: 'action.hover',
border: '1px solid transparent',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
textAlign: 'left',
minHeight: { xs: 100, sm: 120 },
transition: 'all 0.2s',
cursor: 'pointer',
'&:hover': {
bgcolor: 'action.selected',
borderColor: 'divider',
},
'&:focus': {
outline: 'none',
borderColor: 'primary.main',
},
}}
>
<Box
sx={{
color: 'primary.main',
mb: 1.5,
}}
>
<IconComponent sx={{ fontSize: 28 }} />
</Box>
<Box sx={{ flex: 1 }}>
<Box
component="span"
sx={{
display: 'block',
fontWeight: 600,
fontSize: '0.875rem',
color: 'text.primary',
mb: 0.5,
}}
>
{action.title}
</Box>
<Box
component="span"
sx={{
display: { xs: 'none', sm: 'block' },
fontSize: '0.75rem',
color: 'text.secondary',
}}
>
{action.description}
</Box>
</Box>
</Box>
);
})}
</div>
</GlassCard>
);
};
export const QuickActionsSkeleton: React.FC = () => {
return (
<GlassCard padding="md">
<div className="mb-4">
<div className="h-6 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-32 mb-2" />
<div className="h-4 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-48" />
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800 min-h-[100px] sm:min-h-[120px]"
>
<div className="w-7 h-7 bg-slate-100 dark:bg-slate-700 rounded animate-pulse mb-3" />
<div className="h-4 bg-slate-100 dark:bg-slate-700 rounded animate-pulse w-20 mb-2" />
<div className="h-3 bg-slate-100 dark:bg-slate-700 rounded animate-pulse w-full hidden sm:block" />
</div>
))}
</div>
</GlassCard>
);
};

View File

@@ -1,118 +0,0 @@
/**
* @ai-summary Recent activity feed showing latest fuel logs and maintenance events
*/
import React from 'react';
import { Box } from '@mui/material';
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
import BuildRoundedIcon from '@mui/icons-material/BuildRounded';
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
import { RecentActivityItem } from '../types';
interface RecentActivityProps {
items: RecentActivityItem[];
}
const formatRelativeTime = (timestamp: string): string => {
const now = new Date();
const date = new Date(timestamp);
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 0) {
// Future date (upcoming maintenance)
const absDays = Math.abs(diffDays);
if (absDays === 0) return 'Today';
if (absDays === 1) return 'Tomorrow';
return `In ${absDays} days`;
}
if (diffDays === 0) {
if (diffHours === 0) return diffMins <= 1 ? 'Just now' : `${diffMins}m ago`;
return `${diffHours}h ago`;
}
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
};
export const RecentActivity: React.FC<RecentActivityProps> = ({ items }) => {
if (items.length === 0) {
return (
<GlassCard padding="md">
<h3 className="text-base font-semibold text-slate-800 dark:text-avus mb-3">
Recent Activity
</h3>
<p className="text-sm text-slate-400 dark:text-canna text-center py-4">
No recent activity. Start by logging fuel or scheduling maintenance.
</p>
</GlassCard>
);
}
return (
<GlassCard padding="md">
<h3 className="text-base font-semibold text-slate-800 dark:text-avus mb-3">
Recent Activity
</h3>
<div className="space-y-1">
{items.map((item, index) => (
<div
key={`${item.type}-${item.timestamp}-${index}`}
className="flex items-start gap-3 py-2"
>
<Box
sx={{
flexShrink: 0,
width: 32,
height: 32,
borderRadius: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'action.hover',
}}
>
{item.type === 'fuel' ? (
<LocalGasStationRoundedIcon sx={{ fontSize: 18, color: 'primary.main' }} />
) : (
<BuildRoundedIcon sx={{ fontSize: 18, color: 'primary.main' }} />
)}
</Box>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-800 dark:text-avus truncate">
{item.vehicleName}
</p>
<p className="text-xs text-slate-500 dark:text-titanio truncate">
{item.description}
</p>
</div>
<span className="text-xs text-slate-400 dark:text-canna whitespace-nowrap flex-shrink-0">
{formatRelativeTime(item.timestamp)}
</span>
</div>
))}
</div>
</GlassCard>
);
};
export const RecentActivitySkeleton: React.FC = () => {
return (
<GlassCard padding="md">
<div className="h-5 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-32 mb-3" />
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-start gap-3">
<div className="flex-shrink-0 w-8 h-8 rounded-lg bg-slate-100 dark:bg-slate-800 animate-pulse" />
<div className="flex-1 space-y-1.5">
<div className="h-4 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-24" />
<div className="h-3 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-40" />
</div>
</div>
))}
</div>
</GlassCard>
);
};

View File

@@ -1,134 +0,0 @@
/**
* @ai-summary Summary cards showing key dashboard metrics
*/
import React from 'react';
import { Box } from '@mui/material';
import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
import BuildRoundedIcon from '@mui/icons-material/BuildRounded';
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
import { DashboardSummary } from '../types';
import { MobileScreen } from '../../../core/store';
interface SummaryCardsProps {
summary: DashboardSummary;
onNavigate?: (screen: MobileScreen) => void;
}
export const SummaryCards: React.FC<SummaryCardsProps> = ({ summary, onNavigate }) => {
const cards = [
{
title: 'Total Vehicles',
value: summary.totalVehicles,
icon: DirectionsCarRoundedIcon,
color: 'primary.main',
ctaText: 'Add a vehicle',
ctaScreen: 'Vehicles' as MobileScreen,
},
{
title: 'Upcoming Maintenance',
value: summary.upcomingMaintenanceCount,
subtitle: 'Next 30 days',
icon: BuildRoundedIcon,
color: 'primary.main',
ctaText: 'Schedule maintenance',
ctaScreen: 'Maintenance' as MobileScreen,
},
{
title: 'Recent Fuel Logs',
value: summary.recentFuelLogsCount,
subtitle: 'Last 7 days',
icon: LocalGasStationRoundedIcon,
color: 'primary.main',
ctaText: 'Log your first fill-up',
ctaScreen: 'Log Fuel' as MobileScreen,
},
];
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{cards.map((card) => {
const IconComponent = card.icon;
return (
<GlassCard key={card.title} padding="md">
<div className="flex items-start gap-3">
<Box
sx={{
flexShrink: 0,
width: 48,
height: 48,
borderRadius: 3,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'action.hover',
}}
>
<IconComponent sx={{ fontSize: 24, color: card.color }} />
</Box>
<div className="flex-1 min-w-0">
<p className="text-sm text-slate-500 dark:text-titanio font-medium mb-1">
{card.title}
</p>
<Box
component="p"
sx={{
fontSize: '1.875rem',
fontWeight: 700,
color: 'text.primary',
lineHeight: 1.2,
}}
>
{card.value}
</Box>
{card.value === 0 && card.ctaText ? (
<Box
component="button"
onClick={() => onNavigate?.(card.ctaScreen)}
sx={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
color: 'primary.main',
fontSize: '0.75rem',
fontWeight: 500,
mt: 0.5,
'&:hover': { textDecoration: 'underline' },
}}
>
{card.ctaText}
</Box>
) : card.subtitle ? (
<p className="text-xs text-slate-400 dark:text-canna mt-1">
{card.subtitle}
</p>
) : null}
</div>
</div>
</GlassCard>
);
})}
</div>
);
};
export const SummaryCardsSkeleton: React.FC = () => {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<GlassCard key={i} padding="md">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-12 h-12 rounded-xl bg-slate-100 dark:bg-slate-800 animate-pulse" />
<div className="flex-1 min-w-0 space-y-2">
<div className="h-4 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-24" />
<div className="h-8 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-16" />
<div className="h-3 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-20" />
</div>
</div>
</GlassCard>
))}
</div>
);
};

View File

@@ -1,162 +0,0 @@
/**
* @ai-summary List of vehicles needing attention (overdue maintenance)
*/
import React from 'react';
import { Box, SvgIconProps } from '@mui/material';
import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded';
import ErrorRoundedIcon from '@mui/icons-material/ErrorRounded';
import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded';
import ScheduleRoundedIcon from '@mui/icons-material/ScheduleRounded';
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
import { getVehicleLabel } from '@/core/utils/vehicleDisplay';
import { VehicleNeedingAttention } from '../types';
interface VehicleAttentionProps {
vehicles: VehicleNeedingAttention[];
onVehicleClick?: (vehicleId: string) => void;
}
export const VehicleAttention: React.FC<VehicleAttentionProps> = ({ vehicles, onVehicleClick }) => {
if (vehicles.length === 0) {
return (
<GlassCard padding="md">
<div className="text-center py-8">
<Box sx={{ color: 'success.main', mb: 1.5 }}>
<CheckCircleRoundedIcon sx={{ fontSize: 48 }} />
</Box>
<h3 className="text-lg font-semibold text-slate-800 dark:text-avus mb-2">
All Caught Up!
</h3>
<p className="text-sm text-slate-500 dark:text-titanio">
No vehicles need immediate attention
</p>
</div>
</GlassCard>
);
}
const priorityConfig: Record<string, { color: string; icon: React.ComponentType<SvgIconProps> }> = {
high: {
color: 'error.main',
icon: ErrorRoundedIcon,
},
medium: {
color: 'warning.main',
icon: WarningAmberRoundedIcon,
},
low: {
color: 'info.main',
icon: ScheduleRoundedIcon,
},
};
return (
<GlassCard padding="md">
<div className="mb-4">
<h3 className="text-lg font-semibold text-slate-800 dark:text-avus">
Needs Attention
</h3>
<p className="text-sm text-slate-500 dark:text-titanio">
Vehicles with overdue maintenance
</p>
</div>
<div className="space-y-3">
{vehicles.map((vehicle) => {
const config = priorityConfig[vehicle.priority];
const IconComponent = config.icon;
return (
<Box
key={vehicle.id}
onClick={() => onVehicleClick?.(vehicle.id)}
role={onVehicleClick ? 'button' : undefined}
tabIndex={onVehicleClick ? 0 : undefined}
onKeyDown={(e: React.KeyboardEvent) => {
if (onVehicleClick && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onVehicleClick(vehicle.id);
}
}}
sx={{
p: 2,
borderRadius: 3,
bgcolor: 'action.hover',
border: '1px solid',
borderColor: 'divider',
cursor: onVehicleClick ? 'pointer' : 'default',
transition: 'all 0.2s',
'&:hover': onVehicleClick ? {
bgcolor: 'action.selected',
} : {},
}}
>
<div className="flex items-start gap-3">
<Box sx={{ flexShrink: 0, color: config.color }}>
<IconComponent sx={{ fontSize: 24 }} />
</Box>
<div className="flex-1 min-w-0">
<Box
component="h4"
sx={{
fontWeight: 600,
color: 'text.primary',
fontSize: '1rem',
mb: 0.5,
}}
>
{getVehicleLabel(vehicle)}
</Box>
<p className="text-sm text-slate-600 dark:text-titanio">
{vehicle.reason}
</p>
<Box
component="span"
sx={{
display: 'inline-block',
mt: 1,
px: 1.5,
py: 0.5,
borderRadius: 2,
fontSize: '0.75rem',
fontWeight: 500,
bgcolor: 'action.selected',
color: config.color,
}}
>
{vehicle.priority.toUpperCase()} PRIORITY
</Box>
</div>
</div>
</Box>
);
})}
</div>
</GlassCard>
);
};
export const VehicleAttentionSkeleton: React.FC = () => {
return (
<GlassCard padding="md">
<div className="mb-4">
<div className="h-6 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-32 mb-2" />
<div className="h-4 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-48" />
</div>
<div className="space-y-3">
{[1, 2].map((i) => (
<div key={i} className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-6 h-6 bg-slate-100 dark:bg-slate-700 rounded animate-pulse" />
<div className="flex-1 space-y-2">
<div className="h-5 bg-slate-100 dark:bg-slate-700 rounded animate-pulse w-3/4" />
<div className="h-4 bg-slate-100 dark:bg-slate-700 rounded animate-pulse w-1/2" />
<div className="h-6 bg-slate-100 dark:bg-slate-700 rounded animate-pulse w-24 mt-2" />
</div>
</div>
</div>
))}
</div>
</GlassCard>
);
};

View File

@@ -0,0 +1,103 @@
/**
* @ai-summary Vehicle roster card component for dashboard fleet grid
* Displays vehicle image, health status, attention items, and odometer
*/
import React from 'react';
import { clsx } from 'clsx';
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
import { VehicleImage } from '../../vehicles/components/VehicleImage';
import { getVehicleLabel } from '../../../core/utils/vehicleDisplay';
import { VehicleRosterData, AttentionItem } from '../types';
interface VehicleRosterCardProps {
data: VehicleRosterData;
onClick: (vehicleId: string) => void;
}
const getHealthBadgeClass = (health: VehicleRosterData['health']): string => {
switch (health) {
case 'green':
return 'bg-emerald-500';
case 'yellow':
return 'bg-amber-500';
case 'red':
return 'bg-red-500';
}
};
const getAttentionItemClass = (urgency: AttentionItem['urgency']): string => {
switch (urgency) {
case 'overdue':
return 'text-red-600 dark:text-red-400';
case 'due-soon':
return 'text-amber-600 dark:text-amber-400';
case 'upcoming':
return 'text-slate-500 dark:text-titanio';
}
};
const formatAttentionStatus = (item: AttentionItem): string => {
if (item.urgency === 'overdue') {
return 'OVERDUE';
}
return `${item.daysUntilDue} days`;
};
export const VehicleRosterCard: React.FC<VehicleRosterCardProps> = ({
data,
onClick,
}) => {
const { vehicle, health, attentionItems } = data;
const displayedItems = attentionItems.slice(0, 3);
return (
<GlassCard onClick={() => onClick(vehicle.id)}>
{/* Top row: Image, Label, Health Badge */}
<div className="flex items-center gap-3 mb-3">
{/* Vehicle image container - clips the built-in mb-2 margin */}
<div className="w-12 h-12 rounded-lg overflow-hidden flex-shrink-0">
<VehicleImage vehicle={vehicle} height={48} borderRadius={2} />
</div>
{/* Vehicle label */}
<div className="flex-1 min-w-0">
<div className="text-base font-semibold text-slate-800 dark:text-avus truncate">
{getVehicleLabel(vehicle)}
</div>
</div>
{/* Health badge */}
<div
className={clsx(
'w-3 h-3 rounded-full flex-shrink-0',
getHealthBadgeClass(health)
)}
/>
</div>
{/* Attention items */}
<div className="mb-3 space-y-1">
{displayedItems.length === 0 ? (
<div className="text-sm text-emerald-600 dark:text-emerald-400">
All clear
</div>
) : (
displayedItems.map((item, index) => (
<div
key={index}
className={clsx('text-sm', getAttentionItemClass(item.urgency))}
>
{item.label} - {formatAttentionStatus(item)}
</div>
))
)}
</div>
{/* Odometer */}
<div className="text-sm text-slate-500 dark:text-titanio">
{vehicle.odometerReading.toLocaleString()} mi
</div>
</GlassCard>
);
};

View File

@@ -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);
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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');
});
});
});

View File

@@ -1,29 +1,32 @@
/** /**
* @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 { VehicleRosterData } from '../types';
import { computeVehicleHealth } from '../utils/computeVehicleHealth';
export { computeVehicleHealth };
/**
* 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 * Unified hook that fetches all dashboard data in a single query.
* Prevents duplicate API calls for vehicles and maintenance schedules * 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 +34,58 @@ 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 {
// Calculate summary stats const [insuranceDocs, registrationDocs] = await Promise.all([
const thirtyDaysFromNow = new Date(); documentsApi.list({ type: 'insurance' }),
thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30); documentsApi.list({ type: 'registration' }),
]);
const upcomingMaintenance = flatSchedules.filter(schedule => { expiryDocs = [...insuranceDocs, ...registrationDocs]
if (!schedule.nextDueDate) return false; .filter(d => d.expirationDate != null);
const dueDate = new Date(schedule.nextDueDate); } catch {
return dueDate >= now && dueDate <= thirtyDaysFromNow; // Gracefully degrade: dashboard still works with maintenance-only health data
});
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,
});
}
} }
// Sort by priority (high -> medium -> low) // Group documents by vehicleId
const priorityOrder = { high: 0, medium: 1, low: 2 }; const documentsByVehicle = new Map<string, DocumentRecord[]>();
vehiclesNeedingAttention.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]); 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 // Compute roster data per vehicle
const vehicleMap = new Map(vehicles.map(v => [v.id, v])); 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 => ({ return { vehicles, schedulesByVehicle, documentsByVehicle, roster };
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: unknown) => {
if (error?.response?.status === 401 && failureCount < 3) { const status = (error as { response?: { status?: number } })?.response?.status;
console.log(`[Mobile Auth] Dashboard retry ${failureCount + 1}/3 for 401 error`); if (status === 401 && failureCount < 3) {
return true; return true;
} }
return false; return false;
@@ -157,44 +95,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,9 +4,7 @@
export { DashboardScreen } from './components/DashboardScreen'; export { DashboardScreen } from './components/DashboardScreen';
export { DashboardPage } from './pages/DashboardPage'; export { DashboardPage } from './pages/DashboardPage';
export { SummaryCards, SummaryCardsSkeleton } from './components/SummaryCards'; export { VehicleRosterCard } from './components/VehicleRosterCard';
export { VehicleAttention, VehicleAttentionSkeleton } from './components/VehicleAttention'; export { ActionBar } from './components/ActionBar';
export { QuickActions, QuickActionsSkeleton } from './components/QuickActions'; export { useVehicleRoster } from './hooks/useDashboardData';
export { RecentActivity, RecentActivitySkeleton } from './components/RecentActivity'; export type { VehicleHealth, AttentionItem, VehicleRosterData } from './types';
export { useDashboardSummary, useVehiclesNeedingAttention, useRecentActivity } from './hooks/useDashboardData';
export type { DashboardSummary, VehicleNeedingAttention, RecentActivityItem, DashboardData } from './types';

View File

@@ -4,7 +4,7 @@
import React from 'react'; import React from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Box, Typography } from '@mui/material'; import { Box } from '@mui/material';
import { DashboardScreen } from '../components/DashboardScreen'; import { DashboardScreen } from '../components/DashboardScreen';
import { MobileScreen } from '../../../core/store'; import { MobileScreen } from '../../../core/store';
import { Vehicle } from '../../vehicles/types/vehicles.types'; import { Vehicle } from '../../vehicles/types/vehicles.types';
@@ -49,9 +49,6 @@ export const DashboardPage: React.FC = () => {
return ( return (
<Box sx={{ py: 2 }}> <Box sx={{ py: 2 }}>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary', mb: 4 }}>
Dashboard
</Typography>
<DashboardScreen <DashboardScreen
onNavigate={handleNavigate} onNavigate={handleNavigate}
onVehicleClick={handleVehicleClick} onVehicleClick={handleVehicleClick}

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

View File

@@ -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) };
}