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
46 changes: 46 additions & 0 deletions src/css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions src/html/body.html
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@
<div class="cpnl-hero-val" id="cpnl-val">—</div>
<div class="cpnl-hero-change" id="cpnl-change"></div>
</div>
<div class="cpnl-hero-tile" id="cpnl-tile">
<div class="cpnl-tile-row">
<span class="cpnl-tile-lbl">Unrealised</span>
<span class="cpnl-tile-unrealised" id="cpnl-tile-unrealised">—</span>
</div>
<div class="cpnl-tile-row cpnl-tile-row-total">
<span class="cpnl-tile-lbl">Total</span>
<span class="cpnl-tile-total" id="cpnl-tile-total">—</span>
</div>
<div class="cpnl-tile-sub" id="cpnl-tile-sub"></div>
</div>
<div class="cpnl-period-btns">
<button class="cpnl-period-btn" id="cpnl-btn-1M" onclick="setCpnlPeriod('1M')">1M</button>
<button class="cpnl-period-btn" id="cpnl-btn-3M" onclick="setCpnlPeriod('3M')">3M</button>
Expand Down
41 changes: 41 additions & 0 deletions src/js/07-render-charts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<span class="' + cls + '">' + (v >= 0 ? '+$' : '-$') + fmt(Math.abs(v)) + '</span>';
}

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;

Expand Down
97 changes: 97 additions & 0 deletions test/integration/hero-total-tile.test.js
Original file line number Diff line number Diff line change
@@ -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}"`);
});
Loading