/* [MOD] OpsDeck Widget: PnL Card (ES Module)
- Standalone UI widget for realized/unrealized PnL
- No external deps. Works with SSE (/events) or manual push()
- Will be wired by opsdeck-basic.html in the next step
*/
export default function createPnlCard(rootEl, opts = {}) {
const cfg = {
title: opts.title || "PnL",
currency: opts.currency || "USDT",
precision: Number.isInteger(opts.precision) ? opts.precision : 2,
};
if (!rootEl) throw new Error("pnl-card: root element is required");
/* [MOD] State */
const state = {
realized: 0,
unrealized: 0,
fees: 0,
pnlPct: 0,
lastTs: 0,
};
/* [MOD] UI */
rootEl.classList.add("card");
rootEl.innerHTML = `
${cfg.title}
Realized
0.00 ${cfg.currency}
Unrealized
0.00 ${cfg.currency}
Fees
0.00 ${cfg.currency}
waiting for events…
`;
const $ = (sel) => rootEl.querySelector(sel);
const elRealized = $("#pc-realized");
const elUnrealized = $("#pc-unrealized");
const elFees = $("#pc-fees");
const elPct = $("#pc-pct");
const elNote = $("#pc-note");
/* [MOD] Helpers */
const fmt = (n, p = cfg.precision) =>
(Number(n) || 0).toLocaleString(undefined, {
minimumFractionDigits: p,
maximumFractionDigits: p,
});
function repaint() {
elRealized.textContent = `${fmt(state.realized)} ${cfg.currency}`;
elUnrealized.textContent = `${fmt(state.unrealized)} ${cfg.currency}`;
elFees.textContent = `${fmt(state.fees)} ${cfg.currency}`;
elPct.textContent = `${fmt(state.pnlPct, 2)} %`;
// color cue
const pct = Number(state.pnlPct) || 0;
elPct.style.color = pct > 0 ? "#2ecc71" : pct < 0 ? "#e74c3c" : "";
const now = new Date(state.lastTs || Date.now()).toLocaleTimeString();
elNote.textContent = `updated ${now}`;
}
/* [MOD] Public API */
function pushPnL({ realized, unrealized, fees, pnlPct, ts }) {
if (Number.isFinite(realized)) state.realized = realized;
if (Number.isFinite(unrealized)) state.unrealized = unrealized;
if (Number.isFinite(fees)) state.fees = fees;
if (Number.isFinite(pnlPct)) state.pnlPct = pnlPct;
state.lastTs = ts || Date.now();
repaint();
}
function attachEventSource(es) {
if (!es) return;
// Generic SSE handlers:
// - event: log {level,message,meta:{pnl,pnlPct,realized,unrealized,fees}}
// - event: pnl {realized,unrealized,fees,pnlPct,ts}
es.addEventListener("pnl", (ev) => {
try {
const data = JSON.parse(ev.data || "{}");
pushPnL(data);
} catch {}
});
es.addEventListener("log", (ev) => {
try {
const msg = JSON.parse(ev.data || "{}");
const m = msg?.meta || msg?.data || {};
if (
m &&
(Number.isFinite(m.pnl) ||
Number.isFinite(m.pnlPct) ||
Number.isFinite(m.realized) ||
Number.isFinite(m.unrealized) ||
Number.isFinite(m.fees))
) {
pushPnL({
realized: m.realized,
unrealized: m.unrealized,
fees: m.fees,
pnlPct: m.pnlPct ?? m.pnl,
ts: msg.t,
});
}
} catch {}
});
}
// initial paint
repaint();
return {
push: pushPnL,
attachEventSource,
getState: () => ({ ...state }),
setTitle: (t) => (rootEl.querySelector("h3").textContent = t || cfg.title),
};
}