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:
2025-11-17 00:13:07 +00:00
parent ea08bf4289
commit d91a13da60
16 changed files with 1051 additions and 78 deletions

View File

@@ -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 }) => {

View File

@@ -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 (

View File

@@ -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>

View File

@@ -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"
/>

View File

@@ -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;
}
/**

View File

@@ -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