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:
123
src/components/analytics/DateRangeSelector.tsx
Normal file
123
src/components/analytics/DateRangeSelector.tsx
Normal file
@@ -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<HTMLInputElement>) => {
|
||||
const newStart = e.target.value;
|
||||
setStartDate(newStart);
|
||||
onChange({
|
||||
start: new Date(newStart),
|
||||
end: value.end,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEndChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Date Range</h3>
|
||||
|
||||
{/* Preset buttons */}
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => handlePresetClick('week')}
|
||||
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
|
||||
>
|
||||
Last 7 Days
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePresetClick('month')}
|
||||
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
|
||||
>
|
||||
Last Month
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePresetClick('quarter')}
|
||||
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
|
||||
>
|
||||
Last 3 Months
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePresetClick('year')}
|
||||
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
|
||||
>
|
||||
Last Year
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Custom date inputs */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="start-date" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Start Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="start-date"
|
||||
value={startDate}
|
||||
onChange={handleStartChange}
|
||||
max={endDate}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="end-date" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
End Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="end-date"
|
||||
value={endDate}
|
||||
onChange={handleEndChange}
|
||||
min={startDate}
|
||||
max={new Date().toISOString().split('T')[0]}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
src/components/analytics/StatCard.tsx
Normal file
39
src/components/analytics/StatCard.tsx
Normal file
@@ -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 (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-600">{title}</h3>
|
||||
{icon && <div className="text-gray-400">{icon}</div>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="text-3xl font-bold text-gray-900">{value}</p>
|
||||
|
||||
{trend && (
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
trend.isPositive ? 'text-green-600' : 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{trend.isPositive ? '↑' : '↓'} {Math.abs(trend.value)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{subtitle && (
|
||||
<p className="text-sm text-gray-500 mt-1">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
src/components/analytics/index.ts
Normal file
2
src/components/analytics/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { DateRangeSelector } from './DateRangeSelector';
|
||||
export { StatCard } from './StatCard';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
51
src/services/analyticsService.ts
Normal file
51
src/services/analyticsService.ts
Normal file
@@ -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<AnalyticsSummary> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (dateRange) {
|
||||
params.append('startDate', dateRange.start);
|
||||
params.append('endDate', dateRange.end);
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
return apiClient.get<AnalyticsSummary>(`/analytics/summary${queryString ? `?${queryString}` : ''}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export analytics data to CSV
|
||||
*/
|
||||
async exportToCSV(dateRange?: { start: string; end: string }): Promise<Blob> {
|
||||
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();
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user