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,9 +5,11 @@ import dynamic from "next/dynamic";
import { Activity, Share2, Info } from "lucide-react";
import {
fetchNetworkPaymentVolume,
fetchNetworkTransactionsPerDay,
fetchNetworkNewAccounts,
fetchNetworkFeeTrends,
type NetworkPaymentVolumePoint,
type NetworkTransactionsPerDayPoint,
type NetworkNewAccountsPoint,
type NetworkFeeTrendPoint,
} from "@/lib/network-api";
Expand Down Expand Up @@ -38,6 +40,23 @@ const PaymentVolumeChart = dynamic(
},
);

const TransactionsPerDayChart = dynamic(
() =>
import("@/components/charts/TransactionsPerDayChart").then((m) => ({
default: m.TransactionsPerDayChart,
})),
{
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 NewAccountsChart = dynamic(
() =>
import("@/components/charts/NewAccountsChart").then((m) => ({
Expand Down Expand Up @@ -80,6 +99,10 @@ export default function NetworkPage() {
[],
);
const [volumeLoading, setVolumeLoading] = useState(true);
const [txPerDayPoints, setTxPerDayPoints] = useState<
NetworkTransactionsPerDayPoint[]
>([]);
const [txPerDayLoading, setTxPerDayLoading] = useState(true);
const [newAccountsPoints, setNewAccountsPoints] = useState<
NetworkNewAccountsPoint[]
>([]);
Expand Down Expand Up @@ -121,6 +144,22 @@ export default function NetworkPage() {
void loadPaymentVolume();
}, []);

useEffect(() => {
async function loadTransactionsPerDay() {
setTxPerDayLoading(true);
try {
const result = await fetchNetworkTransactionsPerDay(30);
setTxPerDayPoints(result.points);
} catch (err) {
logger.error("Failed to load transactions-per-day panel:", err);
setTxPerDayPoints([]);
} finally {
setTxPerDayLoading(false);
}
}
void loadTransactionsPerDay();
}, []);

useEffect(() => {
async function loadNewAccounts() {
setNewAccountsLoading(true);
Expand Down Expand Up @@ -197,6 +236,10 @@ export default function NetworkPage() {

{/* Network metrics panels */}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<TransactionsPerDayChart
data={txPerDayPoints}
loading={txPerDayLoading}
/>
<NewAccountsChart
data={newAccountsPoints}
loading={newAccountsLoading}
Expand Down
194 changes: 194 additions & 0 deletions frontend/src/components/charts/TransactionsPerDayChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
"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 { NetworkTransactionsPerDayPoint } from "@/lib/network-api";

interface TransactionsPerDayChartProps {
data: NetworkTransactionsPerDayPoint[];
loading?: boolean;
}

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

export function TransactionsPerDayChart({
data,
loading = false,
}: TransactionsPerDayChartProps) {
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 transactions per day"
>
<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="tx-per-day-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 // Transactions
</div>
<h2
id="tx-per-day-heading"
className="text-xl font-black tracking-tighter uppercase italic mb-2 opacity-50"
>
Transactions Per Day
</h2>
<p className="text-sm font-mono text-muted-foreground uppercase tracking-widest text-center max-w-md">
No transaction series yet. Data appears once{" "}
<code className="text-accent">
/api/v1/network/transactions-per-day
</code>{" "}
returns daily transaction counts.
</p>
</section>
);
}

return (
<section
ref={chartRef}
aria-labelledby="tx-per-day-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 // Transactions
</div>
<h2
id="tx-per-day-heading"
className="text-xl font-black tracking-tighter uppercase italic mb-2"
>
Transactions Per Day
</h2>
<p className="text-[10px] font-mono text-muted-foreground uppercase tracking-widest">
Total ledger transactions submitted per day
</p>
</div>
<ChartExportButton chartRef={chartRef} chartName="Transactions Per Day" />
</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)),
"Transactions",
]}
/>
<Bar
dataKey="count"
name="Transactions"
fill="#a855f7"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>
</section>
);
}
68 changes: 68 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/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)
* GET /api/v1/network/payment-volume → {date, volume} (backend#17)
Expand All @@ -24,6 +25,16 @@ export interface NetworkPaymentVolumeResponse {
unit: "usd";
}

export interface NetworkTransactionsPerDayPoint {
date: string;
/** Total transaction count for the day. */
count: number;
}

export interface NetworkTransactionsPerDayResponse {
points: NetworkTransactionsPerDayPoint[];
}

export interface NetworkNewAccountsPoint {
date: string;
/** New accounts created that day. */
Expand Down Expand Up @@ -112,6 +123,39 @@ export async function fetchNetworkPaymentVolume(
}
}

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

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

/**
* Daily transaction count time series (backend#16).
* 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 fetchNetworkTransactionsPerDay(
days = 30,
): Promise<NetworkTransactionsPerDayResponse> {
const url = `${API_BASE}/api/v1/network/transactions-per-day?days=${days}`;
try {
const data = await fetchJson<unknown>(url);
return { points: normalizeTransactionsPerDayPoints(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 transactions-per-day:", error);
}
return { points: [] };
}
}

/**
* New-accounts-per-day time series (backend#18).
* Returns an empty series when the backend is unavailable so the panel
Expand Down
Loading