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
12 changes: 12 additions & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,18 @@ <h2>Portfolio Overview</h2>
</div>
</div>
</div>
<!-- APY history chart (B4) -->
<div class="hist-chart-wrap">
<div class="hist-chart-header">
<span class="hist-chart-title">Net Supply APY history</span>
<div class="hist-win-btns" id="hist-win-btns">
<button class="hist-win-btn" data-win="7d">7d</button>
<button class="hist-win-btn active" data-win="30d">30d</button>
<button class="hist-win-btn" data-win="1y">1y</button>
</div>
</div>
<div id="hist-chart" class="hist-chart"></div>
</div>
</div><!-- stats-body -->
</div><!-- stats-collapsible -->

Expand Down
207 changes: 207 additions & 0 deletions frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<span class="${cls}">${sym}${fmt(Math.abs(delta), 1)}%</span><span class="rate-trend-label">${label}</span>`);
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<string> {
Expand Down Expand Up @@ -749,6 +825,101 @@ function renderApyChart(rs: ReserveStats | undefined, currentLev: number, equity
</svg>`;
}

// ── APY history chart (B4) ────────────────────────────────────────────────────

let _histWin: "7d" | "30d" | "1y" = "30d";

const HIST_WIN_MS: Record<string, number> = {
"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 = `<div class="hist-chart-empty">Not enough history yet — check back after data accumulates.</div>`;
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 =>
`<text x="${px(ts).toFixed(1)}" y="${H - 2}" text-anchor="middle" class="hist-chart-label">${fmtDate(ts)}</text>`
).join("");

// Y-axis labels
const yLabelSvg =
`<text x="${padL - 3}" y="${padT + 6}" text-anchor="end" class="hist-chart-label">${fmt(maxV, 1)}%</text>` +
`<text x="${padL - 3}" y="${H - padB + 4}" text-anchor="end" class="hist-chart-label">${fmt(minV, 1)}%</text>`;

// 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 `<rect x="${(cx - segW / 2).toFixed(1)}" y="${padT}" width="${segW.toFixed(1)}" height="${H - padT - padB}"
fill="transparent" class="hist-hit"
data-tip="${tip}" data-cx="${cx.toFixed(1)}" data-cy="${cy.toFixed(1)}"/>`;
}).join("");

container.innerHTML = `
<svg viewBox="0 0 ${W} ${H}" class="hist-chart-svg" id="hist-chart-svg">
<polyline points="${pts}" class="hist-chart-line"/>
${yLabelSvg}${tickSvg}${hitRects}
<circle id="hist-cursor" r="3.5" class="hist-cursor" style="display:none"/>
<text id="hist-tip-text" class="hist-tip-text" style="display:none"/>
</svg>`;

// 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<SVGRectElement>(".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() {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<HTMLButtonElement>(".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<HTMLButtonElement>(".vault-tab").forEach(btn => {
btn.addEventListener("click", () => {
Expand Down
23 changes: 23 additions & 0 deletions frontend/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down