feat: add recent activity feed to dashboard (refs #166)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,8 @@ import CloseIcon from '@mui/icons-material/Close';
|
||||
import { SummaryCards, SummaryCardsSkeleton } from './SummaryCards';
|
||||
import { VehicleAttention, VehicleAttentionSkeleton } from './VehicleAttention';
|
||||
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 { Button } from '../../../shared-minimal/components/Button';
|
||||
import { PendingAssociationBanner } from '../../email-ingestion/components/PendingAssociationBanner';
|
||||
@@ -37,6 +38,7 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
|
||||
const [showPendingReceipts, setShowPendingReceipts] = useState(false);
|
||||
const { data: summary, isLoading: summaryLoading, error: summaryError } = useDashboardSummary();
|
||||
const { data: vehiclesNeedingAttention, isLoading: attentionLoading, error: attentionError } = useVehiclesNeedingAttention();
|
||||
const { data: recentActivity } = useRecentActivity();
|
||||
|
||||
// Error state
|
||||
if (summaryError || attentionError) {
|
||||
@@ -72,6 +74,7 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
|
||||
<div className="space-y-6">
|
||||
<SummaryCardsSkeleton />
|
||||
<VehicleAttentionSkeleton />
|
||||
<RecentActivitySkeleton />
|
||||
<QuickActionsSkeleton />
|
||||
</div>
|
||||
);
|
||||
@@ -127,6 +130,9 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Recent Activity */}
|
||||
{recentActivity && <RecentActivity items={recentActivity} />}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<QuickActions
|
||||
onAddVehicle={onAddVehicle ?? (() => onNavigate?.('Vehicles'))}
|
||||
|
||||
118
frontend/src/features/dashboard/components/RecentActivity.tsx
Normal file
118
frontend/src/features/dashboard/components/RecentActivity.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -8,8 +8,9 @@ import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { vehiclesApi } from '../../vehicles/api/vehicles.api';
|
||||
import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.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 { getVehicleLabel } from '@/core/utils/vehicleDisplay';
|
||||
|
||||
/**
|
||||
* Combined dashboard data structure
|
||||
@@ -17,6 +18,7 @@ import { MaintenanceSchedule } from '../../maintenance/types/maintenance.types';
|
||||
interface DashboardData {
|
||||
summary: DashboardSummary;
|
||||
vehiclesNeedingAttention: VehicleNeedingAttention[];
|
||||
recentActivity: RecentActivityItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,7 +117,30 @@ export const useDashboardData = () => {
|
||||
const priorityOrder = { high: 0, medium: 1, low: 2 };
|
||||
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,
|
||||
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)
|
||||
* Derives from unified dashboard data query
|
||||
|
||||
@@ -7,5 +7,6 @@ export { DashboardPage } from './pages/DashboardPage';
|
||||
export { SummaryCards, SummaryCardsSkeleton } from './components/SummaryCards';
|
||||
export { VehicleAttention, VehicleAttentionSkeleton } from './components/VehicleAttention';
|
||||
export { QuickActions, QuickActionsSkeleton } from './components/QuickActions';
|
||||
export { useDashboardSummary, useVehiclesNeedingAttention } from './hooks/useDashboardData';
|
||||
export type { DashboardSummary, VehicleNeedingAttention, DashboardData } from './types';
|
||||
export { RecentActivity, RecentActivitySkeleton } from './components/RecentActivity';
|
||||
export { useDashboardSummary, useVehiclesNeedingAttention, useRecentActivity } from './hooks/useDashboardData';
|
||||
export type { DashboardSummary, VehicleNeedingAttention, RecentActivityItem, DashboardData } from './types';
|
||||
|
||||
@@ -15,7 +15,16 @@ export interface VehicleNeedingAttention extends Vehicle {
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
export interface RecentActivityItem {
|
||||
type: 'fuel' | 'maintenance';
|
||||
vehicleId: string;
|
||||
vehicleName: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface DashboardData {
|
||||
summary: DashboardSummary;
|
||||
vehiclesNeedingAttention: VehicleNeedingAttention[];
|
||||
recentActivity: RecentActivityItem[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user