Generally quite nicely working already
This commit is contained in:
287
frontend/app.js
287
frontend/app.js
@@ -1,32 +1,255 @@
|
||||
// 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');
|
||||
|
||||
async function fetchJson(path) {
|
||||
const res = await fetch(path);
|
||||
if (!res.ok) throw new Error('Network response not ok');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function renderPositions(list) {
|
||||
ptbody.innerHTML = '';
|
||||
for (const p of list) {
|
||||
const row = document.createElement('tr');
|
||||
const avg = p.averageCost ?? p.average_cost ?? '';
|
||||
row.innerHTML = `<td>${p.symbol}</td><td>${p.position}</td><td>${avg}</td><td>${p.account ?? ''}</td>`;
|
||||
ptbody.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function pollPositions() {
|
||||
try {
|
||||
const positions = await fetchJson('/api/positions');
|
||||
renderPositions(positions);
|
||||
} catch (e) {
|
||||
console.warn('Positions polling error', e);
|
||||
}
|
||||
}
|
||||
|
||||
// start polling every 2 seconds
|
||||
pollPositions();
|
||||
setInterval(pollPositions, 2000);
|
||||
// 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
|
||||
? '<span class="ltcg-icon good" title="Long-term capital gains eligible">✓</span>'
|
||||
: '<span class="ltcg-icon bad" title="Less than one year old">✗</span>';
|
||||
row.innerHTML = `<td>${securityType}</td><td>${displaySymbol}</td><td>${underlyingSymbol}</td><td class="num">${qty}</td><td class="num">${avg}</td><td class="num">${cost}</td><td class="num">${mprice}</td><td class="num">${mvalue}</td><td class="num">${gain}</td><td class="num">${gainp}</td><td class="num">${cagr}</td><td class="num">${daysSinceBuy}</td><td class="icon-cell">${ltcgIcon}</td><td>${p.buy_time ?? ''}</td>`;
|
||||
ptbody.appendChild(row);
|
||||
}
|
||||
updateHeaderIndicators();
|
||||
}
|
||||
|
||||
function cycleSort(column, multi) {
|
||||
const existingIndex = sortState.findIndex((rule) => rule.column === column);
|
||||
if (!multi) {
|
||||
if (existingIndex === -1) {
|
||||
sortState = [{ column, direction: 'asc' }];
|
||||
} else {
|
||||
const current = sortState[existingIndex];
|
||||
if (current.direction === 'asc') {
|
||||
sortState = [{ column, direction: 'desc' }];
|
||||
} else {
|
||||
sortState = [];
|
||||
}
|
||||
}
|
||||
renderPositions(latestPositions);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingIndex === -1) {
|
||||
sortState.push({ column, direction: 'asc' });
|
||||
} else {
|
||||
const current = sortState[existingIndex];
|
||||
if (current.direction === 'asc') {
|
||||
sortState[existingIndex] = { column, direction: 'desc' };
|
||||
} else {
|
||||
sortState.splice(existingIndex, 1);
|
||||
}
|
||||
}
|
||||
renderPositions(latestPositions);
|
||||
}
|
||||
|
||||
async function pollPositions() {
|
||||
try {
|
||||
const positions = await fetchJson('/api/lots');
|
||||
latestPositions = Array.isArray(positions) ? positions : [];
|
||||
renderPositions(latestPositions);
|
||||
} catch (e) {
|
||||
console.warn('Positions polling error', e);
|
||||
}
|
||||
}
|
||||
|
||||
for (const header of headers) {
|
||||
header.addEventListener('click', (event) => {
|
||||
cycleSort(header.dataset.column, event.shiftKey);
|
||||
});
|
||||
}
|
||||
|
||||
for (const input of filterInputs) {
|
||||
input.addEventListener('input', () => {
|
||||
const value = input.value.trim();
|
||||
if (value) {
|
||||
filterState[input.dataset.column] = value;
|
||||
} else {
|
||||
delete filterState[input.dataset.column];
|
||||
}
|
||||
renderPositions(latestPositions);
|
||||
});
|
||||
}
|
||||
|
||||
// start polling every 15 seconds
|
||||
pollPositions();
|
||||
setInterval(pollPositions, 15000);
|
||||
|
||||
Reference in New Issue
Block a user