From 3d8651e85e2a5ea7a9a3b6c3d358109db888bef2 Mon Sep 17 00:00:00 2001 From: Ndifreke000 Date: Fri, 21 Aug 2026 19:18:57 +0100 Subject: [PATCH] feat(network): add Daily Active Accounts panel to /network dashboard Adds fetchNetworkDailyActiveAccounts() to the network API client and a new DailyActiveAccountsChart component, wired into the /network page alongside the existing Payment Volume panel. Handles loading and empty states independently so the page degrades gracefully if the backend endpoint isn't available yet. Closes #160 --- frontend/src/app/[locale]/network/page.tsx | 44 +++- .../charts/DailyActiveAccountsChart.tsx | 210 ++++++++++++++++++ frontend/src/lib/network-api.ts | 71 ++++++ 3 files changed, 324 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/charts/DailyActiveAccountsChart.tsx diff --git a/frontend/src/app/[locale]/network/page.tsx b/frontend/src/app/[locale]/network/page.tsx index aa0c1886..abd7bb8f 100644 --- a/frontend/src/app/[locale]/network/page.tsx +++ b/frontend/src/app/[locale]/network/page.tsx @@ -5,7 +5,9 @@ import dynamic from "next/dynamic"; import { Activity, Share2, Info } from "lucide-react"; import { fetchNetworkPaymentVolume, + fetchNetworkDailyActiveAccounts, type NetworkPaymentVolumePoint, + type NetworkDailyActiveAccountsPoint, } from "@/lib/network-api"; import { logger } from "@/lib/logger"; @@ -34,6 +36,23 @@ const PaymentVolumeChart = dynamic( }, ); +const DailyActiveAccountsChart = dynamic( + () => + import("@/components/charts/DailyActiveAccountsChart").then((m) => ({ + default: m.DailyActiveAccountsChart, + })), + { + ssr: false, + loading: () => ( +
+
+
+
+
+ ), + }, +); + export default function NetworkPage() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); @@ -42,6 +61,10 @@ export default function NetworkPage() { [], ); const [volumeLoading, setVolumeLoading] = useState(true); + const [daaPoints, setDaaPoints] = useState( + [], + ); + const [daaLoading, setDaaLoading] = useState(true); useEffect(() => { async function fetchGraphData() { @@ -75,6 +98,22 @@ export default function NetworkPage() { void loadPaymentVolume(); }, []); + useEffect(() => { + async function loadDailyActiveAccounts() { + setDaaLoading(true); + try { + const result = await fetchNetworkDailyActiveAccounts(30); + setDaaPoints(result.points); + } catch (err) { + logger.error("Failed to load daily active accounts panel:", err); + setDaaPoints([]); + } finally { + setDaaLoading(false); + } + } + void loadDailyActiveAccounts(); + }, []); + return (
{/* Header Area */} @@ -118,7 +157,10 @@ export default function NetworkPage() {
{/* Network metrics panels */} - +
+ + +
{/* Topology */}
diff --git a/frontend/src/components/charts/DailyActiveAccountsChart.tsx b/frontend/src/components/charts/DailyActiveAccountsChart.tsx new file mode 100644 index 00000000..f1d25f7e --- /dev/null +++ b/frontend/src/components/charts/DailyActiveAccountsChart.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { useRef } from "react"; +import { + Area, + AreaChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { ChartExportButton } from "./ChartExportButton"; +import { getTooltipContentStyle } from "@/lib/chart-utils"; +import type { NetworkDailyActiveAccountsPoint } from "@/lib/network-api"; + +interface DailyActiveAccountsChartProps { + data: NetworkDailyActiveAccountsPoint[]; + loading?: boolean; +} + +function formatCompact(value: number): string { + return new Intl.NumberFormat("en-US", { + notation: "compact", + maximumFractionDigits: 1, + }).format(value); +} + +export function DailyActiveAccountsChart({ + data, + loading = false, +}: DailyActiveAccountsChartProps) { + const chartRef = useRef(null); + + const chartData = data.map((point) => ({ + label: new Date(point.date).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }), + date: point.date, + count: point.count, + })); + + const latest = chartData[chartData.length - 1]?.count ?? 0; + const peak = chartData.length + ? Math.max(...chartData.map((d) => d.count)) + : 0; + const average = chartData.length + ? Math.round(chartData.reduce((sum, d) => sum + d.count, 0) / chartData.length) + : 0; + + if (loading) { + return ( +
+
+
+
+
+ ); + } + + if (chartData.length === 0) { + return ( +
+
+ Network // Daily Active Accounts +
+

+ Daily Active Accounts +

+

+ No active-account series yet. Data appears once{" "} + + /api/v1/network/daily-active-accounts + {" "} + returns daily unique account counts. +

+
+ ); + } + + return ( +
+
+
+
+ Network // Daily Active Accounts +
+

+ Daily Active Accounts +

+

+ Unique accounts transacting per day +

+
+ +
+ +
+
+

+ Latest day +

+

+ {formatCompact(latest)} +

+
+
+

+ Period average +

+

+ {formatCompact(average)} +

+
+
+

+ Peak day +

+

+ {formatCompact(peak)} +

+
+
+ +
+ + + + + + + + + + + + [ + formatCompact(typeof value === "number" ? value : Number(value)), + "Active accounts", + ]} + /> + + + +
+
+ ); +} diff --git a/frontend/src/lib/network-api.ts b/frontend/src/lib/network-api.ts index dc77807f..187ce7fb 100644 --- a/frontend/src/lib/network-api.ts +++ b/frontend/src/lib/network-api.ts @@ -1,6 +1,7 @@ /** * Network dashboard API client. * Backed by Stellar-Insightss/backend#15–#19: + * GET /api/v1/network/daily-active-accounts → {date, count} (backend#15) * GET /api/v1/network/payment-volume → {date, volume} (backend#17) * * Volume unit matches NetworkStats.volume_24h (USD-equivalent) — @@ -22,6 +23,16 @@ export interface NetworkPaymentVolumeResponse { unit: "usd"; } +export interface NetworkDailyActiveAccountsPoint { + date: string; + /** Count of unique accounts that transacted that day. */ + count: number; +} + +export interface NetworkDailyActiveAccountsResponse { + points: NetworkDailyActiveAccountsPoint[]; +} + async function fetchJson(url: string): Promise { const response = await fetch(url, { method: "GET", @@ -89,3 +100,63 @@ export async function fetchNetworkPaymentVolume( return { points: [], unit: "usd" }; } } + +function normalizeDailyActiveAccountsPoints( + raw: unknown, +): NetworkDailyActiveAccountsPoint[] { + const rows = Array.isArray(raw) + ? raw + : Array.isArray((raw as { points?: unknown })?.points) + ? (raw as { points: unknown[] }).points + : Array.isArray((raw as { series?: unknown })?.series) + ? (raw as { series: unknown[] }).series + : Array.isArray((raw as { data?: unknown })?.data) + ? (raw as { data: unknown[] }).data + : []; + + return rows + .map((row) => { + const item = row as { + date?: string; + day?: string; + timestamp?: string; + count?: number; + active_accounts?: number; + daily_active_accounts?: number; + }; + const date = item.date ?? item.day ?? item.timestamp; + const count = + item.count ?? item.active_accounts ?? item.daily_active_accounts; + if (!date || typeof count !== "number" || Number.isNaN(count)) + return null; + return { date, count }; + }) + .filter( + (point): point is NetworkDailyActiveAccountsPoint => point != null, + ) + .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); +} + +/** + * Daily active accounts time series (backend#15). + * Returns an empty series when the backend is unavailable so the panel + * can show an empty state without failing the whole /network page. + */ +export async function fetchNetworkDailyActiveAccounts( + days = 30, +): Promise { + const url = `${API_BASE}/api/v1/network/daily-active-accounts?days=${days}`; + try { + const data = await fetchJson(url); + return { points: normalizeDailyActiveAccountsPoints(data) }; + } catch (error) { + const isNetworkError = + error instanceof TypeError && + (error.message.includes("Failed to fetch") || + error.message.includes("Network request failed")); + if (!isNetworkError) { + logger.error("Failed to fetch network daily active accounts:", error); + } + return { points: [] }; + } +}