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 { 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'))}
|
||||||
|
|||||||
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 { 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
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user