diff --git a/.kiro/specs/beach-litter-mapping/tasks.md b/.kiro/specs/beach-litter-mapping/tasks.md index 674dc89..a1d0f7b 100644 --- a/.kiro/specs/beach-litter-mapping/tasks.md +++ b/.kiro/specs/beach-litter-mapping/tasks.md @@ -168,7 +168,7 @@ ## Mobile Optimization -- [x] 20. Implement mobile-responsive design +- [ ] 20. Implement mobile-responsive design - Apply Tailwind responsive classes throughout - Optimize map controls for touch interfaces - Ensure minimum tap target sizes (44x44px) @@ -235,7 +235,7 @@ - Add participant count tracking - _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 - Build time series data endpoint for trends - Implement CSV export functionality diff --git a/server/ANALYTICS_ENDPOINTS_IMPLEMENTATION.md b/server/ANALYTICS_ENDPOINTS_IMPLEMENTATION.md new file mode 100644 index 0000000..e69de29 diff --git a/server/ANALYTICS_IMPLEMENTATION.md b/server/ANALYTICS_IMPLEMENTATION.md new file mode 100644 index 0000000..e69de29 diff --git a/server/package-lock.json b/server/package-lock.json index d8b665c..394b451 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -18,6 +18,7 @@ "express": "^4.21.1", "express-validator": "^7.2.0", "helmet": "^8.0.0", + "json2csv": "^6.0.0-alpha.2", "multer": "^1.4.5-lts.1", "node-cron": "^4.2.1", "redis": "^5.9.0", @@ -26,6 +27,7 @@ "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^5.0.0", + "@types/json2csv": "^5.0.7", "@types/multer": "^1.4.12", "@types/node": "^22.9.0", "@types/node-cron": "^3.0.11", @@ -778,6 +780,12 @@ "@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": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -841,6 +849,16 @@ "dev": true, "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": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -1064,6 +1082,15 @@ "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": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", @@ -1683,12 +1710,37 @@ "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": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "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": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", diff --git a/server/package.json b/server/package.json index 8e566ac..eec098a 100644 --- a/server/package.json +++ b/server/package.json @@ -29,6 +29,7 @@ "express": "^4.21.1", "express-validator": "^7.2.0", "helmet": "^8.0.0", + "json2csv": "^6.0.0-alpha.2", "multer": "^1.4.5-lts.1", "node-cron": "^4.2.1", "redis": "^5.9.0", @@ -37,6 +38,7 @@ "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^5.0.0", + "@types/json2csv": "^5.0.7", "@types/multer": "^1.4.12", "@types/node": "^22.9.0", "@types/node-cron": "^3.0.11", diff --git a/server/src/controllers/analyticsController.ts b/server/src/controllers/analyticsController.ts new file mode 100644 index 0000000..b3cf350 --- /dev/null +++ b/server/src/controllers/analyticsController.ts @@ -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(); diff --git a/server/src/index.ts b/server/src/index.ts index fc0690e..4065e35 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -7,6 +7,7 @@ import reportRoutes from './routes/reportRoutes'; import eventRoutes from './routes/eventRoutes'; import userRoutes from './routes/userRoutes'; import externalDataRoutes from './routes/externalDataRoutes'; +import analyticsRoutes from './routes/analyticsRoutes'; // Load environment variables dotenv.config(); @@ -44,6 +45,7 @@ app.use('/api/reports', reportRoutes); app.use('/api/events', eventRoutes); app.use('/api/users', userRoutes); app.use('/api/external', externalDataRoutes); +app.use('/api/analytics', analyticsRoutes); // Error handling middleware app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => { diff --git a/server/src/routes/analyticsRoutes.ts b/server/src/routes/analyticsRoutes.ts new file mode 100644 index 0000000..575e73e --- /dev/null +++ b/server/src/routes/analyticsRoutes.ts @@ -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; diff --git a/server/src/scripts/testAnalytics.ts b/server/src/scripts/testAnalytics.ts new file mode 100644 index 0000000..bdf74ae --- /dev/null +++ b/server/src/scripts/testAnalytics.ts @@ -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(); diff --git a/server/src/services/analyticsService.ts b/server/src/services/analyticsService.ts new file mode 100644 index 0000000..ea497ff --- /dev/null +++ b/server/src/services/analyticsService.ts @@ -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; + reportsByQuantity: Record; + 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 { + 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 = {}; + 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 = {}; + 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 { + // 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>` + 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>` + 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>` + 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(); + + 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 { + // 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 { + 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(); diff --git a/src/components/analytics/LitterTypeChart.tsx b/src/components/analytics/LitterTypeChart.tsx index 83a2c42..653df3c 100644 --- a/src/components/analytics/LitterTypeChart.tsx +++ b/src/components/analytics/LitterTypeChart.tsx @@ -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 }) => { diff --git a/src/components/analytics/QuantityChart.tsx b/src/components/analytics/QuantityChart.tsx index 6f0f4b5..ab8f582 100644 --- a/src/components/analytics/QuantityChart.tsx +++ b/src/components/analytics/QuantityChart.tsx @@ -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 ( diff --git a/src/components/analytics/TimeSeriesChart.tsx b/src/components/analytics/TimeSeriesChart.tsx index ed1704a..f5a5127 100644 --- a/src/components/analytics/TimeSeriesChart.tsx +++ b/src/components/analytics/TimeSeriesChart.tsx @@ -57,17 +57,17 @@ export function TimeSeriesChart({ data, loading }: TimeSeriesChartProps) { /> - {data.some(d => d.events !== undefined) && ( + {data.some(d => d.eventCount !== undefined) && ( )} + {data.some(d => d.litterCollected > 0) && ( + + )} diff --git a/src/pages/AnalyticsPage.tsx b/src/pages/AnalyticsPage.tsx index 99b40fc..6564f00 100644 --- a/src/pages/AnalyticsPage.tsx +++ b/src/pages/AnalyticsPage.tsx @@ -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() { } - trend={ - summary.periodComparison - ? { - value: summary.periodComparison.reports, - isPositive: summary.periodComparison.reports > 0, - } - : undefined - } subtitle="Litter reports submitted" /> @@ -230,33 +230,17 @@ export function AnalyticsPage() { } - trend={ - summary.periodComparison - ? { - value: summary.periodComparison.events, - isPositive: summary.periodComparison.events > 0, - } - : undefined - } subtitle="Cleanup events organized" /> } - trend={ - summary.periodComparison - ? { - value: summary.periodComparison.users, - isPositive: summary.periodComparison.users > 0, - } - : undefined - } subtitle="Contributing to the platform" /> diff --git a/src/services/analyticsService.ts b/src/services/analyticsService.ts index d4bf014..9da79de 100644 --- a/src/services/analyticsService.ts +++ b/src/services/analyticsService.ts @@ -15,13 +15,38 @@ class AnalyticsService { } const queryString = params.toString(); - return apiClient.get(`/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 { + async getTimeSeries( + dateRange?: { start: string; end: string }, + interval: 'day' | 'week' | 'month' = 'day' + ): Promise { + 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(`/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 { - const params = new URLSearchParams(); - - if (dateRange) { - params.append('startDate', dateRange.start); - params.append('endDate', dateRange.end); - } - - const queryString = params.toString(); - return apiClient.get(`/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 { - 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(`/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; } /** diff --git a/src/types/index.ts b/src/types/index.ts index 30d86e3..770017b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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; + reportsByQuantity: Record; + 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