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:
@@ -168,7 +168,7 @@
|
|||||||
|
|
||||||
## Mobile Optimization
|
## Mobile Optimization
|
||||||
|
|
||||||
- [x] 20. Implement mobile-responsive design
|
- [ ] 20. Implement mobile-responsive design
|
||||||
- Apply Tailwind responsive classes throughout
|
- Apply Tailwind responsive classes throughout
|
||||||
- Optimize map controls for touch interfaces
|
- Optimize map controls for touch interfaces
|
||||||
- Ensure minimum tap target sizes (44x44px)
|
- Ensure minimum tap target sizes (44x44px)
|
||||||
@@ -235,7 +235,7 @@
|
|||||||
- Add participant count tracking
|
- Add participant count tracking
|
||||||
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5_
|
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5_
|
||||||
|
|
||||||
- [ ] 28. Implement analytics endpoints
|
- [x] 28. Implement analytics endpoints
|
||||||
- Create GET /api/analytics/summary for overall statistics
|
- Create GET /api/analytics/summary for overall statistics
|
||||||
- Build time series data endpoint for trends
|
- Build time series data endpoint for trends
|
||||||
- Implement CSV export functionality
|
- Implement CSV export functionality
|
||||||
|
|||||||
0
server/ANALYTICS_ENDPOINTS_IMPLEMENTATION.md
Normal file
0
server/ANALYTICS_ENDPOINTS_IMPLEMENTATION.md
Normal file
0
server/ANALYTICS_IMPLEMENTATION.md
Normal file
0
server/ANALYTICS_IMPLEMENTATION.md
Normal file
52
server/package-lock.json
generated
52
server/package-lock.json
generated
@@ -18,6 +18,7 @@
|
|||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
"express-validator": "^7.2.0",
|
"express-validator": "^7.2.0",
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
|
"json2csv": "^6.0.0-alpha.2",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"redis": "^5.9.0",
|
"redis": "^5.9.0",
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/json2csv": "^5.0.7",
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^22.9.0",
|
"@types/node": "^22.9.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
@@ -778,6 +780,12 @@
|
|||||||
"@redis/client": "^5.9.0"
|
"@redis/client": "^5.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@streamparser/json": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@streamparser/json/-/json-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-vL9EVn/v+OhZ+Wcs6O4iKE9EUpwHUqHmCtNUMWjqp+6dr85+XPOSGTEsqYNq1Vn04uk9SWlOVmx9J48ggJVT2Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/body-parser": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
@@ -841,6 +849,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/json2csv": {
|
||||||
|
"version": "5.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/json2csv/-/json2csv-5.0.7.tgz",
|
||||||
|
"integrity": "sha512-Ma25zw9G9GEBnX8b12R4EYvnFT6dBh8L3jwsN5EUFXa+fl2dqmbLDbNWN0XuQU3rSXdsbBeCYjI9uHU2PUBxhA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/mime": {
|
"node_modules/@types/mime": {
|
||||||
"version": "1.3.5",
|
"version": "1.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||||
@@ -1064,6 +1082,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/commander": {
|
||||||
|
"version": "6.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz",
|
||||||
|
"integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/concat-stream": {
|
"node_modules/concat-stream": {
|
||||||
"version": "1.6.2",
|
"version": "1.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
|
||||||
@@ -1683,12 +1710,37 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/json2csv": {
|
||||||
|
"version": "6.0.0-alpha.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/json2csv/-/json2csv-6.0.0-alpha.2.tgz",
|
||||||
|
"integrity": "sha512-nJ3oP6QxN8z69IT1HmrJdfVxhU1kLTBVgMfRnNZc37YEY+jZ4nU27rBGxT4vaqM/KUCavLRhntmTuBFqZLBUcA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@streamparser/json": "^0.0.6",
|
||||||
|
"commander": "^6.2.0",
|
||||||
|
"lodash.get": "^4.4.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"json2csv": "bin/json2csv.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12",
|
||||||
|
"npm": ">= 6.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.get": {
|
||||||
|
"version": "4.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
|
||||||
|
"integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==",
|
||||||
|
"deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lower-case": {
|
"node_modules/lower-case": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
"express-validator": "^7.2.0",
|
"express-validator": "^7.2.0",
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
|
"json2csv": "^6.0.0-alpha.2",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"redis": "^5.9.0",
|
"redis": "^5.9.0",
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/json2csv": "^5.0.7",
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^22.9.0",
|
"@types/node": "^22.9.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
|
|||||||
201
server/src/controllers/analyticsController.ts
Normal file
201
server/src/controllers/analyticsController.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { analyticsService } from '../services/analyticsService';
|
||||||
|
|
||||||
|
export class AnalyticsController {
|
||||||
|
/**
|
||||||
|
* GET /api/analytics/summary
|
||||||
|
* Get overall statistics summary
|
||||||
|
*/
|
||||||
|
async getSummary(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { startDate, endDate } = req.query;
|
||||||
|
|
||||||
|
let dateRange;
|
||||||
|
if (startDate && endDate) {
|
||||||
|
dateRange = {
|
||||||
|
startDate: new Date(startDate as string),
|
||||||
|
endDate: new Date(endDate as string),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = await analyticsService.getSummary(dateRange);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: summary,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching analytics summary:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
message: error.message || 'Failed to fetch analytics summary',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/analytics/trends
|
||||||
|
* Get time series data for trends
|
||||||
|
*/
|
||||||
|
async getTrends(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { startDate, endDate, interval = 'day' } = req.query;
|
||||||
|
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: {
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
message: 'startDate and endDate are required',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const validIntervals = ['day', 'week', 'month'];
|
||||||
|
if (!validIntervals.includes(interval as string)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: {
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
message: 'interval must be one of: day, week, month',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeSeries = await analyticsService.getTimeSeries(
|
||||||
|
new Date(startDate as string),
|
||||||
|
new Date(endDate as string),
|
||||||
|
interval as 'day' | 'week' | 'month'
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: timeSeries,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching trends:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
message: error.message || 'Failed to fetch trends data',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/analytics/comparison
|
||||||
|
* Get comparison metrics for period-over-period analysis
|
||||||
|
*/
|
||||||
|
async getComparison(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { currentStart, currentEnd, previousStart, previousEnd } = req.query;
|
||||||
|
|
||||||
|
if (!currentStart || !currentEnd || !previousStart || !previousEnd) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: {
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
message: 'currentStart, currentEnd, previousStart, and previousEnd are required',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const comparison = await analyticsService.getComparisonMetrics(
|
||||||
|
new Date(currentStart as string),
|
||||||
|
new Date(currentEnd as string),
|
||||||
|
new Date(previousStart as string),
|
||||||
|
new Date(previousEnd as string)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: comparison,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching comparison metrics:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
message: error.message || 'Failed to fetch comparison metrics',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/analytics/export
|
||||||
|
* Export analytics data to CSV
|
||||||
|
*/
|
||||||
|
async exportCSV(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { startDate, endDate } = req.query;
|
||||||
|
|
||||||
|
let dateRange;
|
||||||
|
if (startDate && endDate) {
|
||||||
|
dateRange = {
|
||||||
|
startDate: new Date(startDate as string),
|
||||||
|
endDate: new Date(endDate as string),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const csv = await analyticsService.exportToCSV(dateRange);
|
||||||
|
|
||||||
|
// Set headers for CSV download
|
||||||
|
res.setHeader('Content-Type', 'text/csv');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="beach-litter-analytics-${new Date().toISOString().split('T')[0]}.csv"`);
|
||||||
|
|
||||||
|
res.send(csv);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error exporting CSV:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
message: error.message || 'Failed to export CSV',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/analytics/aggregated
|
||||||
|
* Get aggregated litter type and quantity data
|
||||||
|
*/
|
||||||
|
async getAggregatedData(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { startDate, endDate } = req.query;
|
||||||
|
|
||||||
|
let dateRange;
|
||||||
|
if (startDate && endDate) {
|
||||||
|
dateRange = {
|
||||||
|
startDate: new Date(startDate as string),
|
||||||
|
endDate: new Date(endDate as string),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const aggregatedData = await analyticsService.getAggregatedData(dateRange);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: aggregatedData,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching aggregated data:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
message: error.message || 'Failed to fetch aggregated data',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const analyticsController = new AnalyticsController();
|
||||||
@@ -7,6 +7,7 @@ import reportRoutes from './routes/reportRoutes';
|
|||||||
import eventRoutes from './routes/eventRoutes';
|
import eventRoutes from './routes/eventRoutes';
|
||||||
import userRoutes from './routes/userRoutes';
|
import userRoutes from './routes/userRoutes';
|
||||||
import externalDataRoutes from './routes/externalDataRoutes';
|
import externalDataRoutes from './routes/externalDataRoutes';
|
||||||
|
import analyticsRoutes from './routes/analyticsRoutes';
|
||||||
|
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
@@ -44,6 +45,7 @@ app.use('/api/reports', reportRoutes);
|
|||||||
app.use('/api/events', eventRoutes);
|
app.use('/api/events', eventRoutes);
|
||||||
app.use('/api/users', userRoutes);
|
app.use('/api/users', userRoutes);
|
||||||
app.use('/api/external', externalDataRoutes);
|
app.use('/api/external', externalDataRoutes);
|
||||||
|
app.use('/api/analytics', analyticsRoutes);
|
||||||
|
|
||||||
// Error handling middleware
|
// Error handling middleware
|
||||||
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
|||||||
13
server/src/routes/analyticsRoutes.ts
Normal file
13
server/src/routes/analyticsRoutes.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { analyticsController } from '../controllers/analyticsController';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Analytics endpoints
|
||||||
|
router.get('/summary', analyticsController.getSummary.bind(analyticsController));
|
||||||
|
router.get('/trends', analyticsController.getTrends.bind(analyticsController));
|
||||||
|
router.get('/comparison', analyticsController.getComparison.bind(analyticsController));
|
||||||
|
router.get('/export', analyticsController.exportCSV.bind(analyticsController));
|
||||||
|
router.get('/aggregated', analyticsController.getAggregatedData.bind(analyticsController));
|
||||||
|
|
||||||
|
export default router;
|
||||||
127
server/src/scripts/testAnalytics.ts
Normal file
127
server/src/scripts/testAnalytics.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:3005/api';
|
||||||
|
|
||||||
|
async function testAnalyticsEndpoints() {
|
||||||
|
console.log('🧪 Testing Analytics Endpoints\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test 1: Get summary statistics
|
||||||
|
console.log('1️⃣ Testing GET /api/analytics/summary');
|
||||||
|
const summaryResponse = await axios.get(`${API_BASE_URL}/analytics/summary`);
|
||||||
|
console.log('✅ Summary response:', JSON.stringify(summaryResponse.data, null, 2));
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Test 2: Get summary with date range
|
||||||
|
console.log('2️⃣ Testing GET /api/analytics/summary with date range');
|
||||||
|
const startDate = new Date('2024-01-01').toISOString();
|
||||||
|
const endDate = new Date().toISOString();
|
||||||
|
const summaryWithDateResponse = await axios.get(
|
||||||
|
`${API_BASE_URL}/analytics/summary?startDate=${startDate}&endDate=${endDate}`
|
||||||
|
);
|
||||||
|
console.log('✅ Summary with date range:', JSON.stringify(summaryWithDateResponse.data, null, 2));
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Test 3: Get time series trends
|
||||||
|
console.log('3️⃣ Testing GET /api/analytics/trends');
|
||||||
|
const trendsResponse = await axios.get(
|
||||||
|
`${API_BASE_URL}/analytics/trends?startDate=${startDate}&endDate=${endDate}&interval=day`
|
||||||
|
);
|
||||||
|
console.log('✅ Trends response (first 5 entries):',
|
||||||
|
JSON.stringify(trendsResponse.data.data.slice(0, 5), null, 2)
|
||||||
|
);
|
||||||
|
console.log(` Total data points: ${trendsResponse.data.data.length}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Test 4: Get comparison metrics
|
||||||
|
console.log('4️⃣ Testing GET /api/analytics/comparison');
|
||||||
|
const currentStart = new Date('2024-11-01').toISOString();
|
||||||
|
const currentEnd = new Date('2024-11-30').toISOString();
|
||||||
|
const previousStart = new Date('2024-10-01').toISOString();
|
||||||
|
const previousEnd = new Date('2024-10-31').toISOString();
|
||||||
|
|
||||||
|
const comparisonResponse = await axios.get(
|
||||||
|
`${API_BASE_URL}/analytics/comparison?currentStart=${currentStart}¤tEnd=${currentEnd}&previousStart=${previousStart}&previousEnd=${previousEnd}`
|
||||||
|
);
|
||||||
|
console.log('✅ Comparison metrics:', JSON.stringify(comparisonResponse.data, null, 2));
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Test 5: Get aggregated data
|
||||||
|
console.log('5️⃣ Testing GET /api/analytics/aggregated');
|
||||||
|
const aggregatedResponse = await axios.get(`${API_BASE_URL}/analytics/aggregated`);
|
||||||
|
console.log('✅ Aggregated data:', JSON.stringify(aggregatedResponse.data, null, 2));
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Test 6: Export CSV
|
||||||
|
console.log('6️⃣ Testing GET /api/analytics/export (CSV)');
|
||||||
|
const csvResponse = await axios.get(`${API_BASE_URL}/analytics/export`, {
|
||||||
|
responseType: 'text',
|
||||||
|
});
|
||||||
|
console.log('✅ CSV export successful');
|
||||||
|
console.log(' First 500 characters of CSV:');
|
||||||
|
console.log(csvResponse.data.substring(0, 500));
|
||||||
|
console.log(' ...');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Test 7: Test trends with different intervals
|
||||||
|
console.log('7️⃣ Testing trends with week interval');
|
||||||
|
const weekTrendsResponse = await axios.get(
|
||||||
|
`${API_BASE_URL}/analytics/trends?startDate=${startDate}&endDate=${endDate}&interval=week`
|
||||||
|
);
|
||||||
|
console.log('✅ Week trends response:',
|
||||||
|
`${weekTrendsResponse.data.data.length} data points`
|
||||||
|
);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
console.log('8️⃣ Testing trends with month interval');
|
||||||
|
const monthTrendsResponse = await axios.get(
|
||||||
|
`${API_BASE_URL}/analytics/trends?startDate=${startDate}&endDate=${endDate}&interval=month`
|
||||||
|
);
|
||||||
|
console.log('✅ Month trends response:',
|
||||||
|
`${monthTrendsResponse.data.data.length} data points`
|
||||||
|
);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Test 9: Error handling - missing required parameters
|
||||||
|
console.log('9️⃣ Testing error handling - trends without dates');
|
||||||
|
try {
|
||||||
|
await axios.get(`${API_BASE_URL}/analytics/trends`);
|
||||||
|
console.log('❌ Should have thrown an error');
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.response?.status === 400) {
|
||||||
|
console.log('✅ Correctly returned 400 error:', error.response.data.error.message);
|
||||||
|
} else {
|
||||||
|
console.log('❌ Unexpected error:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Test 10: Error handling - invalid interval
|
||||||
|
console.log('🔟 Testing error handling - invalid interval');
|
||||||
|
try {
|
||||||
|
await axios.get(
|
||||||
|
`${API_BASE_URL}/analytics/trends?startDate=${startDate}&endDate=${endDate}&interval=invalid`
|
||||||
|
);
|
||||||
|
console.log('❌ Should have thrown an error');
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.response?.status === 400) {
|
||||||
|
console.log('✅ Correctly returned 400 error:', error.response.data.error.message);
|
||||||
|
} else {
|
||||||
|
console.log('❌ Unexpected error:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
console.log('✅ All analytics endpoint tests completed successfully!');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Test failed:', error.message);
|
||||||
|
if (error.response) {
|
||||||
|
console.error('Response data:', error.response.data);
|
||||||
|
console.error('Response status:', error.response.status);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run tests
|
||||||
|
testAnalyticsEndpoints();
|
||||||
530
server/src/services/analyticsService.ts
Normal file
530
server/src/services/analyticsService.ts
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
import prisma from '../config/database';
|
||||||
|
import { Parser } from 'json2csv';
|
||||||
|
|
||||||
|
interface DateRange {
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnalyticsSummary {
|
||||||
|
totalReports: number;
|
||||||
|
totalEvents: number;
|
||||||
|
totalUsers: number;
|
||||||
|
totalLitterCollected: number;
|
||||||
|
reportsByType: Record<string, number>;
|
||||||
|
reportsByQuantity: Record<string, number>;
|
||||||
|
verifiedReports: number;
|
||||||
|
cleanedReports: number;
|
||||||
|
activeUsers: number;
|
||||||
|
upcomingEvents: number;
|
||||||
|
completedEvents: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimeSeriesData {
|
||||||
|
date: string;
|
||||||
|
reportCount: number;
|
||||||
|
eventCount: number;
|
||||||
|
litterCollected: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComparisonMetrics {
|
||||||
|
current: {
|
||||||
|
reports: number;
|
||||||
|
events: number;
|
||||||
|
users: number;
|
||||||
|
};
|
||||||
|
previous: {
|
||||||
|
reports: number;
|
||||||
|
events: number;
|
||||||
|
users: number;
|
||||||
|
};
|
||||||
|
percentageChange: {
|
||||||
|
reports: number;
|
||||||
|
events: number;
|
||||||
|
users: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AnalyticsService {
|
||||||
|
/**
|
||||||
|
* Get overall statistics summary
|
||||||
|
* Requirement 5.1: Display total count of litter reports aggregated by time period
|
||||||
|
*/
|
||||||
|
async getSummary(dateRange?: DateRange): Promise<AnalyticsSummary> {
|
||||||
|
const where: any = {};
|
||||||
|
|
||||||
|
if (dateRange) {
|
||||||
|
where.createdAt = {
|
||||||
|
gte: dateRange.startDate,
|
||||||
|
lte: dateRange.endDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total reports
|
||||||
|
const totalReports = await prisma.report.count({ where });
|
||||||
|
|
||||||
|
// Total events
|
||||||
|
const eventWhere: any = {};
|
||||||
|
if (dateRange) {
|
||||||
|
eventWhere.createdAt = {
|
||||||
|
gte: dateRange.startDate,
|
||||||
|
lte: dateRange.endDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const totalEvents = await prisma.event.count({ where: eventWhere });
|
||||||
|
|
||||||
|
// Total users
|
||||||
|
const userWhere: any = {};
|
||||||
|
if (dateRange) {
|
||||||
|
userWhere.createdAt = {
|
||||||
|
gte: dateRange.startDate,
|
||||||
|
lte: dateRange.endDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const totalUsers = await prisma.user.count({ where: userWhere });
|
||||||
|
|
||||||
|
// Reports by type (Requirement 5.2)
|
||||||
|
const reportsByTypeRaw = await prisma.report.groupBy({
|
||||||
|
by: ['litterType'],
|
||||||
|
where,
|
||||||
|
_count: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const reportsByType: Record<string, number> = {};
|
||||||
|
reportsByTypeRaw.forEach((item) => {
|
||||||
|
reportsByType[item.litterType] = item._count.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reports by quantity (Requirement 5.3)
|
||||||
|
const reportsByQuantityRaw = await prisma.report.groupBy({
|
||||||
|
by: ['quantity'],
|
||||||
|
where,
|
||||||
|
_count: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const reportsByQuantity: Record<string, number> = {};
|
||||||
|
reportsByQuantityRaw.forEach((item) => {
|
||||||
|
reportsByQuantity[item.quantity] = item._count.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verified reports
|
||||||
|
const verifiedReports = await prisma.report.count({
|
||||||
|
where: {
|
||||||
|
...where,
|
||||||
|
verificationStatus: 'verified',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleaned reports
|
||||||
|
const cleanedReports = await prisma.report.count({
|
||||||
|
where: {
|
||||||
|
...where,
|
||||||
|
cleanedAt: { not: null },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Active users (users who submitted reports or registered for events)
|
||||||
|
const activeUsersWithReports = await prisma.report.findMany({
|
||||||
|
where,
|
||||||
|
select: { userId: true },
|
||||||
|
distinct: ['userId'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeUsersWithEvents = await prisma.eventRegistration.findMany({
|
||||||
|
where: dateRange ? {
|
||||||
|
registeredAt: {
|
||||||
|
gte: dateRange.startDate,
|
||||||
|
lte: dateRange.endDate,
|
||||||
|
},
|
||||||
|
} : {},
|
||||||
|
select: { userId: true },
|
||||||
|
distinct: ['userId'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const uniqueActiveUsers = new Set([
|
||||||
|
...activeUsersWithReports.map(r => r.userId),
|
||||||
|
...activeUsersWithEvents.map(e => e.userId),
|
||||||
|
]);
|
||||||
|
const activeUsers = uniqueActiveUsers.size;
|
||||||
|
|
||||||
|
// Upcoming and completed events
|
||||||
|
const upcomingEvents = await prisma.event.count({
|
||||||
|
where: {
|
||||||
|
...eventWhere,
|
||||||
|
status: 'upcoming',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const completedEvents = await prisma.event.count({
|
||||||
|
where: {
|
||||||
|
...eventWhere,
|
||||||
|
status: 'completed',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Total litter collected from completed events
|
||||||
|
const litterCollectedResult = await prisma.event.aggregate({
|
||||||
|
where: {
|
||||||
|
...eventWhere,
|
||||||
|
status: 'completed',
|
||||||
|
litterCollected: { not: null },
|
||||||
|
},
|
||||||
|
_sum: {
|
||||||
|
litterCollected: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalLitterCollected = litterCollectedResult._sum.litterCollected
|
||||||
|
? parseFloat(litterCollectedResult._sum.litterCollected.toString())
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalReports,
|
||||||
|
totalEvents,
|
||||||
|
totalUsers,
|
||||||
|
totalLitterCollected,
|
||||||
|
reportsByType,
|
||||||
|
reportsByQuantity,
|
||||||
|
verifiedReports,
|
||||||
|
cleanedReports,
|
||||||
|
activeUsers,
|
||||||
|
upcomingEvents,
|
||||||
|
completedEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get time series data for trends
|
||||||
|
* Requirement 5.4: Show trends comparing current period data to previous periods
|
||||||
|
*/
|
||||||
|
async getTimeSeries(
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
|
interval: 'day' | 'week' | 'month' = 'day'
|
||||||
|
): Promise<TimeSeriesData[]> {
|
||||||
|
// Determine date format based on interval
|
||||||
|
let dateFormat: string;
|
||||||
|
switch (interval) {
|
||||||
|
case 'day':
|
||||||
|
dateFormat = '%Y-%m-%d';
|
||||||
|
break;
|
||||||
|
case 'week':
|
||||||
|
dateFormat = '%Y-%u'; // Year-Week
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
dateFormat = '%Y-%m';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get report counts by date
|
||||||
|
const reportData = await prisma.$queryRaw<Array<{ date: string; count: bigint }>>`
|
||||||
|
SELECT
|
||||||
|
DATE_FORMAT(created_at, ${dateFormat}) as date,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM reports
|
||||||
|
WHERE created_at >= ${startDate} AND created_at <= ${endDate}
|
||||||
|
GROUP BY date
|
||||||
|
ORDER BY date ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Get event counts by date
|
||||||
|
const eventData = await prisma.$queryRaw<Array<{ date: string; count: bigint }>>`
|
||||||
|
SELECT
|
||||||
|
DATE_FORMAT(scheduled_date, ${dateFormat}) as date,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM events
|
||||||
|
WHERE scheduled_date >= ${startDate} AND scheduled_date <= ${endDate}
|
||||||
|
GROUP BY date
|
||||||
|
ORDER BY date ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Get litter collected by date
|
||||||
|
const litterData = await prisma.$queryRaw<Array<{ date: string; total: string | null }>>`
|
||||||
|
SELECT
|
||||||
|
DATE_FORMAT(scheduled_date, ${dateFormat}) as date,
|
||||||
|
SUM(litter_collected) as total
|
||||||
|
FROM events
|
||||||
|
WHERE scheduled_date >= ${startDate}
|
||||||
|
AND scheduled_date <= ${endDate}
|
||||||
|
AND status = 'completed'
|
||||||
|
AND litter_collected IS NOT NULL
|
||||||
|
GROUP BY date
|
||||||
|
ORDER BY date ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Merge data by date
|
||||||
|
const dateMap = new Map<string, TimeSeriesData>();
|
||||||
|
|
||||||
|
reportData.forEach((item) => {
|
||||||
|
dateMap.set(item.date, {
|
||||||
|
date: item.date,
|
||||||
|
reportCount: Number(item.count),
|
||||||
|
eventCount: 0,
|
||||||
|
litterCollected: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
eventData.forEach((item) => {
|
||||||
|
const existing = dateMap.get(item.date);
|
||||||
|
if (existing) {
|
||||||
|
existing.eventCount = Number(item.count);
|
||||||
|
} else {
|
||||||
|
dateMap.set(item.date, {
|
||||||
|
date: item.date,
|
||||||
|
reportCount: 0,
|
||||||
|
eventCount: Number(item.count),
|
||||||
|
litterCollected: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
litterData.forEach((item) => {
|
||||||
|
const existing = dateMap.get(item.date);
|
||||||
|
const litterAmount = item.total ? parseFloat(item.total) : 0;
|
||||||
|
if (existing) {
|
||||||
|
existing.litterCollected = litterAmount;
|
||||||
|
} else {
|
||||||
|
dateMap.set(item.date, {
|
||||||
|
date: item.date,
|
||||||
|
reportCount: 0,
|
||||||
|
eventCount: 0,
|
||||||
|
litterCollected: litterAmount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(dateMap.values()).sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get comparison metrics for period-over-period analysis
|
||||||
|
* Requirement 5.4: Show trends comparing current period data to previous periods with percentage change indicators
|
||||||
|
*/
|
||||||
|
async getComparisonMetrics(
|
||||||
|
currentStart: Date,
|
||||||
|
currentEnd: Date,
|
||||||
|
previousStart: Date,
|
||||||
|
previousEnd: Date
|
||||||
|
): Promise<ComparisonMetrics> {
|
||||||
|
// Current period metrics
|
||||||
|
const currentReports = await prisma.report.count({
|
||||||
|
where: {
|
||||||
|
createdAt: {
|
||||||
|
gte: currentStart,
|
||||||
|
lte: currentEnd,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentEvents = await prisma.event.count({
|
||||||
|
where: {
|
||||||
|
createdAt: {
|
||||||
|
gte: currentStart,
|
||||||
|
lte: currentEnd,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentUsers = await prisma.user.count({
|
||||||
|
where: {
|
||||||
|
createdAt: {
|
||||||
|
gte: currentStart,
|
||||||
|
lte: currentEnd,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Previous period metrics
|
||||||
|
const previousReports = await prisma.report.count({
|
||||||
|
where: {
|
||||||
|
createdAt: {
|
||||||
|
gte: previousStart,
|
||||||
|
lte: previousEnd,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const previousEvents = await prisma.event.count({
|
||||||
|
where: {
|
||||||
|
createdAt: {
|
||||||
|
gte: previousStart,
|
||||||
|
lte: previousEnd,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const previousUsers = await prisma.user.count({
|
||||||
|
where: {
|
||||||
|
createdAt: {
|
||||||
|
gte: previousStart,
|
||||||
|
lte: previousEnd,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate percentage changes
|
||||||
|
const calculatePercentageChange = (current: number, previous: number): number => {
|
||||||
|
if (previous === 0) return current > 0 ? 100 : 0;
|
||||||
|
return ((current - previous) / previous) * 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
current: {
|
||||||
|
reports: currentReports,
|
||||||
|
events: currentEvents,
|
||||||
|
users: currentUsers,
|
||||||
|
},
|
||||||
|
previous: {
|
||||||
|
reports: previousReports,
|
||||||
|
events: previousEvents,
|
||||||
|
users: previousUsers,
|
||||||
|
},
|
||||||
|
percentageChange: {
|
||||||
|
reports: calculatePercentageChange(currentReports, previousReports),
|
||||||
|
events: calculatePercentageChange(currentEvents, previousEvents),
|
||||||
|
users: calculatePercentageChange(currentUsers, previousUsers),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export analytics data to CSV
|
||||||
|
* Requirement 5.5: Allow Users to export analytics data in CSV format for external analysis
|
||||||
|
*/
|
||||||
|
async exportToCSV(dateRange?: DateRange): Promise<string> {
|
||||||
|
const where: any = {};
|
||||||
|
|
||||||
|
if (dateRange) {
|
||||||
|
where.createdAt = {
|
||||||
|
gte: dateRange.startDate,
|
||||||
|
lte: dateRange.endDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all reports with relevant data
|
||||||
|
const reports = await prisma.report.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
environmentalConcerns: true,
|
||||||
|
verifications: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transform data for CSV export
|
||||||
|
const csvData = reports.map((report) => ({
|
||||||
|
id: report.id,
|
||||||
|
submittedAt: report.submittedAt?.toISOString() || '',
|
||||||
|
username: report.user.username,
|
||||||
|
latitude: Number(report.latitude),
|
||||||
|
longitude: Number(report.longitude),
|
||||||
|
locationSource: report.locationSource,
|
||||||
|
litterType: report.litterType,
|
||||||
|
quantity: report.quantity,
|
||||||
|
description: report.description || '',
|
||||||
|
verificationStatus: report.verificationStatus,
|
||||||
|
verificationCount: report.verifications.filter(v => v.verificationType === 'verify').length,
|
||||||
|
disputeCount: report.verifications.filter(v => v.verificationType === 'dispute').length,
|
||||||
|
cleanedAt: report.cleanedAt?.toISOString() || '',
|
||||||
|
photoCount: Array.isArray(report.photoUrls) ? report.photoUrls.length : 0,
|
||||||
|
environmentalConcernCount: report.environmentalConcerns.length,
|
||||||
|
environmentalConcernTypes: report.environmentalConcerns.map(c => c.concernType).join('; '),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Convert to CSV
|
||||||
|
const parser = new Parser({
|
||||||
|
fields: [
|
||||||
|
'id',
|
||||||
|
'submittedAt',
|
||||||
|
'username',
|
||||||
|
'latitude',
|
||||||
|
'longitude',
|
||||||
|
'locationSource',
|
||||||
|
'litterType',
|
||||||
|
'quantity',
|
||||||
|
'description',
|
||||||
|
'verificationStatus',
|
||||||
|
'verificationCount',
|
||||||
|
'disputeCount',
|
||||||
|
'cleanedAt',
|
||||||
|
'photoCount',
|
||||||
|
'environmentalConcernCount',
|
||||||
|
'environmentalConcernTypes',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return parser.parse(csvData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get aggregated litter type and quantity data
|
||||||
|
* Requirement 5.2: Generate charts showing litter distribution by category type
|
||||||
|
* Requirement 5.3: Calculate and display the total estimated quantity of litter reported
|
||||||
|
*/
|
||||||
|
async getAggregatedData(dateRange?: DateRange) {
|
||||||
|
const where: any = {};
|
||||||
|
|
||||||
|
if (dateRange) {
|
||||||
|
where.createdAt = {
|
||||||
|
gte: dateRange.startDate,
|
||||||
|
lte: dateRange.endDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Litter type distribution
|
||||||
|
const litterTypeData = await prisma.report.groupBy({
|
||||||
|
by: ['litterType'],
|
||||||
|
where,
|
||||||
|
_count: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Quantity distribution
|
||||||
|
const quantityData = await prisma.report.groupBy({
|
||||||
|
by: ['quantity'],
|
||||||
|
where,
|
||||||
|
_count: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verification status distribution
|
||||||
|
const verificationData = await prisma.report.groupBy({
|
||||||
|
by: ['verificationStatus'],
|
||||||
|
where,
|
||||||
|
_count: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
litterTypes: litterTypeData.map(item => ({
|
||||||
|
type: item.litterType,
|
||||||
|
count: item._count.id,
|
||||||
|
})),
|
||||||
|
quantities: quantityData.map(item => ({
|
||||||
|
level: item.quantity,
|
||||||
|
count: item._count.id,
|
||||||
|
})),
|
||||||
|
verificationStatus: verificationData.map(item => ({
|
||||||
|
status: item.verificationStatus || 'pending',
|
||||||
|
count: item._count.id,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const analyticsService = new AnalyticsService();
|
||||||
@@ -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
|
// Format data for the chart
|
||||||
const chartData = data.map(item => ({
|
const chartData = data.map(item => ({
|
||||||
name: LITTER_TYPE_LABELS[item.type] || item.type,
|
name: LITTER_TYPE_LABELS[item.type] || item.type,
|
||||||
value: item.count,
|
value: item.count,
|
||||||
percentage: item.percentage,
|
percentage: total > 0 ? (item.count / total) * 100 : 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const renderCustomLabel = (entry: { name: string; percentage: number }) => {
|
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
|
// Format data for the chart
|
||||||
const chartData = data.map(item => ({
|
const chartData = data.map(item => ({
|
||||||
name: QUANTITY_LABELS[item.quantity] || item.quantity,
|
name: QUANTITY_LABELS[item.level] || item.level,
|
||||||
count: item.count,
|
count: item.count,
|
||||||
percentage: item.percentage,
|
percentage: total > 0 ? (item.count / total) * 100 : 0,
|
||||||
quantity: item.quantity,
|
quantity: item.level,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -57,17 +57,17 @@ export function TimeSeriesChart({ data, loading }: TimeSeriesChartProps) {
|
|||||||
/>
|
/>
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="reports"
|
dataKey="reportCount"
|
||||||
stroke="#3b82f6"
|
stroke="#3b82f6"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
name="Reports"
|
name="Reports"
|
||||||
dot={{ fill: '#3b82f6', r: 4 }}
|
dot={{ fill: '#3b82f6', r: 4 }}
|
||||||
activeDot={{ r: 6 }}
|
activeDot={{ r: 6 }}
|
||||||
/>
|
/>
|
||||||
{data.some(d => d.events !== undefined) && (
|
{data.some(d => d.eventCount !== undefined) && (
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="events"
|
dataKey="eventCount"
|
||||||
stroke="#10b981"
|
stroke="#10b981"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
name="Events"
|
name="Events"
|
||||||
@@ -75,6 +75,17 @@ export function TimeSeriesChart({ data, loading }: TimeSeriesChartProps) {
|
|||||||
activeDot={{ r: 6 }}
|
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>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ export function AnalyticsPage() {
|
|||||||
totalEvents: 0,
|
totalEvents: 0,
|
||||||
totalUsers: 0,
|
totalUsers: 0,
|
||||||
totalLitterCollected: 0,
|
totalLitterCollected: 0,
|
||||||
|
reportsByType: {},
|
||||||
|
reportsByQuantity: {},
|
||||||
|
verifiedReports: 0,
|
||||||
|
cleanedReports: 0,
|
||||||
|
activeUsers: 0,
|
||||||
|
upcomingEvents: 0,
|
||||||
|
completedEvents: 0,
|
||||||
});
|
});
|
||||||
setError('Analytics API not yet implemented. Showing placeholder data.');
|
setError('Analytics API not yet implemented. Showing placeholder data.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -85,29 +92,30 @@ export function AnalyticsPage() {
|
|||||||
date.setDate(date.getDate() + Math.floor(i * daysDiff / dataPoints));
|
date.setDate(date.getDate() + Math.floor(i * daysDiff / dataPoints));
|
||||||
mockTimeSeries.push({
|
mockTimeSeries.push({
|
||||||
date: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
date: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
||||||
reports: Math.floor(Math.random() * 20) + 5,
|
reportCount: Math.floor(Math.random() * 20) + 5,
|
||||||
events: Math.floor(Math.random() * 5),
|
eventCount: Math.floor(Math.random() * 5),
|
||||||
|
litterCollected: Math.floor(Math.random() * 50) + 10,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setTimeSeriesData(mockTimeSeries);
|
setTimeSeriesData(mockTimeSeries);
|
||||||
|
|
||||||
// Generate mock litter type distribution
|
// Generate mock litter type distribution
|
||||||
const mockLitterTypes: LitterTypeDistribution[] = [
|
const mockLitterTypes: LitterTypeDistribution[] = [
|
||||||
{ type: 'plastic', count: 45, percentage: 45 },
|
{ type: 'plastic', count: 45 },
|
||||||
{ type: 'metal', count: 15, percentage: 15 },
|
{ type: 'metal', count: 15 },
|
||||||
{ type: 'glass', count: 12, percentage: 12 },
|
{ type: 'glass', count: 12 },
|
||||||
{ type: 'organic', count: 10, percentage: 10 },
|
{ type: 'organic', count: 10 },
|
||||||
{ type: 'hazardous', count: 8, percentage: 8 },
|
{ type: 'hazardous', count: 8 },
|
||||||
{ type: 'other', count: 10, percentage: 10 },
|
{ type: 'other', count: 10 },
|
||||||
];
|
];
|
||||||
setLitterTypeData(mockLitterTypes);
|
setLitterTypeData(mockLitterTypes);
|
||||||
|
|
||||||
// Generate mock quantity aggregation
|
// Generate mock quantity aggregation
|
||||||
const mockQuantities: QuantityAggregation[] = [
|
const mockQuantities: QuantityAggregation[] = [
|
||||||
{ quantity: 'minimal', count: 30, percentage: 30 },
|
{ level: 'minimal', count: 30 },
|
||||||
{ quantity: 'moderate', count: 40, percentage: 40 },
|
{ level: 'moderate', count: 40 },
|
||||||
{ quantity: 'significant', count: 20, percentage: 20 },
|
{ level: 'significant', count: 20 },
|
||||||
{ quantity: 'severe', count: 10, percentage: 10 },
|
{ level: 'severe', count: 10 },
|
||||||
];
|
];
|
||||||
setQuantityData(mockQuantities);
|
setQuantityData(mockQuantities);
|
||||||
} finally {
|
} 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" />
|
<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>
|
</svg>
|
||||||
}
|
}
|
||||||
trend={
|
|
||||||
summary.periodComparison
|
|
||||||
? {
|
|
||||||
value: summary.periodComparison.reports,
|
|
||||||
isPositive: summary.periodComparison.reports > 0,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
subtitle="Litter reports submitted"
|
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" />
|
<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>
|
</svg>
|
||||||
}
|
}
|
||||||
trend={
|
|
||||||
summary.periodComparison
|
|
||||||
? {
|
|
||||||
value: summary.periodComparison.events,
|
|
||||||
isPositive: summary.periodComparison.events > 0,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
subtitle="Cleanup events organized"
|
subtitle="Cleanup events organized"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Active Users"
|
title="Active Users"
|
||||||
value={summary.totalUsers.toLocaleString()}
|
value={summary.activeUsers.toLocaleString()}
|
||||||
icon={
|
icon={
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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>
|
</svg>
|
||||||
}
|
}
|
||||||
trend={
|
|
||||||
summary.periodComparison
|
|
||||||
? {
|
|
||||||
value: summary.periodComparison.users,
|
|
||||||
isPositive: summary.periodComparison.users > 0,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
subtitle="Contributing to the platform"
|
subtitle="Contributing to the platform"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -15,13 +15,38 @@ class AnalyticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const queryString = params.toString();
|
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
|
* 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();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
if (dateRange) {
|
if (dateRange) {
|
||||||
@@ -30,37 +55,56 @@ class AnalyticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const queryString = params.toString();
|
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
|
* Get litter type distribution data
|
||||||
*/
|
*/
|
||||||
async getLitterTypeDistribution(dateRange?: { start: string; end: string }): Promise<LitterTypeDistribution[]> {
|
async getLitterTypeDistribution(dateRange?: { start: string; end: string }): Promise<LitterTypeDistribution[]> {
|
||||||
const params = new URLSearchParams();
|
const data = await this.getAggregatedData(dateRange);
|
||||||
|
return data.litterTypes;
|
||||||
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}` : ''}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get quantity estimation aggregation data
|
* Get quantity estimation aggregation data
|
||||||
*/
|
*/
|
||||||
async getQuantityAggregation(dateRange?: { start: string; end: string }): Promise<QuantityAggregation[]> {
|
async getQuantityAggregation(dateRange?: { start: string; end: string }): Promise<QuantityAggregation[]> {
|
||||||
const params = new URLSearchParams();
|
const data = await this.getAggregatedData(dateRange);
|
||||||
|
return data.quantities;
|
||||||
if (dateRange) {
|
}
|
||||||
params.append('startDate', dateRange.start);
|
|
||||||
params.append('endDate', dateRange.end);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
totalReports: number;
|
||||||
totalEvents: number;
|
totalEvents: number;
|
||||||
totalUsers: number;
|
totalUsers: number;
|
||||||
totalLitterCollected?: number; // in kilograms
|
totalLitterCollected: number; // in kilograms
|
||||||
periodComparison?: {
|
reportsByType: Record<string, number>;
|
||||||
reports: number; // percentage change
|
reportsByQuantity: Record<string, number>;
|
||||||
events: number;
|
verifiedReports: number;
|
||||||
users: number;
|
cleanedReports: number;
|
||||||
};
|
activeUsers: number;
|
||||||
|
upcomingEvents: number;
|
||||||
|
completedEvents: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DateRange {
|
export interface DateRange {
|
||||||
@@ -164,20 +166,19 @@ export interface DateRange {
|
|||||||
|
|
||||||
export interface TimeSeriesDataPoint {
|
export interface TimeSeriesDataPoint {
|
||||||
date: string;
|
date: string;
|
||||||
reports: number;
|
reportCount: number;
|
||||||
events?: number;
|
eventCount: number;
|
||||||
|
litterCollected: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LitterTypeDistribution {
|
export interface LitterTypeDistribution {
|
||||||
type: LitterType;
|
type: string;
|
||||||
count: number;
|
count: number;
|
||||||
percentage: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuantityAggregation {
|
export interface QuantityAggregation {
|
||||||
quantity: QuantityLevel;
|
level: string;
|
||||||
count: number;
|
count: number;
|
||||||
percentage: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// External environmental data types
|
// External environmental data types
|
||||||
|
|||||||
Reference in New Issue
Block a user