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
43 changes: 43 additions & 0 deletions frontend/src/app/[locale]/network/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import dynamic from "next/dynamic";
import { Activity, Share2, Info } from "lucide-react";
import {
fetchNetworkPaymentVolume,
fetchNetworkNewAccounts,
fetchNetworkFeeTrends,
type NetworkPaymentVolumePoint,
type NetworkNewAccountsPoint,
type NetworkFeeTrendPoint,
} from "@/lib/network-api";
import { logger } from "@/lib/logger";
Expand Down Expand Up @@ -36,6 +38,23 @@ const PaymentVolumeChart = dynamic(
},
);

const NewAccountsChart = dynamic(
() =>
import("@/components/charts/NewAccountsChart").then((m) => ({
default: m.NewAccountsChart,
})),
{
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 FeeTrendsChart = dynamic(
() =>
import("@/components/charts/FeeTrendsChart").then((m) => ({
Expand All @@ -61,6 +80,10 @@ export default function NetworkPage() {
[],
);
const [volumeLoading, setVolumeLoading] = useState(true);
const [newAccountsPoints, setNewAccountsPoints] = useState<
NetworkNewAccountsPoint[]
>([]);
const [newAccountsLoading, setNewAccountsLoading] = useState(true);
const [feeTrendPoints, setFeeTrendPoints] = useState<NetworkFeeTrendPoint[]>(
[],
);
Expand Down Expand Up @@ -98,6 +121,22 @@ export default function NetworkPage() {
void loadPaymentVolume();
}, []);

useEffect(() => {
async function loadNewAccounts() {
setNewAccountsLoading(true);
try {
const result = await fetchNetworkNewAccounts(30);
setNewAccountsPoints(result.points);
} catch (err) {
logger.error("Failed to load new accounts panel:", err);
setNewAccountsPoints([]);
} finally {
setNewAccountsLoading(false);
}
}
void loadNewAccounts();
}, []);

useEffect(() => {
async function loadFeeTrends() {
setFeeTrendsLoading(true);
Expand Down Expand Up @@ -158,6 +197,10 @@ export default function NetworkPage() {

{/* Network metrics panels */}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<NewAccountsChart
data={newAccountsPoints}
loading={newAccountsLoading}
/>
<FeeTrendsChart data={feeTrendPoints} loading={feeTrendsLoading} />
<PaymentVolumeChart data={volumePoints} loading={volumeLoading} />
</div>
Expand Down
192 changes: 192 additions & 0 deletions frontend/src/components/charts/NewAccountsChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
"use client";

import { useRef } from "react";
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { ChartExportButton } from "./ChartExportButton";
import { getTooltipContentStyle } from "@/lib/chart-utils";
import type { NetworkNewAccountsPoint } from "@/lib/network-api";

interface NewAccountsChartProps {
data: NetworkNewAccountsPoint[];
loading?: boolean;
}

function formatCompact(value: number): string {
return new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 1,
}).format(value);
}

export function NewAccountsChart({
data,
loading = false,
}: NewAccountsChartProps) {
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 total = chartData.reduce((sum, d) => sum + d.count, 0);
const latest = chartData[chartData.length - 1]?.count ?? 0;
const peak = chartData.length
? Math.max(...chartData.map((d) => d.count))
: 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 new 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="new-accounts-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 // New Accounts
</div>
<h2
id="new-accounts-heading"
className="text-xl font-black tracking-tighter uppercase italic mb-2 opacity-50"
>
New Accounts
</h2>
<p className="text-sm font-mono text-muted-foreground uppercase tracking-widest text-center max-w-md">
No new-account series yet. Data appears once{" "}
<code className="text-accent">/api/v1/network/new-accounts</code>{" "}
returns daily account-creation counts.
</p>
</section>
);
}

return (
<section
ref={chartRef}
aria-labelledby="new-accounts-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 // New Accounts
</div>
<h2
id="new-accounts-heading"
className="text-xl font-black tracking-tighter uppercase italic mb-2"
>
New Accounts
</h2>
<p className="text-[10px] font-mono text-muted-foreground uppercase tracking-widest">
Accounts created per day
</p>
</div>
<ChartExportButton chartRef={chartRef} chartName="New 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 total
</p>
<p className="text-xl font-black font-mono tracking-tighter text-foreground/80">
{formatCompact(total)}
</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%">
<BarChart
data={chartData}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
<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)),
"New accounts",
]}
/>
<Bar
dataKey="count"
name="New accounts"
fill="#f59e0b"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>
</section>
);
}
66 changes: 66 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/new-accounts → {date, count} (backend#18)
* GET /api/v1/network/fee-trends → {date, avg_fee} (backend#19)
* GET /api/v1/network/payment-volume → {date, volume} (backend#17)
*
Expand All @@ -23,6 +24,16 @@ export interface NetworkPaymentVolumeResponse {
unit: "usd";
}

export interface NetworkNewAccountsPoint {
date: string;
/** New accounts created that day. */
count: number;
}

export interface NetworkNewAccountsResponse {
points: NetworkNewAccountsPoint[];
}

export interface NetworkFeeTrendPoint {
date: string;
/** Average transaction fee for the day, in stroops. */
Expand Down Expand Up @@ -101,6 +112,37 @@ export async function fetchNetworkPaymentVolume(
}
}

function normalizeNewAccountsPoints(raw: unknown): NetworkNewAccountsPoint[] {
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;
new_accounts?: number;
created_accounts?: number;
};
const date = item.date ?? item.day ?? item.timestamp;
const count = item.count ?? item.new_accounts ?? item.created_accounts;
if (!date || typeof count !== "number" || Number.isNaN(count))
return null;
return { date, count };
})
.filter((point): point is NetworkNewAccountsPoint => point != null)
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
}

function normalizeFeeTrendPoints(raw: unknown): NetworkFeeTrendPoint[] {
const rows = Array.isArray(raw)
? raw
Expand Down Expand Up @@ -133,6 +175,30 @@ function normalizeFeeTrendPoints(raw: unknown): NetworkFeeTrendPoint[] {
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
}

/**
* New-accounts-per-day time series (backend#18).
* 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 fetchNetworkNewAccounts(
days = 30,
): Promise<NetworkNewAccountsResponse> {
const url = `${API_BASE}/api/v1/network/new-accounts?days=${days}`;
try {
const data = await fetchJson<unknown>(url);
return { points: normalizeNewAccountsPoints(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 new accounts:", error);
}
return { points: [] };
}
}

/**
* Daily average-fee time series (backend#19) — completes the Network
* Dashboard panel set (DAA, tx/day, volume, new accounts, fee trends).
Expand Down
Loading