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
21 changes: 21 additions & 0 deletions src/app/dashboard/portfolio/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"use client";

import PortfolioSummary from "@/components/analytics/PortfolioSummary";

export default function PortfolioTrackerPage() {
return (
<div className="min-h-screen bg-neutral-950 p-6 text-neutral-100">
<div className="mb-8 border-b border-neutral-800 pb-6">
<h1 className="bg-gradient-to-r from-white to-neutral-400 bg-clip-text text-3xl font-bold tracking-tight text-transparent">
Portfolio Tracker
</h1>
<p className="mt-1 text-sm text-neutral-400">
Aggregate net worth, allocation, and yield performance across your
wallet, liquidity pools, and vaults.
</p>
</div>

<PortfolioSummary />
</div>
);
}
114 changes: 114 additions & 0 deletions src/app/hooks/usePortfolio.ts
Original file line number Diff line number Diff line change
@@ -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<PortfolioSummaryData, Error> {
const profile = getCacheProfile("portfolioSummary");

return useQuery<PortfolioSummaryData, Error>({
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 };
161 changes: 161 additions & 0 deletions src/components/analytics/PortfolioAllocationChart.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLCanvasElement | null>(null);
const chartRef = useRef<Chart<"doughnut"> | null>(null);
const [hiddenSymbols, setHiddenSymbols] = useState<Set<string>>(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 (
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<div className="relative mx-auto h-48 w-48 shrink-0 sm:mx-0">
<canvas ref={canvasRef} aria-label="Portfolio allocation by asset" />
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span className="text-[11px] uppercase tracking-wider text-neutral-500">
Total
</span>
<span className="font-mono text-lg font-bold text-neutral-100">
{formatUsd(total)}
</span>
</div>
</div>

<ul className="flex-1 space-y-2">
{allocation.map((slice, index) => {
const isHidden = hiddenSymbols.has(slice.symbol);
const pct = total > 0 ? (slice.valueUsd / total) * 100 : 0;

return (
<li key={slice.symbol}>
<button
type="button"
onClick={() => toggleSlice(slice.symbol, index)}
className={`flex w-full items-center justify-between gap-2 rounded-md px-2 py-1 text-left text-sm transition-opacity hover:bg-neutral-800/60 ${
isHidden ? "opacity-40" : ""
}`}
>
<span className="flex items-center gap-2 text-neutral-300">
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{
backgroundColor: SLICE_COLORS[index % SLICE_COLORS.length],
}}
/>
{slice.symbol}
</span>
<span className="font-mono text-xs text-neutral-500">
{pct.toFixed(1)}%
</span>
</button>
</li>
);
})}
</ul>
</div>
);
}
Loading
Loading