// Minimal vanilla-JS frontend to poll only positions and render the positions table const qs = (s) => document.querySelector(s); const ptbody = qs('#positions-table tbody'); const headers = Array.from(document.querySelectorAll('#positions-table thead tr.sort-row th')); const filterInputs = Array.from(document.querySelectorAll('.filter-input')); let latestPositions = []; let sortState = []; let filterState = {}; const numericColumns = new Set([ 'qty', 'avg_price', 'cost_basis', 'market_price', 'market_value', 'gain_dollars', 'gain_percent', 'cagr_percent', 'days_since_buy', ]); const dateColumns = new Set(['buy_time']); const columnAccessors = { security_type: (p) => p.security_type ?? '', display_symbol: (p) => p.display_symbol ?? p.symbol ?? '', underlying_symbol: (p) => p.underlying_symbol ?? '', qty: (p) => p.qty, avg_price: (p) => p.avg_price, cost_basis: (p) => p.cost_basis, market_price: (p) => p.market_price, market_value: (p) => p.market_value, gain_dollars: (p) => p.gain_dollars, gain_percent: (p) => p.gain_percent, cagr_percent: (p) => p.cagr_percent, days_since_buy: (p) => p.days_since_buy, ltcg: (p) => p.ltcg ?? '', buy_time: (p) => p.buy_time ?? '', }; function asNumber(value) { if (value == null || value === '') return null; const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } function formatNumber(value, digits = 2) { const num = asNumber(value); if (num == null) return ''; return num.toLocaleString(undefined, { minimumFractionDigits: digits, maximumFractionDigits: digits, }); } function formatPercent(value) { const num = asNumber(value); if (num == null) return ''; return `${formatNumber(num, 2)}%`; } async function fetchJson(path) { const res = await fetch(path); if (!res.ok) throw new Error('Network response not ok'); return res.json(); } function compareValues(a, b) { const aMissing = a == null || a === ''; const bMissing = b == null || b === ''; if (aMissing && bMissing) return 0; if (aMissing) return 1; if (bMissing) return -1; if (typeof a === 'number' && typeof b === 'number') return a - b; return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' }); } function escapeRegex(text) { return text.replace(/[.+^${}()|[\]\\]/g, '\\$&'); } function wildcardToRegex(pattern) { const escaped = escapeRegex(pattern).replace(/\*/g, '.*').replace(/\?/g, '.'); return new RegExp(`^${escaped}$`, 'i'); } function parseComparator(expression) { const match = String(expression).trim().match(/^(<=|>=|<|>|=)\s*(.+)$/); if (!match) return null; return { operator: match[1], value: match[2].trim() }; } function compareByOperator(left, operator, right) { if (left == null || right == null) return false; switch (operator) { case '<': return left < right; case '<=': return left <= right; case '>': return left > right; case '>=': return left >= right; case '=': return left === right; default: return false; } } function matchesFilter(column, rawValue, expression) { const filter = String(expression ?? '').trim(); if (!filter) return true; const value = rawValue ?? ''; if (column === 'ltcg' && filter === '!X') { return String(value).trim().toUpperCase() !== 'X'; } if (numericColumns.has(column)) { const numericValue = asNumber(value); const comparator = parseComparator(filter); if (comparator) return compareByOperator(numericValue, comparator.operator, asNumber(comparator.value)); return numericValue === asNumber(filter); } if (dateColumns.has(column)) { const normalizedValue = String(value); const comparator = parseComparator(filter); if (comparator) return compareByOperator(normalizedValue, comparator.operator, comparator.value); return wildcardToRegex(filter).test(normalizedValue); } return wildcardToRegex(filter).test(String(value)); } function filterPositions(list) { return list.filter((row) => Object.entries(filterState).every(([column, expression]) => { const accessor = columnAccessors[column]; if (!accessor) return true; return matchesFilter(column, accessor(row), expression); })); } function sortPositions(list) { if (!sortState.length) return [...list]; return [...list].sort((left, right) => { for (const rule of sortState) { const accessor = columnAccessors[rule.column]; if (!accessor) continue; const leftValue = accessor(left); const rightValue = accessor(right); const result = numericColumns.has(rule.column) ? compareValues(asNumber(leftValue), asNumber(rightValue)) : compareValues(leftValue, rightValue); if (result !== 0) return rule.direction === 'asc' ? result : -result; } return 0; }); } function updateHeaderIndicators() { for (const header of headers) { const column = header.dataset.column; const existing = header.querySelector('.sort-meta'); if (existing) existing.remove(); const ruleIndex = sortState.findIndex((rule) => rule.column === column); if (ruleIndex === -1) continue; const rule = sortState[ruleIndex]; const meta = document.createElement('span'); meta.className = 'sort-meta'; meta.textContent = `${rule.direction === 'asc' ? '▲' : '▼'} ${ruleIndex + 1}`; header.appendChild(meta); } } function renderPositions(list) { ptbody.innerHTML = ''; for (const p of sortPositions(filterPositions(list))) { const row = document.createElement('tr'); const securityType = p.security_type ?? ''; const displaySymbol = p.display_symbol ?? p.symbol ?? ''; const underlyingSymbol = p.underlying_symbol ?? ''; const qty = formatNumber(p.qty, 2); const avg = formatNumber(p.avg_price ?? p.averageCost ?? p.average_cost, 2); const cost = formatNumber(p.cost_basis, 2); const mprice = formatNumber(p.market_price, 2); const mvalue = formatNumber(p.market_value, 2); const gain = formatNumber(p.gain_dollars, 2); const gainp = formatPercent(p.gain_percent); const cagr = formatPercent(p.cagr_percent); const daysSinceBuy = formatNumber(p.days_since_buy, 0); const isLongTerm = p.ltcg === 'X'; const ltcgIcon = isLongTerm ? '' : ''; row.innerHTML = `