diff --git a/frontend/index.html b/frontend/index.html index f904f23..b24839a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -339,6 +339,18 @@

Portfolio Overview

+ +
+
+ Net Supply APY history +
+ + + +
+
+
+
diff --git a/frontend/src/main.ts b/frontend/src/main.ts index fcc1ceb..e2a64d3 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -502,6 +502,82 @@ function removePnlEntry(assetId: string, poolId: string) { localStorage.removeItem(`pnl_${poolId}_${assetId}`); } +// ── Rate history (B3) ──────────────────────────────────────────────────────── + +const RATE_HISTORY_KEY = "blendlev_rate_history"; +const RATE_HISTORY_MAX = 200; // keep up to 200 snapshots per key + +interface RateSnapshot { ts: number; val: number; } + +function _rateKey(poolId: string, assetId: string, field: string) { + return `${RATE_HISTORY_KEY}:${poolId}:${assetId}:${field}`; +} + +function recordRateSnapshot(poolId: string, assetId: string, field: string, val: number) { + const key = _rateKey(poolId, assetId, field); + const raw = localStorage.getItem(key); + const snaps: RateSnapshot[] = raw ? JSON.parse(raw) : []; + const now = Date.now(); + // Deduplicate: skip if last snapshot is < 5 min old + if (snaps.length > 0 && now - snaps[snaps.length - 1].ts < 5 * 60_000) { + snaps[snaps.length - 1] = { ts: now, val }; // update in place + } else { + snaps.push({ ts: now, val }); + if (snaps.length > RATE_HISTORY_MAX) snaps.splice(0, snaps.length - RATE_HISTORY_MAX); + } + localStorage.setItem(key, JSON.stringify(snaps)); +} + +function getRateAtWindow(poolId: string, assetId: string, field: string, windowMs: number): number | null { + const key = _rateKey(poolId, assetId, field); + const raw = localStorage.getItem(key); + if (!raw) return null; + const snaps: RateSnapshot[] = JSON.parse(raw); + const cutoff = Date.now() - windowMs; + // Find the oldest snapshot within the window (closest to windowMs ago) + const candidates = snaps.filter(s => s.ts <= cutoff); + if (candidates.length === 0) return null; + return candidates[candidates.length - 1].val; +} + +/** Render a trend arrow element next to an APY value element. */ +function renderTrendArrow( + targetEl: HTMLElement, + current: number, + past24h: number | null, + past7d: number | null +) { + // Remove any existing arrow + const existing = targetEl.parentElement?.querySelector(".rate-trend"); + if (existing) existing.remove(); + + if (past24h === null && past7d === null) return; + + const arrow = document.createElement("span"); + arrow.className = "rate-trend"; + + const parts: string[] = []; + const tipParts: string[] = []; + + for (const [label, past, ms] of [ + ["24h", past24h, 24 * 3600_000], + ["7d", past7d, 7 * 24 * 3600_000], + ] as [string, number | null, number][]) { + if (past === null) continue; + const delta = past !== 0 ? (current - past) / Math.abs(past) * 100 : 0; + const up = delta >= 0; + const sym = up ? "▲" : "▼"; + const cls = up ? "rate-trend-up" : "rate-trend-down"; + parts.push(`${sym}${fmt(Math.abs(delta), 1)}%${label}`); + tipParts.push(`${label}: ${fmt(past, 2)}% → ${fmt(current, 2)}% (${delta >= 0 ? "+" : ""}${fmt(delta, 1)}%)`); + } + + if (parts.length === 0) return; + arrow.innerHTML = parts.join(" "); + arrow.setAttribute("title", tipParts.join(" | ")); + targetEl.insertAdjacentElement("afterend", arrow); +} + // ── Sign + submit ───────────────────────────────────────────────────────────── async function signAndSubmit(xdrStr: string, label: string, stepIndex?: number): Promise { @@ -749,6 +825,101 @@ function renderApyChart(rs: ReserveStats | undefined, currentLev: number, equity `; } +// ── APY history chart (B4) ──────────────────────────────────────────────────── + +let _histWin: "7d" | "30d" | "1y" = "30d"; + +const HIST_WIN_MS: Record = { + "7d": 7 * 24 * 3600_000, + "30d": 30 * 24 * 3600_000, + "1y": 365 * 24 * 3600_000, +}; + +function renderHistoryChart() { + const container = $("hist-chart"); + const raw = localStorage.getItem(`blendlev_rate_history:${selectedPool.id}:${selectedAsset.id}:supply-net`); + const all: { ts: number; val: number }[] = raw ? JSON.parse(raw) : []; + const cutoff = Date.now() - HIST_WIN_MS[_histWin]; + const snaps = all.filter(s => s.ts >= cutoff); + + if (snaps.length < 2) { + container.innerHTML = `
Not enough history yet — check back after data accumulates.
`; + return; + } + + const W = 400, H = 80, padL = 32, padR = 8, padT = 10, padB = 18; + const vals = snaps.map(s => s.val); + const minV = Math.min(...vals), maxV = Math.max(...vals); + const rangeV = maxV - minV || 0.01; + const minTs = snaps[0].ts, maxTs = snaps[snaps.length - 1].ts; + const rangeTs = maxTs - minTs || 1; + + const px = (ts: number) => padL + (ts - minTs) / rangeTs * (W - padL - padR); + const py = (v: number) => padT + (1 - (v - minV) / rangeV) * (H - padT - padB); + + const pts = snaps.map(s => `${px(s.ts).toFixed(1)},${py(s.val).toFixed(1)}`).join(" "); + + // X-axis tick labels (3 ticks: start, mid, end) + const fmtDate = (ts: number) => { + const d = new Date(ts); + return _histWin === "1y" + ? d.toLocaleDateString("en-US", { month: "short", year: "2-digit" }) + : d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + }; + const ticks = [minTs, (minTs + maxTs) / 2, maxTs]; + const tickSvg = ticks.map(ts => + `${fmtDate(ts)}` + ).join(""); + + // Y-axis labels + const yLabelSvg = + `${fmt(maxV, 1)}%` + + `${fmt(minV, 1)}%`; + + // Invisible hit-area rects for tooltip + const segW = (W - padL - padR) / snaps.length; + const hitRects = snaps.map((s, i) => { + const cx = px(s.ts); + const cy = py(s.val); + const tip = `${fmtDate(s.ts)}: ${fmt(s.val, 2)}%`; + return ``; + }).join(""); + + container.innerHTML = ` + + + ${yLabelSvg}${tickSvg}${hitRects} + + + `; + + // Pointer tooltip + const svg = document.getElementById("hist-chart-svg")!; + svg.addEventListener("mouseleave", () => { + (document.getElementById("hist-cursor") as SVGCircleElement | null)?.setAttribute("style", "display:none"); + (document.getElementById("hist-tip-text") as SVGTextElement | null)?.setAttribute("style", "display:none"); + }); + svg.querySelectorAll(".hist-hit").forEach(rect => { + rect.addEventListener("mouseenter", () => { + const cx = rect.dataset.cx!, cy = rect.dataset.cy!, tip = rect.dataset.tip!; + const cursor = document.getElementById("hist-cursor") as SVGCircleElement | null; + const tipEl = document.getElementById("hist-tip-text") as SVGTextElement | null; + if (!cursor || !tipEl) return; + cursor.setAttribute("cx", cx); + cursor.setAttribute("cy", cy); + cursor.setAttribute("style", ""); + const tx = Math.min(Number(cx), W - padR - 60); + const ty = Number(cy) > padT + 20 ? Number(cy) - 8 : Number(cy) + 14; + tipEl.setAttribute("x", String(tx)); + tipEl.setAttribute("y", String(ty)); + tipEl.textContent = tip; + tipEl.setAttribute("style", ""); + }); + }); +} + // ── Render reserve stats for selected asset ─────────────────────────────────── function renderSelectedAsset() { @@ -797,6 +968,32 @@ function renderSelectedAsset() { // Don't auto-collapse — user controls visibility via the toggle + // Rate-trend arrows (A10 / B3) ───────────────────────────────────────────── + const supplyNetVal = aprToApy(rs.interestSupplyApr) + rs.blndSupplyApr; + const borrowNetVal = aprToApy(rs.interestBorrowApr) - rs.blndBorrowApr; + const W24 = 24 * 3600_000; + const W7D = 7 * 24 * 3600_000; + + // Record current values + recordRateSnapshot(selectedPool.id, selectedAsset.id, "supply-net", supplyNetVal); + recordRateSnapshot(selectedPool.id, selectedAsset.id, "borrow-net", borrowNetVal); + + // Render arrows on net rows + renderTrendArrow( + $("supply-net-apr"), + supplyNetVal, + getRateAtWindow(selectedPool.id, selectedAsset.id, "supply-net", W24), + getRateAtWindow(selectedPool.id, selectedAsset.id, "supply-net", W7D), + ); + renderTrendArrow( + $("borrow-net-cost"), + borrowNetVal, + getRateAtWindow(selectedPool.id, selectedAsset.id, "borrow-net", W24), + getRateAtWindow(selectedPool.id, selectedAsset.id, "borrow-net", W7D), + ); + + renderHistoryChart(); + updatePreview(); renderPosition(); renderPortfolioSummary(); @@ -2092,6 +2289,16 @@ $("stats-toggle").addEventListener("click", () => { $("stats-collapsible").classList.toggle("collapsed"); }); +// History chart window selector (B4) +$("hist-win-btns").addEventListener("click", (e) => { + const btn = (e.target as HTMLElement).closest(".hist-win-btn"); + if (!btn) return; + _histWin = btn.dataset.win as "7d" | "30d" | "1y"; + $("hist-win-btns").querySelectorAll(".hist-win-btn").forEach(b => b.classList.remove("active")); + btn.classList.add("active"); + renderHistoryChart(); +}); + // Vault deposit/withdraw tabs document.querySelectorAll(".vault-tab").forEach(btn => { btn.addEventListener("click", () => { diff --git a/frontend/src/style.css b/frontend/src/style.css index 0d4348f..41fa647 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -427,6 +427,29 @@ main { flex: 1; max-width: 1200px; width: 100%; margin: 0 auto; padding: 20px 24 .apr-warn { color: var(--warning); } .apr-bad { color: var(--danger); } +/* ── Rate-trend arrows (A10) ─────────────────────────────────────────────── */ +.rate-trend { display: inline-flex; align-items: center; gap: 2px; font-size: 10px; font-family: var(--mono); margin-left: 5px; opacity: .85; } +.rate-trend-label { color: var(--text-3); font-family: var(--sans); font-size: 10px; margin-right: 3px; } +.rate-trend-up { color: var(--success); } +.rate-trend-down { color: var(--danger); } + +/* ── APY history chart (B4) ──────────────────────────────────────────────── */ +.hist-chart-wrap { margin-top: 10px; } +.hist-chart-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; } +.hist-chart-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .7px; color: var(--text-3); } +.hist-win-btns { display: flex; gap: 3px; } +.hist-win-btn { background: none; border: 1px solid var(--border); border-radius: 4px; color: var(--text-2); font-size: 11px; padding: 2px 7px; cursor: pointer; transition: background .15s, color .15s; } +.hist-win-btn:hover { border-color: var(--primary); color: var(--primary); } +.hist-win-btn.active { background: var(--primary); border-color: var(--primary); color: #fff; } +.hist-chart { min-height: 80px; } +.hist-chart-svg { width: 100%; height: 80px; display: block; overflow: visible; } +.hist-chart-line { fill: none; stroke: var(--primary); stroke-width: 1.5; stroke-linejoin: round; } +.hist-chart-label { font-size: 9px; fill: var(--text-3); font-family: var(--mono); } +.hist-cursor { fill: var(--primary); } +.hist-tip-text { font-size: 10px; fill: var(--text); font-family: var(--mono); font-weight: 600; } +.hist-hit { cursor: crosshair; } +.hist-chart-empty { font-size: 12px; color: var(--text-3); padding: 16px 0; text-align: center; } + /* ── Frozen banner ───────────────────────────────────────────────────────── */ .pool-frozen-banner {