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
406 lines
14 KiB
TypeScript
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)}`;
|
|
}
|