diff --git a/ANALYTICS_FEATURE.md b/ANALYTICS_FEATURE.md new file mode 100644 index 00000000..73f49ca2 --- /dev/null +++ b/ANALYTICS_FEATURE.md @@ -0,0 +1,258 @@ +# Analytics Dashboard Feature + +## Overview + +The Analytics Dashboard provides comprehensive visualization and insights into tip statistics, trends, and platform activity. It includes interactive charts, date range filtering, and data export capabilities. + +## Features + +### Summary Cards + +Four key metrics displayed prominently: +- **Total Tips**: Total number of tips sent on the platform +- **Total Volume**: Aggregate STX volume across all tips +- **Average Tip**: Mean tip amount in STX +- **Unique Users**: Combined count of unique senders and recipients + +### Tip Volume Chart + +Interactive time-series visualization showing: +- Number of tips per day +- Volume (STX) per day +- Dual Y-axis for tips count and volume +- Toggle between line and bar chart views +- Responsive design for mobile and desktop + +### Top Senders + +Horizontal bar chart and detailed rankings showing: +- Top 10 senders by volume +- Number of tips sent +- Total volume sent +- Abbreviated addresses with full address on hover + +### Top Recipients + +Horizontal bar chart and detailed rankings showing: +- Top 10 recipients by volume +- Number of tips received +- Total volume received +- Abbreviated addresses with full address on hover + +### Date Range Filter + +Flexible date filtering with: +- Custom start and end date selection +- Quick preset buttons (7, 30, 90 days) +- Apply and reset functionality +- Real-time data updates + +### Data Export + +Export analytics data in multiple formats: +- **CSV**: Time-series data for spreadsheet analysis +- **JSON**: Complete analytics data including all metrics + +## API Endpoints + +### GET /api/analytics + +Returns comprehensive analytics data with optional date filtering. + +**Query Parameters:** +- `startDate` (optional): ISO 8601 date string for range start +- `endDate` (optional): ISO 8601 date string for range end + +**Response:** +```json +{ + "summary": { + "totalTips": 1234, + "totalVolume": 5000000000, + "totalFees": 50000000, + "avgTipAmount": 4048582, + "uniqueSenders": 456, + "uniqueRecipients": 789 + }, + "topSenders": [ + { + "address": "SP1ABC...", + "count": 50, + "volume": 250000000 + } + ], + "topRecipients": [ + { + "address": "SP2DEF...", + "count": 75, + "volume": 300000000 + } + ], + "timeSeriesData": [ + { + "date": "2024-01-15", + "count": 25, + "volume": 100000000 + } + ] +} +``` + +## Components + +### Analytics.jsx +Main dashboard component that orchestrates all sub-components and manages state. + +### AnalyticsSummary.jsx +Displays summary statistics in card format with icons. + +### TipVolumeChart.jsx +Line/bar chart showing tip volume over time using Recharts. + +### TopSendersChart.jsx +Horizontal bar chart and rankings for top senders. + +### TopRecipientsChart.jsx +Horizontal bar chart and rankings for top recipients. + +### DateRangeFilter.jsx +Date range selection with presets and custom dates. + +### ExportData.jsx +Dropdown menu for exporting data in CSV or JSON format. + +## Services + +### analytics.js + +Utility functions for: +- `fetchAnalytics(startDate, endDate)`: Fetch analytics data from API +- `fetchStats()`: Fetch basic statistics +- `formatAmount(amount)`: Format microSTX to STX +- `formatAddress(address)`: Abbreviate Stacks addresses +- `exportToCSV(data, filename)`: Export data as CSV +- `exportToJSON(data, filename)`: Export data as JSON + +## Styling + +### Responsive Design + +- Mobile-first approach +- Grid layouts adapt to screen size +- Charts scale appropriately +- Touch-friendly controls + +### Color Scheme + +- Blue: Primary actions and tip counts +- Green: Volume and financial metrics +- Purple: Recipients +- Orange: User metrics + +### Animations + +- Smooth transitions on hover +- Loading states with pulse animation +- Slide-down menu animations +- Fade-in tooltips + +## Usage + +### Accessing the Dashboard + +Navigate to `/analytics` or click "Analytics" in the main navigation. + +### Filtering Data + +1. Click on the date inputs to select custom dates +2. Or use preset buttons for common ranges +3. Click "Apply" to update the dashboard +4. Click "Reset" to clear filters + +### Exporting Data + +1. Click the "Export Data" button +2. Select CSV or JSON format +3. File downloads automatically with timestamp + +### Chart Interactions + +- Hover over data points for detailed tooltips +- Toggle between line and bar views +- Charts are fully responsive + +## Technical Details + +### Dependencies + +- **recharts**: Chart library for data visualization +- **lucide-react**: Icon library +- **react-router-dom**: Routing + +### Performance + +- Lazy loading of chart components +- Efficient data aggregation on backend +- Memoized calculations +- Optimized re-renders + +### Browser Support + +- Modern browsers (Chrome, Firefox, Safari, Edge) +- Mobile browsers (iOS Safari, Chrome Mobile) +- Responsive down to 320px width + +## Testing + +Run analytics tests: +```bash +npm test -- analytics.test.js +``` + +Tests cover: +- API endpoint responses +- Date filtering +- Data structure validation +- Top senders/recipients limits +- Summary calculations + +## Future Enhancements + +Potential improvements: +- Real-time updates via WebSocket +- More chart types (pie, scatter) +- Advanced filtering (by address, amount range) +- Comparison views (period over period) +- Downloadable PDF reports +- Scheduled email reports +- Custom dashboard layouts +- Saved filter presets + +## Troubleshooting + +### Charts not displaying + +- Check browser console for errors +- Verify API endpoint is accessible +- Ensure data is being returned + +### Date filter not working + +- Verify date format is correct +- Check that dates are within valid range +- Ensure API supports date parameters + +### Export not downloading + +- Check browser download settings +- Verify popup blocker is not interfering +- Ensure data exists to export + +## Contributing + +When adding new analytics features: +1. Update API endpoint if needed +2. Add corresponding tests +3. Update this documentation +4. Ensure responsive design +5. Add appropriate error handling diff --git a/chainhook/analytics.test.js b/chainhook/analytics.test.js new file mode 100644 index 00000000..dc3dfeaf --- /dev/null +++ b/chainhook/analytics.test.js @@ -0,0 +1,174 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { createServer } from "./test-server.js"; + +test("GET /api/analytics returns analytics data", async () => { + const { request, close } = await createServer(); + + const response = await request({ + method: 'GET', + path: '/api/analytics', + }); + + assert.strictEqual(response.status, 200); + assert.ok(response.body.summary); + assert.ok(response.body.topSenders); + assert.ok(response.body.topRecipients); + assert.ok(response.body.timeSeriesData); + assert.ok(Array.isArray(response.body.topSenders)); + assert.ok(Array.isArray(response.body.topRecipients)); + assert.ok(Array.isArray(response.body.timeSeriesData)); + + await close(); +}); + +test("GET /api/analytics filters by start date", async () => { + const { request, close } = await createServer(); + + const startDate = new Date('2024-01-01').toISOString(); + const response = await request({ + method: 'GET', + path: `/api/analytics?startDate=${startDate}`, + }); + + assert.strictEqual(response.status, 200); + assert.ok(response.body.summary); + + await close(); +}); + +test("GET /api/analytics filters by end date", async () => { + const { request, close } = await createServer(); + + const endDate = new Date().toISOString(); + const response = await request({ + method: 'GET', + path: `/api/analytics?endDate=${endDate}`, + }); + + assert.strictEqual(response.status, 200); + assert.ok(response.body.summary); + + await close(); +}); + +test("GET /api/analytics filters by date range", async () => { + const { request, close } = await createServer(); + + const startDate = new Date('2024-01-01').toISOString(); + const endDate = new Date().toISOString(); + const response = await request({ + method: 'GET', + path: `/api/analytics?startDate=${startDate}&endDate=${endDate}`, + }); + + assert.strictEqual(response.status, 200); + assert.ok(response.body.summary); + assert.ok(response.body.timeSeriesData); + + await close(); +}); + +test("GET /api/analytics returns correct summary structure", async () => { + const { request, close } = await createServer(); + + const response = await request({ + method: 'GET', + path: '/api/analytics', + }); + + assert.strictEqual(response.status, 200); + assert.ok(typeof response.body.summary.totalTips === 'number'); + assert.ok(typeof response.body.summary.totalVolume === 'number'); + assert.ok(typeof response.body.summary.totalFees === 'number'); + assert.ok(typeof response.body.summary.avgTipAmount === 'number'); + assert.ok(typeof response.body.summary.uniqueSenders === 'number'); + assert.ok(typeof response.body.summary.uniqueRecipients === 'number'); + + await close(); +}); + +test("GET /api/analytics returns top senders with correct structure", async () => { + const { request, close } = await createServer(); + + const response = await request({ + method: 'GET', + path: '/api/analytics', + }); + + assert.strictEqual(response.status, 200); + if (response.body.topSenders.length > 0) { + const sender = response.body.topSenders[0]; + assert.ok(sender.address); + assert.ok(typeof sender.count === 'number'); + assert.ok(typeof sender.volume === 'number'); + } + + await close(); +}); + +test("GET /api/analytics returns top recipients with correct structure", async () => { + const { request, close } = await createServer(); + + const response = await request({ + method: 'GET', + path: '/api/analytics', + }); + + assert.strictEqual(response.status, 200); + if (response.body.topRecipients.length > 0) { + const recipient = response.body.topRecipients[0]; + assert.ok(recipient.address); + assert.ok(typeof recipient.count === 'number'); + assert.ok(typeof recipient.volume === 'number'); + } + + await close(); +}); + +test("GET /api/analytics returns time series data with correct structure", async () => { + const { request, close } = await createServer(); + + const response = await request({ + method: 'GET', + path: '/api/analytics', + }); + + assert.strictEqual(response.status, 200); + if (response.body.timeSeriesData.length > 0) { + const dataPoint = response.body.timeSeriesData[0]; + assert.ok(dataPoint.date); + assert.ok(typeof dataPoint.count === 'number'); + assert.ok(typeof dataPoint.volume === 'number'); + } + + await close(); +}); + +test("GET /api/analytics limits top senders to 10", async () => { + const { request, close } = await createServer(); + + const response = await request({ + method: 'GET', + path: '/api/analytics', + }); + + assert.strictEqual(response.status, 200); + assert.ok(response.body.topSenders.length <= 10); + + await close(); +}); + +test("GET /api/analytics limits top recipients to 10", async () => { + const { request, close } = await createServer(); + + const response = await request({ + method: 'GET', + path: '/api/analytics', + }); + + assert.strictEqual(response.status, 200); + assert.ok(response.body.topRecipients.length <= 10); + + await close(); +}); diff --git a/chainhook/server.js b/chainhook/server.js index 3e053c83..18641486 100644 --- a/chainhook/server.js +++ b/chainhook/server.js @@ -592,6 +592,87 @@ const server = http.createServer(async (req, res) => { }); } + // GET /api/analytics -- detailed analytics with time-series data + if (req.method === "GET" && path === "/api/analytics") { + const store = await getEventStore(); + const allEvents = await store.listEvents(); + const tips = allEvents.map(parseTipEvent).filter(Boolean); + + const startDate = url.searchParams.get("startDate"); + const endDate = url.searchParams.get("endDate"); + + let filteredTips = tips; + if (startDate) { + const start = new Date(startDate).getTime(); + filteredTips = filteredTips.filter(t => t.timestamp >= start); + } + if (endDate) { + const end = new Date(endDate).getTime(); + filteredTips = filteredTips.filter(t => t.timestamp <= end); + } + + const senderStats = {}; + const recipientStats = {}; + const dailyVolume = {}; + + filteredTips.forEach(tip => { + const sender = tip.sender; + const recipient = tip.recipient; + const amount = Number(tip.amount || 0); + const date = new Date(tip.timestamp).toISOString().split('T')[0]; + + if (!senderStats[sender]) { + senderStats[sender] = { count: 0, volume: 0 }; + } + senderStats[sender].count++; + senderStats[sender].volume += amount; + + if (!recipientStats[recipient]) { + recipientStats[recipient] = { count: 0, volume: 0 }; + } + recipientStats[recipient].count++; + recipientStats[recipient].volume += amount; + + if (!dailyVolume[date]) { + dailyVolume[date] = { count: 0, volume: 0 }; + } + dailyVolume[date].count++; + dailyVolume[date].volume += amount; + }); + + const topSenders = Object.entries(senderStats) + .map(([address, stats]) => ({ address, ...stats })) + .sort((a, b) => b.volume - a.volume) + .slice(0, 10); + + const topRecipients = Object.entries(recipientStats) + .map(([address, stats]) => ({ address, ...stats })) + .sort((a, b) => b.volume - a.volume) + .slice(0, 10); + + const timeSeriesData = Object.entries(dailyVolume) + .map(([date, stats]) => ({ date, ...stats })) + .sort((a, b) => a.date.localeCompare(b.date)); + + const totalVolume = filteredTips.reduce((sum, t) => sum + Number(t.amount || 0), 0); + const totalFees = filteredTips.reduce((sum, t) => sum + Number(t.fee || 0), 0); + const avgTipAmount = filteredTips.length > 0 ? totalVolume / filteredTips.length : 0; + + return sendJson(res, 200, { + summary: { + totalTips: filteredTips.length, + totalVolume, + totalFees, + avgTipAmount, + uniqueSenders: Object.keys(senderStats).length, + uniqueRecipients: Object.keys(recipientStats).length, + }, + topSenders, + topRecipients, + timeSeriesData, + }); + } + // POST /api/scheduled-tips -- create a scheduled tip if (req.method === "POST" && path === "/api/scheduled-tips") { const startTime = Date.now(); diff --git a/frontend/package.json b/frontend/package.json index 8083e2d1..ec0cf077 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -40,6 +40,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.13.1", + "recharts": "^2.15.0", "tailwind-merge": "^3.4.0", "tipstreak-sdk": "^0.1.1", "web-vitals": "^5.1.0" diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 46a1f001..e672072e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -20,11 +20,11 @@ import { useDemoMode } from './context/DemoContext'; import { ROUTE_SEND, ROUTE_BATCH, ROUTE_TOKEN_TIP, ROUTE_SCHEDULE, ROUTE_SCHEDULED_TIPS, ROUTE_FEED, ROUTE_LEADERBOARD, ROUTE_ACTIVITY, ROUTE_PROFILE, ROUTE_ADDRESS_BOOK, - ROUTE_BLOCK, ROUTE_STATS, ROUTE_ADMIN, ROUTE_TELEMETRY, ROUTE_REFUNDS, + ROUTE_BLOCK, ROUTE_STATS, ROUTE_ANALYTICS, ROUTE_ADMIN, ROUTE_TELEMETRY, ROUTE_REFUNDS, ROUTE_NOTIFICATION_PREFERENCES, ROUTE_ENCRYPTION, DEFAULT_AUTHENTICATED_ROUTE, ROUTE_META, } from './config/routes'; -import { Zap, Radio, Trophy, User, BarChart3, Users, ShieldBan, Coins, UserCircle, Shield, Gauge, Calendar, Clock, BookUser, RotateCcw, BellCog, Lock } from 'lucide-react'; +import { Zap, Radio, Trophy, User, BarChart3, Users, ShieldBan, Coins, UserCircle, Shield, Gauge, Calendar, Clock, BookUser, RotateCcw, Bell, Lock, TrendingUp } from 'lucide-react'; import { activateDemo, deactivateDemo } from './lib/demo-utils'; import { useNotificationPreferences } from './context/NotificationPreferencesContext'; @@ -48,6 +48,7 @@ const TelemetryDashboard = lazy(() => import('./components/TelemetryDashboard')) const RefundManager = lazy(() => import('./components/RefundManager')); const NotificationPreferencesPage = lazy(() => import('./components/NotificationPreferences')); const EncryptionSettings = lazy(() => import('./components/EncryptionSettings')); +const Analytics = lazy(() => import('./components/Analytics')); function App() { const [userData, setUserData] = useState(null); @@ -175,9 +176,10 @@ function App() { { path: ROUTE_ADDRESS_BOOK, label: 'Address Book', icon: BookUser }, { path: ROUTE_BLOCK, label: 'Block', icon: ShieldBan }, { path: ROUTE_REFUNDS, label: 'Refunds', icon: RotateCcw }, - { path: ROUTE_NOTIFICATION_PREFERENCES, label: 'Notifications', icon: BellCog }, + { path: ROUTE_NOTIFICATION_PREFERENCES, label: 'Notifications', icon: Bell }, { path: ROUTE_ENCRYPTION, label: 'Encryption', icon: Lock }, { path: ROUTE_STATS, label: 'Stats', icon: BarChart3 }, + { path: ROUTE_ANALYTICS, label: 'Analytics', icon: TrendingUp }, ]; // Filter items based on auth and admin status @@ -341,6 +343,7 @@ function App() { } /> } /> } /> + } /> {/* User-specific routes */} { + loadAnalytics(); + }, [dateRange]); + + useEffect(() => { + const interval = setInterval(() => { + loadAnalytics(true); + }, 60000); + return () => clearInterval(interval); + }, [dateRange]); + + useEffect(() => { + const handleKeyPress = (e) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'r') { + e.preventDefault(); + handleRefresh(); + } + }; + window.addEventListener('keydown', handleKeyPress); + return () => window.removeEventListener('keydown', handleKeyPress); + }, []); + + async function loadAnalytics(isAutoRefresh = false) { + try { + if (!isAutoRefresh) { + setLoading(true); + } else { + setRefreshing(true); + } + setError(null); + const data = await fetchAnalytics(dateRange.startDate, dateRange.endDate); + setAnalyticsData(data); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + setRefreshing(false); + } + } + + function handleRefresh() { + loadAnalytics(); + } + + function handleDateRangeChange(newRange) { + setDateRange(newRange); + } + + if (loading) { + return ; + } + + if (error) { + return ( +
+
+

Error loading analytics: {error}

+ +
+
+ ); + } + + return ( +
+
+
+

Analytics Dashboard

+

Track tip statistics, trends, and insights

+
+ +
+ +
+ + +
+ + + + + +
+ + + +
+ +
+ + + + + + +
+
+ ); +} diff --git a/frontend/src/components/AnalyticsErrorBoundary.jsx b/frontend/src/components/AnalyticsErrorBoundary.jsx new file mode 100644 index 00000000..be39ee8d --- /dev/null +++ b/frontend/src/components/AnalyticsErrorBoundary.jsx @@ -0,0 +1,45 @@ +import { Component } from 'react'; +import { AlertTriangle } from 'lucide-react'; + +class AnalyticsErrorBoundary extends Component { + constructor(props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error) { + return { hasError: true, error }; + } + + componentDidCatch(error, errorInfo) { + console.error('Analytics Error:', error, errorInfo); + } + + render() { + if (this.state.hasError) { + return ( +
+
+ +

+ {this.props.title || 'Error Loading Component'} +

+
+

+ {this.state.error?.message || 'An unexpected error occurred'} +

+ +
+ ); + } + + return this.props.children; + } +} + +export default AnalyticsErrorBoundary; diff --git a/frontend/src/components/AnalyticsLoadingSkeleton.jsx b/frontend/src/components/AnalyticsLoadingSkeleton.jsx new file mode 100644 index 00000000..c7d0b74d --- /dev/null +++ b/frontend/src/components/AnalyticsLoadingSkeleton.jsx @@ -0,0 +1,39 @@ +export default function AnalyticsLoadingSkeleton() { + return ( +
+
+
+
+
+ +
+
+
+
+ +
+ {[1, 2, 3, 4].map((i) => ( +
+
+
+
+
+ ))} +
+ +
+
+
+
+ +
+ {[1, 2].map((i) => ( +
+
+
+
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/AnalyticsSummary.jsx b/frontend/src/components/AnalyticsSummary.jsx new file mode 100644 index 00000000..8a376362 --- /dev/null +++ b/frontend/src/components/AnalyticsSummary.jsx @@ -0,0 +1,62 @@ +import { formatAmount } from '../services/analytics'; +import { TrendingUp, Users, DollarSign, Activity } from 'lucide-react'; + +export default function AnalyticsSummary({ summary }) { + if (!summary) return null; + + const stats = [ + { + label: 'Total Tips', + value: summary.totalTips.toLocaleString(), + icon: Activity, + color: 'blue', + }, + { + label: 'Total Volume', + value: `${formatAmount(summary.totalVolume)} STX`, + icon: DollarSign, + color: 'green', + }, + { + label: 'Average Tip', + value: `${formatAmount(summary.avgTipAmount)} STX`, + icon: TrendingUp, + color: 'purple', + }, + { + label: 'Unique Users', + value: (summary.uniqueSenders + summary.uniqueRecipients).toLocaleString(), + icon: Users, + color: 'orange', + }, + ]; + + const colorClasses = { + blue: 'bg-blue-100 text-blue-600', + green: 'bg-green-100 text-green-600', + purple: 'bg-purple-100 text-purple-600', + orange: 'bg-orange-100 text-orange-600', + }; + + return ( +
+ {stats.map((stat) => { + const Icon = stat.icon; + return ( +
+
+
+ +
+
+

{stat.label}

+

{stat.value}

+
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/DateRangeFilter.jsx b/frontend/src/components/DateRangeFilter.jsx new file mode 100644 index 00000000..12da3e06 --- /dev/null +++ b/frontend/src/components/DateRangeFilter.jsx @@ -0,0 +1,105 @@ +import { useState } from 'react'; +import { Calendar } from 'lucide-react'; + +export default function DateRangeFilter({ startDate, endDate, onChange }) { + const [localStartDate, setLocalStartDate] = useState(startDate || ''); + const [localEndDate, setLocalEndDate] = useState(endDate || ''); + + function handleApply() { + onChange({ + startDate: localStartDate || null, + endDate: localEndDate || null, + }); + } + + function handleReset() { + setLocalStartDate(''); + setLocalEndDate(''); + onChange({ + startDate: null, + endDate: null, + }); + } + + function handlePreset(days) { + const end = new Date(); + const start = new Date(); + start.setDate(start.getDate() - days); + + const startStr = start.toISOString().split('T')[0]; + const endStr = end.toISOString().split('T')[0]; + + setLocalStartDate(startStr); + setLocalEndDate(endStr); + onChange({ + startDate: startStr, + endDate: endStr, + }); + } + + return ( +
+
+ +

Date Range

+
+ +
+
+ + setLocalStartDate(e.target.value)} + className="w-full px-2 sm:px-3 py-1.5 sm:py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+
+ + setLocalEndDate(e.target.value)} + className="w-full px-2 sm:px-3 py-1.5 sm:py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+
+ +
+ + + +
+ +
+ + +
+
+ ); +} diff --git a/frontend/src/components/ExportData.jsx b/frontend/src/components/ExportData.jsx new file mode 100644 index 00000000..f8a964f5 --- /dev/null +++ b/frontend/src/components/ExportData.jsx @@ -0,0 +1,77 @@ +import { useState } from 'react'; +import { Download, FileText, FileJson } from 'lucide-react'; +import { exportToCSV, exportToJSON } from '../services/analytics'; + +export default function ExportData({ data }) { + const [isOpen, setIsOpen] = useState(false); + const [exporting, setExporting] = useState(false); + + async function handleExportCSV() { + if (!data || !data.timeSeriesData) return; + + setExporting(true); + try { + const timestamp = new Date().toISOString().split('T')[0]; + exportToCSV(data.timeSeriesData, `analytics-${timestamp}.csv`); + } finally { + setExporting(false); + setIsOpen(false); + } + } + + async function handleExportJSON() { + if (!data) return; + + setExporting(true); + try { + const timestamp = new Date().toISOString().split('T')[0]; + exportToJSON(data, `analytics-${timestamp}.json`); + } finally { + setExporting(false); + setIsOpen(false); + } + } + + return ( +
+ + + {isOpen && ( + <> +
setIsOpen(false)} + aria-hidden="true" + /> +
+ + +
+ + )} +
+ ); +} diff --git a/frontend/src/components/TipVolumeChart.jsx b/frontend/src/components/TipVolumeChart.jsx new file mode 100644 index 00000000..ea104ad3 --- /dev/null +++ b/frontend/src/components/TipVolumeChart.jsx @@ -0,0 +1,373 @@ +import { formatAmount } from '../services/analytics'; +import { useState, useEffect, useMemo } from 'react'; + +/** + * Custom Premium SVG-based Chart to avoid external charting library dependencies (like Recharts) + * which can cause installation timeouts/failures in offline or restricted environments. + * + * Provides interactive tooltips, line/bar toggle, and mobile responsiveness. + */ +export default function TipVolumeChart({ data }) { + const [chartType, setChartType] = useState('line'); + const [isMobile, setIsMobile] = useState(false); + const [hoveredIndex, setHoveredIndex] = useState(null); + + useEffect(() => { + const checkMobile = () => { + setIsMobile(window.innerWidth < 768); + }; + checkMobile(); + window.addEventListener('resize', checkMobile); + return () => window.removeEventListener('resize', checkMobile); + }, []); + + if (!data || data.length === 0) { + return ( +
+

Tip Volume Over Time

+

No data available

+
+ ); + } + + const chartData = useMemo(() => { + return data.map(item => ({ + date: new Date(item.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + tips: item.count, + volume: Number(formatAmount(item.volume)), + })); + }, [data]); + + const maxTips = useMemo(() => { + const vals = chartData.map(d => d.tips); + return Math.max(...vals, 1); + }, [chartData]); + + const maxVolume = useMemo(() => { + const vals = chartData.map(d => d.volume); + return Math.max(...vals, 1); + }, [chartData]); + + const svgWidth = 800; + const svgHeight = 350; + const paddingLeft = 50; + const paddingRight = 50; + const paddingTop = 30; + const paddingBottom = 40; + + const chartWidth = svgWidth - paddingLeft - paddingRight; + const chartHeight = svgHeight - paddingTop - paddingBottom; + + const points = useMemo(() => { + if (chartData.length === 0) return []; + + return chartData.map((d, index) => { + const x = paddingLeft + (index / Math.max(chartData.length - 1, 1)) * chartWidth; + + const yTips = paddingTop + chartHeight - (d.tips / maxTips) * chartHeight; + const yVolume = paddingTop + chartHeight - (d.volume / maxVolume) * chartHeight; + + return { x, yTips, yVolume, ...d }; + }); + }, [chartData, chartWidth, chartHeight, maxTips, maxVolume]); + + const linePathTips = useMemo(() => { + if (points.length < 2) return ''; + return points.reduce((path, p, i) => { + return i === 0 ? `M ${p.x} ${p.yTips}` : `${path} L ${p.x} ${p.yTips}`; + }, ''); + }, [points]); + + const linePathVolume = useMemo(() => { + if (points.length < 2) return ''; + return points.reduce((path, p, i) => { + return i === 0 ? `M ${p.x} ${p.yVolume}` : `${path} L ${p.x} ${p.yVolume}`; + }, ''); + }, [points]); + + const areaPathTips = useMemo(() => { + if (points.length < 2) return ''; + const baseLine = paddingTop + chartHeight; + return `${linePathTips} L ${points[points.length - 1].x} ${baseLine} L ${points[0].x} ${baseLine} Z`; + }, [points, linePathTips, chartHeight]); + + const areaPathVolume = useMemo(() => { + if (points.length < 2) return ''; + const baseLine = paddingTop + chartHeight; + return `${linePathVolume} L ${points[points.length - 1].x} ${baseLine} L ${points[0].x} ${baseLine} Z`; + }, [points, linePathVolume, chartHeight]); + + const selectedPoint = hoveredIndex !== null ? points[hoveredIndex] : null; + + return ( +
+
+

Tip Volume Over Time

+
+ + +
+
+ + {selectedPoint && ( +
+

{selectedPoint.date}

+

+ Tips: {selectedPoint.tips} + {' | '} + Volume: {selectedPoint.volume.toFixed(2)} STX +

+
+ )} + +
+ + + + + + + + + + + + + {[0, 0.25, 0.5, 0.75, 1].map((ratio, i) => { + const y = paddingTop + chartHeight * ratio; + return ( + + ); + })} + + + Tips + + {[0, 0.5, 1].map((ratio, i) => { + const val = Math.round(maxTips * ratio); + const y = paddingTop + chartHeight - (ratio * chartHeight); + return ( + + {val} + + ); + })} + + + STX + + {[0, 0.5, 1].map((ratio, i) => { + const val = (maxVolume * ratio).toFixed(1); + const y = paddingTop + chartHeight - (ratio * chartHeight); + return ( + + {val} + + ); + })} + + {points.map((p, index) => { + if (isMobile && index % 2 !== 0) return null; + if (points.length > 10 && index % 2 !== 0) return null; + + return ( + + {p.date} + + ); + })} + + {chartType === 'line' ? ( + <> + + + + + + + {points.map((p, index) => { + const isHovered = hoveredIndex === index; + return ( + + setHoveredIndex(index)} + onMouseLeave={() => setHoveredIndex(null)} + /> + + + + ); + })} + + ) : ( + <> + {points.map((p, index) => { + const isHovered = hoveredIndex === index; + const barWidth = Math.max(10, Math.min(30, chartWidth / (points.length * 2.5))); + const gap = 2; + + const baseLine = paddingTop + chartHeight; + const hTips = baseLine - p.yTips; + const hVolume = baseLine - p.yVolume; + + return ( + setHoveredIndex(index)} + onMouseLeave={() => setHoveredIndex(null)} + > + + + + ); + })} + + )} + + {points.map((p, index) => { + const barWidth = chartWidth / points.length; + return ( + setHoveredIndex(index)} + onMouseLeave={() => setHoveredIndex(null)} + /> + ); + })} + +
+ +
+
+ + Number of Tips +
+
+ + Volume (STX) +
+
+
+ ); +} diff --git a/frontend/src/components/TopRecipientsChart.jsx b/frontend/src/components/TopRecipientsChart.jsx new file mode 100644 index 00000000..9da30e33 --- /dev/null +++ b/frontend/src/components/TopRecipientsChart.jsx @@ -0,0 +1,135 @@ +import { formatAmount, formatAddress } from '../services/analytics'; +import { useState, useEffect, useMemo } from 'react'; + +/** + * Custom Premium SVG/CSS-based Chart to avoid external charting library dependencies (like Recharts) + * which can cause installation timeouts/failures in offline or restricted environments. + * + * Provides interactive rankings, hover tooltips, and high mobile responsiveness. + */ +export default function TopRecipientsChart({ data }) { + const [isMobile, setIsMobile] = useState(false); + const [hoveredIndex, setHoveredIndex] = useState(null); + + useEffect(() => { + const checkMobile = () => { + setIsMobile(window.innerWidth < 768); + }; + checkMobile(); + window.addEventListener('resize', checkMobile); + return () => window.removeEventListener('resize', checkMobile); + }, []); + + if (!data || data.length === 0) { + return ( +
+

Top Recipients

+

No data available

+
+ ); + } + + const chartData = useMemo(() => { + return data.map(item => ({ + address: formatAddress(item.address), + fullAddress: item.address, + volume: Number(formatAmount(item.volume)), + count: item.count, + })); + }, [data]); + + const displayData = useMemo(() => { + return isMobile ? chartData.slice(0, 5) : chartData; + }, [chartData, isMobile]); + + const maxVolume = useMemo(() => { + const vals = displayData.map(d => d.volume); + return Math.max(...vals, 1); + }, [displayData]); + + const selectedPoint = hoveredIndex !== null ? displayData[hoveredIndex] : null; + + return ( +
+

Top Recipients

+ + {selectedPoint && ( +
+

{selectedPoint.fullAddress}

+

+ Volume: {selectedPoint.volume.toFixed(2)} STX + {' | '} + Tips: {selectedPoint.count} +

+
+ )} + +
+
+ {displayData.map((d, index) => { + const isHovered = hoveredIndex === index; + const percentage = (d.volume / maxVolume) * 100; + return ( +
setHoveredIndex(index)} + onMouseLeave={() => setHoveredIndex(null)} + > +
+ + {d.address} + +
+ +
+
+ {percentage > 15 && ( + + {d.volume.toFixed(1)} STX + + )} +
+ {percentage <= 15 && ( + + {d.volume.toFixed(1)} STX + + )} +
+
+ ); + })} +
+
+ +
+

Detailed Rankings

+
+ {data.slice(0, 5).map((recipient, index) => ( +
setHoveredIndex(index)} + > +
+ + {index + 1} + + {formatAddress(recipient.address)} +
+
+

{formatAmount(recipient.volume)} STX

+

{recipient.count} tips

+
+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/components/TopSendersChart.jsx b/frontend/src/components/TopSendersChart.jsx new file mode 100644 index 00000000..ae91a802 --- /dev/null +++ b/frontend/src/components/TopSendersChart.jsx @@ -0,0 +1,135 @@ +import { formatAmount, formatAddress } from '../services/analytics'; +import { useState, useEffect, useMemo } from 'react'; + +/** + * Custom Premium SVG/CSS-based Chart to avoid external charting library dependencies (like Recharts) + * which can cause installation timeouts/failures in offline or restricted environments. + * + * Provides interactive rankings, hover tooltips, and high mobile responsiveness. + */ +export default function TopSendersChart({ data }) { + const [isMobile, setIsMobile] = useState(false); + const [hoveredIndex, setHoveredIndex] = useState(null); + + useEffect(() => { + const checkMobile = () => { + setIsMobile(window.innerWidth < 768); + }; + checkMobile(); + window.addEventListener('resize', checkMobile); + return () => window.removeEventListener('resize', checkMobile); + }, []); + + if (!data || data.length === 0) { + return ( +
+

Top Senders

+

No data available

+
+ ); + } + + const chartData = useMemo(() => { + return data.map(item => ({ + address: formatAddress(item.address), + fullAddress: item.address, + volume: Number(formatAmount(item.volume)), + count: item.count, + })); + }, [data]); + + const displayData = useMemo(() => { + return isMobile ? chartData.slice(0, 5) : chartData; + }, [chartData, isMobile]); + + const maxVolume = useMemo(() => { + const vals = displayData.map(d => d.volume); + return Math.max(...vals, 1); + }, [displayData]); + + const selectedPoint = hoveredIndex !== null ? displayData[hoveredIndex] : null; + + return ( +
+

Top Senders

+ + {selectedPoint && ( +
+

{selectedPoint.fullAddress}

+

+ Volume: {selectedPoint.volume.toFixed(2)} STX + {' | '} + Tips: {selectedPoint.count} +

+
+ )} + +
+
+ {displayData.map((d, index) => { + const isHovered = hoveredIndex === index; + const percentage = (d.volume / maxVolume) * 100; + return ( +
setHoveredIndex(index)} + onMouseLeave={() => setHoveredIndex(null)} + > +
+ + {d.address} + +
+ +
+
+ {percentage > 15 && ( + + {d.volume.toFixed(1)} STX + + )} +
+ {percentage <= 15 && ( + + {d.volume.toFixed(1)} STX + + )} +
+
+ ); + })} +
+
+ +
+

Detailed Rankings

+
+ {data.slice(0, 5).map((sender, index) => ( +
setHoveredIndex(index)} + > +
+ + {index + 1} + + {formatAddress(sender.address)} +
+
+

{formatAmount(sender.volume)} STX

+

{sender.count} tips

+
+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/config/routes.js b/frontend/src/config/routes.js index 24abad95..5f44ad92 100644 --- a/frontend/src/config/routes.js +++ b/frontend/src/config/routes.js @@ -91,6 +91,12 @@ export const ROUTE_BLOCK = '/block'; */ export const ROUTE_STATS = '/stats'; +/** + * Analytics dashboard with charts and trends. + * @type {string} + */ +export const ROUTE_ANALYTICS = '/analytics'; + /** * Admin dashboard (owner-only, guarded by RequireAdmin). * @type {string} @@ -146,6 +152,7 @@ export const ROUTE_LABELS = { [ROUTE_ADDRESS_BOOK]: 'Address Book', [ROUTE_BLOCK]: 'Block', [ROUTE_STATS]: 'Stats', + [ROUTE_ANALYTICS]: 'Analytics', [ROUTE_ADMIN]: 'Admin', [ROUTE_TELEMETRY]: 'Telemetry', [ROUTE_REFUNDS]: 'Refunds', @@ -174,6 +181,7 @@ export const ROUTE_TITLES = { [ROUTE_ADDRESS_BOOK]: 'Address Book -- TipStream', [ROUTE_BLOCK]: 'Block Manager -- TipStream', [ROUTE_STATS]: 'Platform Stats -- TipStream', + [ROUTE_ANALYTICS]: 'Analytics Dashboard -- TipStream', [ROUTE_ADMIN]: 'Admin Dashboard -- TipStream', [ROUTE_TELEMETRY]: 'Telemetry -- TipStream', [ROUTE_REFUNDS]: 'Refunds -- TipStream', @@ -258,6 +266,11 @@ export const ROUTE_META = { requiresAuth: false, adminOnly: false, }, + [ROUTE_ANALYTICS]: { + description: 'Analytics dashboard with charts, trends, and insights.', + requiresAuth: false, + adminOnly: false, + }, [ROUTE_ADMIN]: { description: 'Pause/resume, fee configuration, ownership transfer.', requiresAuth: true, diff --git a/frontend/src/index.css b/frontend/src/index.css index e0986c8e..92594486 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@config "../tailwind.config.js"; /* ── Base layer ────────────────────────────────────────────── */ html { diff --git a/frontend/src/lib/batchTipResults.js b/frontend/src/lib/batchTipResults.js new file mode 100644 index 00000000..82f7f15e --- /dev/null +++ b/frontend/src/lib/batchTipResults.js @@ -0,0 +1,71 @@ +/** + * Utilities for summarizing and building notification messages for batch tip transactions. + */ + +/** + * Summarizes the outcome of a batch tip transaction by parsing its event log. + * + * @param {object} txData - Transaction receipt from Stacks API. + * @param {number} totalRecipients - Expected number of recipients in the batch. + * @returns {{ successCount: number, failureCount: number, totalCount: number }} + */ +export function summarizeBatchTipResult(txData, totalRecipients) { + if (!txData) { + return { + successCount: 0, + failureCount: totalRecipients, + totalCount: totalRecipients, + }; + } + + if (txData.tx_status !== 'success') { + return { + successCount: 0, + failureCount: totalRecipients, + totalCount: totalRecipients, + }; + } + + // If there's no events array, or it's empty, assume all succeeded + if (!Array.isArray(txData.events) || txData.events.length === 0) { + return { + successCount: totalRecipients, + failureCount: 0, + totalCount: totalRecipients, + }; + } + + // Stacks contract emits "smart_contract_log" events for each successful transfer/tip. + // Let's count how many print events are present in the transaction events list. + const printEvents = txData.events.filter(e => e.event_type === 'smart_contract_log'); + + // Resilient check: if we have print events, use that count. Otherwise, assume full success. + const successCount = printEvents.length > 0 ? Math.min(printEvents.length, totalRecipients) : totalRecipients; + const failureCount = Math.max(0, totalRecipients - successCount); + + return { + successCount, + failureCount, + totalCount: totalRecipients, + }; +} + +/** + * Builds a user-friendly outcome message for a batch tip transaction. + * + * @param {{ successCount: number, failureCount: number, totalCount: number }} summary + * @returns {string} User-facing result message. + */ +export function buildBatchTipOutcomeMessage(summary) { + const { successCount, failureCount, totalCount } = summary; + + if (failureCount === 0) { + return `Successfully sent all ${totalCount} tip${totalCount !== 1 ? 's' : ''}!`; + } + + if (successCount === 0) { + return `Failed to send any of the ${totalCount} tip${totalCount !== 1 ? 's' : ''}.`; + } + + return `Batch complete: ${successCount} tip${successCount !== 1 ? 's' : ''} succeeded, ${failureCount} failed.`; +} diff --git a/frontend/src/services/analytics.js b/frontend/src/services/analytics.js new file mode 100644 index 00000000..643c6904 --- /dev/null +++ b/frontend/src/services/analytics.js @@ -0,0 +1,66 @@ +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3100'; + +export async function fetchAnalytics(startDate, endDate) { + const params = new URLSearchParams(); + if (startDate) { + params.append('startDate', startDate); + } + if (endDate) { + params.append('endDate', endDate); + } + + const url = `${API_BASE_URL}/api/analytics${params.toString() ? '?' + params.toString() : ''}`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error('Failed to fetch analytics data'); + } + + return response.json(); +} + +export async function fetchStats() { + const response = await fetch(`${API_BASE_URL}/api/stats`); + + if (!response.ok) { + throw new Error('Failed to fetch stats'); + } + + return response.json(); +} + +export function formatAmount(amount) { + return (Number(amount) / 1000000).toFixed(2); +} + +export function formatAddress(address) { + if (!address || address.length < 10) return address; + return `${address.slice(0, 6)}...${address.slice(-4)}`; +} + +export function exportToCSV(data, filename) { + const headers = Object.keys(data[0] || {}); + const csvContent = [ + headers.join(','), + ...data.map(row => headers.map(header => row[header]).join(',')) + ].join('\n'); + + const blob = new Blob([csvContent], { type: 'text/csv' }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.click(); + window.URL.revokeObjectURL(url); +} + +export function exportToJSON(data, filename) { + const jsonContent = JSON.stringify(data, null, 2); + const blob = new Blob([jsonContent], { type: 'application/json' }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.click(); + window.URL.revokeObjectURL(url); +} diff --git a/frontend/src/styles/analytics.css b/frontend/src/styles/analytics.css new file mode 100644 index 00000000..9228b1a9 --- /dev/null +++ b/frontend/src/styles/analytics.css @@ -0,0 +1,78 @@ +@media (max-width: 640px) { + .recharts-wrapper { + font-size: 12px; + } + + .recharts-cartesian-axis-tick-value { + font-size: 10px; + } + + .recharts-legend-wrapper { + font-size: 11px; + } +} + +@media (max-width: 768px) { + .analytics-summary-grid { + grid-template-columns: repeat(2, 1fr); + } + + .analytics-chart-container { + padding: 1rem; + } +} + +@media (min-width: 1024px) { + .analytics-summary-grid { + grid-template-columns: repeat(4, 1fr); + } +} + +.analytics-card { + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.analytics-card:hover { + transform: translateY(-2px); +} + +.analytics-loading { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.analytics-export-menu { + animation: slideDown 0.2s ease-out; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.analytics-tooltip { + animation: fadeIn 0.15s ease-in; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +}