From 500cf65a9e1cd3cd7e2c805bac7ed828de2e45a6 Mon Sep 17 00:00:00 2001 From: Deedee Date: Thu, 25 Jun 2026 13:35:03 +0100 Subject: [PATCH] feat(dashboard): Implement CSV and JSON export functionality for dashboard data --- frontend/src/views/dashboard.screen.ts | 275 +++++++++++++++- frontend/src/views/dashboard.ts | 439 +++++++++++++++++-------- 2 files changed, 567 insertions(+), 147 deletions(-) diff --git a/frontend/src/views/dashboard.screen.ts b/frontend/src/views/dashboard.screen.ts index f21579a..1d05f5c 100644 --- a/frontend/src/views/dashboard.screen.ts +++ b/frontend/src/views/dashboard.screen.ts @@ -16,10 +16,13 @@ import { fetchAllReserves, fetchUserPositions, aggregatePoolAccount, + fetchPositionEvents, type AssetPosition, + type PositionEvent, } from "../blend"; import { getVaults, fetchVaultStats, fetchUserVaultBalance } from "../defindex"; import { getState, setState } from "../app/state"; +import { toast } from "../app/chrome"; function legsFromRows( rows: ReturnType["rows"], @@ -27,7 +30,12 @@ function legsFromRows( ): Leg[] { return rows.map((r) => { const price = priceBySymbol.get(r.symbol) ?? 0; - const role: Role = r.role === "loop" ? "Looped" : r.role === "collateral" ? "Collateral" : "Borrow"; + const role: Role = + r.role === "loop" + ? "Looped" + : r.role === "collateral" + ? "Collateral" + : "Borrow"; const amountUsd = (r.role === "borrow" ? r.borrowed : r.supplied) * price; const leg: Leg = { asset: r.symbol, assetId: r.assetId, role, amountUsd }; if (r.role === "loop") leg.loopX = r.leverage; @@ -49,7 +57,9 @@ export async function loadDashboardData(addr: string): Promise { ); if (!active.length) return; const agg = aggregatePoolAccount(active, reserves); - const priceBySymbol = new Map(reserves.map((rs) => [rs.asset.symbol, rs.priceUsd])); + const priceBySymbol = new Map( + reserves.map((rs) => [rs.asset.symbol, rs.priceUsd]), + ); poolAccounts.push({ pool: pool.name, poolId: pool.id, @@ -94,10 +104,242 @@ export async function loadDashboardData(addr: string): Promise { return { connected: true, poolAccounts, vaults }; } +interface ExportPayload { + walletAddress: string; + generatedAt: string; + poolAccounts: Array< + PoolAccount & { legs: Array } + >; + vaults: DashboardData["vaults"]; +} + +function formatShortAddress(addr: string): string { + return addr.slice(0, 8); +} + +function buildFilename(addr: string, ext: string): string { + const date = new Date().toISOString().slice(0, 10); + return `${formatShortAddress(addr)}_${date}.${ext}`; +} + +function downloadText( + content: string, + mimeType: string, + filename: string, +): void { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); +} + +function csvCell(value: unknown): string { + if (value === null || value === undefined) return ""; + const s = String(value).replace(/"/g, '""'); + return `"${s}"`; +} + +function buildCsv(payload: ExportPayload): string { + const headers = [ + "walletAddress", + "generatedAt", + "section", + "pool", + "poolId", + "equityUsd", + "netApy", + "accountHealth", + "legAsset", + "legAssetId", + "legRole", + "legAmountUsd", + "legLoopX", + "eventKind", + "eventHash", + "eventTimestamp", + "eventTimestampIso", + "vaultName", + "vaultShare", + "vaultStrategyHealth", + ]; + + const rows = [headers.map(csvCell).join(",")]; + + for (const pool of payload.poolAccounts) { + for (const leg of pool.legs) { + if (leg.events.length) { + for (const event of leg.events) { + rows.push( + [ + payload.walletAddress, + payload.generatedAt, + "pool", + pool.pool, + pool.poolId ?? "", + pool.equityUsd, + pool.netApy, + pool.accountHealth, + leg.asset, + leg.assetId ?? "", + leg.role, + leg.amountUsd, + leg.loopX ?? "", + event.kind, + event.hash, + event.timestamp, + new Date(event.timestamp).toISOString(), + "", + "", + "", + ] + .map(csvCell) + .join(","), + ); + } + } else { + rows.push( + [ + payload.walletAddress, + payload.generatedAt, + "pool", + pool.pool, + pool.poolId ?? "", + pool.equityUsd, + pool.netApy, + pool.accountHealth, + leg.asset, + leg.assetId ?? "", + leg.role, + leg.amountUsd, + leg.loopX ?? "", + "", + "", + "", + "", + "", + "", + "", + ] + .map(csvCell) + .join(","), + ); + } + } + } + + for (const vault of payload.vaults) { + rows.push( + [ + payload.walletAddress, + payload.generatedAt, + "vault", + "", + "", + vault.equityUsd, + vault.netApy, + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + vault.name, + vault.share, + vault.strategyHealth, + ] + .map(csvCell) + .join(","), + ); + } + + return rows.join("\r\n"); +} + +async function buildExportPayload( + data: DashboardData, + addr: string, +): Promise { + const poolsById = new Map(getKnownPools().map((pool) => [pool.id, pool])); + const poolAccounts = await Promise.all( + data.poolAccounts.map(async (account) => { + const pool = account.poolId ? poolsById.get(account.poolId) : undefined; + const legs = await Promise.all( + account.legs.map(async (leg) => { + const events = + pool && leg.assetId + ? await fetchPositionEvents(pool, addr, leg.assetId).catch( + () => [], + ) + : []; + return { + ...leg, + events: events.filter((event) => event.kind !== "harvest"), + }; + }), + ); + return { ...account, legs }; + }), + ); + + return { + walletAddress: addr, + generatedAt: new Date().toISOString(), + poolAccounts, + vaults: data.vaults, + }; +} + +let latestDashboardData: DashboardData | null = null; + +async function handleExport(type: "csv" | "json"): Promise { + const addr = getState().userAddress; + if (!addr) { + toast("Connect your wallet to export your dashboard data.", "error"); + return; + } + if (!latestDashboardData) { + toast( + "Dashboard data is still loading. Please wait and try again.", + "error", + ); + return; + } + + try { + const payload = await buildExportPayload(latestDashboardData, addr); + if (type === "csv") { + const csv = buildCsv(payload); + downloadText(csv, "text/csv;charset=utf-8", buildFilename(addr, "csv")); + } else { + const json = JSON.stringify(payload, null, 2); + downloadText( + json, + "application/json;charset=utf-8", + buildFilename(addr, "json"), + ); + } + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast(`Failed to export dashboard data: ${message}`, "error"); + } +} + const handlers = { onNewPosition: () => setState({ view: "trade", tradeTarget: null }), + onExportCsv: () => void handleExport("csv"), + onExportJson: () => void handleExport("json"), onManagePool: (poolId: string | undefined, assetId: string | undefined) => - setState({ view: "trade", tradeTarget: poolId ? { poolId, assetId } : null }), + setState({ + view: "trade", + tradeTarget: poolId ? { poolId, assetId } : null, + }), onAddLeg: (poolId: string | undefined) => setState({ view: "trade", tradeTarget: poolId ? { poolId } : null }), onGoVault: () => setState({ view: "vault" }), @@ -109,21 +351,36 @@ export function dashboardScreen(): HTMLElement { const addr = getState().userAddress; if (!addr) { - root.replaceChildren(renderDashboard({ connected: false, poolAccounts: [], vaults: [] }, handlers)); + root.replaceChildren( + renderDashboard( + { connected: false, poolAccounts: [], vaults: [] }, + handlers, + ), + ); return root; } + latestDashboardData = null; // Optimistic skeleton while fetching. root.replaceChildren( - el("div", { style: "padding:48px 0;text-align:center;color:var(--tl-text-3);font-size:var(--tl-text-base)" }, [ - "Loading your positions…", - ]), + el( + "div", + { + style: + "padding:48px 0;text-align:center;color:var(--tl-text-3);font-size:var(--tl-text-base)", + }, + ["Loading your positions…"], + ), ); void loadDashboardData(addr) - .then((data) => root.replaceChildren(renderDashboard(data, handlers))) + .then((data) => { + latestDashboardData = data; + root.replaceChildren(renderDashboard(data, handlers)); + }) .catch((e) => { + latestDashboardData = { connected: true, poolAccounts: [], vaults: [] }; console.warn("Dashboard load failed", e); - root.replaceChildren(renderDashboard({ connected: true, poolAccounts: [], vaults: [] }, handlers)); + root.replaceChildren(renderDashboard(latestDashboardData, handlers)); }); return root; diff --git a/frontend/src/views/dashboard.ts b/frontend/src/views/dashboard.ts index 70427bf..2a1f813 100644 --- a/frontend/src/views/dashboard.ts +++ b/frontend/src/views/dashboard.ts @@ -15,23 +15,23 @@ import "./dashboard.css"; // ── Domain types ──────────────────────────────────────────────────────────── -export type Role = 'Looped' | 'Collateral' | 'Borrow'; +export type Role = "Looped" | "Collateral" | "Borrow"; export interface Leg { - asset: string; // e.g. "USDC" - assetId?: string; // asset contract ID — lets Manage deep-link to this exact leg + asset: string; // e.g. "USDC" + assetId?: string; // asset contract ID — lets Manage deep-link to this exact leg role: Role; - amountUsd: number; // USD value of this leg - loopX?: number; // present for Looped legs, e.g. 5.0 + amountUsd: number; // USD value of this leg + loopX?: number; // present for Looped legs, e.g. 5.0 isNew?: boolean; } export interface PoolAccount { - pool: string; // e.g. "YieldBlox" (display name) - poolId?: string; // pool contract ID — lets Manage / Add leg deep-link to this pool + pool: string; // e.g. "YieldBlox" (display name) + poolId?: string; // pool contract ID — lets Manage / Add leg deep-link to this pool legs: Leg[]; - equityUsd: number; // collateral − debt, summed for this pool - netApy: number; // %, can be negative + equityUsd: number; // collateral − debt, summed for this pool + netApy: number; // %, can be negative /** * ACCOUNT-WIDE health (PR #295): Σ(collateralUsd × cFactor) ÷ Σ(debtUsd) over * every reserve in this pool. NOT per-asset. 1.0 = liquidation. @@ -41,9 +41,9 @@ export interface PoolAccount { } export interface VaultHolding { - name: string; // e.g. "USDC · YieldBlox Vault" + name: string; // e.g. "USDC · YieldBlox Vault" equityUsd: number; - share: string; // e.g. "0.12%" + share: string; // e.g. "0.12%" netApy: number; strategyHealth: number; // informational (keeper-protected), not personal liq. } @@ -55,35 +55,39 @@ export interface DashboardData { } // ── Formatting ────────────────────────────────────────────────────────────── -const money = (n: number) => '$' + Math.round(n).toLocaleString('en-US'); -const pct = (n: number) => (n >= 0 ? '+' : '') + n.toFixed(1) + '%'; +const money = (n: number) => "$" + Math.round(n).toLocaleString("en-US"); +const pct = (n: number) => (n >= 0 ? "+" : "") + n.toFixed(1) + "%"; // Risk zone → token color. Mirrors HealthFactor.jsx thresholds. function hfColor(hf: number): string { - if (hf < 1.2) return 'var(--tl-danger)'; - if (hf < 1.5) return 'var(--tl-warning)'; - if (hf < 2.0) return 'var(--tl-warning)'; - return 'var(--tl-success)'; + if (hf < 1.2) return "var(--tl-danger)"; + if (hf < 1.5) return "var(--tl-warning)"; + if (hf < 2.0) return "var(--tl-warning)"; + return "var(--tl-success)"; } function hfLabel(hf: number): string { - if (hf < 1.2) return 'Near liquidation'; - if (hf < 1.5) return 'High risk'; - if (hf < 2.0) return 'Caution'; - return 'Safe'; + if (hf < 1.2) return "Near liquidation"; + if (hf < 1.5) return "High risk"; + if (hf < 2.0) return "Caution"; + return "Safe"; } const ROLE_COLOR: Record = { - Looped: 'var(--tl-primary)', - Collateral: 'var(--tl-success)', - Borrow: 'var(--tl-blnd)', + Looped: "var(--tl-primary)", + Collateral: "var(--tl-success)", + Borrow: "var(--tl-blnd)", }; // ── Tiny DOM helper ───────────────────────────────────────────────────────── type Attrs = Record; -function el(tag: string, attrs: Attrs = {}, children: (Node | string)[] = []): HTMLElement { +function el( + tag: string, + attrs: Attrs = {}, + children: (Node | string)[] = [], +): HTMLElement { const node = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { - if (k === 'class') node.className = v; - else if (k === 'title') node.title = v; + if (k === "class") node.className = v; + else if (k === "title") node.title = v; else node.setAttribute(k, v); } for (const c of children) node.append(c); @@ -91,118 +95,179 @@ function el(tag: string, attrs: Attrs = {}, children: (Node | string)[] = []): H } // ── Subcomponents ─────────────────────────────────────────────────────────── -function metricHero(label: string, value: string, tone?: string, title?: string): HTMLElement { - return el('div', { class: 'tl-hero' }, [ - el('span', { class: 'tl-hero__label', ...(title ? { title } : {}) }, [label]), - el('span', { class: 'tl-hero__value', style: tone ? `color:${tone}` : '' }, [value]), +function metricHero( + label: string, + value: string, + tone?: string, + title?: string, +): HTMLElement { + return el("div", { class: "tl-hero" }, [ + el("span", { class: "tl-hero__label", ...(title ? { title } : {}) }, [ + label, + ]), + el( + "span", + { class: "tl-hero__value", style: tone ? `color:${tone}` : "" }, + [value], + ), ]); } function miniMetric(label: string, value: string, tone?: string): HTMLElement { - return el('div', { class: 'tl-mini' }, [ - el('span', { class: 'tl-mini__label' }, [label]), - el('span', { class: 'tl-mini__value', style: tone ? `color:${tone}` : '' }, [value]), + return el("div", { class: "tl-mini" }, [ + el("span", { class: "tl-mini__label" }, [label]), + el( + "span", + { class: "tl-mini__value", style: tone ? `color:${tone}` : "" }, + [value], + ), ]); } function badge(text: string, tone: string): HTMLElement { - return el('span', { class: `tl-badge tl-badge--${tone}` }, [text]); + return el("span", { class: `tl-badge tl-badge--${tone}` }, [text]); } /** Account Health readout — the account-wide risk number (see HealthFactor.jsx). */ function accountHealth(hf: number, title: string): HTMLElement { const pos = Math.max(0, Math.min(1, (hf - 1) / (3 - 1))) * 100; const color = hfColor(hf); - return el('div', { class: 'tl-hf' }, [ - el('div', { class: 'tl-hf__top' }, [ - el('span', { class: 'tl-hf__label', title }, ['Account Health']), - el('span', { class: 'tl-hf__val', style: `color:${color}` }, [(Number.isFinite(hf) ? hf.toFixed(4) : '∞') + ' · ' + hfLabel(hf)]), + return el("div", { class: "tl-hf" }, [ + el("div", { class: "tl-hf__top" }, [ + el("span", { class: "tl-hf__label", title }, ["Account Health"]), + el("span", { class: "tl-hf__val", style: `color:${color}` }, [ + (Number.isFinite(hf) ? hf.toFixed(4) : "∞") + " · " + hfLabel(hf), + ]), ]), - el('div', { class: 'tl-hf__track' }, [ - el('div', { class: 'tl-hf__grad' }, []), - el('div', { - class: 'tl-hf__mark' + (hf < 1.2 ? ' tl-hf__mark--pulse' : ''), - style: `left:${pos}%;background:${color};box-shadow:0 0 8px ${color}`, - }, []), + el("div", { class: "tl-hf__track" }, [ + el("div", { class: "tl-hf__grad" }, []), + el( + "div", + { + class: "tl-hf__mark" + (hf < 1.2 ? " tl-hf__mark--pulse" : ""), + style: `left:${pos}%;background:${color};box-shadow:0 0 8px ${color}`, + }, + [], + ), ]), ]); } function legRow(lg: Leg, last: boolean): HTMLElement { - const left = el('span', { class: 'tl-leg__left' }, [ - el('span', { class: 'tl-mono tl-leg__sym' }, [lg.asset]), - badge(lg.role, lg.role === 'Looped' ? 'primary' : lg.role === 'Collateral' ? 'success' : 'blnd'), + const left = el("span", { class: "tl-leg__left" }, [ + el("span", { class: "tl-mono tl-leg__sym" }, [lg.asset]), + badge( + lg.role, + lg.role === "Looped" + ? "primary" + : lg.role === "Collateral" + ? "success" + : "blnd", + ), ]); - if (lg.loopX) left.append(el('span', { class: 'tl-leg__sub' }, [lg.loopX.toFixed(1) + '× loop'])); - return el('div', { class: 'tl-leg' + (last ? ' tl-leg--last' : '') }, [ + if (lg.loopX) + left.append( + el("span", { class: "tl-leg__sub" }, [lg.loopX.toFixed(1) + "× loop"]), + ); + return el("div", { class: "tl-leg" + (last ? " tl-leg--last" : "") }, [ left, - el('span', { class: 'tl-mono tl-leg__amt' }, [money(lg.amountUsd)]), + el("span", { class: "tl-mono tl-leg__amt" }, [money(lg.amountUsd)]), ]); } // ── Cards ─────────────────────────────────────────────────────────────────── -function poolCard(acc: PoolAccount, onManage: () => void, onAddLeg: () => void): HTMLElement { +function poolCard( + acc: PoolAccount, + onManage: () => void, + onAddLeg: () => void, +): HTMLElement { const cross = acc.legs.length > 1; const actions: Node[] = []; - if (acc.hasNew) actions.push(badge('New', 'primary')); + if (acc.hasNew) actions.push(badge("New", "primary")); if (cross) { - const xc = badge('Cross-collateralized', 'neutral'); - xc.title = 'More than one asset in this pool. They share one Account Health — every collateral backs every borrow, so liquidation is account-wide, not per leg.'; + const xc = badge("Cross-collateralized", "neutral"); + xc.title = + "More than one asset in this pool. They share one Account Health — every collateral backs every borrow, so liquidation is account-wide, not per leg."; actions.push(xc); } - const card = el('section', { class: 'tl-card' }, [ - el('header', { class: 'tl-card__head' }, [ - el('h2', { class: 'tl-card__title' }, [acc.pool + ' ', el('span', { class: 'tl-card__sub' }, ['account'])]), - el('div', { class: 'tl-card__actions' }, actions), + const card = el("section", { class: "tl-card" }, [ + el("header", { class: "tl-card__head" }, [ + el("h2", { class: "tl-card__title" }, [ + acc.pool + " ", + el("span", { class: "tl-card__sub" }, ["account"]), + ]), + el("div", { class: "tl-card__actions" }, actions), ]), - el('div', { class: 'tl-grid tl-grid--2' }, [ - miniMetric('Equity', money(acc.equityUsd)), - miniMetric('Net APY', pct(acc.netApy), acc.netApy >= 0 ? 'var(--tl-success)' : 'var(--tl-danger)'), + el("div", { class: "tl-grid tl-grid--2" }, [ + miniMetric("Equity", money(acc.equityUsd)), + miniMetric( + "Net APY", + pct(acc.netApy), + acc.netApy >= 0 ? "var(--tl-success)" : "var(--tl-danger)", + ), ]), - el('div', { class: 'tl-legs' }, acc.legs.map((lg, i) => legRow(lg, i === acc.legs.length - 1))), - el('div', { class: 'tl-well' }, [ - accountHealth(acc.accountHealth, 'Pool-wide: total collateral value ÷ total debt across every leg in this pool. Liquidation is account-wide — it triggers when this drops below 1.0.'), + el( + "div", + { class: "tl-legs" }, + acc.legs.map((lg, i) => legRow(lg, i === acc.legs.length - 1)), + ), + el("div", { class: "tl-well" }, [ + accountHealth( + acc.accountHealth, + "Pool-wide: total collateral value ÷ total debt across every leg in this pool. Liquidation is account-wide — it triggers when this drops below 1.0.", + ), ]), ]); if (cross) { - card.append(el('p', { class: 'tl-note' }, [ - `All legs in ${acc.pool} share this health — every collateral backs every borrow. Liquidation is account-wide, not per asset.`, - ])); + card.append( + el("p", { class: "tl-note" }, [ + `All legs in ${acc.pool} share this health — every collateral backs every borrow. Liquidation is account-wide, not per asset.`, + ]), + ); } - const manage = el('button', { class: 'tl-btn tl-btn--secondary tl-flex1' }, ['Manage']); - manage.addEventListener('click', onManage); - const add = el('button', { class: 'tl-btn tl-btn--ghost tl-flex1' }, ['Add leg']); - add.addEventListener('click', onAddLeg); - card.append(el('div', { class: 'tl-row tl-row--gap' }, [manage, add])); + const manage = el("button", { class: "tl-btn tl-btn--secondary tl-flex1" }, [ + "Manage", + ]); + manage.addEventListener("click", onManage); + const add = el("button", { class: "tl-btn tl-btn--ghost tl-flex1" }, [ + "Add leg", + ]); + add.addEventListener("click", onAddLeg); + card.append(el("div", { class: "tl-row tl-row--gap" }, [manage, add])); return card; } function vaultCard(v: VaultHolding, onManage: () => void): HTMLElement { - const managed = badge('Managed', 'blnd'); - managed.title = 'A passive vault position. A permissionless keeper auto-rebalances the strategy, so you’re not liquidated the way a manual pool account is — the strategy health below is informational.'; + const managed = badge("Managed", "blnd"); + managed.title = + "A passive vault position. A permissionless keeper auto-rebalances the strategy, so you’re not liquidated the way a manual pool account is — the strategy health below is informational."; - const manage = el('button', { class: 'tl-btn tl-btn--secondary tl-full' }, ['Manage in Vault']); - manage.addEventListener('click', onManage); + const manage = el("button", { class: "tl-btn tl-btn--secondary tl-full" }, [ + "Manage in Vault", + ]); + manage.addEventListener("click", onManage); - return el('section', { class: 'tl-card' }, [ - el('header', { class: 'tl-card__head' }, [ - el('h2', { class: 'tl-card__title' }, [v.name]), - el('div', { class: 'tl-card__actions' }, [managed]), + return el("section", { class: "tl-card" }, [ + el("header", { class: "tl-card__head" }, [ + el("h2", { class: "tl-card__title" }, [v.name]), + el("div", { class: "tl-card__actions" }, [managed]), ]), - el('div', { class: 'tl-grid tl-grid--3' }, [ - miniMetric('Your Equity', money(v.equityUsd)), - miniMetric('Share', v.share), - miniMetric('Net APY', pct(v.netApy), 'var(--tl-success)'), + el("div", { class: "tl-grid tl-grid--3" }, [ + miniMetric("Your Equity", money(v.equityUsd)), + miniMetric("Share", v.share), + miniMetric("Net APY", pct(v.netApy), "var(--tl-success)"), ]), - el('div', { class: 'tl-strategy' }, [ - el('span', { class: 'tl-dot' }, []), - 'Strategy Health ', - el('span', { class: 'tl-mono tl-strategy__hf' }, [Number.isFinite(v.strategyHealth) ? v.strategyHealth.toFixed(4) : '∞']), - ' · keeper-protected', + el("div", { class: "tl-strategy" }, [ + el("span", { class: "tl-dot" }, []), + "Strategy Health ", + el("span", { class: "tl-mono tl-strategy__hf" }, [ + Number.isFinite(v.strategyHealth) ? v.strategyHealth.toFixed(4) : "∞", + ]), + " · keeper-protected", ]), manage, ]); @@ -212,70 +277,156 @@ function vaultCard(v: VaultHolding, onManage: () => void): HTMLElement { export interface DashboardHandlers { /** "+ New Position" header button — open Trade with no preset. */ onNewPosition: () => void; + /** "Export CSV" header button — download current positions and event history. */ + onExportCsv: () => void; + /** "Export JSON" header button — download current positions and event history. */ + onExportJson: () => void; /** "Manage" — open Trade focused on this pool + the account's primary leg. */ - onManagePool: (poolId: string | undefined, assetId: string | undefined) => void; + onManagePool: ( + poolId: string | undefined, + assetId: string | undefined, + ) => void; /** "Add leg" — open Trade focused on this pool so the user can add another asset. */ onAddLeg: (poolId: string | undefined) => void; onGoVault: () => void; } -export function renderDashboard(data: DashboardData, h: DashboardHandlers): HTMLElement { - const root = el('div', { class: 'tl-dash' }, []); - - if (!data.connected && data.poolAccounts.length === 0 && data.vaults.length === 0) { - return el('section', { class: 'tl-card tl-empty' }, ['Connect your wallet to see your positions.']); +export function renderDashboard( + data: DashboardData, + h: DashboardHandlers, +): HTMLElement { + const root = el("div", { class: "tl-dash" }, []); + + if ( + !data.connected && + data.poolAccounts.length === 0 && + data.vaults.length === 0 + ) { + return el("section", { class: "tl-card tl-empty" }, [ + "Connect your wallet to see your positions.", + ]); } // Header - const newBtn = el('button', { class: 'tl-btn tl-btn--primary' }, ['+ New Position']); - newBtn.addEventListener('click', h.onNewPosition); - root.append(el('div', { class: 'tl-dash__head' }, [ - el('div', {}, [ - el('h1', { class: 'tl-h1' }, ['Dashboard']), - el('p', { class: 'tl-sub' }, ['Your accounts, grouped by pool. Health is shared across every leg in a pool.']), + const exportCsv = el("button", { class: "tl-btn tl-btn--ghost tl-btn--sm" }, [ + "Export CSV", + ]); + exportCsv.addEventListener("click", h.onExportCsv); + const exportJson = el( + "button", + { class: "tl-btn tl-btn--ghost tl-btn--sm" }, + ["Export JSON"], + ); + exportJson.addEventListener("click", h.onExportJson); + const newBtn = el("button", { class: "tl-btn tl-btn--primary" }, [ + "+ New Position", + ]); + newBtn.addEventListener("click", h.onNewPosition); + root.append( + el("div", { class: "tl-dash__head" }, [ + el("div", {}, [ + el("h1", { class: "tl-h1" }, ["Dashboard"]), + el("p", { class: "tl-sub" }, [ + "Your accounts, grouped by pool. Health is shared across every leg in a pool.", + ]), + ]), + el("div", { class: "tl-row tl-row--gap" }, [ + exportCsv, + exportJson, + newBtn, + ]), ]), - newBtn, - ])); + ); // Summary - const totalEquity = data.poolAccounts.reduce((a, p) => a + p.equityUsd, 0) - + data.vaults.reduce((a, v) => a + v.equityUsd, 0); - const poolEquity = data.poolAccounts.reduce((a, p) => a + p.equityUsd, 0) || 1; - const wAvgApy = data.poolAccounts.reduce((a, p) => a + p.netApy * p.equityUsd, 0) / poolEquity; + const totalEquity = + data.poolAccounts.reduce((a, p) => a + p.equityUsd, 0) + + data.vaults.reduce((a, v) => a + v.equityUsd, 0); + const poolEquity = + data.poolAccounts.reduce((a, p) => a + p.equityUsd, 0) || 1; + const wAvgApy = + data.poolAccounts.reduce((a, p) => a + p.netApy * p.equityUsd, 0) / + poolEquity; const lowestHf = data.poolAccounts.length ? Math.min(...data.poolAccounts.map((p) => p.accountHealth)) : 0; - root.append(el('div', { class: 'tl-grid tl-grid--4 tl-dash__summary' }, [ - metricHero('Total Equity', money(totalEquity), undefined, 'Your own capital across all pools and vaults — collateral value minus debt, summed.'), - metricHero('Avg Net APY', pct(wAvgApy), wAvgApy >= 0 ? 'var(--tl-success)' : 'var(--tl-danger)'), - metricHero('Positions', String(data.poolAccounts.length + data.vaults.length), undefined, 'Your active pool accounts plus passive vault positions.'), - metricHero('Lowest Account Health', lowestHf ? (Number.isFinite(lowestHf) ? lowestHf.toFixed(4) : '∞') : '—', hfColor(lowestHf || 99), 'The riskiest pool account. Below 1.0 the whole pool account is liquidated.'), - ])); + root.append( + el("div", { class: "tl-grid tl-grid--4 tl-dash__summary" }, [ + metricHero( + "Total Equity", + money(totalEquity), + undefined, + "Your own capital across all pools and vaults — collateral value minus debt, summed.", + ), + metricHero( + "Avg Net APY", + pct(wAvgApy), + wAvgApy >= 0 ? "var(--tl-success)" : "var(--tl-danger)", + ), + metricHero( + "Positions", + String(data.poolAccounts.length + data.vaults.length), + undefined, + "Your active pool accounts plus passive vault positions.", + ), + metricHero( + "Lowest Account Health", + lowestHf + ? Number.isFinite(lowestHf) + ? lowestHf.toFixed(4) + : "∞" + : "—", + hfColor(lowestHf || 99), + "The riskiest pool account. Below 1.0 the whole pool account is liquidated.", + ), + ]), + ); // Group A — Pool Accounts if (data.poolAccounts.length) { - root.append(el('h2', { class: 'tl-group' }, ['Pool Accounts ', el('span', { class: 'tl-group__sub' }, ['active · self-managed'])])); - root.append(el('div', { class: 'tl-grid tl-grid--2' }, - data.poolAccounts.map((acc) => { - // Manage targets the account's primary leg: the looped one if present, - // else the largest leg by USD — so a single-asset account lands exactly - // on that asset rather than the pool's default tab. - const primary = acc.legs.find((l) => l.role === 'Looped') - ?? [...acc.legs].sort((a, b) => b.amountUsd - a.amountUsd)[0]; - return poolCard( - acc, - () => h.onManagePool(acc.poolId, primary?.assetId), - () => h.onAddLeg(acc.poolId), - ); - }))); + root.append( + el("h2", { class: "tl-group" }, [ + "Pool Accounts ", + el("span", { class: "tl-group__sub" }, ["active · self-managed"]), + ]), + ); + root.append( + el( + "div", + { class: "tl-grid tl-grid--2" }, + data.poolAccounts.map((acc) => { + // Manage targets the account's primary leg: the looped one if present, + // else the largest leg by USD — so a single-asset account lands exactly + // on that asset rather than the pool's default tab. + const primary = + acc.legs.find((l) => l.role === "Looped") ?? + [...acc.legs].sort((a, b) => b.amountUsd - a.amountUsd)[0]; + return poolCard( + acc, + () => h.onManagePool(acc.poolId, primary?.assetId), + () => h.onAddLeg(acc.poolId), + ); + }), + ), + ); } // Group B — Vaults if (data.vaults.length) { - root.append(el('h2', { class: 'tl-group tl-group--mt' }, ['Vaults ', el('span', { class: 'tl-group__sub' }, ['passive · auto-rebalanced'])])); - root.append(el('div', { class: 'tl-grid tl-grid--2' }, - data.vaults.map((v) => vaultCard(v, h.onGoVault)))); + root.append( + el("h2", { class: "tl-group tl-group--mt" }, [ + "Vaults ", + el("span", { class: "tl-group__sub" }, ["passive · auto-rebalanced"]), + ]), + ); + root.append( + el( + "div", + { class: "tl-grid tl-grid--2" }, + data.vaults.map((v) => vaultCard(v, h.onGoVault)), + ), + ); } return root; @@ -286,20 +437,32 @@ export const SEED_DASHBOARD: DashboardData = { connected: true, poolAccounts: [ { - pool: 'YieldBlox', equityUsd: 3200, netApy: 19.8, accountHealth: 1.66, + pool: "YieldBlox", + equityUsd: 3200, + netApy: 19.8, + accountHealth: 1.66, legs: [ - { asset: 'USDC', role: 'Looped', amountUsd: 5000, loopX: 5.0 }, - { asset: 'EURC', role: 'Collateral', amountUsd: 2400 }, - { asset: 'XLM', role: 'Borrow', amountUsd: 3200 }, + { asset: "USDC", role: "Looped", amountUsd: 5000, loopX: 5.0 }, + { asset: "EURC", role: "Collateral", amountUsd: 2400 }, + { asset: "XLM", role: "Borrow", amountUsd: 3200 }, ], }, { - pool: 'Fixed', equityUsd: 640, netApy: 12.1, accountHealth: 1.48, - legs: [{ asset: 'XLM', role: 'Looped', amountUsd: 4480, loopX: 7.0 }], + pool: "Fixed", + equityUsd: 640, + netApy: 12.1, + accountHealth: 1.48, + legs: [{ asset: "XLM", role: "Looped", amountUsd: 4480, loopX: 7.0 }], }, ], vaults: [ - { name: 'USDC · YieldBlox Vault', equityUsd: 3420, share: '0.12%', netApy: 18.6, strategyHealth: 1.62 }, + { + name: "USDC · YieldBlox Vault", + equityUsd: 3420, + share: "0.12%", + netApy: 18.6, + strategyHealth: 1.62, + }, ], };