diff --git a/src/css/styles.css b/src/css/styles.css index 303c6a1..386124b 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -583,6 +583,52 @@ td.td-act { width:1px; white-space:nowrap; padding-right:12px; } } .cpnl-hero-change .chg-pos { color: var(--green); font-weight: 600; } .cpnl-hero-change .chg-neg { color: var(--red); font-weight: 600; } +.cpnl-hero-tile { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + margin-left: auto; + padding: 0 8px; + border-left: 1px solid var(--bd); + padding-left: 16px; +} +.cpnl-tile-row { + display: flex; + align-items: baseline; + gap: 8px; + font-family: var(--mono); +} +.cpnl-tile-lbl { + font-size: .6rem; + letter-spacing: 2px; + color: var(--mu); + text-transform: uppercase; +} +.cpnl-tile-unrealised { + font-size: 1rem; + font-weight: 600; + color: var(--text); + letter-spacing: -.5px; +} +.cpnl-tile-unrealised .pos { color: var(--green); } +.cpnl-tile-unrealised .neg { color: var(--red); } +.cpnl-tile-total { + font-size: 2rem; + font-weight: 700; + line-height: 1; + letter-spacing: -1px; + color: var(--text); +} +.cpnl-tile-total .pos { color: var(--green); } +.cpnl-tile-total .neg { color: var(--red); } +.cpnl-tile-sub { + font-family: var(--mono); + font-size: .65rem; + color: var(--mu); + margin-top: 2px; +} +.cpnl-tile-sub:empty { display: none; } .cpnl-period-btns { display: flex; gap: 4px; diff --git a/src/html/body.html b/src/html/body.html index 7408aa8..76d33a5 100644 --- a/src/html/body.html +++ b/src/html/body.html @@ -41,6 +41,17 @@
+
+
+ Unrealised + +
+
+ Total + +
+
+
diff --git a/src/js/07-render-charts.js b/src/js/07-render-charts.js index 79e6660..91c2503 100644 --- a/src/js/07-render-charts.js +++ b/src/js/07-render-charts.js @@ -8,7 +8,48 @@ function setCpnlPeriod(p) { rCpnlChart(); } +function rHeroTile() { + const tEl = document.getElementById('cpnl-tile-total'); + const uEl = document.getElementById('cpnl-tile-unrealised'); + const sEl = document.getElementById('cpnl-tile-sub'); + if (!tEl || !uEl || !sEl) return; + + const { realised, unrealised, total, missingSpotAssets } = computePnl(trades, sFilter, livePrices); + const { lots } = compute(sFilter); + + const openAssets = new Set(); + Object.keys(lots).forEach(a => { + if (sFilter !== 'ALL' && a !== sFilter) return; + lots[a].forEach(l => { if (!l.endDate && l.size > 0) openAssets.add(a); }); + }); + const openLotsCount = openAssets.size; + const allMissing = openLotsCount > 0 && missingSpotAssets.length === openLotsCount; + + function signed(v) { + const cls = v >= 0 ? 'pos' : 'neg'; + return '' + (v >= 0 ? '+$' : '-$') + fmt(Math.abs(v)) + ''; + } + + if (openLotsCount === 0) { + uEl.innerHTML = '—'; + tEl.innerHTML = signed(realised); + sEl.textContent = ''; + } else if (allMissing) { + uEl.innerHTML = '—'; + tEl.innerHTML = '—'; + sEl.textContent = 'spot unavailable: ' + missingSpotAssets.join(', '); + } else { + uEl.innerHTML = signed(unrealised); + tEl.innerHTML = signed(total); + sEl.textContent = missingSpotAssets.length + ? 'spot unavailable: ' + missingSpotAssets.join(', ') + : ''; + } +} + function rCpnlChart() { + rHeroTile(); + const area = document.getElementById('cpnl-chart-area'); if (!area) return; diff --git a/test/integration/hero-total-tile.test.js b/test/integration/hero-total-tile.test.js new file mode 100644 index 0000000..0f5818c --- /dev/null +++ b/test/integration/hero-total-tile.test.js @@ -0,0 +1,97 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const { setupJsdom } = require('../helpers/setupJsdom'); + +const HOLDING_ETH = { + id: 1, asset: 'ETH', type: 'HOLDING', date: '2026-01-01', expiry: '', + dte: null, strike: 3000, size: 1, premium: 0, outcome: 'OPEN', + closeCost: 0, platform: 'SPOT', +}; +const PUT_ETH_EXPIRED = { + id: 2, asset: 'ETH', type: 'PUT', date: '2026-01-01', expiry: '2026-01-15', + dte: 14, strike: 2800, size: 1, premium: 100, outcome: 'EXPIRED', + closeCost: 0, platform: 'RYSK', +}; +const HOLDING_BTC = { + id: 3, asset: 'BTC', type: 'HOLDING', date: '2026-01-01', expiry: '', + dte: null, strike: 50000, size: 0.1, premium: 0, outcome: 'OPEN', + closeCost: 0, platform: 'SPOT', +}; + +test('hero band contains a Total P&L tile in the header row', (t) => { + const { window, teardown } = setupJsdom({ trades: [HOLDING_ETH] }); + t.after(teardown); + + const tile = window.document.getElementById('cpnl-tile'); + assert.ok(tile, '#cpnl-tile should exist in the hero band'); + + const hero = window.document.getElementById('cpnl-hero'); + assert.ok(hero.contains(tile), 'tile must live inside the hero band'); +}); + +test('hero tile Total = Realised + Unrealised', (t) => { + // PUT EXPIRED netPrem 100 + HOLDING ETH 3000 size 1 spot 3500 → total = 600. + const { window, teardown } = setupJsdom({ trades: [PUT_ETH_EXPIRED, HOLDING_ETH] }); + t.after(teardown); + + window.livePrices = { ETH: 3500 }; + window.render(); + + const total = window.document.getElementById('cpnl-tile-total').textContent; + assert.match(total, /\+\$600/, `expected +$600 in hero Total, got "${total}"`); + + const unrealised = window.document.getElementById('cpnl-tile-unrealised').textContent; + assert.match(unrealised, /\+\$500/, `expected +$500 Unrealised, got "${unrealised}"`); +}); + +test('hero tile respects asset filter', (t) => { + const { window, teardown } = setupJsdom({ trades: [HOLDING_BTC, HOLDING_ETH] }); + t.after(teardown); + + window.livePrices = { BTC: 52000, ETH: 3500 }; + window.setFilter('BTC'); + + // BTC: (52000-50000)*0.1 = 200 + const total = window.document.getElementById('cpnl-tile-total').textContent; + const unrealised = window.document.getElementById('cpnl-tile-unrealised').textContent; + assert.match(unrealised, /\+\$200/, `BTC-only Unrealised should be +$200, got "${unrealised}"`); + assert.match(total, /\+\$200/, `BTC-only Total should be +$200, got "${total}"`); +}); + +test('hero tile partial missing-spot: renders partial Total + sub-line, no asterisk', (t) => { + const { window, teardown } = setupJsdom({ trades: [HOLDING_ETH, HOLDING_BTC] }); + t.after(teardown); + + window.livePrices = { ETH: 3500 }; // BTC missing + window.render(); + + const total = window.document.getElementById('cpnl-tile-total').textContent; + const sub = window.document.getElementById('cpnl-tile-sub').textContent; + // Partial = ETH unrealised only = 500 + assert.match(total, /\+\$500/, `expected +$500 partial Total, got "${total}"`); + assert.match(sub, /BTC/, `sub-line should call out BTC, got "${sub}"`); + const tile = window.document.getElementById('cpnl-tile'); + assert.ok(!tile.textContent.includes('*'), + `tile must not contain an asterisk under partial state, got "${tile.textContent}"`); +}); + +test('hero tile full missing-spot: dashes + sub-line; Realised stays visible', (t) => { + const { window, teardown } = setupJsdom({ trades: [HOLDING_ETH, PUT_ETH_EXPIRED] }); + t.after(teardown); + + // livePrices stays {} → ETH spot missing for the only open lot. + const total = window.document.getElementById('cpnl-tile-total').textContent; + const unrealised = window.document.getElementById('cpnl-tile-unrealised').textContent; + const sub = window.document.getElementById('cpnl-tile-sub').textContent; + assert.match(unrealised, /^—|^-$/, `expected dash Unrealised, got "${unrealised}"`); + assert.match(total, /^—|^-$/, `expected dash Total, got "${total}"`); + assert.match(sub, /spot unavailable/i, `sub-line should say spot unavailable, got "${sub}"`); + + // Realised line header (#cpnl-val) still shows the +$100 from the EXPIRED PUT. + const heroVal = window.document.getElementById('cpnl-val').textContent; + assert.match(heroVal, /\+\$100/, `Realised hero number should still render, got "${heroVal}"`); + + const tile = window.document.getElementById('cpnl-tile'); + assert.ok(!tile.textContent.includes('*'), + `tile must not contain an asterisk under full-miss state, got "${tile.textContent}"`); +});