diff --git a/.kiro/specs/beach-litter-mapping/tasks.md b/.kiro/specs/beach-litter-mapping/tasks.md index 69bc423..c4f85c6 100644 --- a/.kiro/specs/beach-litter-mapping/tasks.md +++ b/.kiro/specs/beach-litter-mapping/tasks.md @@ -76,7 +76,7 @@ ## Analytics and Visualization -- [ ] 10. Build analytics dashboard layout +- [x] 10. Build analytics dashboard layout - Create dashboard page with responsive grid layout - Implement summary statistics cards (total reports, events, users) - Add date range selector component @@ -173,7 +173,7 @@ ## Backend API Development -- [ ] 21. Set up backend project structure +- [x] 21. Set up backend project structure - Initialize Node.js project with Express - Configure TypeScript for backend - Set up folder structure: routes, controllers, services, middleware, utils diff --git a/src/components/analytics/DateRangeSelector.tsx b/src/components/analytics/DateRangeSelector.tsx new file mode 100644 index 0000000..dc12537 --- /dev/null +++ b/src/components/analytics/DateRangeSelector.tsx @@ -0,0 +1,123 @@ +import { useState } from 'react'; +import type { DateRange } from '@/types'; + +interface DateRangeSelectorProps { + value: DateRange; + onChange: (range: DateRange) => void; +} + +export function DateRangeSelector({ value, onChange }: DateRangeSelectorProps) { + const [startDate, setStartDate] = useState( + value.start.toISOString().split('T')[0] + ); + const [endDate, setEndDate] = useState( + value.end.toISOString().split('T')[0] + ); + + const handleStartChange = (e: React.ChangeEvent) => { + const newStart = e.target.value; + setStartDate(newStart); + onChange({ + start: new Date(newStart), + end: value.end, + }); + }; + + const handleEndChange = (e: React.ChangeEvent) => { + const newEnd = e.target.value; + setEndDate(newEnd); + onChange({ + start: value.start, + end: new Date(newEnd), + }); + }; + + const handlePresetClick = (preset: 'week' | 'month' | 'quarter' | 'year') => { + const end = new Date(); + const start = new Date(); + + switch (preset) { + case 'week': + start.setDate(end.getDate() - 7); + break; + case 'month': + start.setMonth(end.getMonth() - 1); + break; + case 'quarter': + start.setMonth(end.getMonth() - 3); + break; + case 'year': + start.setFullYear(end.getFullYear() - 1); + break; + } + + setStartDate(start.toISOString().split('T')[0]); + setEndDate(end.toISOString().split('T')[0]); + onChange({ start, end }); + }; + + return ( +
+

Date Range

+ + {/* Preset buttons */} +
+ + + + +
+ + {/* Custom date inputs */} +
+
+ + +
+
+ + +
+
+
+ ); +} diff --git a/src/components/analytics/StatCard.tsx b/src/components/analytics/StatCard.tsx new file mode 100644 index 0000000..aca066d --- /dev/null +++ b/src/components/analytics/StatCard.tsx @@ -0,0 +1,39 @@ +interface StatCardProps { + title: string; + value: number | string; + icon?: React.ReactNode; + trend?: { + value: number; + isPositive: boolean; + }; + subtitle?: string; +} + +export function StatCard({ title, value, icon, trend, subtitle }: StatCardProps) { + return ( +
+
+

{title}

+ {icon &&
{icon}
} +
+ +
+

{value}

+ + {trend && ( + + {trend.isPositive ? '↑' : '↓'} {Math.abs(trend.value)}% + + )} +
+ + {subtitle && ( +

{subtitle}

+ )} +
+ ); +} diff --git a/src/components/analytics/index.ts b/src/components/analytics/index.ts new file mode 100644 index 0000000..18d1c2b --- /dev/null +++ b/src/components/analytics/index.ts @@ -0,0 +1,2 @@ +export { DateRangeSelector } from './DateRangeSelector'; +export { StatCard } from './StatCard'; diff --git a/src/pages/AnalyticsPage.tsx b/src/pages/AnalyticsPage.tsx index 8219f3f..7b300cf 100644 --- a/src/pages/AnalyticsPage.tsx +++ b/src/pages/AnalyticsPage.tsx @@ -1,15 +1,219 @@ +import { useState, useEffect } from 'react'; +import { DateRangeSelector, StatCard } from '@/components/analytics'; +import { analyticsService } from '@/services'; +import type { DateRange, AnalyticsSummary } from '@/types'; + /** - * Analytics page - placeholder for task 10-12 + * Analytics Dashboard Page + * Displays summary statistics, date range filtering, and CSV export functionality */ export function AnalyticsPage() { + const [dateRange, setDateRange] = useState(() => { + const end = new Date(); + const start = new Date(); + start.setMonth(end.getMonth() - 1); // Default to last month + return { start, end }; + }); + + const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [exporting, setExporting] = useState(false); + + useEffect(() => { + loadSummary(); + }, [dateRange]); + + const loadSummary = async () => { + try { + setLoading(true); + setError(null); + const data = await analyticsService.getSummary({ + start: dateRange.start.toISOString(), + end: dateRange.end.toISOString(), + }); + setSummary(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load analytics'); + console.error('Failed to load analytics:', err); + } finally { + setLoading(false); + } + }; + + const handleExportCSV = async () => { + try { + setExporting(true); + const blob = await analyticsService.exportToCSV({ + start: dateRange.start.toISOString(), + end: dateRange.end.toISOString(), + }); + + // Create download link + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `beach-litter-analytics-${dateRange.start.toISOString().split('T')[0]}-to-${dateRange.end.toISOString().split('T')[0]}.csv`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } catch (err) { + console.error('Failed to export CSV:', err); + alert('Failed to export data. Please try again.'); + } finally { + setExporting(false); + } + }; + return (
-

Analytics Dashboard

-
-

- Analytics dashboard will be implemented in tasks 10-12 -

+ {/* Header */} +
+

+ Analytics Dashboard +

+
+ + {/* Date Range Selector */} +
+ +
+ + {/* Error State */} + {error && ( +
+
+ + + +

{error}

+
+
+ )} + + {/* Loading State */} + {loading && ( +
+ {[1, 2, 3, 4].map((i) => ( +
+
+
+
+ ))} +
+ )} + + {/* Summary Statistics */} + {!loading && summary && ( +
+ + + + } + trend={ + summary.periodComparison + ? { + value: summary.periodComparison.reports, + isPositive: summary.periodComparison.reports > 0, + } + : undefined + } + subtitle="Litter reports submitted" + /> + + + + + } + 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" + /> + + + + + } + subtitle="From cleanup events" + /> +
+ )} + + {/* Placeholder for future charts (tasks 11-12) */} + {!loading && summary && ( +
+

+ Data Visualizations +

+

+ Charts and visualizations will be implemented in tasks 11-12 +

+
+ )}
); } diff --git a/src/services/analyticsService.ts b/src/services/analyticsService.ts new file mode 100644 index 0000000..f6f1c83 --- /dev/null +++ b/src/services/analyticsService.ts @@ -0,0 +1,51 @@ +// Analytics service for API interactions +import { apiClient } from './api'; +import type { AnalyticsSummary } from '@/types'; + +class AnalyticsService { + /** + * Get summary statistics for the analytics dashboard + */ + async getSummary(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/summary${queryString ? `?${queryString}` : ''}`); + } + + /** + * Export analytics data to CSV + */ + async exportToCSV(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(); + const response = await fetch( + `${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api'}/analytics/export${queryString ? `?${queryString}` : ''}`, + { + method: 'GET', + headers: { + 'Accept': 'text/csv', + }, + } + ); + + if (!response.ok) { + throw new Error('Failed to export data'); + } + + return response.blob(); + } +} + +export const analyticsService = new AnalyticsService(); diff --git a/src/services/index.ts b/src/services/index.ts index 39803cd..14d1dbd 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1,5 +1,6 @@ // Service exports export { apiClient } from './api'; export { reportService } from './reportService'; +export { analyticsService } from './analyticsService'; export type { ApiError } from './api'; export type { CreateReportPayload, UploadPhotoResponse } from './reportService'; diff --git a/src/types/index.ts b/src/types/index.ts index cc46db0..d688546 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -119,3 +119,21 @@ export interface FilterState { quantities?: QuantityLevel[]; verificationStatus?: VerificationStatus[]; } + +// Analytics types +export interface AnalyticsSummary { + totalReports: number; + totalEvents: number; + totalUsers: number; + totalLitterCollected?: number; // in kilograms + periodComparison?: { + reports: number; // percentage change + events: number; + users: number; + }; +} + +export interface DateRange { + start: Date; + end: Date; +}