diff --git a/src/css/styles.css b/src/css/styles.css index ae58a11..bf53c2e 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -230,7 +230,7 @@ tr.lot-child.la-sol:hover > td{background:rgba(153,69,255,.08)} .holdings-sec{margin-bottom:0} .holdings-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(252px,1fr));gap:12px} .holdings-merges{margin-top:12px;display:flex;gap:8px;justify-content:flex-end} -.hcard{background:var(--s2);border:1px solid var(--bd2);position:relative;overflow:hidden} +.hcard{background:var(--s2);border:1px solid var(--bd2);position:relative} .hcard-btc{border-left:3px solid var(--btc)} .hcard-eth{border-left:3px solid var(--eth)} .hcard-hype{border-left:3px solid var(--hype)} @@ -288,12 +288,14 @@ tr.lot-child.la-sol:hover > td{background:rgba(153,69,255,.08)} .ppnl-tab.active{border-color:var(--green);color:var(--green)} .ppnl-cards{padding:16px 20px 20px;display:grid;grid-template-columns:repeat(3,minmax(0,340px));gap:12px;justify-content:center} .ppnl-card{background:var(--s2);border:1px solid var(--bd);border-radius:10px;padding:14px 16px;position:relative} -.ppnl-card.has-tip{cursor:help} -.ppnl-tip-ico{color:var(--mu);font-size:.75rem;margin-left:4px;opacity:.7} + +/* Generic styled-popover tooltip. Opt in by adding class="has-tip" + data-tip="…" */ +.has-tip{position:relative;cursor:help} +.tip-ico,.ppnl-tip-ico{color:var(--mu);font-size:.75rem;margin-left:4px;opacity:.7} +.has-tip:hover .tip-ico,.has-tip:hover .ppnl-tip-ico{opacity:1;color:var(--text)} .ppnl-card.has-tip:hover{border-color:var(--mu)} -.ppnl-card.has-tip:hover .ppnl-tip-ico{opacity:1;color:var(--text)} -.ppnl-card.has-tip::after{content:attr(data-tip);position:absolute;left:50%;bottom:calc(100% + 8px);transform:translateX(-50%);background:#0c1119;color:var(--text);border:1px solid var(--bd);border-radius:8px;padding:8px 10px;font-family:var(--mono);font-size:.7rem;line-height:1.45;width:max-content;max-width:300px;white-space:normal;text-align:left;box-shadow:0 8px 24px rgba(0,0,0,.45);opacity:0;pointer-events:none;transition:opacity .12s ease;z-index:20} -.ppnl-card.has-tip:hover::after{opacity:1} +.has-tip::after{content:attr(data-tip);position:absolute;left:50%;bottom:calc(100% + 8px);transform:translateX(-50%);background:#0c1119;color:var(--text);border:1px solid var(--bd);border-radius:8px;padding:8px 10px;font-family:var(--mono);font-size:.7rem;line-height:1.45;width:max-content;max-width:300px;white-space:normal;text-align:left;box-shadow:0 8px 24px rgba(0,0,0,.45);opacity:0;pointer-events:none;transition:opacity .12s ease;z-index:20} +.has-tip:hover::after{opacity:1} .ppnl-lbl{font-size:.6rem;font-weight:700;text-transform:uppercase;letter-spacing:1.1px;color:var(--mu);font-family:var(--mono);margin-bottom:10px} .ppnl-main{font-size:1.05rem;font-weight:700;color:var(--text);font-family:var(--mono);line-height:1.1} .ppnl-sub{font-size:.65rem;color:var(--mu);font-family:var(--mono);margin-top:4px} diff --git a/src/js/06-render-table.js b/src/js/06-render-table.js index 91e5d87..8917aa6 100644 --- a/src/js/06-render-table.js +++ b/src/js/06-render-table.js @@ -350,8 +350,8 @@ function rTable(displayRows, streams, lots) { + editBtn + '' + '' - + '
' - + '
Net Cost / ' + a + '
' + + '
' + + '
Net Cost / ' + a + '
' + '
$' + fmt(nc) + '
' + '
basis $' + fmt(lot.costBasis) + ' — saved $' + fmt(reduction) + ' (' + reductionPct.toFixed(1) + '%)
' + '
' diff --git a/src/js/07-render-charts.js b/src/js/07-render-charts.js index 265bc93..ec892f2 100644 --- a/src/js/07-render-charts.js +++ b/src/js/07-render-charts.js @@ -530,9 +530,7 @@ function rCharts(displayRows, lots) { const s = calcStats(displayRows); const { realised, unrealised, total, missingSpotAssets } = computePnl(trades, sFilter, livePrices); function card(label, main, sub, tip) { - const tipAttr = tip - ? ' data-tip="' + tip.replace(/"/g, '"') + '" title="' + tip.replace(/"/g, '"') + '"' - : ''; + const tipAttr = tip ? ' data-tip="' + tip.replace(/"/g, '"') + '"' : ''; return '
' + '
' + label + (tip ? ' ' : '') + '
' + '
' + main + '
' + @@ -589,7 +587,8 @@ function rCharts(displayRows, lots) { el.innerHTML = [ card('Total Premium Collected', s.totalCount > 0 ? '$' + fmt(s.totalPrem) : dash, - s.totalCount > 0 ? pos(s.settled) + ' settled' + (s.openCount > 0 ? ' · ' + s.openCount + ' open' : '') : ''), + s.totalCount > 0 ? pos(s.settled) + ' settled' + (s.openCount > 0 ? ' · ' + s.openCount + ' open' : '') : '', + 'Sum of every option premium collected (gross of buy-to-close costs). Includes settled and open positions.'), card('Realised P&L', realisedStr, s.totalCount > 0 ? 'settled events only' : '', @@ -604,13 +603,16 @@ function rCharts(displayRows, lots) { totalTip), card('Total Notional', s.totalNotional > 0 ? '$' + fmt(s.totalNotional) : dash, - s.totalCount > 0 ? pos(s.totalCount) + (s.openCount > 0 ? ' · ' + s.openCount + ' open' : '') : ''), + s.totalCount > 0 ? pos(s.totalCount) + (s.openCount > 0 ? ' · ' + s.openCount + ' open' : '') : '', + 'Total Notional = Σ strike × size across every option (settled and open). The capital you would tie up if every put were assigned at strike.'), card('Portfolio APR', s.portfolioAPR !== null ? s.portfolioAPR.toFixed(1) + '%' : dash, - s.settled > 0 ? 'notional-weighted · ' + s.settled + ' settled' : ''), + s.settled > 0 ? 'notional-weighted · ' + s.settled + ' settled' : '', + 'Notional-weighted average APR of settled options. Per-option APR = (netPrem / collateral) / DTE × 365. Open options excluded.'), card('Return Rate', s.returnRate !== null ? s.returnRate.toFixed(1) + '%' : dash, - s.settled > 0 ? s.otmCount + ' / ' + s.settled + ' exp OTM' : ''), + s.settled > 0 ? s.otmCount + ' / ' + s.settled + ' exp OTM' : '', + 'Share of settled options that expired OTM (premium kept, no assignment/call-away). Open options excluded.'), ].join(''); } else { diff --git a/test/integration/pnl-tiles.test.js b/test/integration/pnl-tiles.test.js index 332ce30..1a31ad6 100644 --- a/test/integration/pnl-tiles.test.js +++ b/test/integration/pnl-tiles.test.js @@ -15,6 +15,63 @@ function findRealisedCard(window) { return findCard(window, /Realised P&L/i); } function findUnrealisedCard(window) { return findCard(window, /Unrealised P&L/i); } function findTotalCard(window) { return findCard(window, /^Total P&L/i); } +function assertHasTooltip(card, tipPattern) { + const tip = card.getAttribute('data-tip') || ''; + if (!tipPattern.test(tip)) { + throw new Error('expected data-tip to match ' + tipPattern + ', got "' + tip + '"'); + } + const lbl = card.querySelector('.ppnl-lbl'); + const ico = lbl && lbl.querySelector('.ppnl-tip-ico'); + if (!ico) throw new Error('expected ⓘ glyph (.ppnl-tip-ico) inside .ppnl-lbl'); +} + +test('Total Premium Collected tile has tooltip + ⓘ glyph', (t) => { + const trades = [ + { id: 1, asset: 'BTC', type: 'PUT', date: '2026-01-01', expiry: '2026-01-15', + dte: 14, strike: 50000, size: 0.1, premium: 120, outcome: 'EXPIRED', + closeCost: 0, platform: 'RYSK' }, + ]; + const { window, teardown } = setupJsdom({ trades }); + t.after(teardown); + + const card = findCard(window, /Total Premium Collected/i); + assert.ok(card, 'Total Premium Collected card should exist'); + assertHasTooltip(card, /premium/i); +}); + +test('Total Notional tile has tooltip + ⓘ glyph', (t) => { + const trades = [ + { id: 1, asset: 'BTC', type: 'PUT', date: '2026-01-01', expiry: '2026-01-15', + dte: 14, strike: 50000, size: 0.1, premium: 120, outcome: 'EXPIRED', + closeCost: 0, platform: 'RYSK' }, + ]; + const { window, teardown } = setupJsdom({ trades }); + t.after(teardown); + assertHasTooltip(findCard(window, /Total Notional/i), /notional|strike.*size/i); +}); + +test('Portfolio APR tile has tooltip + ⓘ glyph', (t) => { + const trades = [ + { id: 1, asset: 'BTC', type: 'PUT', date: '2026-01-01', expiry: '2026-01-15', + dte: 14, strike: 50000, size: 0.1, premium: 120, outcome: 'EXPIRED', + closeCost: 0, platform: 'RYSK' }, + ]; + const { window, teardown } = setupJsdom({ trades }); + t.after(teardown); + assertHasTooltip(findCard(window, /Portfolio APR/i), /APR|annualised|annualized/i); +}); + +test('Return Rate tile has tooltip + ⓘ glyph', (t) => { + const trades = [ + { id: 1, asset: 'BTC', type: 'PUT', date: '2026-01-01', expiry: '2026-01-15', + dte: 14, strike: 50000, size: 0.1, premium: 120, outcome: 'EXPIRED', + closeCost: 0, platform: 'RYSK' }, + ]; + const { window, teardown } = setupJsdom({ trades }); + t.after(teardown); + assertHasTooltip(findCard(window, /Return Rate/i), /OTM|expired/i); +}); + test('Realised P&L tile renders settled premium total', (t) => { const trades = [ // BTC PUT expired → +120 @@ -36,8 +93,10 @@ test('Realised P&L tile renders settled premium total', (t) => { const main = card.querySelector('.ppnl-main').textContent; assert.match(main, /\+\$200/, `expected +$200, got "${main}"`); - // Tooltip present. - assert.match(card.getAttribute('title') || '', /Realised P&L/); + // Styled popover present; native title= dropped (it produced a duplicate + // slow browser tooltip on top of the styled one). + assert.match(card.getAttribute('data-tip') || '', /Realised P&L/); + assert.strictEqual(card.getAttribute('title'), null); }); test('Realised P&L tile respects asset filter (sFilter)', (t) => { @@ -115,7 +174,8 @@ test('Unrealised P&L tile marks open lots to market against costBasis', (t) => { assert.ok(card, 'Unrealised P&L card should exist'); const main = card.querySelector('.ppnl-main').textContent; assert.match(main, /\+\$500/, `expected +$500, got "${main}"`); - assert.match(card.getAttribute('title') || '', /Unrealised P&L/); + assert.match(card.getAttribute('data-tip') || '', /Unrealised P&L/); + assert.strictEqual(card.getAttribute('title'), null); }); test('Total P&L tile = Realised + Unrealised', (t) => { @@ -138,7 +198,8 @@ test('Total P&L tile = Realised + Unrealised', (t) => { assert.ok(card, 'Total P&L card should exist'); const main = card.querySelector('.ppnl-main').textContent; assert.match(main, /\+\$600/, `expected +$600, got "${main}"`); - assert.match(card.getAttribute('title') || '', /Total P&L/); + assert.match(card.getAttribute('data-tip') || '', /Total P&L/); + assert.strictEqual(card.getAttribute('title'), null); }); test('Unrealised tile shows dash + spot-unavailable sub-line when spot missing', (t) => { @@ -200,3 +261,44 @@ test('Unrealised + Total tiles respect asset filter', (t) => { // BTC: (52000-50000)*0.1 = 200 assert.match(main, /\+\$200/, `under BTC filter expected +$200, got "${main}"`); }); + +test('Holdings card Net Cost hero has tooltip + ⓘ glyph (lens disambiguation)', (t) => { + const trades = [ + { 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 { window, teardown } = setupJsdom({ trades }); + t.after(teardown); + + // The Net Cost hero block — find by its label. + const heros = window.document.querySelectorAll('.hcard-hero'); + let hero = null; + for (const h of heros) { + const lbl = h.querySelector('.hcard-hero-lbl'); + if (lbl && /Net Cost/i.test(lbl.textContent)) { hero = h; break; } + } + assert.ok(hero, 'expected Net Cost hero block on the holdings card'); + + // Popover wired via the same data-tip + has-tip pattern as the PnL tiles, + // explaining the lens difference vs Unrealised P&L. + assert.ok(hero.classList.contains('has-tip'), 'hero should opt into has-tip styling'); + const tip = hero.getAttribute('data-tip') || ''; + assert.match(tip, /Unrealised|mark-to-market/i, + 'tooltip should disambiguate Net Cost lens from Unrealised P&L lens'); + // ⓘ glyph rendered inside the label, matching the PnL-tile affordance. + const ico = hero.querySelector('.tip-ico, .ppnl-tip-ico'); + assert.ok(ico, 'expected ⓘ glyph next to the Net Cost label'); +}); + +test('Holdings card does not clip its tooltip popover (no overflow:hidden on .hcard)', () => { + const fs = require('fs'); + const path = require('path'); + const css = fs.readFileSync(path.join(__dirname, '..', '..', 'src', 'css', 'styles.css'), 'utf8'); + // Match the .hcard rule (not .hcard-foo) and check it doesn't set overflow:hidden, + // which would clip the .has-tip popover anchored to the .hcard-hero inside. + const m = css.match(/\.hcard\s*\{([^}]*)\}/); + assert.ok(m, '.hcard rule should exist'); + assert.doesNotMatch(m[1], /overflow\s*:\s*hidden/i, + '.hcard must not set overflow:hidden — would clip Net Cost tooltip'); +});