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
59 changes: 59 additions & 0 deletions frontend/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
51 changes: 51 additions & 0 deletions frontend/components/dashboard/dashboard-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import React from "react";

import {
getDashboardAnalytics,
getMockDashboardStats,
type DashboardSnapshot,
} from "@/lib/dashboard";
Expand Down Expand Up @@ -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);

Expand All @@ -50,6 +65,41 @@ function formatActivityTime(timestamp: string): string {
}).format(date);
}

function renderAnalytics(snapshot: DashboardSnapshot | null) {
const metrics = getDashboardAnalytics(snapshot);

return (
<section className="dashboard-analytics-section" aria-label="Analytics overview">
<div className="dashboard-panel__header">
<h3>Analytics Overview</h3>
<span>Computed from wallet activity</span>
</div>

<div className="dashboard-analytics-grid">
{metrics.map((metric) => {
const isUnavailable = metric.value === null;

return (
<article
key={metric.id}
className="dashboard-analytics-card"
data-unavailable={isUnavailable ? "true" : undefined}
>
<p>{metric.label}</p>
<h2>
{isUnavailable
? "No data"
: formatAnalyticsValue(metric.value, metric.format)}
</h2>
<span>{isUnavailable ? metric.unavailableText : metric.detail}</span>
</article>
);
})}
</div>
</section>
);
}

function renderStats(snapshot: DashboardSnapshot) {
const items = [
{
Expand Down Expand Up @@ -242,6 +292,7 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) {
return (
<div className="dashboard-content-stack mt-8">
{renderStats(stats)}
{renderAnalytics(stats)}
{renderStreams(stats, handleTopUp)}
{renderRecentActivity(stats)}
</div>
Expand Down
118 changes: 118 additions & 0 deletions frontend/lib/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<WalletId, DashboardSnapshot | null> = {
freighter: {
totalSent: 12850,
Expand Down Expand Up @@ -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",
},
];
}
Loading