localgreenchain/lib/analytics/aggregator.ts
Claude 816c3b3f2e
Implement Agent 7: Advanced Analytics Dashboard
Add comprehensive analytics system with:
- Analytics data layer (aggregator, metrics, trends, cache)
- 6 API endpoints (overview, plants, transport, farms, sustainability, export)
- 6 chart components (LineChart, BarChart, PieChart, AreaChart, Gauge, Heatmap)
- 5 dashboard widgets (KPICard, TrendIndicator, DataTable, DateRangePicker, FilterPanel)
- 5 dashboard pages (overview, plants, transport, farms, sustainability)
- Export functionality (CSV, JSON)

Dependencies added: recharts, d3, date-fns

Also includes minor fixes:
- Fix EnvironmentalForm spread type error
- Fix AgentOrchestrator Map iteration issues
- Fix next.config.js image domains undefined error
- Add downlevelIteration to tsconfig
2025-11-23 04:02:07 +00:00

406 lines
14 KiB
TypeScript

/**
* Data Aggregator for Analytics
* Aggregates data from various sources for analytics dashboards
*/
import {
AnalyticsOverview,
PlantAnalytics,
TransportAnalytics,
FarmAnalytics,
SustainabilityAnalytics,
TimeRange,
DateRange,
TimeSeriesDataPoint,
AnalyticsFilters,
AggregationConfig,
GroupByPeriod,
} from './types';
import { subDays, subMonths, startOfDay, endOfDay, format, eachDayOfInterval, parseISO } from 'date-fns';
// Mock data generators for demonstration - in production these would query actual databases
/**
* Get date range from TimeRange enum
*/
export function getDateRangeFromTimeRange(timeRange: TimeRange): DateRange {
const end = endOfDay(new Date());
let start: Date;
switch (timeRange) {
case '7d':
start = startOfDay(subDays(new Date(), 7));
break;
case '30d':
start = startOfDay(subDays(new Date(), 30));
break;
case '90d':
start = startOfDay(subDays(new Date(), 90));
break;
case '365d':
start = startOfDay(subDays(new Date(), 365));
break;
case 'all':
default:
start = startOfDay(subMonths(new Date(), 24)); // Default to 2 years
}
return { start, end };
}
/**
* Generate time series data points for a date range
*/
export function generateTimeSeriesPoints(
dateRange: DateRange,
valueGenerator: (date: Date, index: number) => number
): TimeSeriesDataPoint[] {
const days = eachDayOfInterval({ start: dateRange.start, end: dateRange.end });
return days.map((day, index) => ({
timestamp: format(day, 'yyyy-MM-dd'),
value: valueGenerator(day, index),
label: format(day, 'MMM d'),
}));
}
/**
* Aggregate data by time period
*/
export function aggregateByPeriod<T>(
data: T[],
dateField: keyof T,
valueField: keyof T,
period: GroupByPeriod
): Record<string, number> {
const aggregated: Record<string, number> = {};
data.forEach((item) => {
const date = parseISO(item[dateField] as string);
let key: string;
switch (period) {
case 'hour':
key = format(date, 'yyyy-MM-dd HH:00');
break;
case 'day':
key = format(date, 'yyyy-MM-dd');
break;
case 'week':
key = format(date, "yyyy-'W'ww");
break;
case 'month':
key = format(date, 'yyyy-MM');
break;
case 'year':
key = format(date, 'yyyy');
break;
}
aggregated[key] = (aggregated[key] || 0) + (item[valueField] as number);
});
return aggregated;
}
/**
* Calculate percentage change between two values
*/
export function calculateChange(current: number, previous: number): { change: number; percent: number } {
const change = current - previous;
const percent = previous !== 0 ? (change / previous) * 100 : current > 0 ? 100 : 0;
return { change, percent };
}
/**
* Get analytics overview with aggregated metrics
*/
export async function getAnalyticsOverview(
filters: AnalyticsFilters = { timeRange: '30d' }
): Promise<AnalyticsOverview> {
const dateRange = filters.dateRange || getDateRangeFromTimeRange(filters.timeRange);
// In production, these would be actual database queries
// For now, generate realistic mock data
const baseValue = 1000 + Math.random() * 500;
return {
totalPlants: Math.floor(baseValue * 1.5),
plantsRegisteredToday: Math.floor(Math.random() * 15 + 5),
plantsRegisteredThisWeek: Math.floor(Math.random() * 80 + 40),
plantsRegisteredThisMonth: Math.floor(Math.random() * 250 + 150),
totalTransportEvents: Math.floor(baseValue * 2.3),
totalCarbonKg: Math.round((Math.random() * 500 + 200) * 100) / 100,
totalFoodMiles: Math.round((Math.random() * 10000 + 5000) * 10) / 10,
activeUsers: Math.floor(Math.random() * 200 + 100),
growthRate: Math.round((Math.random() * 20 + 5) * 10) / 10,
trendsData: [
{
metric: 'Plants',
currentValue: Math.floor(baseValue * 1.5),
previousValue: Math.floor(baseValue * 1.35),
change: Math.floor(baseValue * 0.15),
changePercent: 11.1,
direction: 'up',
period: filters.timeRange,
},
{
metric: 'Carbon Saved',
currentValue: Math.round((Math.random() * 200 + 100) * 10) / 10,
previousValue: Math.round((Math.random() * 180 + 90) * 10) / 10,
change: Math.round((Math.random() * 20 + 10) * 10) / 10,
changePercent: 12.5,
direction: 'up',
period: filters.timeRange,
},
{
metric: 'Active Users',
currentValue: Math.floor(Math.random() * 200 + 100),
previousValue: Math.floor(Math.random() * 180 + 90),
change: Math.floor(Math.random() * 30 + 10),
changePercent: 8.3,
direction: 'up',
period: filters.timeRange,
},
{
metric: 'Food Miles',
currentValue: Math.round((Math.random() * 5000 + 2500) * 10) / 10,
previousValue: Math.round((Math.random() * 5500 + 2800) * 10) / 10,
change: -Math.round((Math.random() * 500 + 200) * 10) / 10,
changePercent: -8.7,
direction: 'down',
period: filters.timeRange,
},
],
};
}
/**
* Get plant-specific analytics
*/
export async function getPlantAnalytics(
filters: AnalyticsFilters = { timeRange: '30d' }
): Promise<PlantAnalytics> {
const dateRange = filters.dateRange || getDateRangeFromTimeRange(filters.timeRange);
const speciesData = [
{ species: 'Tomato', count: 245, percentage: 28.5, trend: 'up' as const },
{ species: 'Lettuce', count: 198, percentage: 23.0, trend: 'up' as const },
{ species: 'Pepper', count: 156, percentage: 18.1, trend: 'stable' as const },
{ species: 'Basil', count: 134, percentage: 15.6, trend: 'up' as const },
{ species: 'Cucumber', count: 87, percentage: 10.1, trend: 'down' as const },
{ species: 'Other', count: 41, percentage: 4.7, trend: 'stable' as const },
];
return {
totalPlants: speciesData.reduce((sum, s) => sum + s.count, 0),
plantsBySpecies: speciesData,
plantsByGeneration: [
{ generation: 1, count: 340, percentage: 39.5 },
{ generation: 2, count: 280, percentage: 32.5 },
{ generation: 3, count: 156, percentage: 18.1 },
{ generation: 4, count: 68, percentage: 7.9 },
{ generation: 5, count: 17, percentage: 2.0 },
],
registrationsTrend: generateTimeSeriesPoints(dateRange, (_, i) =>
Math.floor(Math.random() * 15 + 5 + Math.sin(i / 7) * 5)
),
averageLineageDepth: 2.3,
topGrowers: [
{ userId: 'user-1', name: 'Green Gardens Co', totalPlants: 145, totalSpecies: 12, averageGeneration: 2.1 },
{ userId: 'user-2', name: 'Urban Farm LLC', totalPlants: 98, totalSpecies: 8, averageGeneration: 1.8 },
{ userId: 'user-3', name: 'Local Seeds Inc', totalPlants: 76, totalSpecies: 15, averageGeneration: 3.2 },
],
recentRegistrations: [
{ id: 'plant-1', name: 'Cherry Tomato #245', species: 'Tomato', registeredAt: new Date().toISOString(), generation: 3 },
{ id: 'plant-2', name: 'Butterhead Lettuce', species: 'Lettuce', registeredAt: new Date().toISOString(), generation: 2 },
{ id: 'plant-3', name: 'Sweet Basil', species: 'Basil', registeredAt: new Date().toISOString(), generation: 1 },
],
};
}
/**
* Get transport analytics
*/
export async function getTransportAnalytics(
filters: AnalyticsFilters = { timeRange: '30d' }
): Promise<TransportAnalytics> {
const dateRange = filters.dateRange || getDateRangeFromTimeRange(filters.timeRange);
return {
totalEvents: 2847,
totalDistanceKm: 15234.5,
totalCarbonKg: 487.3,
carbonSavedKg: 1256.8,
eventsByType: [
{ eventType: 'seed_acquisition', count: 423, percentage: 14.9, carbonKg: 52.3 },
{ eventType: 'growing_transport', count: 687, percentage: 24.1, carbonKg: 112.4 },
{ eventType: 'harvest', count: 534, percentage: 18.8, carbonKg: 45.2 },
{ eventType: 'distribution', count: 756, percentage: 26.6, carbonKg: 178.9 },
{ eventType: 'consumer_delivery', count: 447, percentage: 15.7, carbonKg: 98.5 },
],
eventsByMethod: [
{ method: 'walking', count: 312, percentage: 11.0, distanceKm: 156, carbonKg: 0, efficiency: 100 },
{ method: 'bicycle', count: 534, percentage: 18.8, distanceKm: 1602, carbonKg: 0, efficiency: 100 },
{ method: 'electric_vehicle', count: 687, percentage: 24.1, distanceKm: 4806, carbonKg: 72.1, efficiency: 85 },
{ method: 'gasoline_vehicle', count: 756, percentage: 26.6, distanceKm: 5292, carbonKg: 264.6, efficiency: 45 },
{ method: 'local_delivery', count: 558, percentage: 19.6, distanceKm: 3378, carbonKg: 150.6, efficiency: 60 },
],
dailyStats: generateTimeSeriesPoints(dateRange, (_, i) => ({
date: format(dateRange.start, 'yyyy-MM-dd'),
eventCount: Math.floor(Math.random() * 80 + 40),
distanceKm: Math.round((Math.random() * 500 + 200) * 10) / 10,
carbonKg: Math.round((Math.random() * 20 + 5) * 100) / 100,
})).map(p => ({
date: p.timestamp,
eventCount: p.value,
distanceKm: Math.round((Math.random() * 500 + 200) * 10) / 10,
carbonKg: Math.round((Math.random() * 20 + 5) * 100) / 100,
})),
averageDistancePerEvent: 5.35,
mostEfficientRoutes: [
{ from: 'Local Farm A', to: 'Community Center', method: 'bicycle', distanceKm: 2.3, carbonKg: 0, frequency: 45 },
{ from: 'Urban Garden', to: 'Farmers Market', method: 'walking', distanceKm: 0.8, carbonKg: 0, frequency: 38 },
{ from: 'Rooftop Farm', to: 'Restaurant Row', method: 'electric_vehicle', distanceKm: 4.5, carbonKg: 0.07, frequency: 32 },
],
carbonTrend: generateTimeSeriesPoints(dateRange, (_, i) =>
Math.round((Math.random() * 15 + 10 - i * 0.1) * 100) / 100
),
};
}
/**
* Get farm analytics
*/
export async function getFarmAnalytics(
filters: AnalyticsFilters = { timeRange: '30d' }
): Promise<FarmAnalytics> {
const dateRange = filters.dateRange || getDateRangeFromTimeRange(filters.timeRange);
return {
totalFarms: 24,
totalZones: 156,
activeBatches: 89,
completedBatches: 234,
averageYieldKg: 45.6,
resourceUsage: {
waterLiters: 125000,
energyKwh: 8500,
nutrientsKg: 450,
waterEfficiency: 87.5,
energyEfficiency: 92.3,
},
performanceByZone: [
{ zoneId: 'zone-1', zoneName: 'Zone A - Leafy Greens', currentCrop: 'Lettuce', healthScore: 94, yieldKg: 52.3, efficiency: 91 },
{ zoneId: 'zone-2', zoneName: 'Zone B - Herbs', currentCrop: 'Basil', healthScore: 88, yieldKg: 38.7, efficiency: 85 },
{ zoneId: 'zone-3', zoneName: 'Zone C - Tomatoes', currentCrop: 'Cherry Tomato', healthScore: 92, yieldKg: 67.4, efficiency: 89 },
{ zoneId: 'zone-4', zoneName: 'Zone D - Microgreens', currentCrop: 'Mixed Micro', healthScore: 96, yieldKg: 24.1, efficiency: 94 },
],
batchCompletionTrend: generateTimeSeriesPoints(dateRange, (_, i) =>
Math.floor(Math.random() * 5 + 2)
),
yieldPredictions: [
{ cropType: 'Lettuce', predictedYieldKg: 156.5, confidence: 0.92, harvestDate: format(subDays(new Date(), -7), 'yyyy-MM-dd') },
{ cropType: 'Tomato', predictedYieldKg: 234.8, confidence: 0.87, harvestDate: format(subDays(new Date(), -14), 'yyyy-MM-dd') },
{ cropType: 'Basil', predictedYieldKg: 45.2, confidence: 0.94, harvestDate: format(subDays(new Date(), -5), 'yyyy-MM-dd') },
],
topPerformingCrops: [
{ cropType: 'Lettuce', averageYieldKg: 48.3, growthDays: 28, successRate: 94.5, batches: 45 },
{ cropType: 'Basil', averageYieldKg: 12.4, growthDays: 21, successRate: 91.2, batches: 38 },
{ cropType: 'Cherry Tomato', averageYieldKg: 67.8, growthDays: 65, successRate: 88.7, batches: 22 },
{ cropType: 'Microgreens', averageYieldKg: 5.6, growthDays: 14, successRate: 96.8, batches: 67 },
],
};
}
/**
* Get sustainability analytics
*/
export async function getSustainabilityAnalytics(
filters: AnalyticsFilters = { timeRange: '30d' }
): Promise<SustainabilityAnalytics> {
const dateRange = filters.dateRange || getDateRangeFromTimeRange(filters.timeRange);
return {
overallScore: 82.5,
carbonFootprint: {
totalEmittedKg: 487.3,
totalSavedKg: 1256.8,
netImpactKg: -769.5,
reductionPercentage: 72.1,
equivalentTrees: 38.4,
monthlyTrend: generateTimeSeriesPoints(dateRange, (_, i) =>
Math.round((50 - i * 0.5 + Math.random() * 10) * 10) / 10
),
},
foodMiles: {
totalMiles: 15234.5,
averageMilesPerPlant: 17.7,
savedMiles: 48672.3,
localPercentage: 76.2,
monthlyTrend: generateTimeSeriesPoints(dateRange, (_, i) =>
Math.round((600 - i * 5 + Math.random() * 100) * 10) / 10
),
},
waterUsage: {
totalUsedLiters: 125000,
savedLiters: 87500,
efficiencyScore: 87.5,
perKgProduce: 2.8,
},
localProduction: {
localCount: 654,
totalCount: 861,
percentage: 76.0,
trend: 'up',
},
goals: [
{ id: 'goal-1', name: 'Carbon Neutral by 2025', target: 0, current: 487.3, unit: 'kg CO2', progress: 72, deadline: '2025-12-31', status: 'on_track' },
{ id: 'goal-2', name: '80% Local Production', target: 80, current: 76, unit: '%', progress: 95, deadline: '2024-12-31', status: 'on_track' },
{ id: 'goal-3', name: 'Reduce Food Miles 50%', target: 50, current: 38, unit: '%', progress: 76, deadline: '2024-06-30', status: 'at_risk' },
{ id: 'goal-4', name: 'Water Efficiency 90%', target: 90, current: 87.5, unit: '%', progress: 97, deadline: '2024-12-31', status: 'on_track' },
],
trends: [
{
metric: 'Carbon Reduction',
values: generateTimeSeriesPoints(dateRange, (_, i) =>
Math.round((65 + i * 0.3 + Math.random() * 5) * 10) / 10
),
},
{
metric: 'Local Production',
values: generateTimeSeriesPoints(dateRange, (_, i) =>
Math.round((70 + i * 0.2 + Math.random() * 3) * 10) / 10
),
},
],
};
}
/**
* Cache management for analytics data
*/
const analyticsCache = new Map<string, { data: any; timestamp: number }>();
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
export function getCachedData<T>(key: string): T | null {
const cached = analyticsCache.get(key);
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
return cached.data as T;
}
return null;
}
export function setCachedData<T>(key: string, data: T): void {
analyticsCache.set(key, { data, timestamp: Date.now() });
}
export function clearCache(): void {
analyticsCache.clear();
}
/**
* Generate cache key from filters
*/
export function generateCacheKey(prefix: string, filters: AnalyticsFilters): string {
return `${prefix}-${JSON.stringify(filters)}`;
}