diff --git a/src/css/styles.css b/src/css/styles.css index 16835b2..6c19622 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -263,6 +263,18 @@ tr.lot-child.la-sol:hover > td{background:rgba(153,69,255,.08)} .hcard-hint b{color:var(--orange);font-weight:700} .hcard-hint-ok{color:var(--green)} .lot-badge{font-size:.5rem;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;padding:2px 6px;background:var(--s3);border:1px solid var(--bd2);color:var(--mu2);font-family:var(--mono)} +.hcard-spot-muted{color:var(--mu2)} + +/* Wide-card layout: when <=2 visible open lots and viewport >=720px, + render each card as a full-width horizontal row with 4 columns. */ +@media (min-width:720px){ + .holdings-grid--wide{grid-template-columns:1fr} + .holdings-grid--wide .hcard{display:grid;grid-template-columns:minmax(180px,1fr) minmax(140px,1fr) minmax(220px,1.4fr) minmax(260px,1.4fr);align-items:stretch} + .holdings-grid--wide .hcard-hd{border-bottom:none;border-right:1px solid var(--bd);flex-direction:column;align-items:flex-start;gap:8px;justify-content:center} + .holdings-grid--wide .hcard-hero{border-right:1px solid var(--bd);display:flex;flex-direction:column;justify-content:center;padding:13px} + .holdings-grid--wide .hcard-spot{border-top:none;border-right:1px solid var(--bd);padding:13px;display:flex;flex-direction:column;justify-content:center} + .holdings-grid--wide .hcard-stats{border-top:none;grid-template-columns:1fr 1fr 1fr;align-items:center} +} /* MERGE LOTS */ .btn-merge{font-size:.62rem;font-family:var(--mono);font-weight:700;padding:3px 10px;border-radius:5px;cursor:pointer;border:1px solid var(--ob);color:var(--orange);background:var(--od);transition:all .15s;white-space:nowrap;letter-spacing:.5px;text-transform:uppercase} diff --git a/src/js/06-render-table.js b/src/js/06-render-table.js index a10725c..105cf2a 100644 --- a/src/js/06-render-table.js +++ b/src/js/06-render-table.js @@ -321,8 +321,16 @@ function rTable(displayRows, streams, lots) { : ''; // Live spot, unrealized P&L vs net cost, breakeven hint const spot = livePrices[a]; - let spotBlock = ''; - if (spot) { + let spotBlock; + if (!spot) { + spotBlock = '
' + + '
' + + 'Spot' + + '' + + '
' + + '
spot unavailable
' + + '
'; + } else { const pnlPerToken = spot - nc; const pnlTotal = pnlPerToken * lot.size; const pnlPct = nc > 0 ? (pnlPerToken / nc * 100) : 0; @@ -377,7 +385,7 @@ function rTable(displayRows, streams, lots) { + '
Holdings
' + '' + openLotCount + ' open lot' + (openLotCount !== 1 ? 's' : '') + '' + '' - + '
' + cardsHtml + '
' + + '
' + cardsHtml + '
' + (mergesHtml ? '
' + mergesHtml + '
' : '') + ''; } else { diff --git a/test/integration/holding-card-wide.test.js b/test/integration/holding-card-wide.test.js new file mode 100644 index 0000000..440f4c5 --- /dev/null +++ b/test/integration/holding-card-wide.test.js @@ -0,0 +1,100 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const { setupJsdom } = require('../helpers/setupJsdom'); + +// Issue #52: Wide-card layout for holding cards at low lot counts. +// JS-side contract: when the number of *visible* open lots is <= 2, the +// .holdings-grid element gains the class `holdings-grid--wide`. The CSS +// activates the wide row layout at >= 720px viewport width via @media. + +const ethHolding = { + 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', +}; + +function btcHolding(id, n) { + return { id, asset: 'BTC', type: 'HOLDING', date: '2026-0' + n + '-01', + expiry: '', dte: null, strike: 60000 + id, size: 0.1, premium: 0, + outcome: 'OPEN', closeCost: 0, platform: 'SPOT' }; +} + +function getGrid(window) { + const g = window.document.querySelector('.holdings-grid'); + assert.ok(g, 'expected a .holdings-grid element'); + return g; +} + +test('1 visible open lot: holdings-grid has --wide class', (t) => { + const { window, teardown } = setupJsdom({ trades: [ethHolding] }); + t.after(teardown); + const grid = getGrid(window); + assert.ok(grid.classList.contains('holdings-grid--wide'), + 'grid should have holdings-grid--wide class with 1 visible lot'); +}); + +test('2 visible open lots: holdings-grid has --wide class', (t) => { + const { window, teardown } = setupJsdom({ + trades: [ethHolding, btcHolding(2, 2)], + }); + t.after(teardown); + const grid = getGrid(window); + assert.ok(grid.classList.contains('holdings-grid--wide'), + 'grid should have holdings-grid--wide class with 2 visible lots'); +}); + +test('3 visible open lots: holdings-grid does NOT have --wide class', (t) => { + const { window, teardown } = setupJsdom({ + trades: [ethHolding, btcHolding(2, 2), btcHolding(3, 3)], + }); + t.after(teardown); + const grid = getGrid(window); + assert.ok(!grid.classList.contains('holdings-grid--wide'), + 'grid should NOT have holdings-grid--wide class with 3+ visible lots'); +}); + +test('asset filter honored: BTC filter w/ 1 BTC lot among many → wide', (t) => { + const { window, teardown } = setupJsdom({ + trades: [ethHolding, btcHolding(2, 2), btcHolding(3, 3), + { ...ethHolding, id: 4, date: '2026-02-01' }], + }); + t.after(teardown); + // Filter to BTC: should reduce visible lots to 2 → wide class applies. + window.setFilter('BTC'); + const grid = getGrid(window); + assert.ok(grid.classList.contains('holdings-grid--wide'), + 'asset filter should narrow visible-lot count for the threshold'); +}); + +test('missing spot: card renders stable Spot — placeholder', (t) => { + // No livePrices stub → spot is undefined. + const { window, teardown } = setupJsdom({ trades: [ethHolding] }); + t.after(teardown); + const card = window.document.querySelector('.hcard'); + const spot = card.querySelector('.hcard-spot'); + assert.ok(spot, 'spot block should render even when price missing'); + assert.match(spot.textContent, /Spot\s*—/, + 'placeholder should show "Spot —"'); + assert.match(spot.textContent, /spot unavailable/i, + 'placeholder sub-line should read "spot unavailable"'); +}); + +test('wide layout preserves edit btn, lot badge, merge btn', (t) => { + // 2 ETH lots: edit btn (HOLDING), lot badge (>1 lot), merge btn (>=2 open). + const { window, teardown } = setupJsdom({ + trades: [ + ethHolding, + { ...ethHolding, id: 2, date: '2026-02-01', strike: 3200 }, + ], + }); + t.after(teardown); + const grid = getGrid(window); + assert.ok(grid.classList.contains('holdings-grid--wide'), + 'precondition: 2 lots → wide'); + const cards = window.document.querySelectorAll('.hcard'); + assert.strictEqual(cards.length, 2); + assert.ok(cards[0].querySelector('.hcard-edit'), 'edit button present'); + assert.ok(cards[0].querySelector('.lot-badge'), 'lot badge present'); + assert.ok(window.document.querySelector('.btn-merge'), + 'merge button present'); +});