From f8f11a81cb6f1803715d360a4c8ef4faa995f588 Mon Sep 17 00:00:00 2001 From: ogbemercyada-sketch Date: Sat, 1 Aug 2026 14:19:07 +0000 Subject: [PATCH] Add portfolio tracker dashboard for aggregate asset & yield analytics Adds a PortfolioSummary component showing total net worth across wallet, liquidity pool, and vault positions, an interactive donut chart breaking down allocation by asset, and a historical net-worth line chart with 7D/30D/90D/1Y timeframe switching. Backed by a usePortfolio react-query hook with mock-data fallback (no portfolio data source existed yet), wired up on a new /dashboard/portfolio page. Closes #606 --- src/app/dashboard/portfolio/page.tsx | 21 +++ src/app/hooks/usePortfolio.ts | 114 ++++++++++++ src/app/lib/cacheProfiles.ts | 3 + .../analytics/PortfolioAllocationChart.tsx | 161 +++++++++++++++++ .../analytics/PortfolioHistoryChart.tsx | 166 ++++++++++++++++++ src/components/analytics/PortfolioSummary.tsx | 111 ++++++++++++ src/components/analytics/index.ts | 3 + src/types/portfolio.ts | 37 ++++ 8 files changed, 616 insertions(+) create mode 100644 src/app/dashboard/portfolio/page.tsx create mode 100644 src/app/hooks/usePortfolio.ts create mode 100644 src/components/analytics/PortfolioAllocationChart.tsx create mode 100644 src/components/analytics/PortfolioHistoryChart.tsx create mode 100644 src/components/analytics/PortfolioSummary.tsx create mode 100644 src/components/analytics/index.ts create mode 100644 src/types/portfolio.ts diff --git a/src/app/dashboard/portfolio/page.tsx b/src/app/dashboard/portfolio/page.tsx new file mode 100644 index 0000000..b7d05ab --- /dev/null +++ b/src/app/dashboard/portfolio/page.tsx @@ -0,0 +1,21 @@ +"use client"; + +import PortfolioSummary from "@/components/analytics/PortfolioSummary"; + +export default function PortfolioTrackerPage() { + return ( +
+
+

+ Portfolio Tracker +

+

+ Aggregate net worth, allocation, and yield performance across your + wallet, liquidity pools, and vaults. +

+
+ + +
+ ); +} diff --git a/src/app/hooks/usePortfolio.ts b/src/app/hooks/usePortfolio.ts new file mode 100644 index 0000000..96046e1 --- /dev/null +++ b/src/app/hooks/usePortfolio.ts @@ -0,0 +1,114 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { getCacheProfile } from "../lib/cacheProfiles"; +import type { + PortfolioHistoryPoint, + PortfolioSummaryData, + PortfolioTimeframe, +} from "@/types/portfolio"; + +function buildHistory( + days: number, + startValue: number, + endValue: number, +): PortfolioHistoryPoint[] { + const points: PortfolioHistoryPoint[] = []; + const now = new Date(); + + for (let i = days; i >= 0; i -= Math.max(1, Math.floor(days / 24))) { + const date = new Date(now); + date.setDate(date.getDate() - i); + + const progress = 1 - i / days; + // Deterministic gentle wobble so the line isn't perfectly straight. + const wobble = Math.sin(progress * Math.PI * 3) * (startValue * 0.015); + const netWorthUsd = startValue + (endValue - startValue) * progress + wobble; + + points.push({ + date: date.toISOString().split("T")[0], + netWorthUsd: Math.round(netWorthUsd * 100) / 100, + }); + } + + return points; +} + +function getMockData(): PortfolioSummaryData { + const walletUsd = 8420.31; + const liquidityPoolsUsd = 5210.87; + const vaultsUsd = 3105.5; + const totalNetWorthUsd = walletUsd + liquidityPoolsUsd + vaultsUsd; + + return { + totalNetWorthUsd, + changePercent24h: 2.37, + balances: { walletUsd, liquidityPoolsUsd, vaultsUsd }, + allocation: [ + { symbol: "XLM", assetClass: "native", valueUsd: 5680.4 }, + { symbol: "USDC", assetClass: "native", valueUsd: 2739.91 }, + { symbol: "XLM-USDC-LP", assetClass: "lp", valueUsd: 3420.6 }, + { symbol: "NGN-XLM-LP", assetClass: "lp", valueUsd: 1790.27 }, + { symbol: "Blue Chip Vault", assetClass: "vault", valueUsd: 1950.0 }, + { symbol: "Stable Yield Vault", assetClass: "vault", valueUsd: 1155.5 }, + ], + history: { + "7D": buildHistory(7, totalNetWorthUsd * 0.94, totalNetWorthUsd), + "30D": buildHistory(30, totalNetWorthUsd * 0.82, totalNetWorthUsd), + "90D": buildHistory(90, totalNetWorthUsd * 0.61, totalNetWorthUsd), + "1Y": buildHistory(365, totalNetWorthUsd * 0.35, totalNetWorthUsd), + }, + }; +} + +const QUERY_KEY = ["portfolio-summary"] as const; + +export function usePortfolio(): UseQueryResult { + const profile = getCacheProfile("portfolioSummary"); + + return useQuery({ + queryKey: QUERY_KEY, + queryFn: async () => { + const res = await fetch("/api/portfolio", { + cache: "no-store", + headers: { Accept: "application/json" }, + }); + + if (!res.ok) { + throw new Error(`Failed to fetch portfolio summary: ${res.status}`); + } + + return res.json(); + }, + placeholderData: (prev) => prev, + staleTime: profile.staleTime, + gcTime: profile.gcTime, + refetchOnWindowFocus: false, + retry: 1, + }); +} + +export function usePortfolioWithFallback(): { + data: PortfolioSummaryData; + isLoading: boolean; + isFetching: boolean; + error: Error | null; +} { + const query = usePortfolio(); + + if (query.data) { + return { + data: query.data, + isLoading: false, + isFetching: query.isFetching, + error: query.error, + }; + } + + return { + data: getMockData(), + isLoading: query.isLoading, + isFetching: query.isFetching, + error: query.error, + }; +} + +export type { PortfolioTimeframe }; diff --git a/src/app/lib/cacheProfiles.ts b/src/app/lib/cacheProfiles.ts index 0dc9f17..757a43a 100644 --- a/src/app/lib/cacheProfiles.ts +++ b/src/app/lib/cacheProfiles.ts @@ -10,6 +10,9 @@ export const cacheProfiles = { // Periodic audit checks that don't need constant updates validatorAudit: getCacheOptions('MEDIUM_INTERVAL'), + + // Aggregate wallet/LP/vault balances backing the portfolio dashboard. + portfolioSummary: getCacheOptions('MEDIUM_INTERVAL'), } as const; export type CacheProfile = keyof typeof cacheProfiles; diff --git a/src/components/analytics/PortfolioAllocationChart.tsx b/src/components/analytics/PortfolioAllocationChart.tsx new file mode 100644 index 0000000..42dc8d6 --- /dev/null +++ b/src/components/analytics/PortfolioAllocationChart.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { + Chart, + ArcElement, + DoughnutController, + Tooltip, + type ChartConfiguration, +} from "chart.js"; +import type { PortfolioAllocationSlice } from "@/types/portfolio"; + +Chart.register(ArcElement, DoughnutController, Tooltip); + +const SLICE_COLORS = [ + "#60a5fa", + "#34d399", + "#f59e0b", + "#f472b6", + "#a78bfa", + "#22d3ee", + "#fb923c", + "#4ade80", +]; + +interface PortfolioAllocationChartProps { + allocation: PortfolioAllocationSlice[]; +} + +function formatUsd(value: number): string { + return value.toLocaleString(undefined, { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, + }); +} + +export default function PortfolioAllocationChart({ + allocation, +}: PortfolioAllocationChartProps) { + const canvasRef = useRef(null); + const chartRef = useRef | null>(null); + const [hiddenSymbols, setHiddenSymbols] = useState>(new Set()); + + const total = useMemo( + () => allocation.reduce((sum, slice) => sum + slice.valueUsd, 0), + [allocation], + ); + + useEffect(() => { + if (!canvasRef.current) return; + + const config: ChartConfiguration<"doughnut"> = { + type: "doughnut", + data: { + labels: allocation.map((slice) => slice.symbol), + datasets: [ + { + data: allocation.map((slice) => slice.valueUsd), + backgroundColor: allocation.map( + (_, index) => SLICE_COLORS[index % SLICE_COLORS.length], + ), + borderColor: "#161b22", + borderWidth: 2, + hoverOffset: 8, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + cutout: "68%", + animation: { duration: 250 }, + plugins: { + tooltip: { + callbacks: { + label: (context) => { + const value = context.parsed as number; + const pct = total > 0 ? (value / total) * 100 : 0; + return `${context.label}: ${formatUsd(value)} (${pct.toFixed(1)}%)`; + }, + }, + }, + }, + }, + }; + + chartRef.current = new Chart(canvasRef.current, config); + + return () => { + chartRef.current?.destroy(); + chartRef.current = null; + }; + }, [allocation, total]); + + const toggleSlice = (symbol: string, index: number) => { + const chart = chartRef.current; + if (!chart) return; + + chart.toggleDataVisibility(index); + chart.update(); + + setHiddenSymbols((prev) => { + const next = new Set(prev); + if (next.has(symbol)) { + next.delete(symbol); + } else { + next.add(symbol); + } + return next; + }); + }; + + return ( +
+
+ +
+ + Total + + + {formatUsd(total)} + +
+
+ +
    + {allocation.map((slice, index) => { + const isHidden = hiddenSymbols.has(slice.symbol); + const pct = total > 0 ? (slice.valueUsd / total) * 100 : 0; + + return ( +
  • + +
  • + ); + })} +
+
+ ); +} diff --git a/src/components/analytics/PortfolioHistoryChart.tsx b/src/components/analytics/PortfolioHistoryChart.tsx new file mode 100644 index 0000000..91d0072 --- /dev/null +++ b/src/components/analytics/PortfolioHistoryChart.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { + Chart, + LineController, + LineElement, + PointElement, + LinearScale, + CategoryScale, + Filler, + Tooltip, + type ChartConfiguration, +} from "chart.js"; +import type { PortfolioSummaryData, PortfolioTimeframe } from "@/types/portfolio"; + +Chart.register( + LineController, + LineElement, + PointElement, + LinearScale, + CategoryScale, + Filler, + Tooltip, +); + +const TIMEFRAMES: PortfolioTimeframe[] = ["7D", "30D", "90D", "1Y"]; + +interface PortfolioHistoryChartProps { + history: PortfolioSummaryData["history"]; +} + +function formatUsd(value: number): string { + return value.toLocaleString(undefined, { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, + }); +} + +function formatLabel(date: string, timeframe: PortfolioTimeframe): string { + const parsed = new Date(date); + if (timeframe === "1Y") { + return parsed.toLocaleDateString(undefined, { month: "short" }); + } + return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +export default function PortfolioHistoryChart({ + history, +}: PortfolioHistoryChartProps) { + const canvasRef = useRef(null); + const chartRef = useRef | null>(null); + const [timeframe, setTimeframe] = useState("30D"); + + const points = history[timeframe]; + + const { changeUsd, changePercent } = useMemo(() => { + if (points.length < 2) return { changeUsd: 0, changePercent: 0 }; + const first = points[0].netWorthUsd; + const last = points[points.length - 1].netWorthUsd; + return { + changeUsd: last - first, + changePercent: first !== 0 ? ((last - first) / first) * 100 : 0, + }; + }, [points]); + + const isPositive = changeUsd >= 0; + + useEffect(() => { + if (!canvasRef.current) return; + + const config: ChartConfiguration<"line"> = { + type: "line", + data: { + labels: points.map((point) => formatLabel(point.date, timeframe)), + datasets: [ + { + label: "Net Worth", + data: points.map((point) => point.netWorthUsd), + borderColor: isPositive ? "#34d399" : "#f87171", + backgroundColor: isPositive + ? "rgba(52, 211, 153, 0.12)" + : "rgba(248, 113, 113, 0.12)", + fill: true, + tension: 0.35, + pointRadius: 0, + pointHoverRadius: 4, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { duration: 200 }, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + label: (context) => formatUsd(context.parsed.y as number), + }, + }, + }, + scales: { + x: { + grid: { color: "rgba(255,255,255,0.06)" }, + ticks: { + color: "rgba(255,255,255,0.45)", + maxTicksLimit: 8, + autoSkip: true, + }, + }, + y: { + grid: { color: "rgba(255,255,255,0.06)" }, + ticks: { + color: "rgba(255,255,255,0.45)", + callback: (value) => formatUsd(value as number), + }, + }, + }, + }, + }; + + chartRef.current = new Chart(canvasRef.current, config); + + return () => { + chartRef.current?.destroy(); + chartRef.current = null; + }; + }, [points, timeframe, isPositive]); + + return ( +
+
+ + {isPositive ? "+" : ""} + {formatUsd(changeUsd)} ({isPositive ? "+" : ""} + {changePercent.toFixed(2)}%) · {timeframe} + + +
+ {TIMEFRAMES.map((tf) => ( + + ))} +
+
+ +
+ +
+
+ ); +} diff --git a/src/components/analytics/PortfolioSummary.tsx b/src/components/analytics/PortfolioSummary.tsx new file mode 100644 index 0000000..7d5d6c8 --- /dev/null +++ b/src/components/analytics/PortfolioSummary.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { PieChart, Wallet, Droplets, Vault } from "lucide-react"; +import { usePortfolioWithFallback } from "@/app/hooks/usePortfolio"; +import PortfolioAllocationChart from "./PortfolioAllocationChart"; +import PortfolioHistoryChart from "./PortfolioHistoryChart"; + +function formatUsd(value: number): string { + return value.toLocaleString(undefined, { + style: "currency", + currency: "USD", + maximumFractionDigits: 2, + }); +} + +export default function PortfolioSummary() { + const { data, isLoading, isFetching } = usePortfolioWithFallback(); + const { totalNetWorthUsd, changePercent24h, balances, allocation, history } = data; + const isPositive24h = changePercent24h >= 0; + + return ( +
+
+
+
+ + Total Net Worth + +
+ + {isLoading ? "—" : formatUsd(totalNetWorthUsd)} + + + {isPositive24h ? "+" : ""} + {changePercent24h.toFixed(2)}% (24h) + +
+
+ + +
+ +
+ } + label="Wallet" + valueUsd={balances.walletUsd} + /> + } + label="Liquidity Pools" + valueUsd={balances.liquidityPoolsUsd} + /> + } + label="Vaults" + valueUsd={balances.vaultsUsd} + /> +
+
+ +
+
+

+ Net Worth History +

+ +
+ +
+
+ +

Allocation

+
+ +
+
+
+ ); +} + +function BalanceCard({ + icon, + label, + valueUsd, +}: { + icon: React.ReactNode; + label: string; + valueUsd: number; +}) { + return ( +
+
+ {icon} + {label} +
+ + {formatUsd(valueUsd)} + +
+ ); +} diff --git a/src/components/analytics/index.ts b/src/components/analytics/index.ts new file mode 100644 index 0000000..aa85874 --- /dev/null +++ b/src/components/analytics/index.ts @@ -0,0 +1,3 @@ +export { default as PortfolioSummary } from "./PortfolioSummary"; +export { default as PortfolioAllocationChart } from "./PortfolioAllocationChart"; +export { default as PortfolioHistoryChart } from "./PortfolioHistoryChart"; diff --git a/src/types/portfolio.ts b/src/types/portfolio.ts new file mode 100644 index 0000000..0f948da --- /dev/null +++ b/src/types/portfolio.ts @@ -0,0 +1,37 @@ +/** + * Aggregate portfolio types backing the portfolio tracker dashboard — + * net worth, allocation across wallet/LP/vault positions, and historical + * balance growth. + */ + +export type PortfolioAssetClass = "native" | "lp" | "vault"; + +export interface PortfolioAllocationSlice { + /** Token or position symbol, e.g. "XLM", "XLM-USDC-LP", "Blue Chip Vault". */ + symbol: string; + assetClass: PortfolioAssetClass; + valueUsd: number; +} + +export type PortfolioTimeframe = "7D" | "30D" | "90D" | "1Y"; + +export interface PortfolioHistoryPoint { + /** ISO-8601 date, e.g. "2026-07-15". */ + date: string; + netWorthUsd: number; +} + +export interface PortfolioBalanceBreakdown { + walletUsd: number; + liquidityPoolsUsd: number; + vaultsUsd: number; +} + +export interface PortfolioSummaryData { + totalNetWorthUsd: number; + /** Change over the last 24h, as a percentage (e.g. 2.4 for +2.4%). */ + changePercent24h: number; + balances: PortfolioBalanceBreakdown; + allocation: PortfolioAllocationSlice[]; + history: Record; +}