diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index efa1d13..e45a798 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -507,6 +507,65 @@ body {
line-height: 1.45;
}
+.dashboard-analytics-section {
+ border: 1px solid rgba(19, 38, 61, 0.12);
+ border-radius: 1rem;
+ background: rgba(255, 255, 255, 0.66);
+ padding: 0.94rem;
+}
+
+.dashboard-analytics-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(205px, 1fr));
+ gap: 0.8rem;
+}
+
+.dashboard-analytics-card {
+ border-radius: 0.9rem;
+ border: 1px solid rgba(19, 38, 61, 0.11);
+ background: linear-gradient(
+ 165deg,
+ rgba(250, 255, 255, 0.92),
+ rgba(236, 247, 252, 0.74)
+ );
+ padding: 0.85rem;
+}
+
+.dashboard-analytics-card[data-unavailable="true"] {
+ background: linear-gradient(
+ 165deg,
+ rgba(247, 250, 253, 0.88),
+ rgba(236, 242, 249, 0.75)
+ );
+}
+
+.dashboard-analytics-card p {
+ margin: 0;
+ font-size: 0.78rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: #4f6f8e;
+}
+
+.dashboard-analytics-card h2 {
+ margin: 0.55rem 0 0;
+ font-size: 1.34rem;
+ line-height: 1.15;
+}
+
+.dashboard-analytics-card span {
+ display: block;
+ margin-top: 0.46rem;
+ color: #4d6985;
+ font-size: 0.84rem;
+ line-height: 1.45;
+}
+
+.dashboard-analytics-card[data-unavailable="true"] h2,
+.dashboard-analytics-card[data-unavailable="true"] span {
+ color: #5a748f;
+}
+
.dashboard-panel {
border: 1px solid rgba(19, 38, 61, 0.12);
border-radius: 1rem;
diff --git a/frontend/components/dashboard/dashboard-view.tsx b/frontend/components/dashboard/dashboard-view.tsx
index fbc8636..fceae1b 100644
--- a/frontend/components/dashboard/dashboard-view.tsx
+++ b/frontend/components/dashboard/dashboard-view.tsx
@@ -2,6 +2,7 @@
import React from "react";
import {
+ getDashboardAnalytics,
getMockDashboardStats,
type DashboardSnapshot,
} from "@/lib/dashboard";
@@ -37,6 +38,20 @@ function formatCurrency(value: number): string {
}).format(value);
}
+function formatAnalyticsValue(
+ value: number,
+ format: "currency" | "percent",
+): string {
+ if (format === "currency") {
+ return formatCurrency(value);
+ }
+
+ return new Intl.NumberFormat("en-US", {
+ style: "percent",
+ maximumFractionDigits: 1,
+ }).format(value);
+}
+
function formatActivityTime(timestamp: string): string {
const date = new Date(timestamp);
@@ -50,6 +65,41 @@ function formatActivityTime(timestamp: string): string {
}).format(date);
}
+function renderAnalytics(snapshot: DashboardSnapshot | null) {
+ const metrics = getDashboardAnalytics(snapshot);
+
+ return (
+
+
+
Analytics Overview
+ Computed from wallet activity
+
+
+
+ {metrics.map((metric) => {
+ const isUnavailable = metric.value === null;
+
+ return (
+
+ {metric.label}
+
+ {isUnavailable
+ ? "No data"
+ : formatAnalyticsValue(metric.value, metric.format)}
+
+ {isUnavailable ? metric.unavailableText : metric.detail}
+
+ );
+ })}
+
+
+ );
+}
+
function renderStats(snapshot: DashboardSnapshot) {
const items = [
{
@@ -242,6 +292,7 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) {
return (
{renderStats(stats)}
+ {renderAnalytics(stats)}
{renderStreams(stats, handleTopUp)}
{renderRecentActivity(stats)}
diff --git a/frontend/lib/dashboard.ts b/frontend/lib/dashboard.ts
index 9116b7a..92abb87 100644
--- a/frontend/lib/dashboard.ts
+++ b/frontend/lib/dashboard.ts
@@ -29,6 +29,15 @@ export interface DashboardSnapshot {
streams: Stream[];
}
+export interface DashboardAnalyticsMetric {
+ id: string;
+ label: string;
+ detail: string;
+ format: "currency" | "percent";
+ value: number | null;
+ unavailableText: string;
+}
+
const MOCK_STATS_BY_WALLET: Record = {
freighter: {
totalSent: 12850,
@@ -130,3 +139,112 @@ export function getMockDashboardStats(
streams: source.streams.map((stream) => ({ ...stream })),
};
}
+
+const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
+
+export function getDashboardAnalytics(
+ snapshot: DashboardSnapshot | null,
+): DashboardAnalyticsMetric[] {
+ if (!snapshot) {
+ return [
+ {
+ id: "total-volume-30d",
+ label: "Total Volume (30D)",
+ detail: "All incoming and outgoing activity in the last 30 days",
+ format: "currency",
+ value: null,
+ unavailableText: "No recent activity data",
+ },
+ {
+ id: "net-flow-30d",
+ label: "Net Flow (30D)",
+ detail: "Incoming minus outgoing activity over the same period",
+ format: "currency",
+ value: null,
+ unavailableText: "No recent activity data",
+ },
+ {
+ id: "avg-value-per-stream",
+ label: "Avg Locked Value / Active Stream",
+ detail: "Current TVL divided by active stream count",
+ format: "currency",
+ value: null,
+ unavailableText: "No active stream data",
+ },
+ {
+ id: "stream-utilization",
+ label: "Stream Utilization",
+ detail: "Total withdrawn as a share of total deposited funds",
+ format: "percent",
+ value: null,
+ unavailableText: "No stream funding data",
+ },
+ ];
+ }
+
+ const cutoff = Date.now() - THIRTY_DAYS_MS;
+ const recentActivity = snapshot.recentActivity.filter((item) => {
+ const parsed = Date.parse(item.timestamp);
+ return Number.isFinite(parsed) && parsed >= cutoff;
+ });
+
+ const incoming30d = recentActivity
+ .filter((item) => item.direction === "received")
+ .reduce((sum, item) => sum + item.amount, 0);
+
+ const outgoing30d = recentActivity
+ .filter((item) => item.direction === "sent")
+ .reduce((sum, item) => sum + item.amount, 0);
+
+ const totalVolume30d = incoming30d + outgoing30d;
+ const netFlow30d = incoming30d - outgoing30d;
+ const avgValuePerStream =
+ snapshot.activeStreamsCount > 0
+ ? snapshot.totalValueLocked / snapshot.activeStreamsCount
+ : null;
+
+ const totalDeposited = snapshot.streams.reduce(
+ (sum, stream) => sum + stream.deposited,
+ 0,
+ );
+ const totalWithdrawn = snapshot.streams.reduce(
+ (sum, stream) => sum + stream.withdrawn,
+ 0,
+ );
+ const utilization = totalDeposited > 0 ? totalWithdrawn / totalDeposited : null;
+
+ return [
+ {
+ id: "total-volume-30d",
+ label: "Total Volume (30D)",
+ detail: "All incoming and outgoing activity in the last 30 days",
+ format: "currency",
+ value: recentActivity.length > 0 ? totalVolume30d : null,
+ unavailableText: "No activity in the last 30 days",
+ },
+ {
+ id: "net-flow-30d",
+ label: "Net Flow (30D)",
+ detail: "Incoming minus outgoing activity over the same period",
+ format: "currency",
+ value: recentActivity.length > 0 ? netFlow30d : null,
+ unavailableText: "No activity in the last 30 days",
+ },
+ {
+ id: "avg-value-per-stream",
+ label: "Avg Locked Value / Active Stream",
+ detail: "Current TVL divided by active stream count",
+ format: "currency",
+ value: avgValuePerStream,
+ unavailableText: "No active streams",
+ },
+ {
+ id: "stream-utilization",
+ label: "Stream Utilization",
+ detail: "Total withdrawn as a share of total deposited funds",
+ format: "percent",
+ value: utilization,
+ unavailableText: "No deposited funds yet",
+ },
+ ];
+}