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", + }, + ]; +}