Files
2026-08-25 06:37:14 +01:00

780 lines
30 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
"use strict";
var body = document.body;
var tabbar = document.getElementById("tabbar");
var panels = document.getElementById("tab-panels");
var chartRangeState = {}; // targetKey -> last-picked range, survives panel refresh
var chartModeState = {}; // targetKey -> "simple" | "advanced"
var baseCurrencySymbol = body.dataset.currencySymbol || "";
var pollMs = (parseInt(body.dataset.poll, 10) || 60) * 1000;
// ---------------------------------------------------------------------
// Formatting (mirrors internal/web/format.go closely enough for live ticks)
// ---------------------------------------------------------------------
function fmtMoney(v) {
var neg = v < 0;
if (neg) v = -v;
var s = v.toFixed(2);
var parts = s.split(".");
var intPart = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return (neg ? "-" : "") + baseCurrencySymbol + intPart + "." + parts[1];
}
function signClass(v) {
return v > 0 ? "gain" : v < 0 ? "loss" : "flat";
}
function flash(el) {
el.classList.remove("flash");
void el.offsetWidth;
el.classList.add("flash");
}
// ---------------------------------------------------------------------
// Kraken public WebSocket v2 — live ticker for price/value/P&L, purely a
// between-poll smoothing layer. REST panel refresh remains the source of
// truth for deltas, charts, and everything else.
// ---------------------------------------------------------------------
var wsSocket = null;
var wsSubscribers = {}; // symbol -> [callback, ...]
var lastTickAt = {}; // symbol -> ms, throttles DOM writes to ~1/sec
function sendSubscribe(symbols) {
if (wsSocket && wsSocket.readyState === 1 && symbols.length) {
wsSocket.send(JSON.stringify({ method: "subscribe", params: { channel: "ticker", symbol: symbols } }));
}
}
function ensureWebSocket() {
if (wsSocket && (wsSocket.readyState === 0 || wsSocket.readyState === 1)) return wsSocket;
try {
wsSocket = new WebSocket("wss://ws.kraken.com/v2");
} catch (e) {
return null;
}
wsSocket.addEventListener("open", function () {
sendSubscribe(Object.keys(wsSubscribers));
});
wsSocket.addEventListener("message", function (e) {
var msg;
try { msg = JSON.parse(e.data); } catch (err) { return; }
if (msg.channel !== "ticker" || !msg.data) return;
msg.data.forEach(function (tick) {
var callbacks = wsSubscribers[tick.symbol];
if (!callbacks || typeof tick.last !== "number") return;
var now = Date.now();
if (lastTickAt[tick.symbol] && now - lastTickAt[tick.symbol] < 1000) return;
lastTickAt[tick.symbol] = now;
callbacks.forEach(function (cb) { cb(tick.last); });
});
});
wsSocket.addEventListener("close", function () {
wsSocket = null;
setTimeout(ensureWebSocket, 4000);
});
wsSocket.addEventListener("error", function () {
try { wsSocket.close(); } catch (e) { /* ignore */ }
});
return wsSocket;
}
function subscribeTicker(symbol, callback) {
if (!symbol) return;
var isNew = !wsSubscribers[symbol] || wsSubscribers[symbol].length === 0;
if (!wsSubscribers[symbol]) wsSubscribers[symbol] = [];
wsSubscribers[symbol].push(callback);
var ws = ensureWebSocket();
if (isNew && ws && ws.readyState === 1) sendSubscribe([symbol]);
}
function wireLiveTicker(root) {
// Dashboard rows: live price/value/P&L per currency.
root.querySelectorAll("tr[data-ws-symbol]").forEach(function (tr) {
var symbol = tr.dataset.wsSymbol;
var holdings = parseFloat(tr.dataset.holdings) || 0;
var cost = parseFloat(tr.dataset.cost) || 0;
var fxRate = parseFloat(tr.dataset.fxRate) || 1;
var priceCell = tr.querySelector('[data-field="price"]');
var valueCell = tr.querySelector('[data-field="value"]');
var plCell = tr.querySelector('[data-field="pl"]');
subscribeTicker(symbol, function (last) {
var price = last * fxRate;
var value = holdings * price;
if (priceCell) priceCell.textContent = fmtMoney(price);
if (valueCell) valueCell.textContent = fmtMoney(value);
if (plCell) {
var pl = value - cost;
plCell.textContent = fmtMoney(pl);
plCell.classList.remove("gain", "loss", "flat");
plCell.classList.add(signClass(pl));
}
flash(tr);
});
});
// Position page hero: single currency, live value/P&L.
var hero = root.querySelector("[data-ws-hero]");
if (hero) {
var symbol = hero.dataset.wsHero;
var holdings = parseFloat(hero.dataset.holdings) || 0;
var cost = parseFloat(hero.dataset.cost) || 0;
var fxRate = parseFloat(hero.dataset.fxRate) || 1;
var valueEl = hero.querySelector('[data-field="hero-value"]');
var plEl = hero.querySelector('[data-field="hero-pl"]');
subscribeTicker(symbol, function (last) {
var value = holdings * last * fxRate;
var pl = value - cost;
if (valueEl) valueEl.textContent = fmtMoney(value);
if (plEl) {
var pct = cost ? (pl / cost * 100) : 0;
var sign = pct >= 0 ? "+" : "";
plEl.textContent = fmtMoney(pl) + " (" + sign + pct.toFixed(2) + "%)";
plEl.classList.remove("gain", "loss", "flat");
plEl.classList.add(signClass(pl));
}
flash(hero);
});
}
}
// Topbar portfolio total: lives in base.html outside any tab panel, so it
// is wired once here (not per-panel) and stays live across every page.
// Server-rendered value is the initial paint; /api/summary refresh (every
// poll cycle) keeps holdings/cost authoritative, and Kraken WS ticks
// smooth the value in between polls.
function initTopbar() {
var valueEl = document.querySelector('[data-field="topbar-value"]');
var plEl = document.querySelector('[data-field="topbar-pl"]');
var wrap = document.querySelector('[data-topbar]') || (valueEl && valueEl.closest(".topbar-total"));
if (!valueEl) return;
var positions = []; // {symbol, holdings, cost, fxRate, value}
var subscribed = {}; // ws symbol -> true, so refresh() never double-subscribes
function render() {
var total = 0, cost = 0;
positions.forEach(function (p) { total += p.value; cost += p.cost; });
var pl = total - cost;
var pct = cost ? (pl / cost * 100) : 0;
var sign = pct >= 0 ? "+" : "";
valueEl.textContent = fmtMoney(total);
if (plEl) {
plEl.textContent = fmtMoney(pl) + " (" + sign + pct.toFixed(2) + "%)";
plEl.classList.remove("gain", "loss", "flat");
plEl.classList.add(signClass(pl));
}
}
function refresh() {
fetch("/api/summary").then(function (r) { return r.json(); }).then(function (data) {
positions = (data.positions || []).map(function (p) {
return { symbol: p.ws_symbol, holdings: p.holdings, cost: p.cost, fxRate: p.fx_rate, value: p.value };
});
render();
positions.forEach(function (p) {
if (!p.symbol || subscribed[p.symbol]) return;
subscribed[p.symbol] = true;
subscribeTicker(p.symbol, function (last) {
positions.forEach(function (q) {
if (q.symbol === p.symbol) q.value = q.holdings * last * q.fxRate;
});
render();
if (wrap) flash(wrap);
});
});
}).catch(function () { /* transient error: keep last known values */ });
}
refresh();
setInterval(refresh, pollMs);
}
function initPanel(root) {
root.querySelectorAll(".chart-controls").forEach(function (controls) {
var kind = controls.dataset.chart; // "portfolio" | "currency"
var currency = controls.dataset.currency;
var targetKey = kind === "portfolio" ? "portfolio" : "currency:" + currency;
var svg = root.querySelector('[data-chart-target="' + targetKey + '"]');
var emptyEl = root.querySelector('[data-chart-empty="' + targetKey + '"]');
var captionEl = root.querySelector('[data-chart-caption="' + targetKey + '"]');
if (!svg) return;
var symbol = svg.dataset.symbol || "";
if (emptyEl && emptyEl.dataset.defaultText === undefined) {
emptyEl.dataset.defaultText = emptyEl.textContent;
}
var load = function (range) {
chartRangeState[targetKey] = range;
var url = kind === "portfolio"
? "/api/chart/portfolio?range=" + range
: "/api/chart/currency/" + encodeURIComponent(currency) + "?range=" + range;
fetchJSON(targetKey, url).then(function (points) {
if (points === STALE) return;
renderChart(svg, emptyEl, captionEl, points, symbol);
}).catch(function (err) {
showChartError(svg, emptyEl, captionEl, err);
});
};
controls.querySelectorAll("button[data-range]").forEach(function (btn) {
btn.addEventListener("click", function () {
controls.querySelectorAll("button").forEach(function (b) { b.classList.remove("active"); });
btn.classList.add("active");
load(btn.dataset.range);
});
});
var savedRange = chartRangeState[targetKey] || "all";
controls.querySelectorAll("button[data-range]").forEach(function (b) {
b.classList.toggle("active", b.dataset.range === savedRange);
});
load(savedRange);
});
initAdvancedCharts(root);
initSortableTables(root);
wireLiveTicker(root);
}
// ---------------------------------------------------------------------
// Shared fetch helper for chart/candle data: turns a non-2xx response
// into a real error (so a rate-limit or server error surfaces instead of
// silently failing to parse as JSON), and drops out-of-order responses
// when the user switches timeframes faster than requests return.
// ---------------------------------------------------------------------
var STALE = {};
var requestToken = {};
function fetchJSON(key, url) {
var token = (requestToken[key] = (requestToken[key] || 0) + 1);
return fetch(url).then(function (r) {
if (!r.ok) return r.text().then(function (t) { throw new Error(t || ("HTTP " + r.status)); });
return r.json();
}).then(function (data) {
return requestToken[key] === token ? data : STALE;
}, function (err) {
if (requestToken[key] !== token) return STALE;
throw err;
});
}
function friendlyChartError(err) {
var msg = String((err && err.message) || err);
if (msg.indexOf("Too many requests") !== -1) {
return "Kraken rate-limited this request — it'll work again in a moment.";
}
return "Couldn't load chart data right now — try again shortly.";
}
function showChartError(svg, emptyEl, captionEl, err) {
svg.hidden = true;
if (emptyEl) {
emptyEl.hidden = false;
emptyEl.textContent = friendlyChartError(err);
}
if (captionEl) captionEl.textContent = "";
}
var svgns = "http://www.w3.org/2000/svg";
function fmtAxisDate(unixSeconds, spanSeconds) {
var d = new Date(unixSeconds * 1000);
if (spanSeconds > 400 * 24 * 3600) return d.toLocaleDateString(undefined, { month: "short", year: "2-digit" });
if (spanSeconds > 3 * 24 * 3600) return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
}
function fmtAxisValue(v, symbol) {
var sign = v < 0 ? "-" : "";
var abs = Math.abs(v);
var s = abs >= 1000 ? (abs / 1000).toFixed(1) + "k" : abs.toFixed(abs < 10 ? 4 : 2);
return sign + symbol + s;
}
function svgEl(tag, attrs) {
var el = document.createElementNS(svgns, tag);
for (var k in attrs) el.setAttribute(k, attrs[k]);
return el;
}
// Picks up to targetCount tick indices, evenly spaced by TIME (not by
// array index — data can have irregular gaps, e.g. after a restart),
// then drops any tick that lands within minPxGap of the previous one so
// labels never overlap on dense data. xAtIndex(i) must return that
// point's pixel x position.
function pickTicks(points, targetCount, minPxGap, xAtIndex) {
var t0 = points[0].t, t1 = points[points.length - 1].t;
var span = t1 - t0 || 1;
var chosen = [];
var lastX = -Infinity;
for (var i = 0; i < targetCount; i++) {
var targetT = t0 + (i / (targetCount - 1 || 1)) * span;
var best = 0, bestDiff = Infinity;
for (var j = 0; j < points.length; j++) {
var diff = Math.abs(points[j].t - targetT);
if (diff < bestDiff) { bestDiff = diff; best = j; }
}
var x = xAtIndex(best);
if (x - lastX < minPxGap) continue;
chosen.push(best);
lastX = x;
}
return chosen;
}
function renderChart(svg, emptyEl, captionEl, points, symbol) {
while (svg.firstChild) svg.removeChild(svg.firstChild);
if (!points || points.length < 2) {
if (emptyEl) {
emptyEl.hidden = false;
emptyEl.textContent = emptyEl.dataset.defaultText || emptyEl.textContent;
}
if (captionEl) captionEl.textContent = "";
svg.hidden = true;
return;
}
if (emptyEl) emptyEl.hidden = true;
svg.hidden = false;
var W = 600, H = 220, ML = 68, MR = 12, MT = 12, MB = 26;
var plotW = W - ML - MR, plotH = H - MT - MB;
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
var values = points.map(function (p) { return p.v; });
var min = Math.min.apply(null, values), max = Math.max.apply(null, values);
var range = (max - min) || Math.abs(max) || 1;
var t0 = points[0].t, t1 = points[points.length - 1].t;
var tSpan = (t1 - t0) || 1;
function xAt(t) { return ML + ((t - t0) / tSpan) * plotW; }
function yAt(v) { return MT + (1 - (v - min) / range) * plotH; }
// Y gridlines + value labels (min, mid, max)
[min, (min + max) / 2, max].forEach(function (v) {
var y = yAt(v);
svg.appendChild(svgEl("line", { x1: ML, x2: W - MR, y1: y, y2: y, class: "chart-grid" }));
var text = svgEl("text", { x: ML - 8, y: y, class: "chart-axis-label chart-axis-y" });
text.textContent = fmtAxisValue(v, symbol);
svg.appendChild(text);
});
// X-axis date labels: evenly spaced by time, collision-avoided.
pickTicks(points, 5, 70, function (i) { return xAt(points[i].t); }).forEach(function (idx) {
var p = points[idx];
var text = svgEl("text", { x: xAt(p.t), y: H - 6, class: "chart-axis-label chart-axis-x" });
text.textContent = fmtAxisDate(p.t, tSpan);
svg.appendChild(text);
});
// Price/value line
var coords = points.map(function (p) { return xAt(p.t).toFixed(2) + "," + yAt(p.v).toFixed(2); });
var poly = svgEl("polyline", { points: coords.join(" ") });
var trend = values[values.length - 1] === values[0] ? "flat" : (values[values.length - 1] > values[0] ? "gain" : "loss");
poly.setAttribute("class", "chart-line " + trend);
svg.appendChild(poly);
// Issue transparency: when a range shows the exact same span as "all"
// available history, a shorter timeframe looks identical — say why.
if (captionEl) {
var spanDays = tSpan / 86400;
var first = new Date(t0 * 1000).toLocaleDateString();
var last = new Date(t1 * 1000).toLocaleDateString();
captionEl.textContent = spanDays < 1
? "Showing all recorded history (tracking started " + first + ") — longer ranges will look different once more history builds up."
: "Showing " + first + " " + last;
}
}
var sortState = {}; // sortKey -> {key, dir}
function cellSortValue(row, colIndex) {
var cell = row.children[colIndex];
if (!cell) return "";
if (cell.dataset.sortValue !== undefined) {
if (cell.dataset.sortValue === "") return -Infinity;
var n = parseFloat(cell.dataset.sortValue);
return isNaN(n) ? -Infinity : n;
}
return cell.textContent.trim().toLowerCase();
}
function initSortableTables(root) {
root.querySelectorAll("table.sortable[data-sort-key]").forEach(function (table) {
var sortKey = table.dataset.sortKey;
var tbody = table.querySelector("tbody");
if (!tbody) return;
function applySort(colKey, dir) {
var colIndex = -1;
table.querySelectorAll("th[data-sort]").forEach(function (th) {
th.classList.remove("sort-asc", "sort-desc");
if (th.dataset.sort === colKey) {
colIndex = Array.prototype.indexOf.call(th.parentElement.children, th);
th.classList.add(dir === 1 ? "sort-asc" : "sort-desc");
}
});
if (colIndex === -1) return;
var rows = Array.prototype.slice.call(tbody.querySelectorAll("tr"));
rows.sort(function (a, b) {
var av = cellSortValue(a, colIndex), bv = cellSortValue(b, colIndex);
if (av < bv) return -1 * dir;
if (av > bv) return 1 * dir;
return 0;
});
rows.forEach(function (r) { tbody.appendChild(r); });
}
table.querySelectorAll("th[data-sort]").forEach(function (th) {
th.addEventListener("click", function () {
var key = th.dataset.sort;
var prev = sortState[sortKey];
var dir = prev && prev.key === key ? prev.dir * -1 : 1;
sortState[sortKey] = { key: key, dir: dir };
applySort(key, dir);
});
});
var saved = sortState[sortKey];
if (saved) applySort(saved.key, saved.dir);
});
}
// ---------------------------------------------------------------------
// Advanced chart: candlesticks + Bollinger Bands + RSI
// ---------------------------------------------------------------------
function initAdvancedCharts(root) {
root.querySelectorAll("[data-advanced-chart]").forEach(function (wrap) {
var currency = wrap.dataset.currency;
var targetKey = "currency:" + currency;
var simpleEls = wrap.querySelectorAll("[data-chart-simple]");
var advancedWrap = wrap.querySelector("[data-chart-advanced]");
var priceSvg = wrap.querySelector("[data-candle-price]");
var rsiSvg = wrap.querySelector("[data-candle-rsi]");
var candleEmptyEl = wrap.querySelector("[data-candle-empty]");
var toggle = wrap.querySelector("[data-chart-mode-toggle]");
var rangeControls = wrap.querySelector(".chart-controls");
if (!toggle || !advancedWrap) return;
function setMode(mode) {
chartModeState[targetKey] = mode;
var advanced = mode === "advanced";
advancedWrap.hidden = !advanced;
simpleEls.forEach(function (el) { el.hidden = advanced; });
toggle.textContent = advanced ? "Simple view" : "Advanced view";
if (advanced) loadCandles();
}
function currentRange() {
var active = rangeControls && rangeControls.querySelector("button.active");
return active ? active.dataset.range : "all";
}
function loadCandles() {
var candleKey = "candles:" + currency;
fetchJSON(candleKey, "/api/candles/" + encodeURIComponent(currency) + "?range=" + currentRange())
.then(function (series) {
if (series === STALE) return;
if (candleEmptyEl) candleEmptyEl.hidden = true;
priceSvg.hidden = false;
renderCandles(priceSvg, rsiSvg, series);
})
.catch(function (err) { showChartError(priceSvg, candleEmptyEl, null, err); });
}
toggle.addEventListener("click", function () {
setMode(chartModeState[targetKey] === "advanced" ? "simple" : "advanced");
});
if (rangeControls) {
rangeControls.querySelectorAll("button[data-range]").forEach(function (btn) {
btn.addEventListener("click", function () {
if (chartModeState[targetKey] === "advanced") loadCandles();
});
});
}
setMode(chartModeState[targetKey] === "advanced" ? "advanced" : "simple");
});
}
function sma(values, period, idx) {
if (idx + 1 < period) return null;
var sum = 0;
for (var i = idx + 1 - period; i <= idx; i++) sum += values[i];
return sum / period;
}
function renderCandles(priceSvg, rsiSvg, series) {
if (!priceSvg) return;
while (priceSvg.firstChild) priceSvg.removeChild(priceSvg.firstChild);
if (rsiSvg) while (rsiSvg.firstChild) rsiSvg.removeChild(rsiSvg.firstChild);
var candles = series && series.candles ? series.candles : [];
if (candles.length < 2) return;
var W = 600, PH = 260, ML = 56, MR = 12, MT = 10, MB = 4;
var plotW = W - ML - MR, plotH = PH - MT - MB;
priceSvg.setAttribute("viewBox", "0 0 " + W + " " + PH);
var lows = candles.map(function (c) { return c.l; });
var highs = candles.map(function (c) { return c.h; });
var bandVals = [];
(series.bb_upper || []).forEach(function (v) { if (v !== null) bandVals.push(v); });
(series.bb_lower || []).forEach(function (v) { if (v !== null) bandVals.push(v); });
var min = Math.min.apply(null, lows.concat(bandVals.length ? bandVals : lows));
var max = Math.max.apply(null, highs.concat(bandVals.length ? bandVals : highs));
var range = (max - min) || 1;
var t0 = candles[0].t, t1 = candles[candles.length - 1].t;
var tSpan = (t1 - t0) || 1;
var slot = plotW / candles.length;
function xAt(i) { return ML + (i + 0.5) * slot; }
function yAt(v) { return MT + (1 - (v - min) / range) * plotH; }
[min, (min + max) / 2, max].forEach(function (v) {
var y = yAt(v);
priceSvg.appendChild(svgEl("line", { x1: ML, x2: W - MR, y1: y, y2: y, class: "chart-grid" }));
var text = svgEl("text", { x: ML - 8, y: y, class: "chart-axis-label chart-axis-y" });
text.textContent = fmtAxisValue(v, "");
priceSvg.appendChild(text);
});
// Bollinger Bands band + lines. pathFor takes the y-scaler so it can be
// reused for RSI below (0-100 scale) as well as price-scale series.
function pathFor(arr, yFn) {
var pts = [];
for (var i = 0; i < arr.length; i++) {
if (arr[i] === null || arr[i] === undefined) continue;
pts.push(xAt(i).toFixed(2) + "," + yFn(arr[i]).toFixed(2));
}
return pts;
}
var upperPts = pathFor(series.bb_upper || [], yAt);
var lowerPts = pathFor(series.bb_lower || [], yAt);
if (upperPts.length && lowerPts.length) {
var band = svgEl("polygon", { points: upperPts.join(" ") + " " + lowerPts.slice().reverse().join(" "), class: "bb-band" });
priceSvg.appendChild(band);
priceSvg.appendChild(svgEl("polyline", { points: upperPts.join(" "), class: "bb-line" }));
priceSvg.appendChild(svgEl("polyline", { points: lowerPts.join(" "), class: "bb-line" }));
}
var midPts = pathFor(series.bb_middle || [], yAt);
if (midPts.length) priceSvg.appendChild(svgEl("polyline", { points: midPts.join(" "), class: "bb-mid" }));
// Candlesticks
candles.forEach(function (c, i) {
var x = xAt(i);
var up = c.c >= c.o;
var cls = up ? "candle-up" : "candle-down";
priceSvg.appendChild(svgEl("line", { x1: x, x2: x, y1: yAt(c.h), y2: yAt(c.l), class: "candle-wick " + cls }));
var bodyTop = yAt(Math.max(c.o, c.c));
var bodyH = Math.max(1, Math.abs(yAt(c.o) - yAt(c.c)));
var bw = Math.max(1, slot * 0.6);
priceSvg.appendChild(svgEl("rect", {
x: (x - bw / 2).toFixed(2), y: bodyTop.toFixed(2),
width: bw.toFixed(2), height: bodyH.toFixed(2), class: "candle-body " + cls,
}));
});
// X-axis dates (shared scale with RSI below): evenly spaced by time,
// collision-avoided.
pickTicks(candles, 5, 70, xAt).forEach(function (idx) {
var text = svgEl("text", { x: xAt(idx), y: PH - 6, class: "chart-axis-label chart-axis-x" });
text.textContent = fmtAxisDate(candles[idx].t, tSpan);
priceSvg.appendChild(text);
});
if (!rsiSvg) return;
var RH = 90;
rsiSvg.setAttribute("viewBox", "0 0 " + W + " " + RH);
var rMT = 10, rMB = 20, rPlotH = RH - rMT - rMB;
function ry(v) { return rMT + (1 - v / 100) * rPlotH; }
[30, 50, 70].forEach(function (level) {
var y = ry(level);
rsiSvg.appendChild(svgEl("line", { x1: ML, x2: W - MR, y1: y, y2: y, class: "chart-grid" }));
var text = svgEl("text", { x: ML - 8, y: y, class: "chart-axis-label chart-axis-y" });
text.textContent = level;
rsiSvg.appendChild(text);
});
var rsiPts = pathFor(series.rsi || [], ry);
if (rsiPts.length) rsiSvg.appendChild(svgEl("polyline", { points: rsiPts.join(" "), class: "rsi-line" }));
}
// Favourite star toggle works on any page (delegated), independent of tabs.
document.addEventListener("click", function (e) {
var btn = e.target.closest("[data-favourite-toggle]");
if (!btn) return;
var currency = btn.dataset.currency;
var next = btn.dataset.favourite;
fetch("/api/favourite", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: "currency=" + encodeURIComponent(currency) + "&favourite=" + next,
}).then(function () {
if (typeof refreshPanel === "function") refreshPanel("portfolio");
});
});
initTopbar();
if (!tabbar || !panels) {
// Settings page: no tab system, just wire up any charts on the page.
initPanel(document);
return;
}
var STORAGE_KEY = "basis:tabs";
var FIXED_TABS = { portfolio: "Portfolio", transfers: "Transfers" };
var page = body.dataset.page; // "portfolio" | "position" | "transfers"
var current = body.dataset.current;
function loadStoredTabs() {
try {
var raw = sessionStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch (e) {
return [];
}
}
function saveStoredTabs(tabs) {
try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify(tabs)); } catch (e) { /* ignore */ }
}
var openTabs = loadStoredTabs().filter(function (c) { return c !== current; });
if (page === "position" && current) openTabs.push(current);
saveStoredTabs(openTabs);
var activeKey = page === "position" ? "pos:" + current : (FIXED_TABS[page] ? page : "portfolio");
function panelKey(id) { return FIXED_TABS[id] ? id : "pos:" + id; }
function panelURL(id) {
if (id === "portfolio") return "/api/panel/portfolio";
if (id === "transfers") return "/api/panel/transfers";
return "/api/panel/position/" + encodeURIComponent(id);
}
function pageURL(id) {
if (id === "portfolio") return "/";
if (id === "transfers") return "/transfers";
return "/position/" + encodeURIComponent(id);
}
function renderTabbar() {
tabbar.innerHTML = "";
function make(key, label, closable) {
var btn = document.createElement("button");
btn.type = "button";
btn.className = "tab-btn" + (key === activeKey ? " active" : "");
btn.setAttribute("role", "tab");
var span = document.createElement("span");
span.textContent = label;
btn.appendChild(span);
if (closable) {
var close = document.createElement("span");
close.className = "tab-close";
close.textContent = "×";
close.addEventListener("click", function (e) { e.stopPropagation(); closeTab(key); });
btn.appendChild(close);
}
btn.addEventListener("click", function () {
activateTab(FIXED_TABS[key] ? key : key.slice(4));
});
tabbar.appendChild(btn);
}
make("portfolio", "Portfolio", false);
make("transfers", "Transfers", false);
openTabs.forEach(function (c) { make("pos:" + c, c, true); });
}
function ensurePanel(id) {
var key = panelKey(id);
var el = panels.querySelector('[data-panel="' + key + '"]');
if (el) return Promise.resolve(el);
el = document.createElement("div");
el.className = "tab-panel";
el.dataset.panel = key;
panels.appendChild(el);
return fetch(panelURL(id)).then(function (r) { return r.text(); }).then(function (html) {
el.innerHTML = html;
initPanel(el);
return el;
});
}
function activateTab(id) {
if (!FIXED_TABS[id] && openTabs.indexOf(id) === -1) {
openTabs.push(id);
saveStoredTabs(openTabs);
}
ensurePanel(id).then(function () {
var key = panelKey(id);
panels.querySelectorAll(".tab-panel").forEach(function (p) {
p.classList.toggle("active", p.dataset.panel === key);
});
activeKey = key;
renderTabbar();
var url = pageURL(id);
if (location.pathname !== url) history.pushState({ tab: id }, "", url);
});
}
function closeTab(key) {
var currency = key.slice(4);
openTabs = openTabs.filter(function (c) { return c !== currency; });
saveStoredTabs(openTabs);
var el = panels.querySelector('[data-panel="' + key + '"]');
if (el) el.remove();
if (activeKey === key) {
activateTab("portfolio");
} else {
renderTabbar();
}
}
window.addEventListener("popstate", function () {
var path = location.pathname;
if (path === "/") { activateTab("portfolio"); return; }
if (path === "/transfers") { activateTab("transfers"); return; }
var m = path.match(/^\/position\/([^/]+)/);
if (m) activateTab(decodeURIComponent(m[1]));
});
document.addEventListener("click", function (e) {
var link = e.target.closest("[data-open-tab]");
if (!link) return;
e.preventDefault();
activateTab(link.dataset.openTab);
});
function refreshPanel(id) {
if (!panels) return;
var key = panelKey(id);
var el = panels.querySelector('[data-panel="' + key + '"]');
if (!el) return;
fetch(panelURL(id)).then(function (r) { return r.text(); }).then(function (html) {
el.innerHTML = html;
initPanel(el);
});
}
setInterval(function () {
var id = FIXED_TABS[activeKey] ? activeKey : activeKey.slice(4);
refreshPanel(id);
}, pollMs);
// --- initial render: wire up what the server already sent down. Other
// previously-open tabs are NOT pre-created here — ensurePanel() lazily
// fetches them the first time they're actually activated, whether by
// clicking their tab button or a currency link. (A prior version created
// empty placeholder panels here, which made ensurePanel() think they were
// already loaded and skip fetching — that's the "only the last tab has
// data after refresh" bug.) ---
renderTabbar();
var initialPanel = panels.querySelector(".tab-panel.active");
if (initialPanel) initPanel(initialPanel);
})();