From 1c5f6cfe6a551f3a30dbfe8038a02d59b26cfe46 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Sat, 30 May 2026 16:34:04 +0100 Subject: [PATCH 01/28] Add recharts library for data visualization --- frontend/package.json | 1 + 1 file changed, 1 insertion(+) 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" From aafc166e81b78740a7e19bdd7b305194b27c1334 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Sun, 31 May 2026 07:15:11 +0100 Subject: [PATCH 02/28] Add analytics endpoint with time-series data --- chainhook/server.js | 81 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) 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(); From 7f6b15e98a7c62d3b72880b22e32095ad47b2b46 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Sun, 31 May 2026 07:19:04 +0100 Subject: [PATCH 03/28] Create analytics service for API integration --- frontend/src/services/analytics.js | 66 ++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 frontend/src/services/analytics.js 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); +} From 378b9022f1e86987dafd3178729eb5495c07baed Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Sun, 31 May 2026 08:14:59 +0100 Subject: [PATCH 04/28] Create main Analytics dashboard component --- frontend/src/components/Analytics.jsx | 95 +++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 frontend/src/components/Analytics.jsx diff --git a/frontend/src/components/Analytics.jsx b/frontend/src/components/Analytics.jsx new file mode 100644 index 00000000..c470efc2 --- /dev/null +++ b/frontend/src/components/Analytics.jsx @@ -0,0 +1,95 @@ +import { useState, useEffect } from 'react'; +import { fetchAnalytics } from '../services/analytics'; +import AnalyticsSummary from './AnalyticsSummary'; +import TipVolumeChart from './TipVolumeChart'; +import TopSendersChart from './TopSendersChart'; +import TopRecipientsChart from './TopRecipientsChart'; +import DateRangeFilter from './DateRangeFilter'; +import ExportData from './ExportData'; + +export default function Analytics() { + const [analyticsData, setAnalyticsData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [dateRange, setDateRange] = useState({ + startDate: null, + endDate: null, + }); + + useEffect(() => { + loadAnalytics(); + }, [dateRange]); + + async function loadAnalytics() { + try { + setLoading(true); + setError(null); + const data = await fetchAnalytics(dateRange.startDate, dateRange.endDate); + setAnalyticsData(data); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + } + + function handleDateRangeChange(newRange) { + setDateRange(newRange); + } + + if (loading) { + return ( +
+
+
+

Loading analytics...

+
+
+ ); + } + + if (error) { + return ( +
+
+

Error loading analytics: {error}

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

Analytics Dashboard

+

Track tip statistics, trends, and insights

+
+ +
+ + +
+ + + +
+ +
+ +
+ + +
+
+ ); +} From 3cf0366f0fac2f7b88ed5e698af21072c34ac093 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Sun, 31 May 2026 08:15:43 +0100 Subject: [PATCH 05/28] Add analytics summary cards component --- frontend/src/components/AnalyticsSummary.jsx | 62 ++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 frontend/src/components/AnalyticsSummary.jsx diff --git a/frontend/src/components/AnalyticsSummary.jsx b/frontend/src/components/AnalyticsSummary.jsx new file mode 100644 index 00000000..fb4beb49 --- /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}

+
+ ); + })} +
+ ); +} From 13e179093b78588a31d82e5292b0ba2fa722d152 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Sun, 31 May 2026 08:16:31 +0100 Subject: [PATCH 06/28] Add tip volume chart with line and bar views --- frontend/src/components/TipVolumeChart.jsx | 113 +++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 frontend/src/components/TipVolumeChart.jsx diff --git a/frontend/src/components/TipVolumeChart.jsx b/frontend/src/components/TipVolumeChart.jsx new file mode 100644 index 00000000..99bf13b5 --- /dev/null +++ b/frontend/src/components/TipVolumeChart.jsx @@ -0,0 +1,113 @@ +import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; +import { formatAmount } from '../services/analytics'; +import { useState } from 'react'; + +export default function TipVolumeChart({ data }) { + const [chartType, setChartType] = useState('line'); + + if (!data || data.length === 0) { + return ( +
+

Tip Volume Over Time

+

No data available

+
+ ); + } + + const chartData = data.map(item => ({ + date: new Date(item.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + tips: item.count, + volume: Number(formatAmount(item.volume)), + })); + + const CustomTooltip = ({ active, payload }) => { + if (active && payload && payload.length) { + return ( +
+

{payload[0].payload.date}

+

+ Tips: {payload[0].value} +

+

+ Volume: {payload[1].value} STX +

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

Tip Volume Over Time

+
+ + +
+
+ + + {chartType === 'line' ? ( + + + + + + } /> + + + + + ) : ( + + + + + + } /> + + + + + )} + +
+ ); +} From f04b1d8256344f79def13e2d62ecab1c532dcaa0 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Sun, 31 May 2026 08:17:32 +0100 Subject: [PATCH 07/28] Add top senders chart with rankings --- frontend/src/components/TopSendersChart.jsx | 75 +++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 frontend/src/components/TopSendersChart.jsx diff --git a/frontend/src/components/TopSendersChart.jsx b/frontend/src/components/TopSendersChart.jsx new file mode 100644 index 00000000..edb2bab9 --- /dev/null +++ b/frontend/src/components/TopSendersChart.jsx @@ -0,0 +1,75 @@ +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; +import { formatAmount, formatAddress } from '../services/analytics'; + +export default function TopSendersChart({ data }) { + if (!data || data.length === 0) { + return ( +
+

Top Senders

+

No data available

+
+ ); + } + + const chartData = data.map(item => ({ + address: formatAddress(item.address), + fullAddress: item.address, + volume: Number(formatAmount(item.volume)), + count: item.count, + })); + + const CustomTooltip = ({ active, payload }) => { + if (active && payload && payload.length) { + return ( +
+

+ {payload[0].payload.fullAddress} +

+

+ Tips Sent: {payload[0].payload.count} +

+

+ Total Volume: {payload[0].value} STX +

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

Top Senders

+ + + + + + + } /> + + + + +
+

Detailed Rankings

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

{formatAmount(sender.volume)} STX

+

{sender.count} tips

+
+
+ ))} +
+
+
+ ); +} From 04e78c963f0f6c0502fe030b09a96c55928171d6 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Sun, 31 May 2026 08:18:22 +0100 Subject: [PATCH 08/28] Add top recipients chart with rankings --- .../src/components/TopRecipientsChart.jsx | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 frontend/src/components/TopRecipientsChart.jsx diff --git a/frontend/src/components/TopRecipientsChart.jsx b/frontend/src/components/TopRecipientsChart.jsx new file mode 100644 index 00000000..e6d503cc --- /dev/null +++ b/frontend/src/components/TopRecipientsChart.jsx @@ -0,0 +1,75 @@ +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; +import { formatAmount, formatAddress } from '../services/analytics'; + +export default function TopRecipientsChart({ data }) { + if (!data || data.length === 0) { + return ( +
+

Top Recipients

+

No data available

+
+ ); + } + + const chartData = data.map(item => ({ + address: formatAddress(item.address), + fullAddress: item.address, + volume: Number(formatAmount(item.volume)), + count: item.count, + })); + + const CustomTooltip = ({ active, payload }) => { + if (active && payload && payload.length) { + return ( +
+

+ {payload[0].payload.fullAddress} +

+

+ Tips Received: {payload[0].payload.count} +

+

+ Total Volume: {payload[0].value} STX +

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

Top Recipients

+ + + + + + + } /> + + + + +
+

Detailed Rankings

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

{formatAmount(recipient.volume)} STX

+

{recipient.count} tips

+
+
+ ))} +
+
+
+ ); +} From 133abda2f7c348e4ed45feb8d8e88737f45ea5f4 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Sun, 31 May 2026 08:19:05 +0100 Subject: [PATCH 09/28] Add date range filter with presets --- frontend/src/components/DateRangeFilter.jsx | 105 ++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 frontend/src/components/DateRangeFilter.jsx diff --git a/frontend/src/components/DateRangeFilter.jsx b/frontend/src/components/DateRangeFilter.jsx new file mode 100644 index 00000000..30eefb54 --- /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-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+
+ + setLocalEndDate(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+
+ +
+ + + +
+ +
+ + +
+
+ ); +} From f083749be81067ecc7ac3200c95e1eb21e21a616 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Sun, 31 May 2026 08:20:10 +0100 Subject: [PATCH 10/28] Add data export functionality for CSV and JSON --- frontend/src/components/ExportData.jsx | 60 ++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 frontend/src/components/ExportData.jsx diff --git a/frontend/src/components/ExportData.jsx b/frontend/src/components/ExportData.jsx new file mode 100644 index 00000000..cb5d8094 --- /dev/null +++ b/frontend/src/components/ExportData.jsx @@ -0,0 +1,60 @@ +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); + + function handleExportCSV() { + if (!data || !data.timeSeriesData) return; + + const timestamp = new Date().toISOString().split('T')[0]; + exportToCSV(data.timeSeriesData, `analytics-${timestamp}.csv`); + setIsOpen(false); + } + + function handleExportJSON() { + if (!data) return; + + const timestamp = new Date().toISOString().split('T')[0]; + exportToJSON(data, `analytics-${timestamp}.json`); + setIsOpen(false); + } + + return ( +
+ + + {isOpen && ( + <> +
setIsOpen(false)} + /> +
+ + +
+ + )} +
+ ); +} From d56e881586ca76db17c71459bb69c29461a4833f Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Sun, 31 May 2026 08:39:17 +0100 Subject: [PATCH 11/28] Add analytics route to routes configuration --- frontend/src/config/routes.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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, From 9f57d832ef5f5f03430aa2cb6f320ca114a2adc3 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Sun, 31 May 2026 08:41:10 +0100 Subject: [PATCH 12/28] Integrate analytics dashboard into app routing --- frontend/src/App.jsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 46a1f001..4e35adc1 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, BellCog, 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); @@ -178,6 +179,7 @@ function App() { { path: ROUTE_NOTIFICATION_PREFERENCES, label: 'Notifications', icon: BellCog }, { 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 */} Date: Sun, 31 May 2026 08:41:32 +0100 Subject: [PATCH 13/28] Add responsive styles for analytics dashboard --- frontend/src/styles/analytics.css | 78 +++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 frontend/src/styles/analytics.css 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; + } +} From 623664cbb8acde001cc911c32082aa390a6ba6b4 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Sun, 31 May 2026 08:42:15 +0100 Subject: [PATCH 14/28] Add comprehensive tests for analytics endpoint --- chainhook/analytics.test.js | 174 ++++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 chainhook/analytics.test.js 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(); +}); From eb96603bde7e5be3b9a217cff6b876e9eb868502 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Sun, 31 May 2026 08:43:37 +0100 Subject: [PATCH 15/28] Add comprehensive analytics feature documentation --- ANALYTICS_FEATURE.md | 258 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 ANALYTICS_FEATURE.md 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 From efa2686f093cadcffcfe16b3a0720dfc0d5da6ba Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Sun, 31 May 2026 08:45:19 +0100 Subject: [PATCH 16/28] Add error boundary for analytics components --- .../src/components/AnalyticsErrorBoundary.jsx | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 frontend/src/components/AnalyticsErrorBoundary.jsx 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; From 9bba2e578bb36387710fbe2523e09f6d9f9713b1 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Sun, 31 May 2026 08:46:51 +0100 Subject: [PATCH 17/28] Wrap analytics components with error boundaries --- frontend/src/components/Analytics.jsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/Analytics.jsx b/frontend/src/components/Analytics.jsx index c470efc2..2075a0f7 100644 --- a/frontend/src/components/Analytics.jsx +++ b/frontend/src/components/Analytics.jsx @@ -6,6 +6,8 @@ import TopSendersChart from './TopSendersChart'; import TopRecipientsChart from './TopRecipientsChart'; import DateRangeFilter from './DateRangeFilter'; import ExportData from './ExportData'; +import AnalyticsErrorBoundary from './AnalyticsErrorBoundary'; +import '../styles/analytics.css'; export default function Analytics() { const [analyticsData, setAnalyticsData] = useState(null); @@ -80,15 +82,23 @@ export default function Analytics() {
- + + +
- + + +
- - + + + + + +
); From e3c7726cb89fa06466801286120969c8aaa41e4a Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Sun, 31 May 2026 08:47:19 +0100 Subject: [PATCH 18/28] Add loading skeleton for analytics dashboard --- .../components/AnalyticsLoadingSkeleton.jsx | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 frontend/src/components/AnalyticsLoadingSkeleton.jsx 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) => ( +
+
+
+
+ ))} +
+
+ ); +} From 63e73d31de930a8f5baae147ec00b1765127d417 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Sun, 31 May 2026 08:47:46 +0100 Subject: [PATCH 19/28] Integrate loading skeleton into analytics component --- frontend/src/components/Analytics.jsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/Analytics.jsx b/frontend/src/components/Analytics.jsx index 2075a0f7..b2a8fb8c 100644 --- a/frontend/src/components/Analytics.jsx +++ b/frontend/src/components/Analytics.jsx @@ -7,6 +7,7 @@ import TopRecipientsChart from './TopRecipientsChart'; import DateRangeFilter from './DateRangeFilter'; import ExportData from './ExportData'; import AnalyticsErrorBoundary from './AnalyticsErrorBoundary'; +import AnalyticsLoadingSkeleton from './AnalyticsLoadingSkeleton'; import '../styles/analytics.css'; export default function Analytics() { @@ -40,14 +41,7 @@ export default function Analytics() { } if (loading) { - return ( -
-
-
-

Loading analytics...

-
-
- ); + return ; } if (error) { From 21a3343b2fb9b6d13e646ba1b3d0b28152687919 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Sun, 31 May 2026 08:48:43 +0100 Subject: [PATCH 20/28] Add mobile optimization and chart interactions to volume chart --- frontend/src/components/TipVolumeChart.jsx | 83 +++++++++++++++++----- 1 file changed, 64 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/TipVolumeChart.jsx b/frontend/src/components/TipVolumeChart.jsx index 99bf13b5..0480ebfb 100644 --- a/frontend/src/components/TipVolumeChart.jsx +++ b/frontend/src/components/TipVolumeChart.jsx @@ -1,10 +1,20 @@ import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; import { formatAmount } from '../services/analytics'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; export default function TipVolumeChart({ data }) { const [chartType, setChartType] = useState('line'); + const [isMobile, setIsMobile] = useState(false); + const [selectedPoint, setSelectedPoint] = 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 (
@@ -37,10 +47,18 @@ export default function TipVolumeChart({ data }) { return null; }; + const handleChartClick = (data) => { + if (data && data.activePayload) { + setSelectedPoint(data.activePayload[0].payload); + } + }; + + const chartHeight = isMobile ? 300 : 400; + return ( -
-
-

Tip Volume Over Time

+
+
+

Tip Volume Over Time

@@ -59,29 +78,48 @@ export default function TipVolumeChart({ data }) { ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700 hover:bg-gray-300' }`} + aria-label="Switch to bar chart" > Bar
- + {selectedPoint && ( +
+

{selectedPoint.date}

+

+ Tips: {selectedPoint.tips} + {' | '} + Volume: {selectedPoint.volume} STX +

+
+ )} + + {chartType === 'line' ? ( - + - - - + + + } /> - + {!isMobile && } ) : ( - + - - - + + + } /> - + {!isMobile && } From 4c8ae4a7b22759f962142217bf2cf1c10f69564c Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Sun, 31 May 2026 08:50:09 +0100 Subject: [PATCH 21/28] Add mobile optimization and interactions to top senders chart --- frontend/src/components/TopSendersChart.jsx | 64 ++++++++++++++++++--- 1 file changed, 55 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/TopSendersChart.jsx b/frontend/src/components/TopSendersChart.jsx index edb2bab9..dd3dfd91 100644 --- a/frontend/src/components/TopSendersChart.jsx +++ b/frontend/src/components/TopSendersChart.jsx @@ -1,7 +1,19 @@ import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; import { formatAmount, formatAddress } from '../services/analytics'; +import { useState, useEffect } from 'react'; export default function TopSendersChart({ data }) { + const [isMobile, setIsMobile] = useState(false); + const [selectedSender, setSelectedSender] = 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 (
@@ -37,15 +49,41 @@ export default function TopSendersChart({ data }) { return null; }; + const handleBarClick = (data) => { + if (data && data.fullAddress) { + setSelectedSender(data); + } + }; + + const chartHeight = isMobile ? 250 : 400; + const displayData = isMobile ? chartData.slice(0, 5) : chartData; + return ( -
-

Top Senders

+
+

Top Senders

- - + {selectedSender && ( +
+

{selectedSender.fullAddress}

+

+ Volume: {selectedSender.volume} STX + {' | '} + Tips: {selectedSender.count} +

+
+ )} + + + - - + + } /> @@ -55,15 +93,23 @@ export default function TopSendersChart({ data }) {

Detailed Rankings

{data.slice(0, 5).map((sender, index) => ( -
+
setSelectedSender({ + fullAddress: sender.address, + volume: formatAmount(sender.volume), + count: sender.count + })} + >
{index + 1} - {formatAddress(sender.address)} + {formatAddress(sender.address)}
-

{formatAmount(sender.volume)} STX

+

{formatAmount(sender.volume)} STX

{sender.count} tips

From 214c08a596fa18cffb1b2d962a1128030773f9ab Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Sun, 31 May 2026 08:51:11 +0100 Subject: [PATCH 22/28] Add mobile optimization and interactions to top recipients chart --- .../src/components/TopRecipientsChart.jsx | 64 ++++++++++++++++--- 1 file changed, 55 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/TopRecipientsChart.jsx b/frontend/src/components/TopRecipientsChart.jsx index e6d503cc..b1fb2735 100644 --- a/frontend/src/components/TopRecipientsChart.jsx +++ b/frontend/src/components/TopRecipientsChart.jsx @@ -1,7 +1,19 @@ import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; import { formatAmount, formatAddress } from '../services/analytics'; +import { useState, useEffect } from 'react'; export default function TopRecipientsChart({ data }) { + const [isMobile, setIsMobile] = useState(false); + const [selectedRecipient, setSelectedRecipient] = 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 (
@@ -37,15 +49,41 @@ export default function TopRecipientsChart({ data }) { return null; }; + const handleBarClick = (data) => { + if (data && data.fullAddress) { + setSelectedRecipient(data); + } + }; + + const chartHeight = isMobile ? 250 : 400; + const displayData = isMobile ? chartData.slice(0, 5) : chartData; + return ( -
-

Top Recipients

+
+

Top Recipients

- - + {selectedRecipient && ( +
+

{selectedRecipient.fullAddress}

+

+ Volume: {selectedRecipient.volume} STX + {' | '} + Tips: {selectedRecipient.count} +

+
+ )} + + + - - + + } /> @@ -55,15 +93,23 @@ export default function TopRecipientsChart({ data }) {

Detailed Rankings

{data.slice(0, 5).map((recipient, index) => ( -
+
setSelectedRecipient({ + fullAddress: recipient.address, + volume: formatAmount(recipient.volume), + count: recipient.count + })} + >
{index + 1} - {formatAddress(recipient.address)} + {formatAddress(recipient.address)}
-

{formatAmount(recipient.volume)} STX

+

{formatAmount(recipient.volume)} STX

{recipient.count} tips

From a527d8d58130952d70d6e61cf92e0c26540b2308 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Sun, 31 May 2026 08:52:10 +0100 Subject: [PATCH 23/28] Add refresh button and auto-refresh functionality --- frontend/src/components/Analytics.jsx | 39 +++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/Analytics.jsx b/frontend/src/components/Analytics.jsx index b2a8fb8c..690bc364 100644 --- a/frontend/src/components/Analytics.jsx +++ b/frontend/src/components/Analytics.jsx @@ -8,12 +8,14 @@ import DateRangeFilter from './DateRangeFilter'; import ExportData from './ExportData'; import AnalyticsErrorBoundary from './AnalyticsErrorBoundary'; import AnalyticsLoadingSkeleton from './AnalyticsLoadingSkeleton'; +import { RefreshCw } from 'lucide-react'; import '../styles/analytics.css'; export default function Analytics() { const [analyticsData, setAnalyticsData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [refreshing, setRefreshing] = useState(false); const [dateRange, setDateRange] = useState({ startDate: null, endDate: null, @@ -23,9 +25,20 @@ export default function Analytics() { loadAnalytics(); }, [dateRange]); - async function loadAnalytics() { + useEffect(() => { + const interval = setInterval(() => { + loadAnalytics(true); + }, 60000); + return () => clearInterval(interval); + }, [dateRange]); + + async function loadAnalytics(isAutoRefresh = false) { try { - setLoading(true); + if (!isAutoRefresh) { + setLoading(true); + } else { + setRefreshing(true); + } setError(null); const data = await fetchAnalytics(dateRange.startDate, dateRange.endDate); setAnalyticsData(data); @@ -33,9 +46,14 @@ export default function Analytics() { setError(err.message); } finally { setLoading(false); + setRefreshing(false); } } + function handleRefresh() { + loadAnalytics(); + } + function handleDateRangeChange(newRange) { setDateRange(newRange); } @@ -62,9 +80,20 @@ export default function Analytics() { return (
-
-

Analytics Dashboard

-

Track tip statistics, trends, and insights

+
+
+

Analytics Dashboard

+

Track tip statistics, trends, and insights

+
+
From d33a1d5968df9b56054f54a04151427afff0f127 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Sun, 31 May 2026 08:52:46 +0100 Subject: [PATCH 24/28] Enhance mobile responsiveness for analytics summary cards --- frontend/src/components/AnalyticsSummary.jsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/AnalyticsSummary.jsx b/frontend/src/components/AnalyticsSummary.jsx index fb4beb49..8a376362 100644 --- a/frontend/src/components/AnalyticsSummary.jsx +++ b/frontend/src/components/AnalyticsSummary.jsx @@ -39,21 +39,21 @@ export default function AnalyticsSummary({ summary }) { }; return ( -
+
{stats.map((stat) => { const Icon = stat.icon; return (
-
-
- +
+
+
-

{stat.label}

-

{stat.value}

+

{stat.label}

+

{stat.value}

); })} From e4e997b580ab0466dee34a11302e8a53ee1dec13 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Sun, 31 May 2026 08:53:56 +0100 Subject: [PATCH 25/28] Improve mobile responsiveness for date range filter --- frontend/src/components/DateRangeFilter.jsx | 30 ++++++++++----------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/DateRangeFilter.jsx b/frontend/src/components/DateRangeFilter.jsx index 30eefb54..12da3e06 100644 --- a/frontend/src/components/DateRangeFilter.jsx +++ b/frontend/src/components/DateRangeFilter.jsx @@ -38,49 +38,49 @@ export default function DateRangeFilter({ startDate, endDate, onChange }) { } return ( -
-
- -

Date Range

+
+
+ +

Date Range

-
+
- + setLocalStartDate(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + 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-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + 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" />
-
+
@@ -89,13 +89,13 @@ export default function DateRangeFilter({ startDate, endDate, onChange }) {
From 1acc9061ceada6d4844e523034a2179e766fd42b Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Sun, 31 May 2026 08:54:42 +0100 Subject: [PATCH 26/28] Enhance export functionality with loading states and mobile view --- frontend/src/components/ExportData.jsx | 43 ++++++++++++++++++-------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/ExportData.jsx b/frontend/src/components/ExportData.jsx index cb5d8094..f8a964f5 100644 --- a/frontend/src/components/ExportData.jsx +++ b/frontend/src/components/ExportData.jsx @@ -4,31 +4,45 @@ import { exportToCSV, exportToJSON } from '../services/analytics'; export default function ExportData({ data }) { const [isOpen, setIsOpen] = useState(false); + const [exporting, setExporting] = useState(false); - function handleExportCSV() { + async function handleExportCSV() { if (!data || !data.timeSeriesData) return; - const timestamp = new Date().toISOString().split('T')[0]; - exportToCSV(data.timeSeriesData, `analytics-${timestamp}.csv`); - setIsOpen(false); + setExporting(true); + try { + const timestamp = new Date().toISOString().split('T')[0]; + exportToCSV(data.timeSeriesData, `analytics-${timestamp}.csv`); + } finally { + setExporting(false); + setIsOpen(false); + } } - function handleExportJSON() { + async function handleExportJSON() { if (!data) return; - const timestamp = new Date().toISOString().split('T')[0]; - exportToJSON(data, `analytics-${timestamp}.json`); - setIsOpen(false); + setExporting(true); + try { + const timestamp = new Date().toISOString().split('T')[0]; + exportToJSON(data, `analytics-${timestamp}.json`); + } finally { + setExporting(false); + setIsOpen(false); + } } return (
{isOpen && ( @@ -36,18 +50,21 @@ export default function ExportData({ data }) {
setIsOpen(false)} + aria-hidden="true" /> -
+
{selectedPoint && ( -
-

{selectedPoint.date}

-

- Tips: {selectedPoint.tips} +

+

{selectedPoint.date}

+

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

)} - - {chartType === 'line' ? ( - - - - - - } /> - {!isMobile && } - - - - ) : ( - - - - - - } /> - {!isMobile && } - - - - )} - +
+ + + + + + + + + + + + + {[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 index b1fb2735..9da30e33 100644 --- a/frontend/src/components/TopRecipientsChart.jsx +++ b/frontend/src/components/TopRecipientsChart.jsx @@ -1,10 +1,15 @@ -import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; import { formatAmount, formatAddress } from '../services/analytics'; -import { useState, useEffect } from 'react'; +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 [selectedRecipient, setSelectedRecipient] = useState(null); + const [hoveredIndex, setHoveredIndex] = useState(null); useEffect(() => { const checkMobile = () => { @@ -14,103 +19,112 @@ export default function TopRecipientsChart({ data }) { window.addEventListener('resize', checkMobile); return () => window.removeEventListener('resize', checkMobile); }, []); + if (!data || data.length === 0) { return ( -
-

Top Recipients

-

No data available

+
+

Top Recipients

+

No data available

); } - const chartData = data.map(item => ({ - address: formatAddress(item.address), - fullAddress: item.address, - volume: Number(formatAmount(item.volume)), - count: item.count, - })); + const chartData = useMemo(() => { + return data.map(item => ({ + address: formatAddress(item.address), + fullAddress: item.address, + volume: Number(formatAmount(item.volume)), + count: item.count, + })); + }, [data]); - const CustomTooltip = ({ active, payload }) => { - if (active && payload && payload.length) { - return ( -
-

- {payload[0].payload.fullAddress} -

-

- Tips Received: {payload[0].payload.count} -

-

- Total Volume: {payload[0].value} STX -

-
- ); - } - return null; - }; + const displayData = useMemo(() => { + return isMobile ? chartData.slice(0, 5) : chartData; + }, [chartData, isMobile]); - const handleBarClick = (data) => { - if (data && data.fullAddress) { - setSelectedRecipient(data); - } - }; + const maxVolume = useMemo(() => { + const vals = displayData.map(d => d.volume); + return Math.max(...vals, 1); + }, [displayData]); - const chartHeight = isMobile ? 250 : 400; - const displayData = isMobile ? chartData.slice(0, 5) : chartData; + const selectedPoint = hoveredIndex !== null ? displayData[hoveredIndex] : null; return ( -
-

Top Recipients

+
+

Top Recipients

- {selectedRecipient && ( -
-

{selectedRecipient.fullAddress}

-

- Volume: {selectedRecipient.volume} STX + {selectedPoint && ( +

+

{selectedPoint.fullAddress}

+

+ Volume: {selectedPoint.volume.toFixed(2)} STX {' | '} - Tips: {selectedRecipient.count} + 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

+
+

Detailed Rankings

{data.slice(0, 5).map((recipient, index) => (
setSelectedRecipient({ - fullAddress: recipient.address, - volume: formatAmount(recipient.volume), - count: recipient.count - })} + className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800/40 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800/80 border border-gray-100/50 dark:border-gray-800/40 transition-colors cursor-pointer" + onClick={() => setHoveredIndex(index)} >
- + {index + 1} - {formatAddress(recipient.address)} + {formatAddress(recipient.address)}
-

{formatAmount(recipient.volume)} STX

-

{recipient.count} tips

+

{formatAmount(recipient.volume)} STX

+

{recipient.count} tips

))} diff --git a/frontend/src/components/TopSendersChart.jsx b/frontend/src/components/TopSendersChart.jsx index dd3dfd91..ae91a802 100644 --- a/frontend/src/components/TopSendersChart.jsx +++ b/frontend/src/components/TopSendersChart.jsx @@ -1,10 +1,15 @@ -import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; import { formatAmount, formatAddress } from '../services/analytics'; -import { useState, useEffect } from 'react'; +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 [selectedSender, setSelectedSender] = useState(null); + const [hoveredIndex, setHoveredIndex] = useState(null); useEffect(() => { const checkMobile = () => { @@ -14,103 +19,112 @@ export default function TopSendersChart({ data }) { window.addEventListener('resize', checkMobile); return () => window.removeEventListener('resize', checkMobile); }, []); + if (!data || data.length === 0) { return ( -
-

Top Senders

-

No data available

+
+

Top Senders

+

No data available

); } - const chartData = data.map(item => ({ - address: formatAddress(item.address), - fullAddress: item.address, - volume: Number(formatAmount(item.volume)), - count: item.count, - })); + const chartData = useMemo(() => { + return data.map(item => ({ + address: formatAddress(item.address), + fullAddress: item.address, + volume: Number(formatAmount(item.volume)), + count: item.count, + })); + }, [data]); - const CustomTooltip = ({ active, payload }) => { - if (active && payload && payload.length) { - return ( -
-

- {payload[0].payload.fullAddress} -

-

- Tips Sent: {payload[0].payload.count} -

-

- Total Volume: {payload[0].value} STX -

-
- ); - } - return null; - }; + const displayData = useMemo(() => { + return isMobile ? chartData.slice(0, 5) : chartData; + }, [chartData, isMobile]); - const handleBarClick = (data) => { - if (data && data.fullAddress) { - setSelectedSender(data); - } - }; + const maxVolume = useMemo(() => { + const vals = displayData.map(d => d.volume); + return Math.max(...vals, 1); + }, [displayData]); - const chartHeight = isMobile ? 250 : 400; - const displayData = isMobile ? chartData.slice(0, 5) : chartData; + const selectedPoint = hoveredIndex !== null ? displayData[hoveredIndex] : null; return ( -
-

Top Senders

+
+

Top Senders

- {selectedSender && ( -
-

{selectedSender.fullAddress}

-

- Volume: {selectedSender.volume} STX + {selectedPoint && ( +

+

{selectedPoint.fullAddress}

+

+ Volume: {selectedPoint.volume.toFixed(2)} STX {' | '} - Tips: {selectedSender.count} + 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

+
+

Detailed Rankings

{data.slice(0, 5).map((sender, index) => (
setSelectedSender({ - fullAddress: sender.address, - volume: formatAmount(sender.volume), - count: sender.count - })} + className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800/40 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800/80 border border-gray-100/50 dark:border-gray-800/40 transition-colors cursor-pointer" + onClick={() => setHoveredIndex(index)} >
- + {index + 1} - {formatAddress(sender.address)} + {formatAddress(sender.address)}
-

{formatAmount(sender.volume)} STX

-

{sender.count} tips

+

{formatAmount(sender.volume)} STX

+

{sender.count} tips

))} 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.`; +}