From 0e1c67a54844df7bdc6654097cb569e2a54cfc9e Mon Sep 17 00:00:00 2001 From: johdanike Date: Sat, 27 Jun 2026 04:36:33 +0100 Subject: [PATCH] feat(dashboard): implement pool-centric position view with aggregated health --- frontend/src/views/dashboard.css | 120 +++++++++++++++++ frontend/src/views/dashboard.screen.ts | 4 + frontend/src/views/dashboard.ts | 178 ++++++++++++++++++++----- 3 files changed, 268 insertions(+), 34 deletions(-) diff --git a/frontend/src/views/dashboard.css b/frontend/src/views/dashboard.css index eb366be..38ec3bb 100644 --- a/frontend/src/views/dashboard.css +++ b/frontend/src/views/dashboard.css @@ -96,3 +96,123 @@ .tl-flex1 { flex: 1; } .tl-row { display: flex; } .tl-row--gap { gap: 8px; } + +/* ── PositionPanel — asset breakdown table ───────────────────────────────── */ +.pp-card { display: flex; flex-direction: column; gap: 0; } + +/* 4-cell summary grid override (when collateral/debt available) */ +.pp-card .tl-grid--4 { margin-bottom: var(--tl-space-4); } +.pp-card .tl-grid--2 { margin-bottom: var(--tl-space-4); } + +/* Table wrapper — full bleed inside the card, clipped for overflow on mobile */ +.pp-table-wrap { + margin: 0 -20px var(--tl-space-4); + overflow-x: auto; + border-top: 1px solid var(--tl-border); + border-bottom: 1px solid var(--tl-border); +} +.pp-table { + width: 100%; + border-collapse: collapse; + font-size: var(--tl-text-sm); +} + +/* Header row */ +.pp-table__th { + padding: var(--tl-space-2) var(--tl-space-5); + font-size: var(--tl-text-xs); + font-weight: var(--tl-fw-semibold); + text-transform: uppercase; + letter-spacing: var(--tl-tracking-label); + color: var(--tl-text-3); + white-space: nowrap; + background: var(--tl-surface-2); +} +.pp-table__th--l { text-align: left; } +.pp-table__th--r { text-align: right; } +.pp-table__th--c { text-align: center; } + +/* Data rows */ +.pp-table__row { transition: background var(--tl-dur-fast) var(--tl-ease); } +.pp-table__row:hover { background: var(--tl-tab-hover-bg); } + +.pp-table__td { + padding: var(--tl-space-3) var(--tl-space-5); + border-bottom: 1px solid var(--tl-border); + white-space: nowrap; + vertical-align: middle; +} +.pp-table__row:last-child .pp-table__td { border-bottom: none; } + +.pp-table__td--r { text-align: right; } +.pp-table__td--c { text-align: center; } + +/* Asset symbol cell */ +.pp-table__sym { font-weight: var(--tl-fw-bold); color: var(--tl-text); } + +/* Borrowed column — muted when empty ("—") */ +.pp-table__borrow { color: var(--tl-text-2); } +.pp-table__row:has(.pp-table__borrow:not(:empty)) .pp-table__borrow { color: var(--tl-danger); } + +/* Leverage multiplier inline label (e.g. "5.0×") */ +.pp-table__lev { + font-family: var(--tl-font-mono); + font-size: var(--tl-text-xs); + color: var(--tl-text-3); + margin-left: var(--tl-space-1); +} + +/* ── Days to liquidation row ─────────────────────────────────────────────── */ +.pp-liq { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--tl-space-3); + padding: var(--tl-space-3) var(--tl-space-4); + margin-bottom: var(--tl-space-3); + background: var(--tl-input-bg); + border: 1px solid var(--tl-border); + border-radius: var(--tl-radius-xs); + font-size: var(--tl-text-sm); +} + +.pp-liq__label { + display: inline-flex; + align-items: center; + gap: var(--tl-space-1); + color: var(--tl-text-2); + font-weight: var(--tl-fw-medium); +} + +/* Inline "?" tooltip trigger */ +.pp-liq__tip { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + border-radius: 50%; + background: rgba(255,255,255,.04); + color: var(--tl-text-3); + font-size: 9px; + font-weight: var(--tl-fw-bold); + cursor: help; + font-style: normal; +} +.pp-liq__tip:hover { background: var(--tl-tab-active-bg); color: var(--tl-text-2); } + +.pp-liq__val { + font-weight: var(--tl-fw-bold); + font-size: var(--tl-text-base); +} + +/* ── Cross-collateral explainer note ─────────────────────────────────────── */ +.pp-cross-note { + margin-top: 0; + margin-bottom: var(--tl-space-3); +} + +/* ── Mobile: keep table horizontally scrollable ──────────────────────────── */ +@media (max-width: 720px) { + .pp-table-wrap { margin: 0 -16px var(--tl-space-4); } +} diff --git a/frontend/src/views/dashboard.screen.ts b/frontend/src/views/dashboard.screen.ts index f21579a..f15b6dc 100644 --- a/frontend/src/views/dashboard.screen.ts +++ b/frontend/src/views/dashboard.screen.ts @@ -57,6 +57,10 @@ export async function loadDashboardData(addr: string): Promise { equityUsd: agg.equityUsd, netApy: agg.netApy, accountHealth: agg.poolHF, + collateralUsd: agg.collateralUsd, + debtUsd: agg.debtUsd, + effLeverage: agg.effLeverage, + liqDays: agg.liqDays, }); } catch (e) { console.warn(`Dashboard: pool ${pool.name} load failed`, e); diff --git a/frontend/src/views/dashboard.ts b/frontend/src/views/dashboard.ts index 70427bf..5d3159d 100644 --- a/frontend/src/views/dashboard.ts +++ b/frontend/src/views/dashboard.ts @@ -38,6 +38,11 @@ export interface PoolAccount { */ accountHealth: number; hasNew?: boolean; + // Extended pool-level aggregates from aggregatePoolAccount (PoolAccountSummary). + collateralUsd?: number; // total collateral in USD across all assets in this pool + debtUsd?: number; // total debt in USD across all assets in this pool + effLeverage?: number; // collateralUsd / equityUsd — effective pool-wide leverage + liqDays?: number | null; // estimated days until liquidation at current rates; null = n/a, Infinity = never } export interface VaultHolding { @@ -71,12 +76,6 @@ function hfLabel(hf: number): string { if (hf < 2.0) return 'Caution'; return 'Safe'; } -const ROLE_COLOR: Record = { - 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 { @@ -128,56 +127,167 @@ function accountHealth(hf: number, title: string): HTMLElement { ]); } -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'), +// ── liqDays formatter ─────────────────────────────────────────────────────── +function fmtLiqDays(liqDays: number | null | undefined): string { + if (liqDays == null) return '—'; + if (!Number.isFinite(liqDays)) return 'Never (supply ≥ borrow)'; + if (liqDays > 3650) return 'Over 10y'; + if (liqDays < 1) return '< 1 day'; + return `~${Math.round(liqDays)} days`; +} + +function liqDaysColor(liqDays: number | null | undefined): string { + if (liqDays == null || !Number.isFinite(liqDays) || liqDays > 180) return 'var(--tl-success)'; + if (liqDays > 60) return 'var(--tl-warning)'; + return 'var(--tl-danger)'; +} + +// ── Asset breakdown table ──────────────────────────────────────────────────── +/** + * Per-asset rows showing Supplied / Borrowed / Role for every leg in the pool. + * Wired to PoolAccountRow[] from aggregatePoolAccount — no mocks. + */ +function assetBreakdownTable(legs: Leg[]): HTMLElement { + const thead = el('thead', {}, [ + el('tr', { class: 'pp-table__hrow' }, [ + el('th', { class: 'pp-table__th pp-table__th--l', scope: 'col' }, ['Asset']), + el('th', { class: 'pp-table__th pp-table__th--r', scope: 'col' }, ['Supplied']), + el('th', { class: 'pp-table__th pp-table__th--r', scope: 'col' }, ['Borrowed']), + el('th', { class: 'pp-table__th pp-table__th--c', scope: 'col' }, ['Role']), + ]), ]); - 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)]), + + const tbody = el('tbody', {}); + for (const lg of legs) { + const roleTone = lg.role === 'Looped' ? 'primary' : lg.role === 'Collateral' ? 'success' : 'blnd'; + const roleBadge = badge(lg.role, roleTone); + // Show leverage multiplier inline for looped legs. + const roleCell = el('td', { class: 'pp-table__td pp-table__td--c' }, [ + roleBadge, + ...(lg.loopX ? [el('span', { class: 'pp-table__lev' }, [` ${lg.loopX.toFixed(1)}×`])] : []), + ]); + // Supplied = amountUsd for Looped/Collateral; Borrowed = amountUsd for Borrow. + // We don't have raw token amounts on Leg, only USD — show USD for both columns, + // leaving the opposite column as "—" so the table is honest about what it has. + const suppliedUsd = lg.role !== 'Borrow' ? money(lg.amountUsd) : '—'; + const borrowedUsd = lg.role === 'Borrow' ? money(lg.amountUsd) : '—'; + tbody.append(el('tr', { class: 'pp-table__row' }, [ + el('td', { class: 'pp-table__td pp-table__sym' }, [ + el('span', { class: 'tl-mono' }, [lg.asset]), + ]), + el('td', { class: 'pp-table__td pp-table__td--r tl-mono' }, [suppliedUsd]), + el('td', { class: 'pp-table__td pp-table__td--r tl-mono pp-table__borrow' }, [borrowedUsd]), + roleCell, + ])); + } + + return el('div', { class: 'pp-table-wrap' }, [ + el('table', { class: 'pp-table' }, [thead, tbody]), ]); } -// ── Cards ─────────────────────────────────────────────────────────────────── +// ── PositionPanel (pool card) ──────────────────────────────────────────────── +/** + * Renders a single pool account as a PositionPanel card. + * + * Data wiring (all from aggregatePoolAccount — no mocks): + * accountHealth → agg.poolHF (Σ collateral×cFactor ÷ Σ debt/lFactor) + * liqDays → agg.liqDays (ln(poolHF) / spread × 365) + * effLeverage → agg.effLeverage (collateralUsd / equityUsd) + * collateralUsd → agg.collateralUsd + * debtUsd → agg.debtUsd + * legs (table) → agg.rows mapped via legsFromRows + */ 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')); + const hasLiqData = acc.liqDays !== undefined; + + // ── Header badges ────────────────────────────────────────────────────────── + const headerBadges: Node[] = []; + if (acc.hasNew) headerBadges.push(badge('New', 'primary')); + if (acc.effLeverage != null && acc.effLeverage > 1) { + const levBadge = badge(`${acc.effLeverage.toFixed(1)}×`, 'neutral'); + levBadge.title = `Effective pool-wide leverage: total collateral ÷ equity across all assets in this pool.`; + headerBadges.push(levBadge); + } 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.'; - actions.push(xc); + headerBadges.push(xc); } - const card = el('section', { class: 'tl-card' }, [ + // ── Summary metrics row (4 cells when full data, 2 when not) ────────────── + const hasFull = acc.collateralUsd != null && acc.debtUsd != null; + const summaryItems: HTMLElement[] = hasFull + ? [ + miniMetric('Equity', money(acc.equityUsd)), + miniMetric('Collateral', money(acc.collateralUsd!)), + miniMetric('Borrowed', money(acc.debtUsd!), acc.debtUsd! > 0 ? 'var(--tl-danger)' : undefined), + miniMetric('Net APY', pct(acc.netApy), acc.netApy >= 0 ? 'var(--tl-success)' : 'var(--tl-danger)'), + ] + : [ + miniMetric('Equity', money(acc.equityUsd)), + miniMetric('Net APY', pct(acc.netApy), acc.netApy >= 0 ? 'var(--tl-success)' : 'var(--tl-danger)'), + ]; + const summaryGridClass = hasFull ? 'tl-grid tl-grid--4' : 'tl-grid tl-grid--2'; + + // ── liqDays row ──────────────────────────────────────────────────────────── + const liqRow = hasLiqData + ? el('div', { class: 'pp-liq' }, [ + el('span', { class: 'pp-liq__label' }, [ + 'Days to liquidation', + el('span', { + class: 'pp-liq__tip', + title: 'At current interest rates: how long before borrow cost erodes your buffer to Health Factor 1.0. Claim & Convert BLND to extend it.', + }, ['?']), + ]), + el('span', { + class: 'pp-liq__val tl-mono', + style: `color:${liqDaysColor(acc.liqDays)}`, + }, [fmtLiqDays(acc.liqDays)]), + ]) + : null; + + // ── Account Health gauge ─────────────────────────────────────────────────── + const hfTip = cross + ? `Pool-wide health across all ${acc.legs.length} assets: Σ(collateral × cFactor) ÷ Σ(debt / lFactor). Every collateral backs every borrow — liquidation is account-wide.` + : 'Pool-wide: total collateral value ÷ total debt. Liquidation triggers when this drops below 1.0.'; + + // ── Cross-collateral explainer (only for multi-asset pools) ─────────────── + const crossNote = cross + ? el('p', { class: 'tl-note pp-cross-note' }, [ + `All ${acc.legs.length} assets in ${acc.pool} share one account-wide Health Factor — every collateral backs every borrow. There is no per-asset liquidation line.`, + ]) + : null; + + // ── Assemble card ────────────────────────────────────────────────────────── + const children: (HTMLElement | null)[] = [ 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('h2', { class: 'tl-card__title' }, [ + acc.pool, + el('span', { class: 'tl-card__sub' }, [' account']), + ]), + el('div', { class: 'tl-card__actions' }, headerBadges), ]), - 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: summaryGridClass }, summaryItems), + assetBreakdownTable(acc.legs), 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.'), + accountHealth(acc.accountHealth, hfTip), ]), - ]); + liqRow, + crossNote, + ]; - 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.`, - ])); - } + const card = el('section', { class: 'tl-card pp-card' }, + children.filter((c): c is HTMLElement => c !== null), + ); 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; }