feat: implement analytics endpoints (task 28)
- Created analytics service with summary, trends, comparison, and aggregated data methods - Implemented 5 analytics endpoints: summary, trends, comparison, aggregated, and CSV export - Added time series data with day/week/month interval support - Implemented period-over-period comparison metrics with percentage changes - Added CSV export functionality using json2csv - Updated frontend analytics service to unwrap API responses - Fixed chart components to work with new data structure (reportCount, eventCount, litterCollected) - Updated TypeScript types to match backend API structure - All endpoints tested and verified working Requirements satisfied: 5.1, 5.2, 5.3, 5.4, 5.5
This commit is contained in:
@@ -47,11 +47,14 @@ export function LitterTypeChart({ data, loading }: LitterTypeChartProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate total for percentages
|
||||
const total = data.reduce((sum, item) => sum + item.count, 0);
|
||||
|
||||
// Format data for the chart
|
||||
const chartData = data.map(item => ({
|
||||
name: LITTER_TYPE_LABELS[item.type] || item.type,
|
||||
value: item.count,
|
||||
percentage: item.percentage,
|
||||
percentage: total > 0 ? (item.count / total) * 100 : 0,
|
||||
}));
|
||||
|
||||
const renderCustomLabel = (entry: { name: string; percentage: number }) => {
|
||||
|
||||
@@ -43,12 +43,15 @@ export function QuantityChart({ data, loading }: QuantityChartProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate total for percentages
|
||||
const total = data.reduce((sum, item) => sum + item.count, 0);
|
||||
|
||||
// Format data for the chart
|
||||
const chartData = data.map(item => ({
|
||||
name: QUANTITY_LABELS[item.quantity] || item.quantity,
|
||||
name: QUANTITY_LABELS[item.level] || item.level,
|
||||
count: item.count,
|
||||
percentage: item.percentage,
|
||||
quantity: item.quantity,
|
||||
percentage: total > 0 ? (item.count / total) * 100 : 0,
|
||||
quantity: item.level,
|
||||
}));
|
||||
|
||||
return (
|
||||
|
||||
@@ -57,17 +57,17 @@ export function TimeSeriesChart({ data, loading }: TimeSeriesChartProps) {
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="reports"
|
||||
dataKey="reportCount"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
name="Reports"
|
||||
dot={{ fill: '#3b82f6', r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
{data.some(d => d.events !== undefined) && (
|
||||
{data.some(d => d.eventCount !== undefined) && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="events"
|
||||
dataKey="eventCount"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
name="Events"
|
||||
@@ -75,6 +75,17 @@ export function TimeSeriesChart({ data, loading }: TimeSeriesChartProps) {
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
)}
|
||||
{data.some(d => d.litterCollected > 0) && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="litterCollected"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth={2}
|
||||
name="Litter Collected (kg)"
|
||||
dot={{ fill: '#f59e0b', r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
@@ -46,6 +46,13 @@ export function AnalyticsPage() {
|
||||
totalEvents: 0,
|
||||
totalUsers: 0,
|
||||
totalLitterCollected: 0,
|
||||
reportsByType: {},
|
||||
reportsByQuantity: {},
|
||||
verifiedReports: 0,
|
||||
cleanedReports: 0,
|
||||
activeUsers: 0,
|
||||
upcomingEvents: 0,
|
||||
completedEvents: 0,
|
||||
});
|
||||
setError('Analytics API not yet implemented. Showing placeholder data.');
|
||||
} finally {
|
||||
@@ -85,29 +92,30 @@ export function AnalyticsPage() {
|
||||
date.setDate(date.getDate() + Math.floor(i * daysDiff / dataPoints));
|
||||
mockTimeSeries.push({
|
||||
date: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
||||
reports: Math.floor(Math.random() * 20) + 5,
|
||||
events: Math.floor(Math.random() * 5),
|
||||
reportCount: Math.floor(Math.random() * 20) + 5,
|
||||
eventCount: Math.floor(Math.random() * 5),
|
||||
litterCollected: Math.floor(Math.random() * 50) + 10,
|
||||
});
|
||||
}
|
||||
setTimeSeriesData(mockTimeSeries);
|
||||
|
||||
// Generate mock litter type distribution
|
||||
const mockLitterTypes: LitterTypeDistribution[] = [
|
||||
{ type: 'plastic', count: 45, percentage: 45 },
|
||||
{ type: 'metal', count: 15, percentage: 15 },
|
||||
{ type: 'glass', count: 12, percentage: 12 },
|
||||
{ type: 'organic', count: 10, percentage: 10 },
|
||||
{ type: 'hazardous', count: 8, percentage: 8 },
|
||||
{ type: 'other', count: 10, percentage: 10 },
|
||||
{ type: 'plastic', count: 45 },
|
||||
{ type: 'metal', count: 15 },
|
||||
{ type: 'glass', count: 12 },
|
||||
{ type: 'organic', count: 10 },
|
||||
{ type: 'hazardous', count: 8 },
|
||||
{ type: 'other', count: 10 },
|
||||
];
|
||||
setLitterTypeData(mockLitterTypes);
|
||||
|
||||
// Generate mock quantity aggregation
|
||||
const mockQuantities: QuantityAggregation[] = [
|
||||
{ quantity: 'minimal', count: 30, percentage: 30 },
|
||||
{ quantity: 'moderate', count: 40, percentage: 40 },
|
||||
{ quantity: 'significant', count: 20, percentage: 20 },
|
||||
{ quantity: 'severe', count: 10, percentage: 10 },
|
||||
{ level: 'minimal', count: 30 },
|
||||
{ level: 'moderate', count: 40 },
|
||||
{ level: 'significant', count: 20 },
|
||||
{ level: 'severe', count: 10 },
|
||||
];
|
||||
setQuantityData(mockQuantities);
|
||||
} finally {
|
||||
@@ -211,14 +219,6 @@ export function AnalyticsPage() {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
}
|
||||
trend={
|
||||
summary.periodComparison
|
||||
? {
|
||||
value: summary.periodComparison.reports,
|
||||
isPositive: summary.periodComparison.reports > 0,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
subtitle="Litter reports submitted"
|
||||
/>
|
||||
|
||||
@@ -230,33 +230,17 @@ export function AnalyticsPage() {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
}
|
||||
trend={
|
||||
summary.periodComparison
|
||||
? {
|
||||
value: summary.periodComparison.events,
|
||||
isPositive: summary.periodComparison.events > 0,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
subtitle="Cleanup events organized"
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
title="Active Users"
|
||||
value={summary.totalUsers.toLocaleString()}
|
||||
value={summary.activeUsers.toLocaleString()}
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
}
|
||||
trend={
|
||||
summary.periodComparison
|
||||
? {
|
||||
value: summary.periodComparison.users,
|
||||
isPositive: summary.periodComparison.users > 0,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
subtitle="Contributing to the platform"
|
||||
/>
|
||||
|
||||
|
||||
@@ -15,13 +15,38 @@ class AnalyticsService {
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
return apiClient.get<AnalyticsSummary>(`/analytics/summary${queryString ? `?${queryString}` : ''}`);
|
||||
const response = await apiClient.get<{ success: boolean; data: AnalyticsSummary }>(`/analytics/summary${queryString ? `?${queryString}` : ''}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time series data for report trends
|
||||
*/
|
||||
async getTimeSeries(dateRange?: { start: string; end: string }): Promise<TimeSeriesDataPoint[]> {
|
||||
async getTimeSeries(
|
||||
dateRange?: { start: string; end: string },
|
||||
interval: 'day' | 'week' | 'month' = 'day'
|
||||
): Promise<TimeSeriesDataPoint[]> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (dateRange) {
|
||||
params.append('startDate', dateRange.start);
|
||||
params.append('endDate', dateRange.end);
|
||||
}
|
||||
params.append('interval', interval);
|
||||
|
||||
const queryString = params.toString();
|
||||
const response = await apiClient.get<{ success: boolean; data: TimeSeriesDataPoint[] }>(`/analytics/trends${queryString ? `?${queryString}` : ''}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aggregated data (litter types, quantities, verification status)
|
||||
*/
|
||||
async getAggregatedData(dateRange?: { start: string; end: string }): Promise<{
|
||||
litterTypes: LitterTypeDistribution[];
|
||||
quantities: QuantityAggregation[];
|
||||
verificationStatus: Array<{ status: string; count: number }>;
|
||||
}> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (dateRange) {
|
||||
@@ -30,37 +55,56 @@ class AnalyticsService {
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
return apiClient.get<TimeSeriesDataPoint[]>(`/analytics/trends${queryString ? `?${queryString}` : ''}`);
|
||||
const response = await apiClient.get<{ success: boolean; data: {
|
||||
litterTypes: LitterTypeDistribution[];
|
||||
quantities: QuantityAggregation[];
|
||||
verificationStatus: Array<{ status: string; count: number }>;
|
||||
} }>(`/analytics/aggregated${queryString ? `?${queryString}` : ''}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get litter type distribution data
|
||||
*/
|
||||
async getLitterTypeDistribution(dateRange?: { start: string; end: string }): Promise<LitterTypeDistribution[]> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (dateRange) {
|
||||
params.append('startDate', dateRange.start);
|
||||
params.append('endDate', dateRange.end);
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
return apiClient.get<LitterTypeDistribution[]>(`/analytics/litter-types${queryString ? `?${queryString}` : ''}`);
|
||||
const data = await this.getAggregatedData(dateRange);
|
||||
return data.litterTypes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get quantity estimation aggregation data
|
||||
*/
|
||||
async getQuantityAggregation(dateRange?: { start: string; end: string }): Promise<QuantityAggregation[]> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (dateRange) {
|
||||
params.append('startDate', dateRange.start);
|
||||
params.append('endDate', dateRange.end);
|
||||
}
|
||||
const data = await this.getAggregatedData(dateRange);
|
||||
return data.quantities;
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
return apiClient.get<QuantityAggregation[]>(`/analytics/quantities${queryString ? `?${queryString}` : ''}`);
|
||||
/**
|
||||
* Get comparison metrics for period-over-period analysis
|
||||
*/
|
||||
async getComparisonMetrics(
|
||||
currentStart: string,
|
||||
currentEnd: string,
|
||||
previousStart: string,
|
||||
previousEnd: string
|
||||
): Promise<{
|
||||
current: { reports: number; events: number; users: number };
|
||||
previous: { reports: number; events: number; users: number };
|
||||
percentageChange: { reports: number; events: number; users: number };
|
||||
}> {
|
||||
const params = new URLSearchParams({
|
||||
currentStart,
|
||||
currentEnd,
|
||||
previousStart,
|
||||
previousEnd,
|
||||
});
|
||||
|
||||
const response = await apiClient.get<{ success: boolean; data: {
|
||||
current: { reports: number; events: number; users: number };
|
||||
previous: { reports: number; events: number; users: number };
|
||||
percentageChange: { reports: number; events: number; users: number };
|
||||
} }>(`/analytics/comparison?${params.toString()}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -149,12 +149,14 @@ export interface AnalyticsSummary {
|
||||
totalReports: number;
|
||||
totalEvents: number;
|
||||
totalUsers: number;
|
||||
totalLitterCollected?: number; // in kilograms
|
||||
periodComparison?: {
|
||||
reports: number; // percentage change
|
||||
events: number;
|
||||
users: number;
|
||||
};
|
||||
totalLitterCollected: number; // in kilograms
|
||||
reportsByType: Record<string, number>;
|
||||
reportsByQuantity: Record<string, number>;
|
||||
verifiedReports: number;
|
||||
cleanedReports: number;
|
||||
activeUsers: number;
|
||||
upcomingEvents: number;
|
||||
completedEvents: number;
|
||||
}
|
||||
|
||||
export interface DateRange {
|
||||
@@ -164,20 +166,19 @@ export interface DateRange {
|
||||
|
||||
export interface TimeSeriesDataPoint {
|
||||
date: string;
|
||||
reports: number;
|
||||
events?: number;
|
||||
reportCount: number;
|
||||
eventCount: number;
|
||||
litterCollected: number;
|
||||
}
|
||||
|
||||
export interface LitterTypeDistribution {
|
||||
type: LitterType;
|
||||
type: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface QuantityAggregation {
|
||||
quantity: QuantityLevel;
|
||||
level: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
// External environmental data types
|
||||
|
||||
Reference in New Issue
Block a user