// 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 = '
'; 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 = `
${hasEmpty ? `
` : ''}
${shown.map(c => cardHtml(c)).join('')}
`; 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 = `
${assetLabelHtml(l.asset)}${val(fmtUnit(l.saldo, l.asset))}
${anchor}${value}
`; return pick ? `
${anchorBtnHtml(l, 'acc-anchor-btn')}
` : `
${inner}
`; } const anchorBtnHtml = (l, cls = '') => (balanceCaptureOn(l.asset) ? `` : ''); function badgesHtml(c) { return [ `${esc(t('enums.account_kind.' + c.account.kind))}`, c.account.isActive ? '' : `${t('eventAccounts.closed')}`, c.open ? `${t('eventAccounts.openGaps', { n: c.open })}` : '', ].join(''); } const totalHtml = c => (c.lines.length && c.priced ? `
${val(fmtBase(c.total))}
` : ''); 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 ? `
${parts.map(p => `${esc(p)}`).join(' · ')}
` : ''; } 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 `
${parts.map(p => `${esc(p)}`).join(' · ')}
`; } function cardHtml(c) { const { account } = c; const lines = c.lines.length ? c.lines.map(l => lineHtml(l, false)).join('') : `
${t('eventAccounts.empty')}
`; return `
${esc(account.name)}${badgesHtml(c)}
${totalHtml(c)} ${interestHtml(c)} ${reservationHtml(c)}
${lines}
`; } function summaryHtml(c, assetId) { if (!c.lines.length) return `
${t('eventAccounts.empty')}
`; 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 `
${assetLabelHtml(l.asset)}${anchor} ${anchorBtnHtml(l)}
`; } 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: `
`, }); 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; }