feat: add recent activity feed to dashboard (refs #166)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-02-13 19:48:06 -06:00
parent accb0533c6
commit f2b20aab1a
5 changed files with 179 additions and 5 deletions

View File

@@ -10,7 +10,8 @@ import CloseIcon from '@mui/icons-material/Close';
import { SummaryCards, SummaryCardsSkeleton } from './SummaryCards'; import { SummaryCards, SummaryCardsSkeleton } from './SummaryCards';
import { VehicleAttention, VehicleAttentionSkeleton } from './VehicleAttention'; import { VehicleAttention, VehicleAttentionSkeleton } from './VehicleAttention';
import { QuickActions, QuickActionsSkeleton } from './QuickActions'; import { QuickActions, QuickActionsSkeleton } from './QuickActions';
import { useDashboardSummary, useVehiclesNeedingAttention } 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';
@@ -37,6 +38,7 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
const [showPendingReceipts, setShowPendingReceipts] = useState(false); const [showPendingReceipts, setShowPendingReceipts] = useState(false);
const { data: summary, isLoading: summaryLoading, error: summaryError } = useDashboardSummary(); const { data: summary, isLoading: summaryLoading, error: summaryError } = useDashboardSummary();
const { data: vehiclesNeedingAttention, isLoading: attentionLoading, error: attentionError } = useVehiclesNeedingAttention(); const { data: vehiclesNeedingAttention, isLoading: attentionLoading, error: attentionError } = useVehiclesNeedingAttention();
const { data: recentActivity } = useRecentActivity();
// Error state // Error state
if (summaryError || attentionError) { if (summaryError || attentionError) {
@@ -72,6 +74,7 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
<div className="space-y-6"> <div className="space-y-6">
<SummaryCardsSkeleton /> <SummaryCardsSkeleton />
<VehicleAttentionSkeleton /> <VehicleAttentionSkeleton />
<RecentActivitySkeleton />
<QuickActionsSkeleton /> <QuickActionsSkeleton />
</div> </div>
); );
@@ -127,6 +130,9 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
/> />
)} )}
{/* Recent Activity */}
{recentActivity && <RecentActivity items={recentActivity} />}
{/* Quick Actions */} {/* Quick Actions */}
<QuickActions <QuickActions
onAddVehicle={onAddVehicle ?? (() => onNavigate?.('Vehicles'))} onAddVehicle={onAddVehicle ?? (() => onNavigate?.('Vehicles'))}

View File

@@ -0,0 +1,118 @@
/**
* @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

@@ -8,8 +8,9 @@ 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 { 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 } from '../types'; import { DashboardSummary, VehicleNeedingAttention, RecentActivityItem } from '../types';
import { MaintenanceSchedule } from '../../maintenance/types/maintenance.types'; import { MaintenanceSchedule } from '../../maintenance/types/maintenance.types';
import { getVehicleLabel } from '@/core/utils/vehicleDisplay';
/** /**
* Combined dashboard data structure * Combined dashboard data structure
@@ -17,6 +18,7 @@ import { MaintenanceSchedule } from '../../maintenance/types/maintenance.types';
interface DashboardData { interface DashboardData {
summary: DashboardSummary; summary: DashboardSummary;
vehiclesNeedingAttention: VehicleNeedingAttention[]; vehiclesNeedingAttention: VehicleNeedingAttention[];
recentActivity: RecentActivityItem[];
} }
/** /**
@@ -115,7 +117,30 @@ export const useDashboardData = () => {
const priorityOrder = { high: 0, medium: 1, low: 2 }; const priorityOrder = { high: 0, medium: 1, low: 2 };
vehiclesNeedingAttention.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]); vehiclesNeedingAttention.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);
return { summary, vehiclesNeedingAttention }; // Build recent activity feed
const vehicleMap = new Map(vehicles.map(v => [v.id, v]));
const fuelActivity: RecentActivityItem[] = recentFuelLogs.map(log => ({
type: 'fuel' as const,
vehicleId: log.vehicleId,
vehicleName: getVehicleLabel(vehicleMap.get(log.vehicleId)),
description: `Filled ${log.fuelUnits.toFixed(1)} gal at $${log.costPerUnit.toFixed(2)}/gal`,
timestamp: log.dateTime,
}));
const maintenanceActivity: RecentActivityItem[] = upcomingMaintenance.map(schedule => ({
type: 'maintenance' as const,
vehicleId: schedule.vehicleId,
vehicleName: getVehicleLabel(vehicleMap.get(schedule.vehicleId)),
description: `${schedule.category.replace(/_/g, ' ')} due${schedule.nextDueDate ? ` ${new Date(schedule.nextDueDate).toLocaleDateString()}` : ''}`,
timestamp: schedule.nextDueDate || now.toISOString(),
}));
const recentActivity = [...fuelActivity, ...maintenanceActivity]
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, 7);
return { summary, vehiclesNeedingAttention, recentActivity };
}, },
enabled: isAuthenticated && !authLoading, enabled: isAuthenticated && !authLoading,
staleTime: 2 * 60 * 1000, // 2 minutes staleTime: 2 * 60 * 1000, // 2 minutes
@@ -146,6 +171,21 @@ export const useDashboardSummary = () => {
}; };
}; };
/**
* 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) * Hook to fetch vehicles needing attention (overdue maintenance)
* Derives from unified dashboard data query * Derives from unified dashboard data query

View File

@@ -7,5 +7,6 @@ export { DashboardPage } from './pages/DashboardPage';
export { SummaryCards, SummaryCardsSkeleton } from './components/SummaryCards'; export { SummaryCards, SummaryCardsSkeleton } from './components/SummaryCards';
export { VehicleAttention, VehicleAttentionSkeleton } from './components/VehicleAttention'; export { VehicleAttention, VehicleAttentionSkeleton } from './components/VehicleAttention';
export { QuickActions, QuickActionsSkeleton } from './components/QuickActions'; export { QuickActions, QuickActionsSkeleton } from './components/QuickActions';
export { useDashboardSummary, useVehiclesNeedingAttention } from './hooks/useDashboardData'; export { RecentActivity, RecentActivitySkeleton } from './components/RecentActivity';
export type { DashboardSummary, VehicleNeedingAttention, DashboardData } from './types'; export { useDashboardSummary, useVehiclesNeedingAttention, useRecentActivity } from './hooks/useDashboardData';
export type { DashboardSummary, VehicleNeedingAttention, RecentActivityItem, DashboardData } from './types';

View File

@@ -15,7 +15,16 @@ export interface VehicleNeedingAttention extends Vehicle {
priority: 'high' | 'medium' | 'low'; priority: 'high' | 'medium' | 'low';
} }
export interface RecentActivityItem {
type: 'fuel' | 'maintenance';
vehicleId: string;
vehicleName: string;
description: string;
timestamp: string;
}
export interface DashboardData { export interface DashboardData {
summary: DashboardSummary; summary: DashboardSummary;
vehiclesNeedingAttention: VehicleNeedingAttention[]; vehiclesNeedingAttention: VehicleNeedingAttention[];
recentActivity: RecentActivityItem[];
} }