Files
AtreidesAssetsFree/client/pages/event-accounts.js
T
2026-10-30 12:28:48 +01:00

430 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Generated, do not edit. sha256:ae28972ed451b65f2e773094a56cc2c886f8928f30dfae76134014f65aa7c8e8
import { listAccounts } from '../data/accounts.js';
import { listAssets } from '../data/assets.js';
import { accountHoldings } from '../data/positions.js';
import { listOpenValuationDiffs, listValuations } from '../data/valuations.js';
import { mountSparkline } from '../ui/sparkline.js';
import { activityItems, heartbeat } from '../ui/activity.js';
import { setRowFocus } from '../ui/row-focus.js';
import { listTrades, listTransfers } from '../data/events.js';
import { listAllSchedules, listInterest } from '../data/interest-schedules.js';
import { scheduleForecast } from '../data/interest-credits.js';
import { latestPricePoint } from '../data/price-history.js';
import { basePriceOf } from '../price/providers.js';
import { loadFiat } from '../ui/fiat.js';
import { t } from '../i18n/index.js';
import { assetLabelHtml, byKindAndLabel } from '../ui/asset-label.js';
import { fmtBase, fmtUnit, fromSubunit, isBaseAsset, isMetal, val } from '../ui/format.js';
import { esc } from '../ui/escape.js';
import { emptyStateHtml } from '../ui/empty-state.js';
import { create as createTabs } from '../ui/tabs.js';
import { create as createFilter, resolveSelection } from '../ui/filter.js';
import { getPref, setPref } from '../data/preferences.js';
import { setPageHint } from '../ui/page-hint.js';
import * as dialog from '../ui/dialog.js';
import * as loading from '../ui/loading.js';
import { localDate, localDateNl, localTime } from '../../shared/time.js';
import { ACCOUNT_ASSET_KINDS, CONFIG, ENUMS, EVENTS_PAGE, TARGETS, balanceCaptureOn, targetHash } from '../../shared/config.js';
import * as valuations from './valuations.js';
import { recordBalance } from './valuations.js';
import * as trades from './trades.js';
import * as transfers from './transfers.js';
import * as interest from './interest.js';
import * as reservations from './reservations.js';
import { listReservations } from '../data/reservations.js';
import { reservationAccount } from '../actions/reservation-book.js';
const TABS = EVENTS_PAGE.TABS;
const DETAIL = [
[TABS.VALUATIONS, valuations],
[TABS.TRADES, trades],
[TABS.TRANSFERS, transfers],
[TABS.INTEREST, interest],
[TABS.RESERVATIONS, reservations],
];
let lastDetailTab = TABS.VALUATIONS;
export async function render(el) {
el.innerHTML = '<div data-acc-cards></div>';
const host = el.querySelector('[data-acc-cards]');
loading.show(host, 'cards', { count: 3 });
setPageHint(el, { title: t('eventAccounts.hintTitle'), rows: [
t('eventAccounts.hintBalance'), t('eventAccounts.hintDetail'), t('eventAccounts.hintInterest'), t('reservations.hint'),
t('eventAccounts.hintActivity'), t('eventAccounts.hintCapture'),
{ heading: t('nav.valuations') }, t('valuations.rateNote'),
{ heading: t('nav.trades') }, t('trades.fiatNote'),
{ heading: t('nav.transfers') }, t('transfers.fiatNote'),
{ heading: t('nav.interest') }, t('interest.importNote'),
] });
try {
await renderCards(host);
} catch (err) {
loading.error(host, err && err.message);
}
}
async function loadModel() {
const none = () => [];
const [accounts, assets, open, schedules, interestRows, trades, transfers, valuationRows, reservationRows] = await Promise.all([
listAccounts(), listAssets(), listOpenValuationDiffs(),
listAllSchedules().catch(none), listInterest().catch(none),
listTrades().catch(none), listTransfers().catch(none), listValuations().catch(none),
listReservations().catch(none),
]);
const startYear = Number(await getPref('app', 'start_year').catch(() => null)) || CONFIG.START_YEAR.AUTO;
const fromIso = startYear !== CONFIG.START_YEAR.AUTO ? `${startYear}-01-01T12:00:00Z` : null;
const events = { accounts, schedules, interest: interestRows, trades, transfers, valuations: valuationRows,
reservations: reservationRows, interestDue: [], fromIso };
const holdings = await accountHoldings(accounts.map(a => a.id));
const assetById = new Map(assets.map(a => [a.id, a]));
const held = [...new Set(holdings.map(h => h.assetId))].map(id => assetById.get(id)).filter(Boolean);
const spot = await loadFiat(held.filter(a => !isBaseAsset(a))).catch(() => new Map());
const prices = new Map();
for (const a of held) {
if (isBaseAsset(a)) { prices.set(a.id, 1); continue; }
let price = basePriceOf(spot.get(a.id));
if (price == null) price = (await latestPricePoint(a.id).catch(() => null))?.fiatPrice ?? null;
prices.set(a.id, price);
}
const openPer = new Map();
for (const v of open) if (v.accountId != null) openPer.set(v.accountId, (openPer.get(v.accountId) || 0) + 1);
const today = localDate(new Date().toISOString());
const running = schedules.filter(s => !s.endsOn || s.endsOn >= today);
const forecasts = new Map(await Promise.all(running.filter(s => s.rateKind === 'fixed')
.map(async s => [s.id, await scheduleForecast(s).catch(() => null)])));
events.interestDue = running.flatMap(s => (forecasts.get(s.id)?.upcoming || [])
.map(p => ({ accountId: s.accountId, periodEnd: p.periodEnd })));
const interestOf = id => {
const own = running.filter(s => s.accountId === id);
const booked = interestRows.filter(r => r.toAccountId === id);
const next = own.map(s => {
const p = forecasts.get(s.id)?.upcoming[0];
return p ? { periodEnd: p.periodEnd, amount: p.projectedSub, meta: payoutMeta(s) } : null;
}).filter(Boolean);
return { agreements: own.length, booked: booked.length, lastAt: booked[0]?.occurredAt ?? null, next };
};
const cards = accounts.map(account => {
const lines = holdings.filter(h => h.accountId === account.id).map(h => {
const asset = assetById.get(h.assetId);
const price = prices.get(h.assetId);
const value = asset && price != null ? (h.saldo / Math.pow(10, asset.decimals)) * price : null;
return { ...h, asset, value };
}).filter(l => l.asset).sort((a, b) => byKindAndLabel(a.asset, b.asset));
const total = lines.reduce((s, l) => s + (l.value ?? 0), 0);
const own = reservationRows.filter(r => reservationAccount(r).id === account.id);
return { account, lines, total, priced: lines.every(l => l.value != null), open: openPer.get(account.id) || 0,
interest: interestOf(account.id), reservations: { n: own.length, nextAt: own[0]?.occurredAt ?? null } };
});
const kindRank = k => { const i = ENUMS.ACCOUNT_KIND.indexOf(k); return i < 0 ? ENUMS.ACCOUNT_KIND.length : i; };
return {
events,
cards: cards
.filter(c => c.account.isActive || c.lines.length)
.sort((a, b) => kindRank(a.account.kind) - kindRank(b.account.kind) || b.total - a.total
|| a.account.name.localeCompare(b.account.name)),
};
}
function dayLabel(day, x, names) {
const date = localDateNl(new Date(x).toISOString());
if (!day?.length) return date;
const counts = new Map();
for (const it of day) counts.set(it.kind, (counts.get(it.kind) || 0) + 1);
const what = [...counts].map(([k, n]) => `${t('eventAccounts.act.' + k)}${n > 1 ? ` ${n}×` : ''}`).join(', ');
const who = names ? [...new Set(day.map(it => names.get(it.accountId)))].join(', ') : '';
return `${date}: ${what}${who ? ` (${who})` : ''}`;
}
const MARK_VARIANT = { balance: 'balance', agreementStart: 'agreement', agreementEnd: 'agreement',
interestDue: 'agreement', reservation: 'reservation' };
function mountActivity(host, events, accountIds, { onPick = null, nowText = null } = {}) {
const nowIso = new Date().toISOString();
const items = activityItems({ accountIds, ...events });
const beat = heartbeat(items, nowIso, events.fromIso);
if (!beat) { host.innerHTML = ''; return false; }
const names = accountIds.length > 1 ? new Map(events.accounts.map(a => [a.id, a.name])) : null;
const d = localDate(nowIso);
const todayX = Date.UTC(Number(d.slice(0, 4)), Number(d.slice(5, 7)) - 1, Number(d.slice(8, 10)));
const label = x => {
const day = dayLabel(beat.byDay.get(x), x, names);
return nowText && x === todayX ? `${day} · ${nowText}` : day;
};
const marks = items.filter(it => it.dir === 0)
.map(it => ({ x: it.x, variant: MARK_VARIANT[it.kind] || 'account', label: label(it.x) }))
.sort((a, b) => (a.variant === 'balance') - (b.variant === 'balance'));
if (nowText) marks.unshift({ x: todayX, variant: 'now', label: label(todayX) });
const peak = Math.max(1, ...beat.points.map(p => Math.abs(p[1])));
mountSparkline(host, beat.points, {
yRange: [-peak, peak],
markers: marks,
snap: items.filter(it => it.dir !== 0).map(it => it.x),
formatValue: (_v, x) => label(x),
onPick: onPick && (x => {
const day = beat.byDay.get(x) || [];
const hit = day.find(it => it.table && it.dir !== 0) || day.find(it => it.table);
if (hit) onPick(hit);
}),
});
return true;
}
const PREF_SCOPE = EVENTS_PAGE.ROUTE;
const PREF_KINDS = 'accountKinds';
const PREF_EMPTY = 'accountsEmpty';
async function renderCards(host) {
const { cards, events } = await loadModel();
if (!cards.length) {
host.innerHTML = emptyStateHtml(t('eventAccounts.none'), {
why: t('eventAccounts.noneWhy'), href: targetHash(TARGETS.ACCOUNTS), linkText: t('eventAccounts.manage'),
});
return;
}
const kinds = ENUMS.ACCOUNT_KIND.filter(k => cards.some(c => c.account.kind === k));
const items = kinds.map(k => ({ id: k, label: t('enums.account_kind.' + k) }));
let selected = resolveSelection({ saved: await getPref(PREF_SCOPE, PREF_KINDS, null).catch(() => null), items });
let showEmpty = (await getPref(PREF_SCOPE, PREF_EMPTY, true).catch(() => true)) !== false;
const hasEmpty = cards.some(c => !c.lines.length);
const open = id => openDetail(id).then(() => { if (host.isConnected) renderCards(host); });
const draw = () => {
const shown = cards.filter(c => selected.has(c.account.kind) && (showEmpty || c.lines.length));
host.innerHTML = `<div class="acc-pulse" data-acc-pulse></div>
<div class="mx-filter acc-filter"><div data-filter></div>${hasEmpty
? `<div class="fbar fbar--chip"><button type="button" class="fbar-item${showEmpty ? ' is-on' : ''}" data-empty-toggle
aria-pressed="${showEmpty}">${t('eventAccounts.showEmpty')}</button></div>` : ''}</div>
<div class="wealth-grid acc-grid">${shown.map(c => cardHtml(c)).join('')}</div>`;
host.querySelectorAll('[data-acc-spark]').forEach(s => mountActivity(s, events, [Number(s.dataset.accSpark)]));
const total = shown.reduce((s, c) => s + c.total, 0);
const partial = shown.some(c => c.lines.length && !c.priced);
const pulse = host.querySelector('[data-acc-pulse]');
pulse.hidden = !mountActivity(pulse, events, shown.map(c => c.account.id), {
nowText: t(partial ? 'eventAccounts.nowTotalPartial' : 'eventAccounts.nowTotal', { bedrag: fmtBase(total) }),
});
createFilter(host.querySelector('[data-filter]'), {
items, selected,
onChange: sel => { selected = sel; setPref(PREF_SCOPE, PREF_KINDS, [...sel]).catch(() => {}); draw(); },
});
host.querySelector('[data-empty-toggle]')?.addEventListener('click', () => {
showEmpty = !showEmpty;
setPref(PREF_SCOPE, PREF_EMPTY, showEmpty).catch(() => {});
draw();
});
host.querySelectorAll('[data-account]').forEach(card => {
const id = Number(card.dataset.account);
card.addEventListener('click', () => open(id));
card.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(id); } });
});
};
draw();
}
function lineHtml(l, pick, chosen) {
const value = isBaseAsset(l.asset) ? '' : (l.value != null ? val(fmtBase(l.value)) : t('eventAccounts.noPrice'));
const anchor = l.anchor
? t('eventAccounts.lastBalance', { datum: localDateNl(l.anchor.rateAt) })
: t('eventAccounts.fromBookings');
const inner = `
<div class="acc-line-main"><span>${assetLabelHtml(l.asset)}</span><span>${val(fmtUnit(l.saldo, l.asset))}</span></div>
<div class="acc-line-sub t-muted-cell"><span>${anchor}</span><span>${value}</span></div>`;
return pick
? `<div class="acc-pick">
<button type="button" class="acc-line acc-line--pick${chosen ? ' is-on' : ''}" data-pick-asset="${l.assetId}"
aria-pressed="${chosen}" title="${esc(t('eventAccounts.pickAsset'))}">${inner}</button>
${anchorBtnHtml(l, 'acc-anchor-btn')}
</div>`
: `<div class="acc-line">${inner}</div>`;
}
const anchorBtnHtml = (l, cls = '') => (balanceCaptureOn(l.asset)
? `<button type="button" class="btn btn-ghost btn-xs ${cls}" data-anchor-asset="${l.assetId}"
title="${esc(t('eventAccounts.recordBalanceHint'))}">${t('eventAccounts.recordBalance')}</button>`
: '');
function badgesHtml(c) {
return [
`<span class="badge">${esc(t('enums.account_kind.' + c.account.kind))}</span>`,
c.account.isActive ? '' : `<span class="badge badge-neutral">${t('eventAccounts.closed')}</span>`,
c.open ? `<span class="badge badge-accent">${t('eventAccounts.openGaps', { n: c.open })}</span>` : '',
].join('');
}
const totalHtml = c => (c.lines.length && c.priced ? `<div class="acc-total">${val(fmtBase(c.total))}</div>` : '');
const payoutMeta = s => ({ decimals: s.payout_decimals, unit: s.payout_unit, subunit: s.payout_subunit, displayUnit: s.payout_display_unit });
function interestHtml(c) {
const { agreements, booked, lastAt, next } = c.interest;
const parts = [
agreements === 1 ? t('eventAccounts.interestAgreement')
: agreements > 1 ? t('eventAccounts.interestAgreements', { n: agreements }) : '',
booked ? t('eventAccounts.interestBooked', { n: booked, datum: localDateNl(lastAt) }) : '',
...next.map(n => t('eventAccounts.interestNext', { datum: localDateNl(n.periodEnd), bedrag: fmtUnit(n.amount, n.meta) })),
].filter(Boolean);
return parts.length
? `<div class="acc-interest t-muted-cell">${parts.map(p => `<span>${esc(p)}</span>`).join(' · ')}</div>` : '';
}
function reservationHtml(c) {
const { n, nextAt } = c.reservations;
if (!n) return '';
const parts = [n === 1 ? t('reservations.cardOne') : t('reservations.cardMany', { n }),
t('reservations.cardNext', { datum: localDateNl(nextAt) })];
return `<div class="acc-interest t-muted-cell">${parts.map(p => `<span>${esc(p)}</span>`).join(' · ')}</div>`;
}
function cardHtml(c) {
const { account } = c;
const lines = c.lines.length
? c.lines.map(l => lineHtml(l, false)).join('')
: `<div class="t-muted-cell">${t('eventAccounts.empty')}</div>`;
return `<div class="card wealth-card acc-card" data-account="${account.id}" role="button" tabindex="0"
aria-label="${esc(t('eventAccounts.open'))}: ${esc(account.name)}">
<div class="wealth-head">
<strong>${esc(account.name)}</strong>${badgesHtml(c)}
</div>
${totalHtml(c)}
${interestHtml(c)}
${reservationHtml(c)}
<div class="acc-lines">${lines}</div>
<div class="wealth-spark-wrap acc-spark-wrap"><div class="wealth-spark" data-acc-spark="${account.id}"></div></div>
</div>`;
}
function summaryHtml(c, assetId) {
if (!c.lines.length) return `<div class="t-muted-cell">${t('eventAccounts.empty')}</div>`;
if (c.lines.length === 1) {
const l = c.lines[0];
const anchor = l.anchor
? t('eventAccounts.lastBalance', { datum: localDateNl(l.anchor.rateAt) })
: t('eventAccounts.fromBookings');
return `<div class="acc-single">
<span>${assetLabelHtml(l.asset)}</span><span class="t-muted-cell">${anchor}</span>
${anchorBtnHtml(l)}
</div>`;
}
return c.lines.map(l => lineHtml(l, true, l.assetId === assetId)).join('');
}
const asideHtml = c => `${badgesHtml(c)}${totalHtml(c)}`;
const unitLabel = a => {
if (isMetal(a)) return '';
const u = a.displayUnit === 'subunit' && a.subunit ? a.subunit : a.unit;
return u ? ` (${u})` : '';
};
async function recordBalanceFor(line, accountId) {
const now = new Date().toISOString();
const res = await dialog.form({
title: t('eventAccounts.recordBalanceTitle', { asset: line.asset.symbol }),
fields: [
{ key: 'valuedOn', label: t('valuations.colDate'), type: 'date', value: localDate(now) },
{ key: '_time', label: t('valuations.colTime'), type: 'time', value: localTime(now) },
{ key: 'balance', label: `${t('valuations.colBalance')}${unitLabel(line.asset)}`, type: 'number',
value: String(fromSubunit(line.saldo, line.asset)) },
],
confirmLabel: t('eventAccounts.recordBalance'),
});
if (!res) return false;
try {
await recordBalance({ ...res, accountId, assetId: line.assetId });
return true;
} catch (err) {
await dialog.alert({ title: t('dialog.error'), message: err.message });
return false;
}
}
async function tabFor(line, accountId) {
if (line.anchor) return TABS.VALUATIONS;
const [tr, tf, ir] = await Promise.all([listTrades(), listTransfers(), listInterest()]).catch(() => [[], [], []]);
const own = r => r.assetId === line.assetId;
if (tr.some(r => own(r) && (r.type === 'buy' ? r.toAccountId : r.fromAccountId) === accountId)) return TABS.TRADES;
if (tf.some(r => own(r) && (r.fromAccountId === accountId || r.toAccountId === accountId))) return TABS.TRANSFERS;
if (ir.some(r => own(r) && r.toAccountId === accountId)) return TABS.INTEREST;
return null;
}
async function openDetail(accountId) {
const model = await loadModel();
const c = model.cards.find(x => x.account.id === accountId);
if (!c) return;
let assetId = null;
let tabs = null;
const closed = dialog.detail({
title: c.account.name, size: 'xl',
html: `<div class="wealth-detail acc-detail"><div class="acc-summary" data-acc-summary></div>
<div class="wealth-detail-tabs" data-acc-tabs></div>
<div class="wealth-detail-spark"><div class="acc-detail-plot" data-acc-activity></div></div></div>`,
});
const root = document.querySelector('.dialog-overlay .acc-detail');
if (!root) return closed;
const summary = root.querySelector('[data-acc-summary]');
const activity = root.querySelector('[data-acc-activity]');
const aside = document.querySelector('.dialog-overlay [data-dialog-aside]');
const pickEvent = it => {
if (assetId != null && it.assetId !== assetId) { assetId = null; draw(); }
setRowFocus(it.table, it.id);
if (tabs.active !== it.table) tabs.select(it.table);
else tabs.redraw();
};
let current = c;
let events = model.events;
const draw = () => {
summary.innerHTML = reservationHtml(current) + summaryHtml(current, assetId);
if (aside) aside.innerHTML = asideHtml(current);
};
const drawActivity = () => { mountActivity(activity, events, [accountId], { onPick: pickEvent }); };
const refreshSummary = async () => {
const fresh = await loadModel().catch(() => null);
const card = fresh?.cards.find(x => x.account.id === accountId);
if (card) { current = card; events = fresh.events; }
if (summary.isConnected) { draw(); drawActivity(); }
};
draw();
drawActivity();
summary.addEventListener('click', async e => {
const anchorBtn = e.target.closest('[data-anchor-asset]');
if (anchorBtn) {
const line = current.lines.find(l => l.assetId === Number(anchorBtn.dataset.anchorAsset));
if (line && await recordBalanceFor(line, accountId)) {
assetId = line.assetId;
await refreshSummary();
if (tabs.active !== TABS.VALUATIONS) tabs.select(TABS.VALUATIONS);
else tabs.redraw();
}
return;
}
const b = e.target.closest('[data-pick-asset]');
if (!b) return;
const id = Number(b.dataset.pickAsset);
assetId = assetId === id ? null : id;
draw();
const line = current.lines.find(l => l.assetId === id);
const tab = assetId != null && line ? await tabFor(line, accountId) : null;
if (tab && tab !== tabs.active) tabs.select(tab);
else { tabs.redraw(); refreshSummary(); }
});
const assetKinds = [...new Set([...(ACCOUNT_ASSET_KINDS[c.account.kind] || ENUMS.ASSET_KIND),
...c.lines.map(l => l.asset.kind)])];
tabs = createTabs(root.querySelector('[data-acc-tabs]'), {
active: lastDetailTab,
items: DETAIL.map(([id]) => ({ id, label: t(`nav.${id}`) })),
render: (id, panel) => DETAIL.find(([x]) => x === id)[1].render(panel, { accountId, assetId, assetKinds }),
onChange: id => { lastDetailTab = id; refreshSummary(); },
});
const onLedger = () => { if (summary.isConnected) refreshSummary(); };
document.addEventListener('ledger-changed', onLedger);
closed.finally(() => document.removeEventListener('ledger-changed', onLedger));
return closed;
}