Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions frontend/src/app/[locale]/network/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import dynamic from "next/dynamic";
import { Activity, Share2, Info } from "lucide-react";
import {
fetchNetworkPaymentVolume,
fetchNetworkDailyActiveAccounts,
fetchNetworkTransactionsPerDay,
fetchNetworkNewAccounts,
fetchNetworkFeeTrends,
type NetworkPaymentVolumePoint,
type NetworkDailyActiveAccountsPoint,
type NetworkTransactionsPerDayPoint,
type NetworkNewAccountsPoint,
type NetworkFeeTrendPoint,
Expand Down Expand Up @@ -40,6 +42,23 @@ const PaymentVolumeChart = dynamic(
},
);

const DailyActiveAccountsChart = dynamic(
() =>
import("@/components/charts/DailyActiveAccountsChart").then((m) => ({
default: m.DailyActiveAccountsChart,
})),
{
ssr: false,
loading: () => (
<div className="glass-card rounded-2xl p-6 border border-border/50 h-[420px] animate-pulse">
<div className="h-4 w-40 bg-white/5 rounded mb-4" />
<div className="h-8 w-64 bg-white/5 rounded mb-8" />
<div className="h-[260px] w-full bg-white/5 rounded-xl" />
</div>
),
},
);

const TransactionsPerDayChart = dynamic(
() =>
import("@/components/charts/TransactionsPerDayChart").then((m) => ({
Expand Down Expand Up @@ -99,6 +118,10 @@ export default function NetworkPage() {
[],
);
const [volumeLoading, setVolumeLoading] = useState(true);
const [daaPoints, setDaaPoints] = useState<NetworkDailyActiveAccountsPoint[]>(
[],
);
const [daaLoading, setDaaLoading] = useState(true);
const [txPerDayPoints, setTxPerDayPoints] = useState<
NetworkTransactionsPerDayPoint[]
>([]);
Expand Down Expand Up @@ -144,6 +167,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();
}, []);

useEffect(() => {
async function loadTransactionsPerDay() {
setTxPerDayLoading(true);
Expand Down Expand Up @@ -236,6 +275,7 @@ export default function NetworkPage() {

{/* Network metrics panels */}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<DailyActiveAccountsChart data={daaPoints} loading={daaLoading} />
<TransactionsPerDayChart
data={txPerDayPoints}
loading={txPerDayLoading}
Expand Down
210 changes: 210 additions & 0 deletions frontend/src/components/charts/DailyActiveAccountsChart.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 (
<div
className="glass-card rounded-2xl p-6 border border-border/50 h-[420px] animate-pulse"
aria-busy="true"
aria-label="Loading daily active accounts"
>
<div className="h-4 w-40 bg-white/5 rounded mb-4" />
<div className="h-8 w-64 bg-white/5 rounded mb-8" />
<div className="h-[260px] w-full bg-white/5 rounded-xl" />
</div>
);
}

if (chartData.length === 0) {
return (
<section
aria-labelledby="daa-heading"
className="glass-card rounded-2xl p-6 border border-border/50 flex flex-col items-center justify-center h-[420px]"
>
<div className="text-[10px] font-mono text-accent uppercase tracking-[0.2em] mb-2">
Network // Daily Active Accounts
</div>
<h2
id="daa-heading"
className="text-xl font-black tracking-tighter uppercase italic mb-2 opacity-50"
>
Daily Active Accounts
</h2>
<p className="text-sm font-mono text-muted-foreground uppercase tracking-widest text-center max-w-md">
No active-account series yet. Data appears once{" "}
<code className="text-accent">
/api/v1/network/daily-active-accounts
</code>{" "}
returns daily unique account counts.
</p>
</section>
);
}

return (
<section
ref={chartRef}
aria-labelledby="daa-heading"
className="glass-card rounded-2xl p-6 border border-border/50"
>
<div className="flex flex-col md:flex-row md:items-start justify-between mb-8 gap-4">
<div className="flex-1">
<div className="text-[10px] font-mono text-accent uppercase tracking-[0.2em] mb-2">
Network // Daily Active Accounts
</div>
<h2
id="daa-heading"
className="text-xl font-black tracking-tighter uppercase italic mb-2"
>
Daily Active Accounts
</h2>
<p className="text-[10px] font-mono text-muted-foreground uppercase tracking-widest">
Unique accounts transacting per day
</p>
</div>
<ChartExportButton chartRef={chartRef} chartName="Daily Active Accounts" />
</div>

<div className="grid grid-cols-3 gap-4 mb-8">
<div className="p-3 rounded-xl bg-slate-900/30 border border-white/5">
<p className="text-[9px] font-mono text-muted-foreground uppercase tracking-wider mb-1">
Latest day
</p>
<p className="text-xl font-black font-mono tracking-tighter text-emerald-400">
{formatCompact(latest)}
</p>
</div>
<div className="p-3 rounded-xl bg-slate-900/30 border border-white/5">
<p className="text-[9px] font-mono text-muted-foreground uppercase tracking-wider mb-1">
Period average
</p>
<p className="text-xl font-black font-mono tracking-tighter text-foreground/80">
{formatCompact(average)}
</p>
</div>
<div className="p-3 rounded-xl bg-slate-900/30 border border-white/5">
<p className="text-[9px] font-mono text-muted-foreground uppercase tracking-wider mb-1">
Peak day
</p>
<p className="text-xl font-black font-mono tracking-tighter text-accent">
{formatCompact(peak)}
</p>
</div>
</div>

<div className="h-[300px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={chartData}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="colorDAA" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#38bdf8" stopOpacity={0.35} />
<stop offset="95%" stopColor="#38bdf8" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="rgba(255,255,255,0.05)"
vertical={false}
/>
<XAxis
dataKey="label"
stroke="rgba(255,255,255,0.3)"
tick={{ fontSize: 10, fontFamily: "monospace" }}
axisLine={false}
tickLine={false}
dy={10}
/>
<YAxis
stroke="rgba(255,255,255,0.3)"
tickFormatter={formatCompact}
tick={{ fontSize: 10, fontFamily: "monospace" }}
axisLine={false}
tickLine={false}
dx={-10}
width={48}
/>
<Tooltip
contentStyle={getTooltipContentStyle({
backgroundColor: "rgba(15, 23, 42, 0.9)",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "12px",
fontSize: "10px",
fontFamily: "monospace",
})}
labelStyle={{ color: "#94a3b8", marginBottom: "4px" }}
formatter={(value) => [
formatCompact(typeof value === "number" ? value : Number(value)),
"Active accounts",
]}
/>
<Area
type="monotone"
dataKey="count"
stroke="#38bdf8"
strokeWidth={2.5}
fill="url(#colorDAA)"
name="Active accounts"
activeDot={{
r: 4,
fill: "#38bdf8",
stroke: "#fff",
strokeWidth: 2,
}}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</section>
);
}
71 changes: 71 additions & 0 deletions frontend/src/lib/network-api.ts
Original file line number Diff line number Diff line change
@@ -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/transactions-per-day → {date, count} (backend#16)
* GET /api/v1/network/new-accounts → {date, count} (backend#18)
* GET /api/v1/network/fee-trends → {date, avg_fee} (backend#19)
Expand All @@ -25,6 +26,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[];
}

export interface NetworkTransactionsPerDayPoint {
date: string;
/** Total transaction count for the day. */
Expand Down Expand Up @@ -123,6 +134,42 @@ export async function fetchNetworkPaymentVolume(
}
}

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());
}

function normalizeTransactionsPerDayPoints(
raw: unknown,
): NetworkTransactionsPerDayPoint[] {
Expand Down Expand Up @@ -219,6 +266,30 @@ function normalizeFeeTrendPoints(raw: unknown): NetworkFeeTrendPoint[] {
.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<NetworkDailyActiveAccountsResponse> {
const url = `${API_BASE}/api/v1/network/daily-active-accounts?days=${days}`;
try {
const data = await fetchJson<unknown>(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: [] };
}
}

/**
* Daily transaction count time series (backend#16).
* Returns an empty series when the backend is unavailable so the panel
Expand Down
Loading