feat: implement analytics dashboard layout (task 10)

- Add analytics types (AnalyticsSummary, DateRange)
- Create analyticsService for API calls and CSV export
- Implement DateRangeSelector component with presets
- Create StatCard component for summary statistics
- Build responsive AnalyticsPage with:
  - Summary statistics cards (reports, events, users, litter collected)
  - Date range filtering with presets
  - CSV export functionality
  - Loading and error states
  - Responsive grid layout

Requirements: 5.1, 5.5
This commit is contained in:
2025-11-16 18:52:34 +00:00
parent fc2fff5cb1
commit 9f7fc43fb2
8 changed files with 446 additions and 8 deletions

View File

@@ -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<DateRange>(() => {
const end = new Date();
const start = new Date();
start.setMonth(end.getMonth() - 1); // Default to last month
return { start, end };
});
const [summary, setSummary] = useState<AnalyticsSummary | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Analytics Dashboard</h2>
<div className="bg-white rounded-lg shadow p-8">
<p className="text-gray-500">
Analytics dashboard will be implemented in tasks 10-12
</p>
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-6">
<h1 className="text-3xl font-bold text-gray-900 mb-4 sm:mb-0">
Analytics Dashboard
</h1>
<button
onClick={handleExportCSV}
disabled={exporting || loading}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{exporting ? (
<>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Exporting...
</>
) : (
<>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
Export to CSV
</>
)}
</button>
</div>
{/* Date Range Selector */}
<div className="mb-6">
<DateRangeSelector value={dateRange} onChange={setDateRange} />
</div>
{/* Error State */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<div className="flex items-center">
<svg className="w-5 h-5 text-red-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<p className="text-red-800">{error}</p>
</div>
</div>
)}
{/* Loading State */}
{loading && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="bg-white rounded-lg shadow p-6 animate-pulse">
<div className="h-4 bg-gray-200 rounded w-1/2 mb-4" />
<div className="h-8 bg-gray-200 rounded w-3/4" />
</div>
))}
</div>
)}
{/* Summary Statistics */}
{!loading && summary && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatCard
title="Total Reports"
value={summary.totalReports.toLocaleString()}
icon={
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
}
trend={
summary.periodComparison
? {
value: summary.periodComparison.reports,
isPositive: summary.periodComparison.reports > 0,
}
: undefined
}
subtitle="Litter reports submitted"
/>
<StatCard
title="Total Events"
value={summary.totalEvents.toLocaleString()}
icon={
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
}
trend={
summary.periodComparison
? {
value: summary.periodComparison.events,
isPositive: summary.periodComparison.events > 0,
}
: undefined
}
subtitle="Cleanup events organized"
/>
<StatCard
title="Active Users"
value={summary.totalUsers.toLocaleString()}
icon={
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
}
trend={
summary.periodComparison
? {
value: summary.periodComparison.users,
isPositive: summary.periodComparison.users > 0,
}
: undefined
}
subtitle="Contributing to the platform"
/>
<StatCard
title="Litter Collected"
value={
summary.totalLitterCollected
? `${summary.totalLitterCollected.toLocaleString()} kg`
: 'N/A'
}
icon={
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
}
subtitle="From cleanup events"
/>
</div>
)}
{/* Placeholder for future charts (tasks 11-12) */}
{!loading && summary && (
<div className="mt-8 bg-white rounded-lg shadow p-8">
<h2 className="text-xl font-semibold text-gray-900 mb-4">
Data Visualizations
</h2>
<p className="text-gray-500">
Charts and visualizations will be implemented in tasks 11-12
</p>
</div>
)}
</div>
);
}