Versie 0.2.0

This commit is contained in:
Atreides Assets committed 2026-10-29 11:57:29 +01:00
commit e77d8fd373
495 files changed
+81026

No files matched your search

+376
View File
@@ -0,0 +1,376 @@
// Generated, do not edit. sha256:51ab737fb6604c8493f238a8893184408226a1a90ddd465fcd92bf0040b5a50e
import { mergeCheck, mergeAccounts } from '../data/accounts-merge.js';
import { listAccounts, listSelectableAccounts, createAccount, updateAccount, deleteAccount } from '../data/accounts.js';
import { listConnections } from '../data/connections.js';
import { tableViewCfg } from '../data/preferences.js';
import {
listWalletSources, createWalletSource, updateWalletSource,
deleteWalletSource
} from '../data/wallet-sources.js';
import { t } from '../i18n/index.js';
import { listSchedulesForAccount } from '../data/interest-schedules.js';
import { CONFIG, ENUMS, ACCOUNT_SOURCE_KINDS, ACCOUNT_INTEREST_KINDS, TARGETS, targetHash } from '../../shared/config.js';
import { renderTable } from '../ui/table.js';
import { takeRowFocus } from '../ui/row-focus.js';
import { copyButton } from '../ui/copy-button.js';
import { fmtInt, pct } from '../ui/format.js';
import { addressInfo } from '../price/btc-explorer.js';
import * as dialog from '../ui/dialog.js';
import { openPopover, closePopover } from '../ui/popover.js';
import { MERGE } from '../ui/icons.js';
import { esc } from '../ui/escape.js';
import { counterText, limitFor, locked } from '../license.js';
let tableApi = null;
const trunc = s => (s && s.length > 22) ? `${s.slice(0, 12)}…${s.slice(-6)}` : (s || '');
let bankCount = 0;
function kindOpts(vals) {
const max = limitFor('fiatAccounts');
const full = max !== null && bankCount >= max && vals?.kind !== 'bank';
return ENUMS.ACCOUNT_KIND.filter(v => !(full && v === 'bank'))
.map(v => ({ value: v, label: t('enums.account_kind.' + v) }));
}
function taxCategorieOpts() {
return [{ value: '', label: t('accounts.taxCategoryAuto') },
...ENUMS.TAX_CATEGORY.map(v => ({ value: v, label: t('enums.tax_category.' + v) }))];
}
function taxScopeOpts() {
return [{ value: '', label: t('accounts.taxScopeAuto') },
...ENUMS.TAX_SCOPE.map(v => ({ value: v, label: t('enums.tax_scope.' + v) }))];
}
function wealthScopeOpts() {
return [{ value: '', label: t('accounts.wealthScopeAuto') },
...ENUMS.WEALTH_SCOPE.map(v => ({ value: v, label: t('enums.wealth_scope.' + v) }))];
}
export async function render(el) {
el.innerHTML = `<div id="tbl"></div>`;
await buildTable(el);
}
async function buildTable(el) {
const columns = [
{ key: 'name', label: t('accounts.name'), sortable: true, text: true,
edit: { type: 'text', placeholder: 'Ledger / Bitvavo / ING' } },
{ key: 'kind', label: t('accounts.kind'), sortable: true,
render: a => t('enums.account_kind.' + a.kind),
group: { value: a => a.kind, label: v => t('enums.account_kind.' + v) },
edit: { type: 'select', options: kindOpts } },
{ key: 'isOwnCustody', label: t('accounts.ownCustody'), align: 'center', sortable: true,
render: a => `<span class="badge ${a.isOwnCustody ? 'badge-accent' : 'badge-neutral'}">${a.isOwnCustody ? t('accounts.yes') : t('accounts.no')}</span>`,
group: { value: a => a.isOwnCustody ? t('accounts.yes') : t('accounts.no'), label: v => v },
edit: { type: 'checkbox' } },
{ key: 'scanEnabled', label: t('accounts.scanEnabled'), align: 'center', sortable: true,
render: a => `<span class="badge ${a.scanEnabled ? 'badge-accent' : 'badge-neutral'}">${a.scanEnabled ? t('accounts.yes') : t('accounts.no')}</span>`,
group: { value: a => a.scanEnabled ? t('accounts.yes') : t('accounts.no'), label: v => v },
edit: { type: 'checkbox' } },
{ key: 'taxCategory', label: t('accounts.taxCategory'), sortable: true,
render: a => a.taxCategory
? t('enums.tax_category.' + a.taxCategory)
: `<span class="t-muted-cell">${t('accounts.taxCategoryAuto')}</span>`,
group: { value: a => a.taxCategory || '', label: v => v ? t('enums.tax_category.' + v) : t('accounts.taxCategoryAuto') },
edit: { type: 'select', options: taxCategorieOpts } },
{ key: 'taxScope', label: t('accounts.taxScope'), sortable: true,
render: a => a.taxScope
? t('enums.tax_scope.' + a.taxScope)
: `<span class="t-muted-cell">${t('accounts.taxScopeAuto')}</span>`,
group: { value: a => a.taxScope || '', label: v => v ? t('enums.tax_scope.' + v) : t('accounts.taxScopeAuto') },
edit: { type: 'select', options: taxScopeOpts } },
{ key: 'wealthScope', label: t('accounts.wealthScope'), sortable: true,
render: a => a.wealthScope
? t('enums.wealth_scope.' + a.wealthScope)
: `<span class="t-muted-cell">${t('accounts.wealthScopeAuto')}</span>`,
group: { value: a => a.wealthScope || '', label: v => v ? t('enums.wealth_scope.' + v) : t('accounts.wealthScopeAuto') },
edit: { type: 'select', options: wealthScopeOpts } },
];
function toPayload(v) {
if (!String(v.name || '').trim()) throw new Error(t('accounts.nameRequired'));
return {
name: v.name.trim(), kind: v.kind,
isOwnCustody: v.isOwnCustody ? 1 : 0,
scanEnabled: v.scanEnabled ? 1 : 0,
taxCategory: v.taxCategory || null,
taxScope: v.taxScope || null,
wealthScope: v.wealthScope || null,
};
}
const rijen = await listAccounts();
bankCount = countBanks(rijen);
tableApi = renderTable(el.querySelector('#tbl'), {
columns, rows: rijen,
...(limitFor('fiatAccounts') !== null
? { toolbarNote: () => `${t('license.name_fiatAccounts')}: ${counterText('fiatAccounts', bankCount)}` } : {}),
...(await tableViewCfg('accounts', { key: 'name', dir: 'asc' })),
emptyText: t('accounts.none'),
noneLabel: t('table.none'),
collapsibleGroups: true,
newLabel: t('table.new'),
newDefaults: { kind: 'exchange', isOwnCustody: false, scanEnabled: false, taxCategory: '', taxScope: '', wealthScope: '' },
saveLabel: t('accounts.save'), cancelLabel: t('accounts.cancel'),
onCreate: async v => {
const id = await createAccount(toPayload(v));
const r = await listAccounts();
bankCount = countBanks(r);
tableApi.refresh(r, { locate: id });
},
onSave: async (id, v) => { await updateAccount(id, toPayload(v)); reload(); },
onDelete: async id => {
if (!(await dialog.confirm({ message: t('accounts.confirmDelete'), danger: true }))) return;
try { await deleteAccount(id); reload(); } catch (e) { dialog.alert({ title: t('dialog.error'), message: e.message }); }
},
toolbarActions: [{ id: 'merge', icon: MERGE, label: t('accounts.merge'), title: t('accounts.mergeTitle') }],
onToolbarAction: (id, anchor) => { if (id === 'merge') openMergeMenu(anchor); },
rowExpansion: () => '',
onExpansionMount: (a, host) => mountExpansion(host, a.id),
});
tableApi.focusRow(takeRowFocus('accounts'));
}
async function openMergeMenu(anchor) {
const doelen = await listSelectableAccounts();
const bronnen = await listSelectableAccounts({ metGesloten: true });
if (!doelen.length || bronnen.length < 2) { dialog.alert({ title: t('accounts.merge'), message: t('accounts.mergeNeedTwo') }); return; }
const { el: pop, position } = openPopover(anchor, { className: 'new-menu merge-menu' });
const ph = `<option value="">${esc(t('accounts.mergePick'))}</option>`;
const optionsHtml = (lijst, selected, exclude) => ph + lijst
.filter(a => a.id !== exclude)
.map(a => `<option value="${a.id}"${a.id === selected ? ' selected' : ''}>${esc(a.name)}</option>`).join('');
pop.innerHTML = `
<div class="t-label">${t('accounts.mergeKeep')}</div>
<select class="input" data-merge-keep>${optionsHtml(doelen, null, null)}</select>
<div class="t-label">${t('accounts.mergeDrop')}</div>
<select class="input" data-merge-drop>${optionsHtml(bronnen, null, null)}</select>
<div class="t-body-sm t-muted" data-merge-status></div>
<button class="btn btn-primary" data-merge-go>${t('accounts.merge')}</button>`;
position();
const keepSel = pop.querySelector('[data-merge-keep]');
const dropSel = pop.querySelector('[data-merge-drop]');
const statusEl = pop.querySelector('[data-merge-status]');
const goBtn = pop.querySelector('[data-merge-go]');
function rebuildOpposite(changed, other) {
const otherVal = other.value;
const lijst = other === keepSel ? doelen : bronnen;
other.innerHTML = optionsHtml(lijst, Number(otherVal) || null, Number(changed.value) || null);
other.value = otherVal;
}
async function refresh() {
const survivorId = Number(keepSel.value), victimId = Number(dropSel.value);
const chk = await mergeCheck(survivorId, victimId);
if (chk.ok) {
const c = chk.counts;
statusEl.className = 't-body-sm t-muted';
statusEl.textContent = t('accounts.mergeSummary')
.replace('{drop}', chk.victimName).replace('{keep}', chk.survivorName)
.replace('{sources}', c.sources).replace('{events}', c.events)
.replace('{schedules}', c.schedules).replace('{valuations}', c.valuations);
goBtn.disabled = false;
} else {
statusEl.className = 't-body-sm t-neg';
statusEl.textContent = t('accounts.mergeReason_' + chk.reason);
goBtn.disabled = true;
}
position();
}
keepSel.onchange = () => { rebuildOpposite(keepSel, dropSel); refresh(); };
dropSel.onchange = () => { rebuildOpposite(dropSel, keepSel); refresh(); };
refresh();
goBtn.onclick = async () => {
const survivorId = Number(keepSel.value), victimId = Number(dropSel.value);
closePopover();
if (!(await dialog.confirm({ message: t('accounts.mergeConfirm'), danger: true }))) return;
try {
await mergeAccounts(survivorId, victimId);
tableApi.refresh(await listAccounts(), { locate: survivorId });
dialog.alert({ title: t('accounts.merge'), message: t('accounts.mergeDone') });
} catch (e) {
dialog.alert({ title: t('dialog.error'), message: e.message });
}
};
}
const countBanks = rows => rows.filter(r => r.kind === 'bank').length;
async function reload() { const r = await listAccounts(); bankCount = countBanks(r); tableApi.refresh(r); }
async function mountExpansion(host, accountId) {
const account = (await listAccounts()).find(a => a.id === accountId);
if (!account) { host.innerHTML = ''; return; }
const srcAllowed = ACCOUNT_SOURCE_KINDS[account.kind] || [];
const hasInterest = ACCOUNT_INTEREST_KINDS.includes(account.kind);
const parts = [];
if (srcAllowed.length) parts.push('<div data-src-host></div>');
if (hasInterest) parts.push(await interestBlok(accountId));
if (!parts.length) {
host.innerHTML = `<div class="src-drawer"><div class="t-body-sm t-muted">${t('accounts.noSourceKind')}</div></div>`;
return;
}
host.innerHTML = parts.join('');
const link = host.querySelector('[data-interest-focus]');
if (link) link.onclick = () => sessionStorage.setItem(CONFIG.FOCUS.INTEREST_ACCOUNT, String(accountId));
if (srcAllowed.length) await mountSources(host.querySelector('[data-src-host]'), accountId, srcAllowed);
}
function interestRegel(s) {
const delen = [esc(s.payout_symbol)];
if (s.rateKind === 'fixed') {
if (s.annualRate != null) delen.push(pct(s.annualRate));
if (s.creditCadence) delen.push(esc(t('enums.interest_cadence.' + s.creditCadence)));
if (s.compounding) delen.push(esc(t('interest.colCompounding')));
} else {
delen.push(esc(t('enums.interest_rate_kind.variable')));
}
delen.push(`${esc(s.startsOn)} → ${s.endsOn ? esc(s.endsOn) : esc(t('interest.endsOnOpen'))}`);
return `<div class="t-body-sm">${delen.join(' · ')}</div>`;
}
async function interestBlok(accountId) {
const scheds = await listSchedulesForAccount(accountId);
const lijst = scheds.length
? scheds.map(interestRegel).join('')
: `<div class="t-body-sm t-muted">${t('accounts.interestNone')}</div>`;
return `<div class="src-drawer">
<div class="t-label">${t('interest.schedulesTitle')}</div>
${lijst}
<div class="t-body-sm t-muted mt-sm">${t('accounts.interestManageHint')}
<a href="${targetHash(TARGETS.INTEREST)}" data-interest-focus="1">${t('accounts.interestMovedLink')}</a></div>
</div>`;
}
async function testSource(host, accountId, id) {
const out = host.querySelector('[data-src-test]');
const s = (await listWalletSources(accountId)).find(x => x.id === id);
if (!out || !s) return;
out.className = 't-body-sm t-muted mt-sm';
out.textContent = t('accounts.testing');
try {
let html;
if (s.kind === 'nwc') {
const { testNwc } = await import('../lightning/nwc.js');
const r = await testNwc(s.id);
html = `${t('accounts.testValid')}${r.alias ? ' · ' + esc(r.alias) : ''}`;
} else if (s.kind === 'lnd') {
if (!s.connectionId) throw new Error(t('accounts.lndNoConnection'));
const { testLnd } = await import('../lightning/lnd.js');
const r = await testLnd(s.connectionId);
html = `${t('accounts.testValid')}${r.alias ? ' · ' + esc(r.alias) : ''} · ${t('accounts.lndHeight')} ${fmtInt(r.blockHeight)}`;
} else if (s.kind === 'xpub') {
const { firstReceiveAddress } = await import('../price/wallet-scanner.js');
const addr = firstReceiveAddress(s.value, s.scriptType);
const info = await addressInfo(addr);
html = `${t('accounts.testValid')} · <span class="t-mono">${esc(trunc(addr))}</span> · ${fmtInt(info.transactionCount)} ${t('accounts.testTxs')}`;
} else {
const info = await addressInfo(s.value);
html = `${t('accounts.testValid')} · ${fmtInt(info.transactionCount)} ${t('accounts.testTxs')}`;
}
out.className = 't-body-sm t-pos mt-sm';
out.innerHTML = `${esc(s.name || t('accounts.colSourceName'))}: ${html}`;
} catch (e) {
console.error('[bron-test]', e);
const msg = (e && (e.message || e.name)) || String(e);
out.className = 't-body-sm t-neg mt-sm';
out.textContent = `${s.name || t('accounts.colSourceName')}: ${t('accounts.testFail')}: ${msg}`;
}
}
async function mountSources(host, accountId, allowed) {
host.innerHTML = `<div class="src-drawer">
<div data-src-table></div>
<div class="t-body-sm mt-sm" data-src-test></div>
</div>`;
const KIND_LABEL = { xpub: 'accounts.xpub', address: 'accounts.address', nwc: 'accounts.nwc', lnd: 'accounts.lnd' };
const LIGHTNING = new Set(['nwc', 'lnd']);
const srcKindOpts = (vals) => allowed
.filter(k => !(LIGHTNING.has(k) && locked('lightning') && vals?.kind !== k))
.map(k => ({ value: k, label: t(KIND_LABEL[k]) }));
const scriptTypeOpts = () => ENUMS.SCRIPT_TYPE.map(s => ({ value: s, label: t('accounts.script_' + s) }));
const lndConnOpts = async () => [{ value: '', label: t('accounts.noNode') },
...(await listConnections('lnd')).map(c => ({ value: c.id, label: c.name }))];
const ONCHAIN = new Set(['xpub', 'address']);
const toSrc = async (v, existing = null) => {
const name = String(v.name || '').trim();
const value = String(v.value || '').trim();
if (!name) throw new Error(t('accounts.sourceNameRequired'));
if (v.kind === 'lnd') {
const connectionId = Number(v.connectionId);
if (!connectionId) throw new Error(t('accounts.lndNoConnection'));
return { src: { kind: 'lnd', value: '', name, connectionId } };
}
if (v.kind === 'nwc') {
if (!value && existing) return { src: { kind: 'nwc', value: existing.value, name }, secret: null };
if (!value) throw new Error(t('accounts.sourceValueRequired'));
const { validateUri } = await import('../lightning/nwc.js');
const parsed = await validateUri(value);
return { src: { kind: 'nwc', value: parsed.walletPubkey, name }, secret: value };
}
if (!value) throw new Error(t('accounts.sourceValueRequired'));
const src = { kind: v.kind, value, name };
if (v.kind === 'xpub') src.scriptType = v.scriptType;
if (ONCHAIN.has(v.kind)) src.connectionId = Number(v.connectionId) || null;
return { src };
};
let api = null;
const reloadSrc = async () => api.refresh(await listWalletSources(accountId));
api = renderTable(host.querySelector('[data-src-table]'), {
title: t('accounts.sources'),
columns: [
{ key: 'name', label: t('accounts.colSourceName'), sortable: true, text: true,
render: s => s.name ? esc(s.name) : '<span class="t-muted-cell">-</span>',
edit: { type: 'text', placeholder: t('accounts.sourceNamePh') } },
{ key: 'kind', label: t('accounts.colSourceKind'), sortable: true,
render: s => s.kind === 'xpub' ? 'xpub' : s.kind === 'nwc' ? 'NWC' : s.kind === 'lnd' ? 'LND' : t('accounts.address'),
edit: { type: 'select', options: srcKindOpts, reactive: true } },
{ key: 'value', label: t('accounts.colSourceValue'),
render: s => s.kind === 'nwc'
? `<span class="t-muted">${t('accounts.nwcPubkey')}</span> <span class="t-mono" title="${esc(s.value)}">${esc(trunc(s.value))}</span>${copyButton(s.value)}`
: s.kind === 'lnd'
? `<span class="t-muted">${t('accounts.lndVia')}</span> ${esc(s.connection_name || t('accounts.lndNoConnection'))}`
: `<span class="t-mono" title="${esc(s.value)}">${esc(trunc(s.value))}</span>${copyButton(s.value)}`,
edit: { type: 'text', visible: v => v.kind !== 'lnd',
placeholder: v => v.kind === 'nwc'
? (v._new ? 'nostr+walletconnect://…' : t('accounts.nwcKeepPh'))
: 'xpub6… / zpub… / bc1q…',
value: r => r ? (r.kind === 'nwc' ? '' : r.value) : '' } },
{ key: 'connectionId', label: t('accounts.colConnection'),
render: s => s.kind === 'lnd' ? esc(s.connection_name || t('accounts.lndNoConnection'))
: ONCHAIN.has(s.kind) && s.connection_name
? `<span class="t-muted" title="${esc(t('accounts.onchainOfNodeHint'))}">${t('accounts.onchainOfNode')}</span> ${esc(s.connection_name)}`
: '<span class="t-muted-cell">-</span>',
edit: { type: 'select', options: lndConnOpts, visible: v => v.kind === 'lnd' || ONCHAIN.has(v.kind),
value: r => r && r.connectionId != null ? r.connectionId : '' } },
{ key: 'scriptType', label: t('accounts.colScriptType'),
render: s => s.kind === 'xpub' ? t('accounts.script_' + s.scriptType) : '<span class="t-muted-cell">-</span>',
edit: { type: 'select', options: scriptTypeOpts, visible: v => v.kind === 'xpub' } },
],
rows: await listWalletSources(accountId),
...(await tableViewCfg('accounts.sources', { key: 'name', dir: 'asc' })),
emptyText: t('accounts.noSources'),
newLabel: t('table.new'),
newDefaults: { name: '', kind: allowed[0], value: '', scriptType: 'p2wpkh', connectionId: '', _new: 1 },
saveLabel: t('accounts.save'), cancelLabel: t('accounts.cancel'),
onCreate: async v => {
const { src, secret } = await toSrc(v);
await createWalletSource({ accountId: accountId, ...src, secret });
reloadSrc();
},
onSave: async (id, v) => {
const existing = (await listWalletSources(accountId)).find(s => s.id === id) || null;
const { src, secret } = await toSrc(v, existing);
await updateWalletSource(id, { ...src, secret });
reloadSrc();
},
onDelete: async id => {
if (!(await dialog.confirm({ message: t('accounts.confirmDeleteSource'), danger: true }))) return;
try { await deleteWalletSource(id); reloadSrc(); } catch (e) { dialog.alert({ title: t('dialog.error'), message: e.message }); }
},
extraActions: [{ key: 'test', label: t('accounts.testSource') }],
onAction: (key, id) => { if (key === 'test') testSource(host, accountId, id); },
});
}
+84
View File
@@ -0,0 +1,84 @@
// Generated, do not edit. sha256:0bce2d8ce656c1145d2eaf8627da39c4fe16f1583c0379c2a0fa2919e295eae7
import { t } from '../i18n/index.js';
import { renderAiProposals } from '../actions/ai-proposals-widget.js';
import { renderReconcile } from '../actions/reconcile-widget.js';
import { renderInterestWidget } from '../actions/interest-widget.js';
import { renderValuationWidget } from '../actions/valuation-widget.js';
import { renderReservationsWidget } from '../actions/reservations-widget.js';
import { fmtInt } from '../ui/format.js';
import { esc } from '../ui/escape.js';
import { CHECK } from '../ui/icons.js';
export const title = () => t('actions.title');
export const headerInColumn = true;
const BLOKKEN = [
{ id: 'act-ai', label: () => t('aiProposals.title') },
{ id: 'act-reconcile', label: () => t('home.reconcile') },
{ id: 'act-interest', label: () => t('actions.interestTitle') },
{ id: 'act-valuation', label: () => t('actions.valuationTitle') },
{ id: 'act-reservation', label: () => t('reservations.title') },
];
export async function render(el) {
el.innerHTML = `
<div class="page-layout">
<aside class="page-aside act-aside">
<div class="act-block">
<div class="t-label">${t('actions.openTitle')}</div>
<div class="t-num-lg" data-act="total">…</div>
<div class="t-muted-cell" data-act="totalSub"></div>
</div>
<nav class="act-jump" data-jump></nav>
</aside>
<div class="page-body">
<div class="page-header"><h1 class="page-title">${esc(title())}</h1></div>
<div id="act-ai"></div>
<div id="act-reconcile" class="mt-lg"></div>
<div id="act-interest" class="mt-lg"></div>
<div id="act-valuation" class="mt-lg"></div>
<div id="act-reservation" class="mt-lg"></div>
</div>
</div>`;
const refresh = () => render(el);
const aantal = {};
for (const [id, teken] of [['act-ai', renderAiProposals], ['act-reconcile', renderReconcile],
['act-interest', renderInterestWidget], ['act-valuation', renderValuationWidget],
['act-reservation', renderReservationsWidget]]) {
const host = el.querySelector('#' + id);
if (!host) return;
aantal[id] = await teken(host, refresh);
}
vulKolom(el, aantal);
}
function vulKolom(el, aantal) {
const zichtbaar = BLOKKEN.filter(b => aantal[b.id] != null);
const totaal = zichtbaar.reduce((som, b) => som + aantal[b.id], 0);
const zet = (sleutel, html) => {
const e = el.querySelector(`[data-act="${sleutel}"]`);
if (e) e.innerHTML = html;
};
zet('total', totaal ? fmtInt(totaal) : CHECK);
zet('totalSub', totaal
? (totaal === 1 ? t('actions.openOne') : t('actions.openMany'))
: t('actions.openNone'));
const nav = el.querySelector('[data-jump]');
if (!nav) return;
nav.innerHTML = zichtbaar.map(b => `
<button type="button" class="act-jump-item${aantal[b.id] ? '' : ' is-done'}" data-to="${b.id}">
<span class="act-jump-label">${esc(b.label())}</span>
<span class="act-jump-count">${aantal[b.id] ? fmtInt(aantal[b.id]) : CHECK}</span>
</button>`).join('');
nav.onclick = e => {
const knop = e.target.closest('[data-to]');
if (!knop) return;
const doel = el.querySelector('#' + knop.dataset.to);
if (doel) doel.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
}
+607
View File
@@ -0,0 +1,607 @@
// Generated, do not edit. sha256:9d5e374482da80f40653036b5e206e6d6ff98a8d9dbf55c030df645a418ea367
import {
listAiToolCalls, countAiToolCalls, lastAiToolCallAt, aiToolCallMessages,
} from '../data/ai-audit.js';
import { tableViewCfg, getPref, setPref } from '../data/preferences.js';
import { t, localeTag } from '../i18n/index.js';
import { fmtInt } from '../ui/format.js';
import { esc } from '../ui/escape.js';
import { alertHtml } from '../ui/alert.js';
import { renderTable } from '../ui/table.js';
import { refreshAttention } from '../ui/attention.js';
import { CHEVRON, COPY, CHECK } from '../ui/icons.js';
import { expanders } from '../ui/expand.js';
import * as dialog from '../ui/dialog.js';
import { cachedPortfolioName } from '../portfolio.js';
import { CONFIG } from '../../shared/config.js';
function moment(iso) {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString(localeTag(), { dateStyle: 'short', timeStyle: 'medium' });
}
function dag(iso) {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString(localeTag(), { dateStyle: 'short' });
}
function deelGroep(velden) {
if (velden == null) return t('ai.sharedUnknown');
return velden.length ? t('ai.sharedSome') : t('ai.sharedNothing');
}
function gedeeld(velden) {
if (velden == null) return `<span class="t-muted">${t('ai.sharedUnknown')}</span>`;
if (!velden.length) return `<span class="t-muted">${t('ai.sharedNothing')}</span>`;
return velden.map(v => `<code>${esc(v)}</code>`).join(', ');
}
function notitie(json) {
if (!json) return '';
try {
const args = JSON.parse(json);
if (args && typeof args.note === 'string' && args.note) return esc(args.note);
} catch { }
return '';
}
async function koppelGegevens() {
try {
const res = await fetch('/api/mcp/config');
if (!res.ok) return null;
const cfg = await res.json();
return cfg && typeof cfg.snippet === 'string' ? cfg : null;
} catch { return null; }
}
async function dryTools() {
try {
const res = await fetch('/api/mcp/tools');
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data.tools) ? data.tools : [];
} catch { return []; }
}
const scopeTekst = scope => {
try { return t(`ai.dryScope.${scope}`); } catch { return scope; }
};
const gezieneVerwijzingen = new Set();
const VERWIJZING = /\b[a-z]+:[0-9a-f]{6}\b/g;
function onthoudVerwijzingen(tekst) {
for (const m of String(tekst || '').matchAll(VERWIJZING)) gezieneVerwijzingen.add(m[0]);
}
const VERWIJZING_VELDEN = new Set(['handle']);
function dryArgVelden(tool) {
const schema = tool && tool.inputSchema;
const props = schema && schema.properties ? Object.entries(schema.properties) : [];
if (!props.length) return '';
const verplicht = new Set(Array.isArray(schema.required) ? schema.required : []);
const velden = props.map(([naam, spec]) => {
const nodig = verplicht.has(naam);
const label = `${esc(naam)}${nodig ? ` <span class="t-muted">(${t('ai.dryArgRequired')})</span>` : ''}`;
const bediening = Array.isArray(spec.enum)
? `<select class="input" data-arg="${esc(naam)}">
<option value="">${t('ai.dryArgPick')}</option>
${spec.enum.map(w => `<option value="${esc(w)}">${esc(w)}</option>`).join('')}
</select>`
: tekstVeld(naam, spec);
return `<label class="form-field">
<span class="form-label">${label}</span>
${bediening}
</label>`;
}).join('');
const hulp = props.some(([naam]) => VERWIJZING_VELDEN.has(naam))
? `<div class="t-body-sm t-muted" style="margin-top:0.4rem">${
gezieneVerwijzingen.size ? t('ai.dryArgHandleSome') : t('ai.dryArgHandleNone')}</div>`
: '';
return `<div class="t-body-sm t-muted" style="margin-top:0.6rem">${t('ai.dryArgs')}</div>
<div class="flex" style="gap:0.5rem;align-items:flex-end;margin-top:0.3rem;flex-wrap:wrap">${velden}</div>
${hulp}`;
}
function tekstVeld(naam, spec) {
const suggesties = VERWIJZING_VELDEN.has(naam) ? [...gezieneVerwijzingen].sort() : [];
const lijstId = `dry-lijst-${naam}`;
return `<input type="text" class="input" data-arg="${esc(naam)}"
${spec.maxLength ? `maxlength="${Number(spec.maxLength)}"` : ''}
${suggesties.length ? `list="${esc(lijstId)}"` : ''}
placeholder="${esc(spec.description || '')}">
${suggesties.length
? `<datalist id="${esc(lijstId)}">${suggesties.map(v => `<option value="${esc(v)}"></option>`).join('')}</datalist>`
: ''}`;
}
function dryBlok(tools, expandAttr) {
if (!tools.length) return '';
const opties = tools.map(x =>
`<option value="${esc(x.name)}">${esc(x.title || x.name)} (${scopeTekst(x.scope)})</option>`).join('');
return `
<details class="card-flat ai-sec" style="margin-bottom:0.8rem" ${expandAttr}>
<summary><span class="chev-btn">${CHEVRON}</span><span>${t('ai.dryTitle')}</span></summary>
<div style="margin-top:0.6rem">
<div class="t-body-sm t-muted">${t('ai.dryIntro')}</div>
<div class="flex" style="gap:0.5rem;align-items:flex-end;margin-top:0.6rem;flex-wrap:wrap">
<label class="form-field">
<span class="form-label">${t('ai.dryPick')}</span>
<select id="dry-tool" class="input">${opties}</select>
</label>
<button type="button" class="btn btn-secondary btn-sm btn--field" id="dry-run">${t('ai.dryRun')}</button>
<span class="t-body-sm t-muted" id="dry-melding"></span>
</div>
<div id="dry-args">${dryArgVelden(tools[0])}</div>
<div id="dry-uitslag" style="margin-top:0.8rem"></div>
</div>
</details>`;
}
function dryUitslag(r) {
const detail = r.detail ? `<div class="t-body-sm t-muted">${esc(r.detail)}</div>` : '';
return `
<div class="flex" style="gap:1.5rem;flex-wrap:wrap;margin-bottom:0.5rem">
<div><div class="t-body-sm t-muted">${t('ai.result')}</div><div>${
r.outcome === 'error' ? `<span class="t-neg">${t('ai.error')}</span>` : t(`ai.${r.outcome}`)}</div></div>
<div><div class="t-body-sm t-muted">${t('ai.shared')}</div><div>${gedeeld(r.fieldsShared)}</div></div>
<div><div class="t-body-sm t-muted">${t('ai.characters')}</div><div>${
r.charactersOut == null ? '' : fmtInt(r.charactersOut)}</div></div>
</div>
${detail}
<div class="t-body-sm t-muted" style="margin-top:0.5rem">${t('ai.dryResult')}</div>
${tekstBlok(r.text)}`;
}
function tekstBlok(tekst) {
return `<pre style="overflow-x:auto;white-space:pre-wrap"><code>${esc(tekst)}</code></pre>`;
}
const gesprekLade = (inhoud) => `<div class="ai-gesprek">${inhoud}</div>`;
function gesprekBlok(regels) {
if (!regels.length) return `<div class="t-body-sm t-muted">${t('ai.noConversation')}</div>`;
const blokken = regels.map(m => {
const label = t(m.kind === 'request' ? 'ai.msgRequest' : 'ai.msgResponse');
return `<div style="margin-bottom:0.6rem">
<div class="t-body-sm t-muted">${label}, ${esc(moment(m.occurredAt))}</div>
${tekstBlok(m.text)}
</div>`;
}).join('');
return blokken;
}
function bindDry(el, tools) {
const knop = el.querySelector('#dry-run');
if (!knop) return;
const keuze = el.querySelector('#dry-tool');
const melding = el.querySelector('#dry-melding');
const uitslag = el.querySelector('#dry-uitslag');
const argVak = el.querySelector('#dry-args');
const toolVan = naam => tools.find(x => x.name === naam) || null;
keuze.addEventListener('change', () => {
argVak.innerHTML = dryArgVelden(toolVan(keuze.value));
uitslag.innerHTML = '';
melding.textContent = '';
});
function werkSuggestiesBij() {
for (const veld of argVak.querySelectorAll('[data-arg]')) {
if (!VERWIJZING_VELDEN.has(veld.dataset.arg)) continue;
const lijstId = `dry-lijst-${veld.dataset.arg}`;
let lijst = argVak.querySelector(`#${lijstId}`);
if (!lijst) {
lijst = document.createElement('datalist');
lijst.id = lijstId;
veld.after(lijst);
veld.setAttribute('list', lijstId);
}
lijst.innerHTML = [...gezieneVerwijzingen].sort()
.map(v => `<option value="${esc(v)}"></option>`).join('');
}
}
function argumenten() {
const uit = {};
for (const veld of argVak.querySelectorAll('[data-arg]')) {
const w = veld.value.trim();
if (w) uit[veld.dataset.arg] = w;
}
return uit;
}
function ontbreekt(tool) {
const nodig = tool?.inputSchema?.required;
if (!Array.isArray(nodig)) return false;
const ingevuld = argumenten();
return nodig.some(naam => !ingevuld[naam]);
}
knop.addEventListener('click', async () => {
if (ontbreekt(toolVan(keuze.value))) { melding.textContent = t('ai.dryNeedsArg'); return; }
knop.disabled = true;
melding.textContent = t('ai.dryBusy');
try {
const res = await fetch('/api/mcp/dry-run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tool: keuze.value, arguments: argumenten() }),
});
if (!res.ok) throw new Error(t('ai.dryFail'));
const r = await res.json();
uitslag.innerHTML = dryUitslag(r);
melding.textContent = '';
const voor = gezieneVerwijzingen.size;
onthoudVerwijzingen(r.text);
if (gezieneVerwijzingen.size !== voor) werkSuggestiesBij();
} catch (e) {
melding.textContent = e.message || t('ai.dryFail');
} finally {
knop.disabled = false;
}
});
}
const cel = (kop, waarde) =>
`<span class="ai-stat"><span class="t-body-sm t-muted">${kop}</span><span>${waarde}</span></span>`;
function schuifCel(kop, id, aan, hint) {
return cel(kop,
`<span class="toggle-switch" id="${id}-wrap" role="switch" aria-checked="${aan ? 'true' : 'false'}"`
+ ` title="${esc(hint)}" style="cursor:pointer">`
+ `<input type="checkbox" id="${id}" tabindex="-1" disabled${aan ? ' checked' : ''}>`
+ `<span class="toggle-track"></span></span>`);
}
function tellerCellen(telling, laatste) {
if (!telling) return '';
return cel(t('ai.total'), fmtInt(telling.total))
+ cel(t('ai.ok'), fmtInt(telling.byOutcome.ok))
+ cel(t('ai.refused'), fmtInt(telling.byOutcome.refused))
+ cel(t('ai.error'), fmtInt(telling.byOutcome.error))
+ cel(t('ai.lastAt'), esc(moment(laatste)));
}
function bedieningsBlok(standen, telling, laatste) {
const { actief, lezen, schrijven, besturen, onbewerkt } = standen;
return `<span class="ai-stats">`
+ `<span class="ai-schuiven">`
+ schuifCel(t('ai.activeHead'), 'ai-active', actief, actief ? t('ai.activeOn') : t('ai.activeOff'))
+ schuifCel(t('ai.switchHead'), 'ai-read', lezen, lezen ? t('ai.switchOn') : t('ai.switchOff'))
+ schuifCel(t('ai.writeHead'), 'ai-write', schrijven, schrijven ? t('ai.writeOn') : t('ai.writeOff'))
+ schuifCel(t('ai.controlHead'), 'ai-control', besturen, besturen ? t('ai.controlOn') : t('ai.controlOff'))
+ schuifCel(t('ai.rawHead'), 'ai-raw', onbewerkt, onbewerkt ? t('ai.rawOn') : t('ai.rawOff'))
+ `</span>`
+ `<span class="ai-tellers">${tellerCellen(telling, laatste)}</span>`
+ `</span>`;
}
function precisieBlok(standen) {
const { emmerAandeel, emmerTotaal, emmerBedrag, emmerAantal } = standen;
return `
<div class="mt-md">
<div class="t-label">${t('ai.precisionTitle')}</div>
<div class="t-body-sm t-muted mt-sm">${t('ai.precisionIntro')}</div>
<span class="ai-schuiven mt-sm">
${schuifCel(t('ai.sharesHead'), 'ai-bshares', emmerAandeel,
emmerAandeel ? t('ai.sharesOn') : t('ai.sharesOff'))}
${schuifCel(t('ai.totalsHead'), 'ai-btotals', emmerTotaal,
emmerTotaal ? t('ai.totalsOn') : t('ai.totalsOff'))}
${schuifCel(t('ai.amountsHead'), 'ai-bamounts', emmerBedrag,
emmerBedrag ? t('ai.amountsOn') : t('ai.amountsOff'))}
${schuifCel(t('ai.quantitiesHead'), 'ai-bquantities', emmerAantal,
emmerAantal ? t('ai.quantitiesOn') : t('ai.quantitiesOff'))}
</span>
<span class="form-hint t-neg" id="ai-bshares-stand"></span>
<span class="form-hint t-neg" id="ai-btotals-stand"></span>
<span class="form-hint t-neg" id="ai-bamounts-stand"></span>
<span class="form-hint t-neg" id="ai-bquantities-stand"></span>
<div class="t-body-sm t-muted mt-sm">${t('ai.precisionCombo')}</div>
</div>`;
}
function koppelBlok(cfg, expandAttr, bediening, standen) {
if (!cfg) return `<div class="t-body-sm t-muted">${t('ai.connectFail')}</div>`;
return `
<details class="card-flat ai-sec" style="margin-bottom:0.8rem" ${expandAttr}>
<summary>
<span class="chev-btn">${CHEVRON}</span>
<span>${t('ai.connectTitle')}</span>
<span class="form-hint t-neg" id="ai-active-stand" style="margin-top:0"></span>
<span class="form-hint t-neg" id="ai-read-stand" style="margin-top:0"></span>
<span class="form-hint t-neg" id="ai-write-stand" style="margin-top:0"></span>
<span class="form-hint t-neg" id="ai-control-stand" style="margin-top:0"></span>
<span class="form-hint t-neg" id="ai-raw-stand" style="margin-top:0"></span>
${bediening}
</summary>
<div style="margin-top:0.6rem">
<div class="t-body-sm t-muted">${esc(portefeuilleRegel())}</div>
<div class="t-body-sm t-muted mt-sm">${t('ai.revoke')}</div>
<div class="t-body-sm t-muted mt-sm">${t('ai.rawHint')}</div>
${precisieBlok(standen)}
<div class="t-body-sm t-muted mt-md">${t('ai.connectIntro')}</div>
<div class="codeblock">
<div class="codeblock-tools">
<button type="button" class="codeblock-copy" id="mcp-config-copy"
aria-label="${esc(t('ai.connectCopy'))}" title="${esc(t('ai.connectCopy'))}">${COPY}</button>
<span class="codeblock-tag">JSON</span>
</div>
<pre><code id="mcp-config">${esc(cfg.snippet)}</code></pre>
</div>
<span class="t-body-sm t-muted" id="mcp-config-melding"></span>
<div class="t-body-sm t-muted" style="margin-top:0.6rem">
${t('ai.connectFile')} ${t('ai.connectRestart')} ${t('ai.controlHint')}
</div>
</div>
</details>`;
}
function bindSchuif(el, { wrapId, vakId, standId, scope, key, aanTekst, uitTekst, faalTekst, bevestigAan = null }) {
const wrap = el.querySelector(`#${wrapId}`);
if (!wrap) return;
const vak = el.querySelector(`#${vakId}`);
const stand = el.querySelector(`#${standId}`);
const toon = (aan, melding = '') => {
if (vak) vak.checked = aan;
wrap.setAttribute('aria-checked', aan ? 'true' : 'false');
wrap.title = aan ? aanTekst : uitTekst;
if (stand) stand.textContent = melding;
};
wrap.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const aan = wrap.getAttribute('aria-checked') !== 'true';
if (aan && bevestigAan && !await bevestigAan()) return;
toon(aan);
try {
await setPref(scope, key, aan ? 1 : 0);
} catch {
toon(!aan, faalTekst);
}
});
}
function bindSchuiven(el) {
bindSchuif(el, {
wrapId: 'ai-active-wrap', vakId: 'ai-active', standId: 'ai-active-stand',
scope: 'ai', key: 'enabled',
aanTekst: t('ai.activeOn'), uitTekst: t('ai.activeOff'), faalTekst: t('ai.switchFail'),
});
bindSchuif(el, {
wrapId: 'ai-read-wrap', vakId: 'ai-read', standId: 'ai-read-stand',
scope: 'ai', key: 'allow_read',
aanTekst: t('ai.switchOn'), uitTekst: t('ai.switchOff'), faalTekst: t('ai.switchFail'),
});
bindSchuif(el, {
wrapId: 'ai-write-wrap', vakId: 'ai-write', standId: 'ai-write-stand',
scope: 'ai', key: 'allow_write',
aanTekst: t('ai.writeOn'), uitTekst: t('ai.writeOff'), faalTekst: t('ai.switchFail'),
});
bindSchuif(el, {
wrapId: 'ai-control-wrap', vakId: 'ai-control', standId: 'ai-control-stand',
scope: 'ai', key: 'allow_control',
aanTekst: t('ai.controlOn'), uitTekst: t('ai.controlOff'), faalTekst: t('ai.switchFail'),
});
bindSchuif(el, {
wrapId: 'ai-raw-wrap', vakId: 'ai-raw', standId: 'ai-raw-stand',
scope: 'ai', key: 'allow_raw',
aanTekst: t('ai.rawOn'), uitTekst: t('ai.rawOff'), faalTekst: t('ai.switchFail'),
bevestigAan: () => dialog.confirm({
message: t('ai.rawConfirm').replace('{naam}', cachedPortfolioName() || '?'),
danger: true,
}),
});
for (const [id, key] of [
['ai-bshares', 'bucket_shares'],
['ai-btotals', 'bucket_totals'],
['ai-bamounts', 'bucket_amounts'],
['ai-bquantities', 'bucket_quantities'],
]) {
const stam = key.replace('bucket_', '');
bindSchuif(el, {
wrapId: `${id}-wrap`, vakId: id, standId: `${id}-stand`,
scope: 'ai', key,
aanTekst: t(`ai.${stam}On`), uitTekst: t(`ai.${stam}Off`), faalTekst: t('ai.switchFail'),
});
}
}
function portefeuilleRegel() {
const naam = cachedPortfolioName();
return naam ? t('ai.scope').replace('{naam}', naam) : t('ai.rawHint');
}
const BEVESTIGING_MS = 2000;
function bindKopieer(el, cfg) {
const knop = el.querySelector('#mcp-config-copy');
if (!knop || !cfg) return;
const melding = el.querySelector('#mcp-config-melding');
let terug = null;
const herstel = () => {
knop.classList.remove('is-done');
knop.innerHTML = COPY;
knop.setAttribute('aria-label', t('ai.connectCopy'));
knop.setAttribute('title', t('ai.connectCopy'));
if (melding) melding.textContent = '';
};
knop.addEventListener('click', () => {
navigator.clipboard.writeText(cfg.snippet)
.then(() => {
knop.classList.add('is-done');
knop.innerHTML = CHECK;
knop.setAttribute('aria-label', t('ai.connectCopied'));
knop.setAttribute('title', t('ai.connectCopied'));
if (melding) melding.textContent = '';
})
.catch(() => {
knop.classList.remove('is-done');
knop.innerHTML = COPY;
if (melding) melding.textContent = t('ai.connectCopyFail');
})
.finally(() => { clearTimeout(terug); terug = setTimeout(herstel, BEVESTIGING_MS); });
});
}
let live = null;
let liveGebonden = false;
const gesprekken = new Map();
function bindLive() {
if (liveGebonden) return;
liveGebonden = true;
document.addEventListener('ai-activity-changed', async () => {
if (!live || !live.el || !live.el.isConnected) { live = null; return; }
let regels, telling, laatste;
try {
[regels, telling, laatste] = await Promise.all([
listAiToolCalls(CONFIG.AI.AUDIT_PAGE_SIZE), countAiToolCalls(), lastAiToolCallAt(),
]);
} catch { return; }
if (!live.el.isConnected) { live = null; return; }
live.tabel.refresh(regels);
const tellers = live.el.querySelector('.ai-tellers');
if (tellers) tellers.innerHTML = tellerCellen(telling, laatste);
await markeerGezien(regels);
});
}
async function markeerGezien(regels) {
const hoogste = regels.reduce((n, r) => Math.max(n, Number(r.id) || 0), 0);
if (!hoogste) return;
try {
await setPref('ai', 'seen_call_id', hoogste);
await refreshAttention();
} catch { }
}
export async function render(el) {
let regels, telling, laatste;
try {
[regels, telling, laatste] = await Promise.all([
listAiToolCalls(CONFIG.AI.AUDIT_PAGE_SIZE), countAiToolCalls(), lastAiToolCallAt(),
]);
} catch (err) {
el.innerHTML = alertHtml({ kind: 'error', body: err.message });
return;
}
const cfg = await koppelGegevens();
const tools = await dryTools();
const lezen = await getPref('ai', 'allow_read', 1).then(v => v !== 0).catch(() => true);
const uitBijTwijfel = (sleutel) => getPref('ai', sleutel, 0).then(v => v === 1).catch(() => false);
const aanBijTwijfel = (sleutel) => getPref('ai', sleutel, 1).then(v => v !== 0).catch(() => true);
const standen = {
actief: await getPref('ai', 'enabled', 1).then(v => v !== 0).catch(() => true),
lezen,
schrijven: await uitBijTwijfel('allow_write'),
besturen: await uitBijTwijfel('allow_control'),
onbewerkt: await uitBijTwijfel('allow_raw'),
emmerAandeel: await aanBijTwijfel('bucket_shares'),
emmerTotaal: await aanBijTwijfel('bucket_totals'),
emmerBedrag: await aanBijTwijfel('bucket_amounts'),
emmerAantal: await aanBijTwijfel('bucket_quantities'),
};
const expand = await expanders('ai', { connect: !telling.total, dryRun: false });
if (!telling.total) {
el.innerHTML = `${koppelBlok(cfg, expand.attr('connect'), bedieningsBlok(standen, null), standen)}${dryBlok(tools, expand.attr('dryRun'))}
${alertHtml({ kind: 'neutral', body: t('ai.notConnected'),
extra: `<div class="t-body-sm t-muted" style="margin-top:0.5rem">${t('ai.howTo')}</div>` })}`;
bindSchuiven(el);
bindKopieer(el, cfg);
bindDry(el, tools);
expand.bind(el);
return;
}
el.innerHTML = `${koppelBlok(cfg, expand.attr('connect'), bedieningsBlok(standen, telling, laatste), standen)}`
+ `${dryBlok(tools, expand.attr('dryRun'))}<div id="tbl"></div>`;
bindSchuiven(el);
bindKopieer(el, cfg);
bindDry(el, tools);
expand.bind(el);
gesprekken.clear();
const tabel = renderTable(el.querySelector('#tbl'), {
columns: [
{
key: 'when', label: t('ai.when'), sortable: true,
sortValue: r => r.occurredAt || '',
render: r => esc(moment(r.occurredAt)),
group: { value: r => dag(r.occurredAt), label: v => v },
},
{
key: 'what', label: t('ai.what'), sortable: true, text: true,
sortValue: r => r.tool || '',
render: r => {
const note = notitie(r.arguments);
const detail = r.detail ? `<div class="t-body-sm t-muted">${esc(r.detail)}</div>` : '';
const noteRegel = note ? `<div class="t-body-sm t-muted">"${note}"</div>` : '';
return `<code>${esc(r.tool)}</code>${noteRegel}${detail}`;
},
group: { value: r => r.tool || '', label: v => v },
},
{
key: 'who', label: t('ai.who'), sortable: true,
sortValue: r => r.client || '',
render: r => (r.client ? esc(r.client) : `<span class="t-muted">${t('ai.unknownClient')}</span>`),
group: { value: r => r.client || t('ai.unknownClient'), label: v => v },
},
{
key: 'shared', label: t('ai.shared'), sortable: true, text: true,
sortValue: r => (r.fieldsShared == null ? -1 : r.fieldsShared.length),
render: r => gedeeld(r.fieldsShared),
group: { value: r => deelGroep(r.fieldsShared), label: v => v },
},
{
key: 'characters', label: t('ai.characters'), align: 'num', sortable: true,
sortValue: r => r.charactersOut ?? -1,
render: r => (r.charactersOut == null ? '' : fmtInt(r.charactersOut)),
},
{
key: 'result', label: t('ai.result'), sortable: true,
sortValue: r => r.outcome || '',
render: r => (r.outcome === 'error'
? `<span class="t-neg">${t(`ai.${r.outcome}`)}</span>` : t(`ai.${r.outcome}`)),
group: { value: r => t(`ai.${r.outcome}`), label: v => v },
},
],
rows: regels,
idKey: 'id',
rowExpansion: (row) => gesprekLade(gesprekken.get(row.id) ?? `<div class="t-body-sm t-muted">${t('ai.conversation')}</div>`),
onExpansionMount: async (row, host) => {
if (gesprekken.has(row.id)) { host.innerHTML = gesprekLade(gesprekken.get(row.id)); return; }
try {
gesprekken.set(row.id, gesprekBlok(await aiToolCallMessages(row.id)));
} catch {
host.innerHTML = gesprekLade(`<div class="t-body-sm t-muted">${t('ai.conversationFail')}</div>`);
return;
}
host.innerHTML = gesprekLade(gesprekken.get(row.id));
},
...(await tableViewCfg('ai', { key: 'when', dir: 'desc' })),
noneLabel: t('table.none'),
collapsibleGroups: true,
emptyText: t('ai.noRows'),
});
live = { el, tabel };
bindLive();
await markeerGezien(regels);
}
+156
View File
@@ -0,0 +1,156 @@
// Generated, do not edit. sha256:7de3dc53eab20f070a6ac8c7162fee1bd0695f724c6a13e314a994181782906e
import { listAssets, listBookableAssets, createAsset, updateAsset, deleteAsset } from '../data/assets.js';
import { hasMetalAssets } from '../data/metals.js';
import { tableViewCfg } from '../data/preferences.js';
import { t } from '../i18n/index.js';
import * as dialog from '../ui/dialog.js';
import { esc } from '../ui/escape.js';
import { ENUMS, CONFIG, balanceCaptureOn } from '../../shared/config.js';
import { REIKWIJDTE, categorieVoorAsset } from '../tax/constants.js';
import { renderTable } from '../ui/table.js';
import { takeRowFocus } from '../ui/row-focus.js';
import * as metalProducts from './metal-products.js';
import { capacityCfg } from '../ui/lock.js';
import { baseCurrency } from '../app-settings.js';
let tableApi = null;
export async function render(el) {
el.innerHTML = `
<div id="tbl"></div>
<div id="mp-section"></div>`;
await buildTable(el);
if (await hasMetalAssets()) {
const sec = el.querySelector('#mp-section');
sec.innerHTML = `<div id="mp-tbl" class="mt-lg"></div>`;
metalProducts.mount(sec.querySelector('#mp-tbl'));
}
}
function kindOpts() { return ENUMS.ASSET_KIND.map(v => ({ value: v, label: t('enums.asset_kind.' + v) })); }
function displayUnitOpts() { return ENUMS.ASSET_DISPLAY_UNIT.map(v => ({ value: v, label: t('enums.displayUnit.' + v) })); }
function taxCategorieOpts(vals = {}) {
const derived = categorieVoorAsset(vals);
return [{ value: '', label: derived
? t('assets.taxCategoryAutoIs', { waarde: t('enums.tax_category.' + derived) })
: t('assets.taxCategoryAuto') },
...ENUMS.TAX_CATEGORY.filter(v => v !== derived || v === vals.taxCategory)
.map(v => ({ value: v, label: t('enums.tax_category.' + v) }))];
}
function taxScopeOpts(vals = {}) {
return [{ value: '', label: t('assets.taxScopeAuto') },
...ENUMS.TAX_SCOPE.filter(v => v !== REIKWIJDTE.ALL || v === vals.taxScope)
.map(v => ({ value: v, label: t('enums.tax_scope.' + v) }))];
}
function balanceCaptureOpts() {
return [{ value: '', label: t('assets.taxCategoryAuto') },
{ value: '1', label: t('assets.balanceCaptureOn') }, { value: '0', label: t('assets.balanceCaptureOff') }];
}
const captureValue = v => (v === '' || v == null ? null : Number(v));
async function buildTable(el) {
const columns = [
{ key: 'name', label: t('assets.name'), sortable: true, text: true,
edit: { type: 'text', placeholder: 'Bitcoin' } },
{ key: 'symbol', label: t('assets.symbol'), sortable: true,
render: a => `<strong>${esc(a.symbol)}</strong>`,
edit: { type: 'text', placeholder: 'BTC' } },
{ key: 'kind', label: t('assets.kind'), sortable: true,
render: a => t('enums.asset_kind.' + a.kind),
group: { value: a => a.kind, label: v => t('enums.asset_kind.' + v) },
edit: { type: 'select', options: kindOpts, reactive: true } },
{ key: 'unit', label: t('assets.unit'), sortable: true,
render: a => `${esc(a.unit)}${a.subunit ? ` <span class="t-muted-cell">/ ${esc(a.subunit)}</span>` : ''}`,
edit: { type: 'text', placeholder: 'BTC / g / ozt' } },
{ key: 'subunit', label: t('assets.subunit'),
render: a => (a.subunit ? esc(a.subunit) : '-'),
edit: { type: 'text', placeholder: 'sat / mg' } },
{ key: 'displayUnit', label: t('assets.displayUnit'), sortable: true,
render: a => {
if (!a.subunit) return `<strong>${esc(a.unit)}</strong>`;
const subChosen = a.displayUnit === 'subunit';
const part = (txt, chosen) => chosen ? `<strong>${txt}</strong>` : `<span class="t-muted-cell">${txt}</span>`;
return `${part(esc(a.unit), !subChosen)} <span class="t-muted-cell">/</span> ${part(esc(a.subunit), subChosen)}`;
},
edit: { type: 'select', options: displayUnitOpts } },
{ key: 'decimals', label: t('assets.decimals'), align: 'num', sortable: true,
edit: { type: 'number', attrs: { min: 0, step: 1 } } },
{ key: 'taxCategory', label: t('assets.taxCategory'), sortable: true,
render: a => a.taxCategory
? t('enums.tax_category.' + a.taxCategory)
: `<span class="t-muted-cell">${categorieVoorAsset(a)
? t('assets.taxCategoryAutoIs', { waarde: t('enums.tax_category.' + categorieVoorAsset(a)) })
: t('assets.taxCategoryAuto')}</span>`,
group: { value: a => a.taxCategory || '', label: v => v ? t('enums.tax_category.' + v) : t('assets.taxCategoryAuto') },
edit: { type: 'select', options: taxCategorieOpts } },
{ key: 'taxScope', label: t('assets.taxScope'), sortable: true,
render: a => a.taxScope
? t('enums.tax_scope.' + a.taxScope)
: `<span class="t-muted-cell">${t('assets.taxScopeAuto')}</span>`,
group: { value: a => a.taxScope || '', label: v => v ? t('enums.tax_scope.' + v) : t('assets.taxScopeAuto') },
edit: { type: 'select', options: taxScopeOpts } },
{ key: 'balanceCapture', label: t('assets.balanceCapture'), sortable: true,
render: a => a.balanceCapture == null
? `<span class="t-muted-cell">${t(balanceCaptureOn(a) ? 'assets.balanceCaptureAutoOn' : 'assets.balanceCaptureAutoOff')}</span>`
: a.balanceCapture === 1
? `<span class="badge badge-accent">${t('assets.balanceCaptureOn')}</span>`
: `<span class="badge badge-neutral">${t('assets.balanceCaptureOff')}</span>`,
edit: { type: 'select', options: balanceCaptureOpts } },
];
function toPayload(v) {
const symbol = String(v.symbol || '').trim();
const name = String(v.name || '').trim();
const unit = String(v.unit || '').trim();
if (!symbol || !name || !unit) throw new Error(t('assets.fillSymbolNameUnit'));
if (v.kind === 'metal') {
const M = CONFIG.METALS;
if (!M.UNITS[unit]) {
throw new Error(t('assets.metalUnitUnknown', { eenheden: Object.keys(M.UNITS).join(', ') }));
}
return {
symbol, name, kind: 'metal',
unit, subunit: M.BASE_UNIT, decimals: M.BASE_DECIMALS,
displayUnit: v.displayUnit === 'subunit' ? 'subunit' : 'unit',
taxCategory: v.taxCategory || null,
taxScope: v.taxScope || null,
balanceCapture: captureValue(v.balanceCapture),
};
}
const decimals = parseInt(v.decimals, 10);
if (!Number.isInteger(decimals) || decimals < 0) throw new Error(t('assets.fillDecimals'));
const subunit = (v.subunit || '').trim() || null;
const displayUnit = (subunit && v.displayUnit === 'subunit') ? 'subunit' : 'unit';
return {
symbol, name, kind: v.kind,
unit, subunit, decimals, displayUnit,
taxCategory: v.taxCategory || null,
taxScope: v.taxScope || null,
balanceCapture: captureValue(v.balanceCapture),
};
}
const rijen = await listAssets();
const bookable = async () => (await listBookableAssets()).filter(r => r.symbol !== baseCurrency()).length;
let used = await bookable();
tableApi = renderTable(el.querySelector('#tbl'), {
columns, rows: rijen,
...capacityCfg('assets', () => used),
...(await tableViewCfg('assets', { key: 'symbol', dir: 'asc' })),
emptyText: t('assets.none'),
noneLabel: t('table.none'),
collapsibleGroups: true,
newLabel: t('table.new'),
newDefaults: { kind: 'crypto', decimals: '', displayUnit: 'unit', taxCategory: '', taxScope: '', balanceCapture: '' },
saveLabel: t('assets.save'), cancelLabel: t('assets.cancel'),
onCreate: async v => { const id = await createAsset(toPayload(v)); used = await bookable(); tableApi.refresh(await listAssets(), { locate: id }); },
onSave: async (id, v) => { await updateAsset(id, toPayload(v)); tableApi.refresh(await listAssets()); },
onDelete: async id => {
if (!(await dialog.confirm({ message: t('assets.confirmDelete'), danger: true }))) return;
try { await deleteAsset(id); used = await bookable(); tableApi.refresh(await listAssets()); } catch (e) { dialog.alert({ title: t('dialog.error'), message: e.message }); }
},
});
tableApi.focusRow(takeRowFocus('assets'));
}
+308
View File
@@ -0,0 +1,308 @@
// Generated, do not edit. sha256:1f57286ac82ef2aadfb0cdfa809fcb1f771cf74bba815b7e579bbb106a986dd4
import { t } from '../i18n/index.js';
import { listConnections, saveConnection, setActiveConnection, deleteConnection, recordConnectionStatus } from '../data/connections.js';
import { localDateTimeNl } from '../../shared/time.js';
import { CONFIG, ENUMS } from '../../shared/config.js';
import { originAllowedByCsp } from '../../shared/csp.js';
import * as dialog from '../ui/dialog.js';
import * as lnd from '../lightning/lnd.js';
import * as market from '../market-service.js';
import { appSetting, setAppSetting } from '../app-settings.js';
import { clearSpotCache } from '../price/providers.js';
import { fmtInt } from '../ui/format.js';
import { esc } from '../ui/escape.js';
import { locked } from '../license.js';
import { serviceState } from '../data/integration-log.js';
import { lastFailureHtml, openFailureLog } from '../integrations/failure-log.js';
const KINDS = ['btc_explorer', 'lnd'];
const KIND_META = {
btc_explorer: { label: 'connections.kindBtcExplorer', usedBy: 'connections.usedByBtcExplorer' },
lnd: { label: 'connections.kindLnd', usedBy: 'connections.usedByLnd' },
};
const explorerUrlsAtLoad = Promise.all([listConnections('btc_explorer')])
.then(([rows]) => rows.map(c => c.url)).catch(() => []);
const liveResult = new Map();
const fadeTimers = new Map();
function clearFade(kind) {
clearTimeout(fadeTimers.get(kind));
fadeTimers.delete(kind);
}
function scheduleFade(el, kind) {
clearFade(kind);
fadeTimers.set(kind, setTimeout(() => {
if (!isOnsPaneel(el)) { liveResult.delete(kind); return; }
const host = el.querySelector(`[data-result="${kind}"]`);
if (host) {
host.style.transitionDuration = `${CONFIG.CONNECTIONS.RESULT_FADE_MS}ms`;
host.classList.add('is-fading');
}
fadeTimers.set(kind, setTimeout(() => {
liveResult.delete(kind);
if (isOnsPaneel(el)) paintResult(el, kind);
}, CONFIG.CONNECTIONS.RESULT_FADE_MS));
}, CONFIG.CONNECTIONS.RESULT_HOLD_MS));
}
async function testConnection(conn) {
if (conn.kind === 'btc_explorer') {
if (!originAllowedByCsp(conn.url, location.origin, await explorerUrlsAtLoad)) {
throw new Error(t('connections.cspBlocked'));
}
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), CONFIG.CONNECTIONS.TEST_TIMEOUT_MS);
try {
const res = await fetch(`${conn.url}/api/blocks/tip/height`, { signal: ctl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const height = Number((await res.text()).trim());
if (!Number.isFinite(height) || height <= 0) throw new Error(t('connections.badResponse'));
return `${t('connections.blockHeight')} ${fmtInt(height)}`;
} finally { clearTimeout(timer); }
}
if (conn.kind === 'lnd') {
const r = await lnd.testLnd(conn.id);
const sync = t(r.synced ? 'connections.lndSynced' : 'connections.lndNotSynced');
return `${r.alias ? `${esc(r.alias)} · ` : ''}${sync} · ${t('connections.blockHeight')} ${fmtInt(r.blockHeight)}`;
}
throw new Error(t('connections.testUnsupported'));
}
async function runTest(el, conn, { footer = false } = {}) {
if (footer) { clearFade(conn.kind); liveResult.set(conn.kind, { pending: true }); paintResult(el, conn.kind); }
try {
const detail = await testConnection(conn);
await recordConnectionStatus(conn.id, true);
if (footer) liveResult.set(conn.kind, { ok: true, text: `${esc(conn.name)}: ${t('connections.statusOk')} · ${detail}` });
} catch (e) {
await recordConnectionStatus(conn.id, false);
if (footer) liveResult.set(conn.kind, { ok: false, text: `${esc(conn.name)}: ${t('connections.statusFail')}: ${esc(e.message)}` });
}
if (!isOnsPaneel(el)) return;
draw(el);
if (footer) scheduleFade(el, conn.kind);
}
function paintResult(el, kind) {
const host = el.querySelector(`[data-result="${kind}"]`);
if (!host) return;
host.style.transitionDuration = '';
const r = liveResult.get(kind);
if (!r) { host.className = 'conn-result'; host.innerHTML = ''; return; }
if (r.pending) { host.className = 'conn-result'; host.textContent = t('connections.testing'); return; }
host.className = `conn-result ${r.ok ? 'is-ok' : 'is-fail'}`;
host.innerHTML = r.text;
}
function dotHtml(c) {
const cls = c.lastStatus === 'ok' ? ' dot-ok' : c.lastStatus === 'fail' ? ' dot-fail' : '';
const label = c.lastStatus === 'ok' ? t('connections.statusOk')
: c.lastStatus === 'fail' ? t('connections.statusFail') : t('connections.notTested');
const when = c.lastCheckedAt ? ` · ${t('connections.lastChecked')} ${localDateTimeNl(c.lastCheckedAt)}` : '';
return `<span class="connp-dot${cls}" title="${esc(label + when)}"></span>`;
}
function tileHtml(c) {
const hover = c.isSystem ? '' : `<span class="connp-hover">
<button type="button" class="connp-mini" data-edit="${c.id}" title="${esc(t('connections.edit'))}">✎</button>
<button type="button" class="connp-mini" data-del="${c.id}" title="${esc(t('connections.delete'))}">✕</button>
</span>`;
return `<div class="connp${c.isActive ? ' is-active' : ''}" role="button" tabindex="0" data-select="${c.id}">
${dotHtml(c)}
<div class="connp-name">${esc(c.name)}</div>
<div class="connp-url" title="${esc(c.url)}">${esc(c.url)}</div>
${hover}
</div>`;
}
const KIND_FEATURE = Object.freeze({ btc_explorer: 'ownMempool', lnd: 'lightning', nwc: 'lightning' });
async function frameHtml(kind) {
const list = await listConnections(kind);
const opSlot = locked(KIND_FEATURE[kind]);
if (opSlot && !list.length) return '';
const tiles = list.map(tileHtml).join('');
return `<div class="card conn-frame" data-kind="${kind}">
<div class="t-label src-drawer-title">${t(KIND_META[kind].label)}</div>
<div class="t-body-sm t-muted conn-usedby">${t(KIND_META[kind].usedBy)}</div>
<div class="connp-row">${tiles}</div>
<div class="conn-foot">
${opSlot ? '' : `<button class="btn btn-ghost btn-md" data-new="${kind}" title="${esc(t('connections.newProfile'))}">+</button>`}
<button class="btn btn-primary btn-md" data-test-active="${kind}">${t('connections.test')}</button>
<div class="conn-result" data-result="${kind}"></div>
</div>
</div>`;
}
const MARKET = 'market';
let marketStatus = null;
function tokenLine(st) {
if (st.token_source === 'license') return t('connections.marketTokenLicense', { tier: t(`license.level_${st.tier}`) });
if (st.token_source === 'manual') return t('connections.marketTokenManual');
return t(st.can_register ? 'connections.marketTokenPending' : 'connections.marketTokenNoLicense');
}
async function marketFrameHtml() {
const st = await market.status();
let shown = marketStatus;
if (!shown && st.token_set) {
try {
const s = await serviceState();
if (s.ok != null) shown = { ok: s.ok, at: Date.parse(s.at) };
} catch { }
}
const cls = !st.token_set || !shown ? '' : shown.ok ? ' dot-ok' : ' dot-fail';
const label = !st.token_set ? t('connections.marketTokenUnset')
: !shown ? t('connections.notTested') : t(shown.ok ? 'connections.statusOk' : 'connections.statusFail');
const when = shown ? ` · ${t('connections.lastChecked')} ${localDateTimeNl(new Date(shown.at).toISOString())}` : '';
const lastFailure = st.token_set ? await lastFailureHtml('service').catch(() => '') : '';
const url = st.override ? `${st.url} (${t('connections.marketOverrideUrl')})` : '';
const stand = [tokenLine(st), t('connections.marketSpotShort_' + appSetting('market_spot'))].join(' · ');
return `<div class="card conn-frame" data-kind="${MARKET}">
<div class="t-label src-drawer-title">${t('connections.kindMarket')}</div>
<div class="t-body-sm t-muted conn-usedby">${t('connections.usedByMarket')}</div>
<div class="connp-row">
<div class="connp${st.token_set ? ' is-active' : ''}" role="button" tabindex="0" data-market-edit title="${esc(t('connections.marketConfigure'))}">
<span class="connp-dot${cls}" title="${esc(label + when)}"></span>
<div class="connp-name">Atreides Services</div>
${url ? `<div class="connp-url" title="${esc(url)}">${esc(url)}</div>` : ''}
</div>
</div>
<div class="t-body-sm t-muted">${esc(stand)}</div>
${lastFailure}
<div class="conn-foot">
<button class="btn btn-primary btn-md" data-market-test>${t('connections.test')}</button>
<div class="conn-result" data-result="${MARKET}"></div>
</div>
</div>`;
}
async function runMarketTest(el, { footer = false } = {}) {
if (footer) { clearFade(MARKET); liveResult.set(MARKET, { pending: true }); paintResult(el, MARKET); }
try {
const r = await market.test();
marketStatus = { ok: true, at: Date.now() };
const detail = t('connections.marketTestOk', { app: r.app, version: r.version || '', n: fmtInt(r.instruments) });
if (footer) liveResult.set(MARKET, { ok: true, text: `${t('connections.statusOk')} · ${esc(detail)}` });
} catch (e) {
marketStatus = { ok: false, at: Date.now() };
if (footer) liveResult.set(MARKET, { ok: false, text: `${t('connections.statusFail')}: ${esc(e.message)}` });
}
if (!isOnsPaneel(el)) return;
await draw(el);
if (footer) scheduleFade(el, MARKET);
}
async function marketDialog(el) {
const res = await dialog.form({
title: t('connections.marketDialogTitle'),
confirmLabel: t('connections.save'),
fields: [
{ key: 'spot', label: t('connections.marketSpot'), type: 'select', value: appSetting('market_spot'),
options: ENUMS.MARKET_SPOT.map(v => ({ value: v, label: t('connections.marketSpot_' + v) })) },
],
});
if (!res) return;
if (res.spot && res.spot !== appSetting('market_spot')) {
await setAppSetting('market_spot', res.spot);
clearSpotCache();
}
await draw(el);
runMarketTest(el, { footer: true });
}
const byId = async id => (await listConnections()).find(c => c.id === Number(id));
const activeOf = async kind => (await listConnections(kind)).find(c => c.isActive);
async function profileDialog(el, kind, c = null) {
const fields = [
{ key: 'name', label: t('connections.name'), value: c ? c.name : '' },
{ key: 'url', label: t(kind === 'lnd' ? 'connections.lndUrl' : 'connections.url'),
value: c ? c.url : '', placeholder: kind === 'lnd' ? '10.8.0.1:8080' : 'https://…' },
];
if (kind === 'lnd') {
const keep = c ? t('connections.secretKeepHint') : '';
fields.push(
{ key: 'macaroon', label: t('connections.lndMacaroon'), type: 'password', placeholder: keep || '0201036c6e64…' },
{ key: 'tls_cert', label: t('connections.lndTlsCert'), type: 'textarea', placeholder: keep || '-----BEGIN CERTIFICATE-----' },
);
}
const res = await dialog.form({
title: c ? t('connections.editProfile') : t('connections.newProfile'),
confirmLabel: t('connections.save'),
fields,
});
if (!res) return;
if (kind === 'lnd' && res.url && !/^[a-z]+:\/\//i.test(res.url.trim())) res.url = `https://${res.url.trim()}`;
const id = await saveConnection({ id: c ? c.id : null, kind, name: res.name, url: res.url });
if (kind === 'lnd') await lnd.setConfig(id, { url: res.url, macaroon: res.macaroon, tls_cert: res.tls_cert });
draw(el);
}
const isOnsPaneel = (el) => !!el && !!el.querySelector('[data-result]');
async function draw(el) {
const [marketFrame, frames] = await Promise.all([marketFrameHtml(), Promise.all(KINDS.map(frameHtml))]);
el.innerHTML = marketFrame + frames.join('');
for (const kind of [MARKET, ...KINDS]) paintResult(el, kind);
el.onclick = async e => {
try {
if (e.target.closest('[data-market-edit]')) { await marketDialog(el); return; }
if (e.target.closest('[data-market-test]')) { await runMarketTest(el, { footer: true }); return; }
const ilog = e.target.closest('[data-ilog-open]');
if (ilog) { await openFailureLog(ilog.dataset.ilogOpen); return; }
const edit = e.target.closest('[data-edit]');
if (edit) { const c = await byId(edit.dataset.edit); if (c) await profileDialog(el, c.kind, c); return; }
const del = e.target.closest('[data-del]');
if (del) {
const c = await byId(del.dataset.del);
if (!c) return;
if (!(await dialog.confirm({ message: t('connections.confirmDelete'), danger: true }))) return;
if (c.kind === 'lnd') await lnd.purgeConfig(c.id);
await deleteConnection(c.id);
draw(el);
if (c.isActive) { const a = await activeOf(c.kind); if (a) runTest(el, a); }
return;
}
const nieuw = e.target.closest('[data-new]');
if (nieuw) {
await profileDialog(el, nieuw.dataset.new);
return;
}
const testBtn = e.target.closest('[data-test-active]');
if (testBtn) { const a = await activeOf(testBtn.dataset.testActive); if (a) await runTest(el, a, { footer: true }); return; }
const tile = e.target.closest('[data-select]');
if (tile) {
const c = await byId(tile.dataset.select);
if (!c || c.isActive) return;
await setActiveConnection(c.id);
draw(el);
runTest(el, c);
return;
}
} catch (err) { dialog.alert({ title: t('dialog.error'), message: err.message }); }
};
el.onkeydown = e => {
if ((e.key === 'Enter' || e.key === ' ') && e.target.matches && e.target.matches('[data-select], [data-market-edit]')) {
e.preventDefault();
e.target.click();
}
};
}
export async function render(el) {
for (const kind of [MARKET, ...KINDS]) clearFade(kind);
liveResult.clear();
await draw(el);
const st = await market.status();
if (st.token_set || st.can_register) runMarketTest(el);
for (const kind of KINDS) {
for (const c of await listConnections(kind)) runTest(el, c);
}
}
+121
View File
@@ -0,0 +1,121 @@
// Generated, do not edit. sha256:1c9c0ff79637180d3780a7e1dd1e59dbb03533899d9a66468c9cdf709d9ee0fe
import { t, localeTag } from '../i18n/index.js';
import { listAssets } from '../data/assets.js';
import { liveSpotFor, basePriceOf } from '../price/providers.js';
import { fmtMoney, fmtInt, currencySymbol, currencyName, parseLocaleNumber } from '../ui/format.js';
import { baseCurrency } from '../app-settings.js';
const TO_BTC = { btc: 1, mbtc: 1e-3, bit: 1e-6, sat: 1e-8, msat: 1e-11 };
const UNITS = ['fiat', 'btc', 'mbtc', 'bit', 'sat', 'msat'];
export const title = () => t('conversion.title');
const parseNumber = str => parseLocaleNumber(str) || 0;
function formatUnit(unit, value) {
if (value === 0) return '';
const nl = localeTag();
switch (unit) {
case 'fiat': return value.toLocaleString(nl, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
case 'btc': return value.toLocaleString(nl, { maximumFractionDigits: 8 });
case 'mbtc': return value.toLocaleString(nl, { maximumFractionDigits: 5 });
case 'bit': return value.toLocaleString(nl, { maximumFractionDigits: 2 });
case 'sat':
case 'msat': return Math.round(value).toLocaleString(nl);
}
}
export async function render(el) {
const n = fmtInt;
el.innerHTML = `
<div class="card price-hero">
<div>
<div class="t-label price-label">${t('conversion.btcRate')}</div>
<div class="t-value price-val" id="price-display">€ -</div>
<div class="price-meta" id="price-meta">${t('conversion.loading')}</div>
</div>
</div>
<div class="conv-grid">
${field('fiat', t('conversion.fiat'), '€ EUR', t('conversion.fiatHint'), true)}
${field('btc', 'Bitcoin', '₿ BTC', t('conversion.btcHint'))}
${field('mbtc', 'Millibitcoin', 'mBTC', `1 BTC = ${n(1e3)} mBTC`)}
${field('bit', 'Bit (µBTC)', 'bit', `1 BTC = ${n(1e6)} bits`)}
${field('sat', 'Satoshi', 'sat', `1 BTC = ${n(1e8)} sats`)}
${field('msat', 'Millisatoshi', 'msat', `1 sat = ${n(1e3)} msats · Lightning`)}
</div>
<div class="info">
<div class="t-label info-title">${t('conversion.hierarchy')}</div>
<div class="info-body">
<strong>1 BTC</strong> = ${n(1e3)} mBTC = ${n(1e6)} bits = ${n(1e8)} sats = ${n(1e11)} msats<br>
<strong>1 mBTC</strong> = ${n(1e3)} bits = ${n(1e5)} sats<br>
<strong>1 bit</strong> = 100 sats · <strong>1 sat</strong> = ${n(1e3)} msats
</div>
<div class="info-note">${t('conversion.satNote')}</div>
</div>
<div class="conv-footer">${t('conversion.footer')}</div>`;
const q = id => el.querySelector('#' + id);
const currency = baseCurrency();
let livePrice = null;
function updateAll(sourceUnit, sourceValue) {
const rate = livePrice;
let btcValue;
if (sourceUnit === 'fiat') {
if (!rate) return;
btcValue = sourceValue / rate;
} else {
btcValue = sourceValue * TO_BTC[sourceUnit];
}
UNITS.forEach(unit => {
if (unit === sourceUnit) return;
const value = unit === 'fiat' ? (rate ? btcValue * rate : 0) : btcValue / TO_BTC[unit];
q(unit).value = formatUnit(unit, value);
});
}
function recalcFromFiat() {
const f = q('fiat');
if (f.value) updateAll('fiat', parseNumber(f.value));
}
function renderPrice() {
const rate = livePrice;
q('price-display').textContent = rate ? fmtMoney(rate, currency, { decimals: 0 }) : `${currencySymbol(currency)} -`;
}
function applyCurrencyUI() {
q('fiat-name').textContent = currencyName(currency);
q('fiat-badge').textContent = `${currencySymbol(currency)} ${currency}`;
renderPrice();
recalcFromFiat();
}
UNITS.forEach(unit => q(unit).addEventListener('input', function () {
updateAll(unit, parseNumber(this.value));
}));
applyCurrencyUI();
const btc = (await listAssets()).find(a => a.symbol === 'BTC');
Promise.resolve(btc ? liveSpotFor(btc) : null).then(p => {
if (!p) { q('price-meta').textContent = t('conversion.rateUnavailable'); return; }
livePrice = basePriceOf(p);
renderPrice(); recalcFromFiat();
const now = new Date().toLocaleTimeString(localeTag(), { hour: '2-digit', minute: '2-digit' });
q('price-meta').textContent = `${t('conversion.updatedAt')} ${now}`;
}).catch(() => { q('price-meta').textContent = t('conversion.rateUnavailable'); });
}
function field(id, name, badge, hint, isFiat = false) {
const nameAttr = isFiat ? ' id="fiat-name"' : '';
const badgeAttr = isFiat ? ' id="fiat-badge"' : '';
return `
<label class="field">
<div class="field-head">
<span class="field-name"${nameAttr}>${name}</span>
<span class="badge"${badgeAttr}>${badge}</span>
</div>
<input type="text" class="field-input" id="${id}" placeholder="0" inputmode="decimal">
<span class="field-hint">${hint}</span>
</label>`;
}
+428
View File
@@ -0,0 +1,428 @@
// Generated, do not edit. sha256:aff33ec09f0738c89d055fc48911d73d2e952b3818493f0f70824b837ab7c4bf
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) }));
if (nowText) marks.push({ 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;
}
+59
View File
@@ -0,0 +1,59 @@
// Generated, do not edit. sha256:8af265c01fc0e6317d5563eb76a9a712b80e1831a203564f5447704bbed5c878
import { t } from '../i18n/index.js';
import { getPref, setPref } from '../data/preferences.js';
import { EVENTS_PAGE, targetHash } from '../../shared/config.js';
import { create as createTabs } from '../ui/tabs.js';
import { attentionForRoute, attentionDotHtml } from '../ui/attention.js';
import { clearPageHint } from '../ui/page-hint.js';
import * as accounts from './event-accounts.js';
import * as valuations 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';
export const title = () => t('nav.events');
const ROUTE = EVENTS_PAGE.ROUTE;
const TABS = EVENTS_PAGE.TABS;
const TAB_IDS = Object.values(TABS);
const PREF_SCOPE = ROUTE;
const RENDER = {
[TABS.ACCOUNTS]: (panel) => accounts.render(panel),
[TABS.VALUATIONS]: (panel) => valuations.render(panel),
[TABS.TRADES]: (panel) => trades.render(panel),
[TABS.TRANSFERS]: (panel) => transfers.render(panel),
[TABS.INTEREST]: (panel) => interest.render(panel),
[TABS.RESERVATIONS]: (panel) => reservations.render(panel),
};
const parseHash = () => (location.hash || '').replace(/^#\/?/, '').split('/')[1] || null;
const hashFor = tab => targetHash({ route: ROUTE, tab });
function tabDot(tabId) {
return attentionDotHtml(attentionForRoute(ROUTE).filter(a => (a.tab || '').split('/')[0] === tabId));
}
export async function render(el) {
let tab = parseHash();
if (!TAB_IDS.includes(tab)) {
const att = attentionForRoute(ROUTE).find(a => TAB_IDS.includes((a.tab || '').split('/')[0]));
tab = att ? att.tab.split('/')[0] : await getPref(PREF_SCOPE, 'tab', TABS.ACCOUNTS);
if (!TAB_IDS.includes(tab)) tab = TABS.ACCOUNTS;
history.replaceState(null, '', hashFor(tab));
}
el.innerHTML = '<div data-tabs></div>';
createTabs(el.querySelector('[data-tabs]'), {
size: 'lg',
active: tab,
items: TAB_IDS.map(id => ({ id, label: t(`nav.${id}`), dot: () => tabDot(id) })),
render: (id, panel) => { clearPageHint(); return RENDER[id](panel); },
onChange: (id) => {
setPref(PREF_SCOPE, 'tab', id).catch(() => { });
if (location.hash !== hashFor(id)) history.pushState(null, '', hashFor(id));
},
syncOn: ['attention-changed'],
});
}
+148
View File
@@ -0,0 +1,148 @@
// Generated, do not edit. sha256:a22e862b7b1defbcc74c1844981b5027dbd831d7e549f1dff573576ab6b2f26e
import { t, locale } from '../i18n/index.js';
import { ROUTE_KEYS, TARGETS, PLACE_LABELS, targetHash } from '../../shared/config.js';
import { GUIDE_SECTIONS, GUIDE_GROUPS, splitPlaceTokens, guideText } from '../../shared/guide.js';
import { create as createTabs } from '../ui/tabs.js';
import { esc } from '../ui/escape.js';
import { CHEVRON_RIGHT, ADJUST } from '../ui/icons.js';
import { GUIDE_FIGURE_DRAWINGS } from '../ui/guide-figures.js';
import { placeAvailable } from '../routes.js';
export const title = () => t('guide.title');
export const headerInColumn = true;
const ROUTE = ROUTE_KEYS.GUIDE;
const IDS = GUIDE_SECTIONS.map(s => s.id);
const BY_ID = new Map(GUIDE_SECTIONS.map(s => [s.id, s]));
const GROUP_OF = new Map(GUIDE_GROUPS.flatMap(g => g.sections.map(id => [id, g.id])));
const texts = () => guideText(locale());
const titleOf = id => texts().sections[id].title;
const shown = id => { const p = BY_ID.get(id)?.place; return !p || placeAvailable(TARGETS[p]); };
const parseHash = () => (location.hash || '').replace(/^#\/?/, '').split('/')[1] || null;
const hashFor = id => targetHash({ route: ROUTE, tab: id });
const segLabel = id => { try { return t(PLACE_LABELS[id] || `nav.${id}`); } catch { return id; } };
const segsOf = key => { const { route, tab, sub } = TARGETS[key]; return [route, tab, sub].filter(Boolean); };
const placeName = key => segLabel(segsOf(key).at(-1));
function placeNameUnique(key) {
const naam = placeName(key);
const dubbel = Object.keys(TARGETS).some(k => k !== key && placeName(k) === naam);
if (!dubbel) return naam;
const segs = segsOf(key);
return `${segLabel(segs.at(-2) ?? segs[0])} › ${naam}`;
}
const placeLink = (key, label, cls = '', after = '') =>
`<a${cls ? ` class="${cls}"` : ''} href="${esc(targetHash(TARGETS[key]))}" target="_blank" rel="noopener" `
+ `title="${esc(t('guide.newTab'))}">${esc(label)}${after}</a>`;
const ICONS = Object.freeze({ adjust: ADJUST });
function inline(text) {
return splitPlaceTokens(text).map(part => {
if (typeof part === 'string') return esc(part);
if (part.icon) return ICONS[part.icon] ? `<span class="guide-icon">${ICONS[part.icon]}</span>` : '';
return placeAvailable(TARGETS[part.place]) ? placeLink(part.place, placeName(part.place)) : esc(placeName(part.place));
}).join('');
}
const lead = (term, text) => `<strong>${esc(term)}</strong> ${inline(text)}`;
function block(b) {
if (typeof b === 'string') return `<p>${inline(b)}</p>`;
if (b.heading) return `<h3 class="guide-h">${esc(b.heading)}</h3>`;
if (b.code) return `<pre class="guide-code"><code>${esc(b.code)}</code></pre>`;
if (b.note) return `<div class="guide-note">${inline(b.note)}</div>`;
if (b.figure) {
const draw = GUIDE_FIGURE_DRAWINGS[b.figure];
return draw ? `<figure style="margin:0.4rem 0 1.2rem">`
+ `<div style="overflow-x:auto;padding:0.8rem;border:1px solid var(--border-mid);border-radius:var(--radius-sm)">${draw(b.caption)}</div>`
+ `<figcaption style="margin-top:0.4rem;font-size:0.85rem;color:var(--text-sec)">${inline(b.caption)}</figcaption></figure>` : '';
}
if (b.list && b.list.every(Array.isArray)) {
return `<dl class="guide-terms">${b.list.map(([term, text]) => `<dt>${esc(term)}</dt><dd>${inline(text)}</dd>`).join('')}</dl>`;
}
if (b.term) return `<p>${lead(b.term, b.text)}</p>`;
if (b.list) {
return `<ul>${b.list.map(item => `<li>${Array.isArray(item) ? lead(item[0], item[1]) : inline(item)}</li>`).join('')}</ul>`;
}
return '';
}
function sectionHtml(s) {
const naar = s.place
? placeLink(s.place, t('guide.goTo', { plek: placeNameUnique(s.place) }), 'btn btn-ghost btn-sm guide-goto', CHEVRON_RIGHT)
: '';
const related = s.related.filter(shown);
const zie = related.length
? `<div class="guide-related"><span class="t-label">${esc(t('guide.related'))}</span> ${related
.map(id => `<a href="${esc(hashFor(id))}" data-section="${esc(id)}">${esc(titleOf(id))}</a>`).join(', ')}</div>`
: '';
return `
<article class="guide-section">
<h2 class="t-h3">${esc(titleOf(s.id))}</h2>
<div class="t-body guide-body">${texts().sections[s.id].body.map(block).join('')}</div>
${naar || zie ? `<footer class="guide-foot">${zie}${naar}</footer>` : ''}
</article>`;
}
export async function render(el) {
const groups = GUIDE_GROUPS.map(g => ({ ...g, sections: g.sections.filter(shown) })).filter(g => g.sections.length);
let current = parseHash();
if (!IDS.includes(current) || !shown(current)) {
current = groups[0].sections[0];
history.replaceState(null, '', hashFor(current));
}
el.innerHTML = `
<div class="page-layout">
<aside class="page-aside guide-aside">
<div class="guide-groups" data-group-bar></div>
<nav class="tabset guide-sections" role="tablist" data-sections></nav>
</aside>
<div class="page-body">
<div class="page-header"><h1 class="page-title">${esc(title())}</h1></div>
<div data-groups></div>
</div>
</div>`;
const menu = el.querySelector('[data-sections]');
let panel = null;
let ready = false;
const show = (sid) => {
current = sid;
panel.innerHTML = sectionHtml(BY_ID.get(sid));
menu.querySelectorAll('[data-section]').forEach(b => {
const on = b.dataset.section === sid;
b.classList.toggle('is-on', on);
b.setAttribute('aria-selected', String(on));
});
if (ready && location.hash !== hashFor(sid)) history.pushState(null, '', hashFor(sid));
};
const tabs = createTabs(el.querySelector('[data-groups]'), {
barHost: el.querySelector('[data-group-bar]'),
active: GROUP_OF.get(current),
items: groups.map(g => ({ id: g.id, label: esc(texts().groups[g.id]) })),
render: (gid, host) => {
panel = host;
const groep = groups.find(g => g.id === gid);
menu.innerHTML = groep.sections.map(sid => `
<button type="button" class="tabset-tab" role="tab" data-section="${esc(sid)}">${esc(titleOf(sid))}</button>`).join('');
show(GROUP_OF.get(current) === gid ? current : groep.sections[0]);
},
});
ready = true;
el.querySelector('.page-layout').onclick = e => {
const doel = e.target.closest('[data-section]');
if (!doel || !BY_ID.has(doel.dataset.section)) return;
e.preventDefault();
const sid = doel.dataset.section;
if (tabs && GROUP_OF.get(sid) !== tabs.active) { current = sid; tabs.select(GROUP_OF.get(sid)); }
else show(sid);
};
}
+157
View File
@@ -0,0 +1,157 @@
// Generated, do not edit. sha256:148777e8a3d0dd6536d8b6d4b8cae6c0c4cbf2ed945fdf8bb8ed57c23e617bd8
import { listBookableAssets } from '../data/assets.js';
import { appSetting, setAppSetting } from '../app-settings.js';
import { t } from '../i18n/index.js';
import { renderNotifications } from '../actions/notifications-widget.js';
import { currentStanding } from '../wealth/data.js';
import { fmtBase, fmtBaseSigned, colouredSigns, fmtUnit, val } from '../ui/format.js';
import { esc } from '../ui/escape.js';
import { assetLabel, assetLabelHtml } from '../ui/asset-label.js';
import { originsHtml } from '../ui/price-origin.js';
import { setPageHint } from '../ui/page-hint.js';
import { CHEVRON_RIGHT } from '../ui/icons.js';
import { renderDashboard } from '../dashboard/dashboard.js';
import { renderSpotWidget, renderSpotSettings } from '../widgets/spot.js';
import { secondCurrencyOf, secondAmount, renderSecondCurrencySetting } from '../widgets/second-currency.js';
import { STANDING_GROUPINGS, standingGroupingOf, groupStanding, standingToday, standingShowsToday } from '../widgets/standing-groups.js';
import { renderMetricsWidget } from '../widgets/metrics.js';
import { renderClocksWidget, renderClocksSettings } from '../widgets/clocks.js';
import { renderReturnWidget, renderReturnSettings } from '../widgets/return.js';
import { registerWidget, availableWidgets } from '../widgets/registry.js';
import { DASHBOARD, TARGETS, targetHash } from '../../shared/config.js';
export const title = () => t('home.title');
registerWidget('notifications', { render: host => renderNotifications(host) });
registerWidget('standing', { render: (host, inst) => renderStandingWidget(host, inst), settings: renderStandingSettings });
registerWidget('spot', { render: (host, inst) => renderSpotWidget(host, inst), settings: renderSpotSettings });
registerWidget('metrics', { render: host => renderMetricsWidget(host) });
registerWidget('clocks', { render: (host, inst) => renderClocksWidget(host, inst), settings: renderClocksSettings });
registerWidget('return', { render: (host, inst) => renderReturnWidget(host, inst), settings: renderReturnSettings });
async function buildCatalog(layout) {
const present = new Set(layout.map(it => it.instId));
const persona = appSetting('persona');
const { renderers } = availableWidgets();
const items = [];
for (const [type, def] of Object.entries(DASHBOARD.WIDGETS)) {
if (def.instanceable || present.has(type) || !renderers[type]) continue;
if (def.persona && def.persona !== persona) continue;
items.push({ instId: type, type, params: null, label: t(def.label_key) });
}
let n = 1;
while (present.has(`return-${n}`)) n++;
items.push({ instId: `return-${n}`, type: 'return', params: null, label: t('dashboard.widget.return') });
for (const a of await listBookableAssets()) {
const instId = `spot-${a.id}`;
if (present.has(instId)) continue;
items.push({
instId, type: 'spot', params: { assetId: a.id },
label: `${t('dashboard.widget.spot')}: ${assetLabel(a)}`,
labelHtml: `${esc(t('dashboard.widget.spot'))}: ${assetLabelHtml(a)}`,
});
}
return items;
}
export async function render(el, opts = {}) {
if (!el.querySelector('#dashboard-root')) el.innerHTML = '<div id="dashboard-root"></div>';
const { renderers, settings } = availableWidgets();
await renderDashboard(el.querySelector('#dashboard-root'), {
renderers,
settings,
edit: !!opts.edit,
onEdit: opts.setEdit,
catalog: buildCatalog,
toolbarHost: document.getElementById('nav-tools') || document.getElementById('page-tools'),
});
setPageHint(el, { title: t('home.hintTitle'), rows: [
t('home.hintEdit'), t('home.hintDone'), t('home.hintAdd'), t('home.hintWidget'), t('home.hintProfiles'),
] });
}
function standingGroupLabel(g, by) {
const r = g.sample;
if (by === 'asset') return assetLabelHtml(r.asset);
if (by === 'assetKind') return esc(t('enums.asset_kind.' + r.asset.kind));
if (by === 'accountKind') return esc(g.key === 'none' ? t('wealth.noAccount') : t('enums.account_kind.' + g.key));
return esc(r.account.name);
}
function todayHtml(amount, cls, always = false) {
if (amount == null || (!always && Math.abs(amount) < 0.005)) return '';
const tone = amount > 0.005 ? 't-pos' : amount < -0.005 ? 't-neg' : '';
return `<span class="${[cls, tone].filter(Boolean).join(' ')}">${val(fmtBaseSigned(amount, colouredSigns()))}</span>`;
}
async function renderStandingSettings(host, instance, commit) {
host.classList.add('dash-settings--wide');
const huidig = () => standingGroupingOf(instance.params && instance.params.groupBy);
const label = { account: 'home.groupAccount', accountKind: 'home.groupAccountKind', asset: 'home.groupAsset', assetKind: 'home.groupAssetKind' };
const chips = () => STANDING_GROUPINGS.map(id => `<button type="button" class="fbar-item${huidig() === id ? ' is-on' : ''}"
data-standing-group="${id}" aria-pressed="${huidig() === id}">${esc(t(label[id]))}</button>`).join('');
const block = document.createElement('div');
block.className = 'dash-set-row';
block.innerHTML = `<span class="t-label t-label--ter">${esc(t('home.groupBy'))}</span><div class="fbar fbar--chip">${chips()}</div>`;
host.appendChild(block);
block.addEventListener('click', e => {
const chip = e.target.closest('[data-standing-group]');
if (!chip || chip.dataset.standingGroup === huidig()) return;
instance.params = { ...(instance.params || {}), groupBy: standingGroupingOf(chip.dataset.standingGroup) };
block.querySelector('.fbar').innerHTML = chips();
commit({ resize: true });
});
const today = document.createElement('div');
today.className = 'dash-set-row';
today.innerHTML = `<label class="clockw-check"><span class="toggle-switch"><input type="checkbox" data-standing-today${standingShowsToday(instance.params) ? ' checked' : ''}/><span class="toggle-track"></span></span><span class="clockw-check-name">${esc(t('home.showToday'))}</span></label>`;
host.appendChild(today);
today.addEventListener('change', e => {
const box = e.target.closest('[data-standing-today]');
if (!box) return;
instance.params = { ...(instance.params || {}), showToday: box.checked };
commit({ resize: true });
});
await renderSecondCurrencySetting(host, instance, commit);
}
async function renderStandingWidget(host, instance) {
const by = standingGroupingOf(instance && instance.params && instance.params.groupBy);
const second = secondCurrencyOf(instance);
host.innerHTML = `<div class="card standing-card">
<div class="flex-between">
<div class="t-label">${t('home.standing')}</div>
<a class="wealth-detail-btn card-go-btn" href="${targetHash(TARGETS.WEALTH)}" title="${esc(t('home.toWealth'))}" aria-label="${esc(t('home.toWealth'))}">${CHEVRON_RIGHT}</a>
</div>
<div id="standing-body" class="t-body-sm t-muted">…</div>
</div>`;
try {
const s = await currentStanding();
const body = host.querySelector('#standing-body');
if (s.rows.length === 0) { body.textContent = t('home.standingNone'); return; }
const toon = standingShowsToday(instance && instance.params);
const perGroup = toon ? standingToday(s.rows, by, s.today) : new Map();
const lines = groupStanding(s.rows, by).map(g => `<div class="dash-trow stw-acc">
<span class="tname"><span class="stw-dot" aria-hidden="true"></span>${standingGroupLabel(g, by)}</span>
<span class="stw-today-cell">${todayHtml(perGroup.get(g.key), 'stw-today-row')}</span>
<span class="n po-host">${val(fmtBase(g.value))}${originsHtml(g.origins)}</span>
</div>`).join('');
const vandaag = toon && s.today
? `<div class="stw-today">${todayHtml(s.today.total, '', true)}</div>` : '';
const tweede = second ? await secondAmount(s.total, second, { decimals: 0 }) : '';
body.className = '';
body.innerHTML = `
<div class="sigw-price po-host" style="margin:0.4rem 0 0.1rem">${val(fmtBase(s.total))}${tweede ? `<span class="amount-second">${val(tweede)}</span>` : ''}${originsHtml(s.rows.map(r => ({ origin: r.origin, asset: r.asset })))}</div>
${vandaag}
<div class="t-muted-cell" style="margin-bottom:0.6rem">${s.rows.every(r => r.asset.id === s.rows[0].asset.id) ? val(fmtUnit(s.totalSats, s.rows[0].asset)) : ''}</div>
<div class="dash-tables stw-grid">${lines}</div>`;
} catch (err) {
console.error('[standing-widget]', err);
const body = host.querySelector('#standing-body');
if (body) body.textContent = '⚠ ' + err.message;
}
}
+366
View File
@@ -0,0 +1,366 @@
// Generated, do not edit. sha256:b3c098ab83c22c91c33b24c5b6d1f67c7d41af1244da6963ffe67e33da9270d3
import { listAccounts, listSelectableAccounts } from '../data/accounts.js';
import { dayNl, localDate, localDateNl, localTime, toUtcIso } from '../../shared/time.js';
import { listAssets, listBookableAssets } from '../data/assets.js';
import { createEvent, updateEvent, deleteEvent } from '../data/events.js';
import { listInterest, listAllSchedules, createSchedule, updateSchedule, deleteSchedule,
listPeriods, createPeriod, updatePeriod, deletePeriod } from '../data/interest-schedules.js';
import { tableViewCfg } from '../data/preferences.js';
import { t } from '../i18n/index.js';
import { assetLabelHtml } from '../ui/asset-label.js';
import * as dialog from '../ui/dialog.js';
import { renderTable } from '../ui/table.js';
import { takeRowFocus } from '../ui/row-focus.js';
import { fmtUnit, val, subline, toSubunit, fromSubunit, pct } from '../ui/format.js';
import { fiatAtPrice, fiatHistorical, fiatHistoricalOrigin, loadHistorical, loadFiat, fiatValue, fiatValueOrigin } from '../ui/fiat.js';
import { bookingOriginHtml, originHtml } from '../ui/price-origin.js';
import { renderScheduleStatus } from '../actions/interest-widget.js';
import { compoundsViaBalance, looksLikeDepositWithoutPrincipal } from '../actions/interest-compounding.js';
import { CONFIG, ENUMS, ACCOUNT_INTEREST_KINDS, TARGETS, targetHash } from '../../shared/config.js';
import { esc } from '../ui/escape.js';
import { emptyStateHtml } from '../ui/empty-state.js';
import { setPageHint } from '../ui/page-hint.js';
let recordsApi = null;
let onlyAccount = null;
let onlyAsset = null;
let onlyKinds = null;
const assetAllowed = a => (onlyAsset == null || a.id === onlyAsset) && (onlyKinds == null || onlyKinds.includes(a.kind));
const accountAllowed = a => onlyAccount == null || a.id === onlyAccount;
const withoutAccount = c => onlyAccount == null || (c.key !== 'accountId' && c.key !== 'toAccountId');
const ownSchedules = async () => (await listAllSchedules()).filter(s => (onlyAccount == null || s.accountId === onlyAccount)
&& (onlyAsset == null || s.principalAssetId === onlyAsset || s.payoutAssetId === onlyAsset));
const ownInterest = async () => (await listInterest()).filter(r => (onlyAccount == null || r.toAccountId === onlyAccount)
&& (onlyAsset == null || r.assetId === onlyAsset));
async function herlaadRecords() { if (recordsApi) await recordsApi.refresh(await ownInterest()); }
let schedApi = null;
let herlaadStatus = () => {};
export async function render(el, { accountId = null, assetId = null, assetKinds = null } = {}) {
onlyAccount = accountId;
onlyAsset = assetId;
onlyKinds = assetKinds;
if ((await listAssets()).length === 0) {
el.innerHTML = `
${emptyStateHtml(t('interest.none'), {
why: t('interest.needAsset'), href: targetHash(TARGETS.ASSETS), linkText: t('common.manageAssets'),
})}`;
return;
}
el.innerHTML = `
<div id="sched"></div>
<div id="sched-status"></div>
<div id="tbl" class="mt-lg"></div>`;
if (onlyAccount == null) setPageHint(el, { title: t('nav.interest'), rows: [t('interest.importNote')] });
buildSchedules(el);
buildRecords(el);
}
async function interestAccountOpts(huidigeId) {
return (await listSelectableAccounts({ ookId: Number(huidigeId) || null }))
.filter(a => ACCOUNT_INTEREST_KINDS.includes(a.kind) && accountAllowed(a)).map(a => ({ value: a.id, label: a.name }));
}
async function buildSchedules(el) {
const alleAssets = await listAssets();
const assetOpts = async () => (await listBookableAssets()).filter(assetAllowed).map(a => ({ value: a.id, label: a.symbol }));
const rateKindOpts = () => ENUMS.INTEREST_RATE_KIND.map(rk => ({ value: rk, label: t('enums.interest_rate_kind.' + rk) }));
const cadenceOpts = () => ENUMS.INTEREST_CADENCE.map(c => ({ value: c, label: t('enums.interest_cadence.' + c) }));
const weekdayOpts = () => [0, 1, 2, 3, 4, 5, 6].map(d => ({ value: d, label: t('accounts.wd' + d) }));
const cadenceLabel = r => t('enums.interest_cadence.' + r.creditCadence);
const principalMeta = r => ({ decimals: r.principal_decimals, unit: r.principal_unit, subunit: r.principal_subunit, displayUnit: r.principal_display_unit });
const toSched = async v => {
const accountId = Number(v.accountId ?? onlyAccount);
if (!accountId) throw new Error(t('interest.schAccountRequired'));
const payoutAssetId = Number(v.payoutAssetId);
if (!payoutAssetId) throw new Error(t('accounts.schPayoutRequired'));
const base = {
accountId, rateKind: v.rateKind, payoutAssetId,
startsOn: v.startsOn, endsOn: v.endsOn || null,
note: (v.note || '').trim() || null,
};
if (v.rateKind === 'variable') {
return { ...base, principalAssetId: null, principalAmount: null, annualRate: null,
creditCadence: null, creditWeekday: null, creditDayOfMonth: null, compounding: 0 };
}
const asset = (await listAssets()).find(a => a.id === Number(v.principalAssetId));
if (!asset) throw new Error(t('accounts.schAssetRequired'));
const annualRate = Number(v.annualRate) / 100;
if (!Number.isFinite(annualRate)) throw new Error(t('accounts.schRateInvalid'));
return {
...base,
principalAssetId: Number(v.principalAssetId),
principalAmount: String(v.principalAmount ?? '').trim() === '' ? null : toSubunit(v.principalAmount, asset),
annualRate,
creditCadence: v.creditCadence,
creditWeekday: v.creditCadence === 'weekly' ? Number(v.creditWeekday) : null,
creditDayOfMonth: v.creditCadence === 'monthly' ? Number(v.creditDayOfMonth) : null,
compounding: Number(v.compounding) === 1 ? 1 : 0,
};
};
const yesNoOpts = () => [{ value: 0, label: t('accounts.no') }, { value: 1, label: t('accounts.yes') }];
const confirmDeposit = async v => {
if (!looksLikeDepositWithoutPrincipal(v)) return;
const ok = await dialog.confirm({ title: t('interest.depositTitle'), message: t('interest.depositWarning'),
confirmLabel: t('interest.depositSaveAnyway'), cancelLabel: t('interest.depositBack') });
if (!ok) throw new Error(t('interest.depositNotSaved'));
};
const reloadStatus = async () => {
const host = el.querySelector('#sched-status');
const accountIds = [...new Set((await ownSchedules()).map(s => s.accountId))];
if (!accountIds.length) { host.innerHTML = ''; return; }
const accounts = await listAccounts();
const naamHtml = id => onlyAccount != null ? '' : `<div class="ip-acc-name">${esc(accounts.find(a => a.id === id)?.name || '')}</div>`;
host.innerHTML = `<section class="card-flat ip-box">
<div class="ip-box-head"><span class="t-label">${t('accounts.projection')}</span>
<span class="t-body-sm t-muted">${t('accounts.projectionHint')}</span></div>
${accountIds.map(id => `<div class="ip-acc" data-acc="${id}">${naamHtml(id)}<div data-acc-status></div></div>`).join('')}
</section>`;
const getekend = await Promise.all(accountIds.map(id =>
renderScheduleStatus(host.querySelector(`[data-acc="${id}"] [data-acc-status]`), id, reloadAll)));
accountIds.forEach((id, i) => { if (!getekend[i]) host.querySelector(`[data-acc="${id}"]`).remove(); });
if (!getekend.some(Boolean)) host.innerHTML = '';
};
herlaadStatus = reloadStatus;
const reloadSched = async () => schedApi.refresh(await ownSchedules());
const reloadAll = () => { reloadSched(); reloadStatus(); herlaadRecords(); };
schedApi = renderTable(el.querySelector('#sched'), {
columns: [
{ key: 'accountId', label: t('interest.colAccount'), sortable: true, sortValue: r => r.account_name,
render: r => esc(r.account_name),
group: { value: r => r.account_name, label: v => v },
edit: { type: 'select', sticky: true, options: vals => interestAccountOpts(vals.accountId), value: r => r ? r.accountId : '' } },
{ key: 'rateKind', label: t('accounts.schRateType'), sortable: true,
render: r => t('enums.interest_rate_kind.' + r.rateKind),
group: { value: r => r.rateKind, label: v => t('enums.interest_rate_kind.' + v) },
edit: { type: 'select', sticky: true, reactive: true, options: rateKindOpts } },
{ key: 'principalAssetId', label: t('accounts.schPrincipalAsset'), sortable: true, sortValue: r => r.principal_symbol,
render: r => r.principal_symbol || '<span class="t-muted-cell">-</span>',
edit: { type: 'select', sticky: true, reactive: true, options: assetOpts, visible: v => v.rateKind === 'fixed' } },
{ key: 'principalAmount', label: t('accounts.schPrincipal'), align: 'num', sortable: true, sortValue: r => r.principalAmount,
render: r => {
if (r.principalAmount == null) return `<span class="t-muted-cell">${t('interest.principalFollowsBalance')}</span>`;
const asset = alleAssets.find(a => a.id === r.principalAssetId);
const fiat = asset ? fiatValue(r.principalAmount, asset) : '';
return `${val(fmtUnit(r.principalAmount, principalMeta(r)))}${subline(fiat ? val(fiat) + originHtml(fiatValueOrigin(asset), asset) : '')}`;
},
edit: { type: 'number', attrs: { min: 0, step: 'any' }, visible: v => v.rateKind === 'fixed',
value: r => r && r.principalAmount != null ? fromSubunit(r.principalAmount, principalMeta(r)) : '',
convert: vals => alleAssets.find(a => a.id === Number(vals.principalAssetId)) } },
{ key: 'annualRate', label: t('accounts.schRate'), align: 'num', sortable: true, sortValue: r => r.annualRate,
render: r => r.annualRate != null ? pct(r.annualRate) : '<span class="t-muted-cell">-</span>',
edit: { type: 'number', attrs: { min: 0, step: 'any' }, visible: v => v.rateKind === 'fixed', value: r => r && r.annualRate != null ? r.annualRate * 100 : '' } },
{ key: 'compounding', label: t('interest.colCompounding'),
render: r => r.rateKind !== 'fixed' ? '<span class="t-muted-cell">-</span>'
: compoundsViaBalance(r) ? `<span class="t-muted-cell">${t('interest.compoundingViaBalance')}</span>`
: t(r.compounding ? 'accounts.yes' : 'accounts.no'),
edit: { type: 'select', options: yesNoOpts, visible: v => v.rateKind === 'fixed', value: r => r ? r.compounding : 0 } },
{ key: 'payoutAssetId', label: t('accounts.schPayoutAsset'), sortable: true, sortValue: r => r.payout_symbol,
render: r => r.payout_symbol,
group: { value: r => r.payout_symbol, label: v => v },
edit: { type: 'select', sticky: true, options: assetOpts } },
{ key: 'startsOn', label: t('accounts.schStart'), sortable: true,
render: r => dayNl(r.startsOn),
edit: { type: 'date', sticky: true, value: r => r ? r.startsOn : new Date().toISOString().slice(0, 10) } },
{ key: 'endsOn', label: t('accounts.schEnd'),
render: r => dayNl(r.endsOn) || `<span class="t-muted-cell">${t('interest.endsOnOpen')}</span>`,
edit: { type: 'date', value: r => r && r.endsOn ? r.endsOn : '' } },
{ key: 'creditCadence', label: t('accounts.schCadence'),
render: r => r.creditCadence ? cadenceLabel(r) : '<span class="t-muted-cell">-</span>',
group: { value: r => r.creditCadence || '', label: v => v ? t('enums.interest_cadence.' + v) : t('interest.cadenceNone') },
edit: { type: 'select', sticky: true, options: cadenceOpts, reactive: true, visible: v => v.rateKind === 'fixed' } },
{ key: 'creditWeekday', label: t('accounts.schWeekday'),
render: r => r.creditCadence === 'weekly' ? t('accounts.wd' + r.creditWeekday) : '<span class="t-muted-cell">-</span>',
edit: { type: 'select', options: weekdayOpts, visible: vals => vals.creditCadence === 'weekly' } },
{ key: 'creditDayOfMonth', label: t('accounts.schDayOfMonth'),
render: r => r.creditCadence === 'monthly' ? String(r.creditDayOfMonth) : '<span class="t-muted-cell">-</span>',
edit: { type: 'number', attrs: { min: 1, max: 31, step: 1 }, visible: vals => vals.creditCadence === 'monthly' } },
{ key: 'note', label: t('accounts.schNote'), text: true,
render: r => `<span class="t-muted-cell">${esc(r.note || '')}</span>`,
edit: { type: 'textarea', expand: true } },
].filter(withoutAccount),
rows: await ownSchedules(),
title: t('interest.schedulesTitle'),
...(await tableViewCfg('interest.schedules', { key: 'startsOn', dir: 'desc' }, onlyAccount != null ? 'payoutAssetId' : null)),
emptyText: t('interest.noSchedules'),
newLabel: t('table.new'),
convertLabel: t('convert.title'),
collapsibleGroups: true,
newDefaults: { ...(onlyAccount != null ? { accountId: onlyAccount } : {}), rateKind: 'fixed', creditCadence: 'annual', principalAmount: '', annualRate: '', endsOn: '', note: '', creditWeekday: 1, creditDayOfMonth: 1, compounding: 0 },
saveLabel: t('accounts.save'), cancelLabel: t('accounts.cancel'),
onCreate: async v => { await confirmDeposit(v); await createSchedule(await toSched(v)); reloadAll(); },
onSave: async (id, v) => { await confirmDeposit(v); await updateSchedule(id, await toSched(v)); reloadAll(); },
onDelete: async id => {
if (!(await dialog.confirm({ message: t('accounts.confirmDeleteSchedule'), danger: true }))) return;
try { await deleteSchedule(id); reloadAll(); } catch (e) { dialog.alert({ title: t('dialog.error'), message: e.message }); }
},
rowExpansion: () => '',
onExpansionMount: (r, host) => mountPeriods(host, r, reloadStatus),
});
reloadStatus();
const inleg = [...new Set((await ownSchedules()).filter(s => s.principalAmount != null).map(s => s.principalAssetId))]
.map(id => alleAssets.find(a => a.id === id)).filter(Boolean);
if (inleg.length) loadFiat(inleg).then(reloadSched).catch(() => {});
const focusAccount = Number(sessionStorage.getItem(CONFIG.FOCUS.INTEREST_ACCOUNT) || 0);
sessionStorage.removeItem(CONFIG.FOCUS.INTEREST_ACCOUNT);
if (focusAccount) schedApi.focusGroup('accountId', r => r.accountId === focusAccount);
}
async function assetOptsFull() { return (await listBookableAssets()).filter(assetAllowed).map(a => ({ value: a.id, label: `${a.symbol}: ${a.name}` })); }
async function accountOpts(huidigeId) {
return (await listSelectableAccounts({ ookId: Number(huidigeId) || null })).filter(accountAllowed).map(a => ({ value: a.id, label: a.name }));
}
async function buildRecords(el) {
const alleAssets = await listAssets();
const rows = ownInterest;
const meta = r => ({ decimals: r.asset_decimals, unit: r.asset_unit, subunit: r.asset_subunit, displayUnit: r.asset_display_unit });
const assetOf = r => ({ ...meta(r), id: r.assetId, kind: r.asset_kind, symbol: r.asset_symbol });
const today = new Date().toISOString().slice(0, 10);
const columns = [
{ key: 'occurredAt', label: t('interest.colDate'), sortable: true,
render: r => localDateNl(r.occurredAt),
group: { value: r => localDate(r.occurredAt).slice(0, 4), label: v => v, order: 'desc' },
edit: { type: 'date', sticky: true, attrs: { max: today }, value: r => r ? localDate(r.occurredAt) : today } },
{ key: '_time', label: t('interest.time'),
render: r => localTime(r.occurredAt),
edit: { type: 'time', value: r => r ? localTime(r.occurredAt) : '00:00' } },
{ key: 'toAccountId', label: t('interest.colAccount'), sortable: true, sortValue: r => r.to_name,
render: r => r.to_name || '-', group: { value: r => r.to_name || '-', label: v => v },
edit: { type: 'select', sticky: true, options: vals => accountOpts(vals.toAccountId), value: r => r ? r.toAccountId ?? '' : '' } },
{ key: 'assetId', label: t('interest.colAsset'), sortable: true, sortValue: r => r.asset_symbol,
render: r => assetLabelHtml(r), group: { value: r => r.asset_symbol, label: v => v },
edit: { type: 'select', sticky: true, reactive: true, options: assetOptsFull } },
{ key: 'amount', label: t('interest.colAmount'), align: 'num', sortable: true, sortValue: r => r.amount,
render: r => {
const asset = assetOf(r);
if (r.fiatPrice != null) {
const fiat = fiatAtPrice(r.amount, meta(r).decimals, r.fiatPrice);
return `${val(fmtUnit(r.amount, meta(r)))}${subline(fiat ? val(fiat) + bookingOriginHtml(r, asset) : '')}`;
}
const dag = localDate(r.occurredAt);
const fiat = fiatHistorical(r.amount, asset, dag);
return `${val(fmtUnit(r.amount, meta(r)))}${subline(fiat ? val(fiat) + originHtml(fiatHistoricalOrigin(asset, dag), asset) : '')}`;
},
edit: { type: 'number', attrs: { min: 0, step: 'any' }, value: r => r ? fromSubunit(r.amount, meta(r)) : '', convert: vals => alleAssets.find(a => a.id === Number(vals.assetId)) } },
{ key: 'source', label: t('interest.colSource'), sortable: true,
render: r => r.source ? `<span class="badge ${r.source === 'manual' ? 'badge-accent' : 'badge-neutral'}">${t('enums.event_source.' + r.source)}</span>` : '',
group: { value: r => r.source, label: v => t('enums.event_source.' + v) } },
{ key: 'schedule', label: t('interest.colSchedule'), sortable: true, sortValue: r => r.schedule_label || '',
render: r => r.schedule_label || `<span class="t-muted-cell">-</span>`,
group: { value: r => r.schedule_label || t('interest.noSchedule'), label: v => v } },
{ key: 'note', label: t('interest.colNote'), text: true,
render: r => `<span class="t-muted-cell">${esc(r.note || '')}</span>`,
edit: { type: 'textarea', expand: true } },
].filter(withoutAccount);
async function toPayload(v, existing) {
const asset = (await listAssets()).find(a => a.id === Number(v.assetId));
const qty = parseFloat(v.amount);
const toAccountId = v.toAccountId ?? onlyAccount;
const account = toAccountId !== '' && toAccountId != null ? Number(toAccountId) : null;
if (!v.occurredAt || !asset || !account || !Number.isFinite(qty) || qty <= 0) {
throw new Error(t('interest.invalid'));
}
return {
assetId: Number(v.assetId), type: 'interest',
occurredAt: toUtcIso(`${v.occurredAt}T${v._time || '00:00'}:00`),
amount: toSubunit(qty, asset),
fiatPrice: existing ? existing.fiatPrice ?? null : null,
fiatFee: null, unitFee: null,
fromAccountId: null, toAccountId: account,
source: existing ? existing.source : 'manual',
note: (v.note || '').trim() || null,
};
}
const reload = () => { herlaadRecords(); herlaadStatus(); };
const editable = r => r.source === 'manual';
recordsApi = renderTable(el.querySelector('#tbl'), {
columns, rows: await rows(),
title: t('interest.recordsTitle'),
...(await tableViewCfg('interest', { key: 'occurredAt', dir: 'desc' }, onlyAccount != null ? 'assetId' : null)),
emptyText: t('interest.none'),
noneLabel: t('table.none'),
collapsibleGroups: true,
groupSummary: items => {
const first = items[0]; if (!first) return '';
if (!items.every(r => r.assetId === first.assetId)) return '';
const tot = items.reduce((s, r) => s + (r.amount || 0), 0);
return val(fmtUnit(tot, meta(first)));
},
newLabel: t('table.new'),
convertLabel: t('convert.title'),
...(onlyAccount != null ? { newDefaults: { toAccountId: onlyAccount } } : {}),
saveLabel: t('interest.save'), cancelLabel: t('interest.cancel'),
rowEditable: editable, rowDeletable: editable,
onCreate: async v => { const id = await createEvent(await toPayload(v, null)); recordsApi.refresh(await rows(), { locate: id }); herlaadStatus(); },
onSave: async (id, v) => {
const ex = (await listInterest()).find(x => x.id === id);
await updateEvent(id, await toPayload(v, ex)); reload();
},
onDelete: async id => {
if (!(await dialog.confirm({ message: t('interest.confirmDelete'), danger: true }))) return;
await deleteEvent(id); reload();
},
});
recordsApi.focusRow(takeRowFocus('interest'));
const zonderPrijs = (await rows()).filter(r => r.fiatPrice == null);
loadHistorical(zonderPrijs.map(r => ({ assetId: r.assetId, dateKey: localDate(r.occurredAt) })))
.then(async () => { if (recordsApi) recordsApi.refresh(await rows()); }).catch(() => {});
}
async function mountPeriods(host, sched, naWijziging) {
if (sched.rateKind !== 'fixed') {
host.innerHTML = `<div class="src-drawer"><div class="t-body-sm t-muted">${t('interest.periodsVariable')}</div></div>`;
return;
}
host.innerHTML = `<div class="src-drawer">
<div data-periods></div>
<div class="t-body-sm t-muted mt-sm">${t('interest.periodsBase')} ${esc(sched.startsOn)}: ${pct(sched.annualRate)}</div>
</div>`;
const toPeriod = (v) => {
const rate = parseFloat(String(v.annualRate).replace(',', '.'));
if (!Number.isFinite(rate)) throw new Error(t('interest.periodRateRequired'));
return { startsOn: v.startsOn, annualRate: rate / 100 };
};
const dagErna = (iso) => {
const d = new Date(`${iso}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + 1);
return d.toISOString().slice(0, 10);
};
let perioden = await listPeriods(sched.id);
const volgendeStart = () => dagErna(perioden.length ? perioden[perioden.length - 1].startsOn : sched.startsOn);
const herlaad = async (api) => { perioden = await listPeriods(sched.id); api.refresh(perioden); };
const api = renderTable(host.querySelector('[data-periods]'), {
columns: [
{ key: 'startsOn', label: t('interest.periodStartsOn'), sortable: true,
render: r => dayNl(r.startsOn),
edit: { type: 'date', value: r => r ? r.startsOn : volgendeStart() } },
{ key: 'annualRate', label: t('accounts.schRate'), align: 'num',
render: r => pct(r.annualRate),
edit: { type: 'number', attrs: { min: 0, step: 'any' }, value: r => r ? r.annualRate * 100 : '' } },
],
rows: perioden,
title: t('interest.periods'),
sort: { key: 'startsOn', dir: 'asc' },
emptyText: t('interest.noPeriods'),
newLabel: t('table.new'),
newDefaults: { annualRate: '' },
saveLabel: t('accounts.save'), cancelLabel: t('accounts.cancel'),
onCreate: async v => { await createPeriod(sched.id, toPeriod(v)); await herlaad(api); naWijziging(); },
onSave: async (id, v) => { await updatePeriod(id, toPeriod(v)); await herlaad(api); naWijziging(); },
onDelete: async id => {
if (!(await dialog.confirm({ message: t('interest.confirmDeletePeriod'), danger: true }))) return;
await deletePeriod(id); await herlaad(api); naWijziging();
},
});
}
+642
View File
@@ -0,0 +1,642 @@
// Generated, do not edit. sha256:5dea810da6d84ed187a1154955f4194498f812771e3ddaa6b6dc7c547a305b8d
import { t, localeTag } from '../i18n/index.js';
import { CONFIG } from '../../shared/config.js';
import { esc } from '../ui/escape.js';
import { licenseSnapshot, loadLicense, markLicenseSeen } from '../license.js';
import { installLicense } from '../data/license-install.js';
import { refreshAttention } from '../ui/attention.js';
import { npubFromHex, shortKey, secretFromText, publicKeyOf, nsecFromSecret, newSecret } from '../nostr/keys.js';
import { keySigner, extensionSigner, nip98Header } from '../nostr/nip98.js';
import { qrSvg, invoiceQrText } from '../ui/qr.js';
import { CHECK, CHEVRON } from '../ui/icons.js';
import { splashMarkHtml } from '../ui/splash.js';
import { expanders } from '../ui/expand.js';
import { supportInfo, supportMessages, sendSupport, supportAttachment } from '../data/support.js';
import { licenseShop, buyerLicenses, saleInfo, placeOrder, orderStatus } from '../data/license-buyer.js';
const BROKEN = new Set(['invalid', 'unreadable']);
function buyerOf(s) {
const npub = npubFromHex(s.buyer);
return { text: shortKey(npub ?? s.buyer, 10), title: npub ?? s.buyer ?? '' };
}
function lines(s) {
const out = [];
const line = (text, title = '') => out.push({ text, title });
if (s.why === 'develop' || s.why === 'forced-develop') line(t('license.cardDevelop'));
line(s.level === 'full' ? t('license.cardFull') : t('license.cardFree'));
if (s.installed) {
const buyer = buyerOf(s);
line(t('license.cardFacts', {
buyer: buyer.text, issued: s.issued ?? '',
from: s.installed.majorFrom,
to: s.installed.majorTo === 'unlimited' ? t('license.unlimited') : s.installed.majorTo,
}), buyer.title);
if (!s.installedCovers) line(t('license.cardOutOfRange', { version: s.version }));
}
if (BROKEN.has(s.why)) line(`${t('license.cardProblem')} ${t(`license.why_${s.why}`)}.`);
if (s.level !== 'full') line(t('license.cardBuy'));
return out;
}
async function installAndReload(text, message) {
message.className = 't-body-sm';
message.textContent = '';
try {
const result = await installLicense(text);
message.classList.add('t-pos');
message.textContent = result.covers ? t('license.installOk') : t('license.installOkFuture');
setTimeout(() => location.reload(), 1200);
} catch (err) {
message.classList.add('t-neg');
message.textContent = err.message;
}
}
const INFO = ['Nostr', 'Why', 'SignIn', 'Zap', 'Privacy'];
const link = (url, html) => `<a href="${esc(url)}" target="_blank" rel="noopener noreferrer">${html}</a>`;
const payHtml = (expand) => `<div class="lic-head"><div class="t-label">${say('license.payTitle')}</div></div>
<p class="t-body-sm">${say('license.payIntro')}</p>`
+ ['easy', 'other'].map(group => `<div class="lic-apps"><div class="t-caption">${say(`license.payGroup_${group}`)}</div>`
+ CONFIG.LICENSE.PAY_APPS.filter(a => a.group === group)
.map(a => `<p class="t-body-sm">${link(a.url, `<strong>${esc(a.name)}</strong>`)} ${say(`license.payApp_${a.id}`)}</p>`).join('')
+ '</div>').join('')
+ `<p class="t-caption">${say('license.payNote')}</p>
<details class="lic-more" ${expand.attr('nostr')}><summary class="t-body-sm">${say('license.nostrMore')}</summary>`
+ INFO.map(k => `<p class="t-body-sm"><strong>${say(`license.info${k}Head`)}</strong> ${say(`license.info${k}`)}</p>`).join('')
+ CONFIG.LICENSE.INFO_LINKS.map(l => `<p class="t-body-sm">${link(l.url, say(`license.link_${l.id}`))}</p>`).join('')
+ '</details>';
let signer = null;
let newKey = null;
const npubText = (hex) => {
const npub = npubFromHex(hex) ?? hex;
return `<span title="${esc(npub)}">${esc(shortKey(npub, 10))}</span>`;
};
const say = (key, params) => esc(t(key, params));
async function drawSignIn(box, s) {
let shop = { serviceUrl: null, buyer: null };
try { shop = await licenseShop(); } catch { }
serviceUrl = shop.serviceUrl;
drawBuy();
drawSupport();
const head = `<div class="lic-head"><div class="t-label">${say('license.signInTitle')}</div></div>`;
if (signer) {
const who = t(signer.kind === 'extension' ? 'license.signedInExtension' : 'license.signedIn', { npub: '§' });
const [before, after] = who.split('§');
const other = s.installed && s.buyer && s.buyer !== signer.pubkey;
box.innerHTML = `${head}
<p class="t-body-sm">${esc(before)}${npubText(signer.pubkey)}${esc(after)}</p>
${other ? `<p class="t-body-sm">${say('license.otherBuyer')}</p>` : ''}
<div data-licenses class="mt-sm"></div>
<p class="t-body-sm" data-signin-msg></p>
<div class="flex gap-sm"><button class="btn btn-secondary btn-sm" type="button" data-signout>${say('license.signOut')}</button></div>`;
box.querySelector('[data-signout]').onclick = () => { signer = null; drawSignIn(box, s); };
await drawLicenses(box.querySelector('[data-licenses]'), box.querySelector('[data-signin-msg]'), shop);
return;
}
if (newKey) {
const nsec = nsecFromSecret(newKey.secret);
box.innerHTML = `${head}
<p class="t-body-sm">${say('license.newKeyIntro')}</p>
<input class="input t-mono" type="text" readonly value="${esc(nsec)}" data-nsec aria-label="${esc(t('license.newKeyTitle'))}">
<p class="t-caption mt-sm">${say('license.newKeyPublic', { npub: npubFromHex(publicKeyOf(newKey.secret)) })}</p>
<div class="flex gap-sm mt-sm">
<button class="btn btn-secondary btn-sm" type="button" data-key-copy>${say(newKey.copied ? 'license.newKeyCopied' : 'license.newKeyCopy')}</button>
<button class="btn btn-secondary btn-sm" type="button" data-download>${say('license.newKeyDownload')}</button>
</div>
<label class="flex gap-sm mt-md t-body-sm"><input type="checkbox" data-saved> ${say('license.newKeySaved')}</label>
<div class="flex gap-sm mt-md">
<button class="btn btn-primary btn-sm" type="button" data-go disabled>${say('license.newKeyGo')}</button>
<button class="btn btn-ghost btn-sm" type="button" data-cancel>${say('license.newKeyCancel')}</button>
</div>`;
const go = box.querySelector('[data-go]');
const saved = box.querySelector('[data-saved]');
const allow = () => { go.disabled = !(saved.checked && newKey.copied); };
saved.onchange = allow;
box.querySelector('[data-key-copy]').onclick = (e) => {
newKey.copied = true;
allow();
copyText(nsec, box.querySelector('[data-nsec]')).then((ok) => { if (ok) e.target.textContent = t('license.newKeyCopied'); });
};
box.querySelector('[data-download]').onclick = () => {
download(t('license.newKeyFileName'), `${nsec}\n`);
newKey.copied = true;
allow();
};
go.onclick = () => { signer = keySigner(newKey.secret); newKey = null; drawSignIn(box, s); };
box.querySelector('[data-cancel]').onclick = () => { newKey = null; drawSignIn(box, s); };
return;
}
const hasExtension = !!globalThis.nostr;
const known = s.installed && s.buyer ? { key: 'license.signInKnown', hex: s.buyer }
: shop.buyer ? { key: 'license.signInLast', hex: shop.buyer } : null;
box.innerHTML = `${head}
<p class="t-body-sm">${say('license.signInIntro')}</p>
${known ? `<p class="t-body-sm">${esc(t(known.key, { npub: '§' })).replace('§', npubText(known.hex))}</p>` : ''}
<div class="flex gap-sm">
<button class="btn btn-primary btn-sm" type="button" data-ext${hasExtension ? '' : ' disabled'}>${say('license.signInExtension')}</button>
<button class="btn btn-secondary btn-sm" type="button" data-key>${say('license.signInKey')}</button>
<button class="btn btn-ghost btn-sm" type="button" data-new>${say('license.signInNew')}</button>
</div>
${hasExtension ? '' : `<p class="t-caption mt-sm">${say('license.signInExtensionNone')}</p>`}
<form class="mt-md" data-key-form hidden autocomplete="off">
<label class="form-label" for="lic-nsec">${say('license.signInKeyLabel')}</label>
<input class="input t-mono mt-sm" id="lic-nsec" type="password" autocomplete="off" spellcheck="false">
<p class="t-caption mt-sm">${say('license.signInKeyHint')}</p>
<div class="flex gap-sm mt-sm"><button class="btn btn-primary btn-sm" type="submit">${say('license.signInGo')}</button></div>
</form>
<p class="t-body-sm mt-sm" data-signin-msg></p>`;
const msg = box.querySelector('[data-signin-msg]');
const fault = (text) => { msg.className = 't-body-sm mt-sm t-neg'; msg.textContent = text; };
box.querySelector('[data-ext]').onclick = async () => {
try { signer = await extensionSigner(); drawSignIn(box, s); } catch (err) { fault(err.message); }
};
const form = box.querySelector('[data-key-form]');
box.querySelector('[data-key]').onclick = () => { form.hidden = false; form.querySelector('input').focus(); };
form.onsubmit = (e) => {
e.preventDefault();
const input = form.querySelector('input');
const { secret, error } = secretFromText(input.value);
input.value = '';
if (error) { fault(t(error === 'npub' ? 'license.signInNpub' : 'license.signInBadKey')); return; }
signer = keySigner(secret);
drawSignIn(box, s);
};
box.querySelector('[data-new]').onclick = () => { newKey = { secret: newSecret(), copied: false }; drawSignIn(box, s); };
}
const BUY_POLL_MS = 3000;
let buyBox = null;
let serviceUrl = null;
let purchase = null;
let pollTimer = null;
const sats = (n) => Number(n).toLocaleString(localeTag());
const clock = (ms) => new Date(ms).toLocaleTimeString(localeTag(), { hour: '2-digit', minute: '2-digit' });
const FINAL = { expired: 'license.buyExpired', canceled: 'license.buyCanceled', underpaid: 'license.buyUnderpaid' };
const AWAY = new Set(['marketService.unreachable', 'marketService.timeout', 'marketService.unavailable']);
async function drawBuy() {
const box = buyBox;
if (!box) return;
const head = `<div class="lic-head"><div class="t-label">${say('license.buyTitle')}</div></div>`;
if (purchase) {
const o = purchase.order;
const final = FINAL[o.status];
const state = final ? say(final)
: o.status === 'paid' ? say(o.waived ? 'license.buyWaived' : 'license.buyPaid')
: say('license.buyWaiting');
box.innerHTML = `${head}
${o.status === 'open' && o.paymentRequest ? `
<p class="t-body-sm">${say('license.buyInvoice', { time: clock(o.expiresAt) })}</p>
<div class="mt-sm">${qrSvg(invoiceQrText(o.paymentRequest), { label: t('license.buyQr') })}</div>
<input class="input t-mono mt-sm" type="text" readonly value="${esc(o.paymentRequest)}" data-invoice aria-label="${esc(t('license.buyTitle'))}">
<div class="flex gap-sm mt-sm">
<a class="btn btn-primary btn-sm" href="${esc(`lightning:${o.paymentRequest}`)}">${say('license.buyOpenWallet')}</a>
<button class="btn btn-secondary btn-sm" type="button" data-invoice-copy>${say('license.buyCopy')}</button>
</div>` : ''}
<p class="t-body-sm mt-md${final ? ' t-neg' : ''}">${state}</p>
<p class="t-body-sm" data-buy-msg>${purchase.note ? esc(purchase.note) : ''}</p>
${final ? `<div class="flex gap-sm"><button class="btn btn-secondary btn-sm" type="button" data-buy-again>${say('license.buyAgain')}</button></div>` : ''}`;
const copy = box.querySelector('[data-invoice-copy]');
if (copy) copy.onclick = (e) => copyText(o.paymentRequest, box.querySelector('[data-invoice]')).then((ok) => { if (ok) e.target.textContent = t('license.buyCopied'); });
const again = box.querySelector('[data-buy-again]');
if (again) again.onclick = () => { purchase = null; drawBuy(); };
return;
}
let sale = null;
let problem = null;
try { sale = await saleInfo(); } catch (err) { problem = AWAY.has(err.key) ? t('license.buyAway') : err.message; }
const price = sale?.open ? say('license.buyPrice', { major: sale.major, sats: sats(sale.priceSat) })
: sale ? say('license.buyClosed') : esc(problem ?? '');
box.innerHTML = `${head}
<p class="t-body-sm${problem ? ' t-neg' : ''}">${price}</p>
${sale?.open && !signer ? `<p class="t-body-sm">${say('license.buySignInFirst')}</p>` : ''}
<div class="flex gap-sm"><button class="btn btn-primary btn-sm" type="button" data-buy-go${sale?.open && signer ? '' : ' disabled'}>${say('license.buyButton')}</button></div>
<p class="t-body-sm mt-sm" data-buy-msg></p>`;
box.querySelector('[data-buy-go]').onclick = async (e) => {
const msg = box.querySelector('[data-buy-msg]');
e.target.disabled = true;
try {
const body = '{}';
const header = await nip98Header(signer, { url: `${serviceUrl}/v1/orders`, method: 'POST', body });
const { order, readKey } = await placeOrder(header, body);
purchase = { order, readKey, note: '' };
drawBuy();
schedulePoll();
} catch (err) {
e.target.disabled = false;
msg.className = 't-body-sm mt-sm t-neg';
msg.textContent = err.message;
}
};
}
function schedulePoll() {
clearTimeout(pollTimer);
pollTimer = setTimeout(pollOnce, BUY_POLL_MS);
}
async function pollOnce() {
if (!purchase?.readKey) return;
try {
const { order, license } = await orderStatus(purchase.order.id, purchase.readKey);
purchase.order = order;
purchase.note = '';
if (license) {
drawBuy();
const msg = buyBox?.querySelector('[data-buy-msg]');
if (!license.valid) { purchase.note = t('license.buyNotValid'); drawBuy(); return; }
if (msg && buyBox.isConnected) await installAndReload(license.file, msg);
else await installLicense(license.file).then(() => location.reload());
return;
}
drawBuy();
if (FINAL[order.status]) return;
} catch (err) {
purchase.note = err.message;
drawBuy();
}
schedulePoll();
}
const SUPPORT_POLL_MS = 30_000;
let supportBox = null;
let supportDraft = '';
let supportFile = null;
let supportTimer = null;
const shots = new Map();
const supportUrl = (info, path) => `${info.serviceUrl}/v1/support/${path}`;
async function drawSupport() {
const box = supportBox;
if (!box) return;
clearInterval(supportTimer);
box.hidden = !signer;
if (!signer) return;
let info = null;
try { info = await supportInfo(); } catch { info = null; }
const head = `<div class="lic-head"><div class="t-label">${say('license.supportTitle')}</div>
${info ? `<button class="btn btn-ghost btn-sm" type="button" data-support-refresh>${say('license.supportRefresh')}</button>` : ''}</div>`;
const nostr = info?.nostr?.pubkey
? `<p class="t-caption">${esc(t('license.supportNostr', { name: '§' })).replace('§', `<strong>${esc(info.nostr.name)}</strong> (${npubText(info.nostr.pubkey)})`)}</p>` : '';
if (!info?.serviceUrl) {
box.innerHTML = `${head}<p class="t-body-sm">${say('license.supportIntro')}</p>${nostr}`;
return;
}
const mb = info.maxAttachmentBytes / 1024 / 1024;
box.innerHTML = `${head}
<p class="t-body-sm">${say('license.supportIntro')}</p>
<div class="lic-chat" data-support-thread></div>
<label class="form-label" for="lic-support">${say('license.supportLabel')}</label>
<textarea class="input mt-sm" id="lic-support" rows="3" maxlength="${info.maxText}" placeholder="${esc(t('license.supportPlaceholder'))}">${esc(supportDraft)}</textarea>
<div data-support-file></div>
<div class="flex gap-sm mt-sm">
<button class="btn btn-secondary btn-sm" type="button" data-support-pick>${say('license.supportAttach')}</button>
<input type="file" accept="${esc(info.attachmentTypes.join(','))}" data-support-input hidden>
<button class="btn btn-primary btn-sm" type="button" data-support-send>${say('license.supportSend')}</button>
<span class="t-caption">${say('license.supportAttachHint', { mb })}</span>
</div>
<p class="t-body-sm mt-sm" data-support-msg></p>
${nostr}`;
const field = box.querySelector('#lic-support');
const msg = box.querySelector('[data-support-msg]');
const thread = box.querySelector('[data-support-thread]');
const picker = box.querySelector('[data-support-input]');
const show = (cls, line) => { msg.className = `t-body-sm mt-sm ${cls}`; msg.textContent = line; };
field.oninput = () => { supportDraft = field.value; };
const showFile = () => {
const area = box.querySelector('[data-support-file]');
area.innerHTML = supportFile ? `<div class="lic-pending"><img class="lic-shot" src="${esc(supportFile.url)}" alt="${esc(t('license.supportScreenshot'))}">
<button class="btn btn-ghost btn-sm" type="button" data-support-unpick>${say('license.supportRemoveAttachment')}</button></div>` : '';
area.querySelector('[data-support-unpick]')?.addEventListener('click', () => { supportFile = null; showFile(); });
};
const take = (file) => {
if (!file) return;
if (!info.attachmentTypes.includes(file.type)) { show('t-neg', t('license.supportAttachType')); return; }
if (file.size > info.maxAttachmentBytes) { show('t-neg', t('license.supportAttachTooLarge', { mb })); return; }
const reader = new FileReader();
reader.onload = () => {
const url = String(reader.result);
supportFile = { type: file.type, data: url.slice(url.indexOf(',') + 1), url };
show('', '');
showFile();
};
reader.readAsDataURL(file);
};
showFile();
box.querySelector('[data-support-pick]').onclick = () => picker.click();
picker.onchange = () => { take(picker.files?.[0]); picker.value = ''; };
field.addEventListener('paste', (e) => {
const item = [...(e.clipboardData?.items ?? [])].find(i => i.kind === 'file' && i.type.startsWith('image/'));
if (item) { e.preventDefault(); take(item.getAsFile()); }
});
box.querySelector('[data-support-refresh]').onclick = () => drawThread(thread, info);
drawThread(thread, info);
if (signer.kind === 'key') {
supportTimer = setInterval(() => {
if (!thread.isConnected) { clearInterval(supportTimer); return; }
drawThread(thread, info, { quiet: true });
}, SUPPORT_POLL_MS);
}
box.querySelector('[data-support-send]').onclick = async (e) => {
const text = field.value.trim();
if (!text && !supportFile) { show('t-neg', t('license.supportEmpty')); return; }
if (text.length > info.maxText) { show('t-neg', t('license.supportTooLong', { max: info.maxText })); return; }
e.target.disabled = true;
e.target.textContent = t('license.supportSending');
try {
const body = JSON.stringify({ text, attachment: supportFile ? { type: supportFile.type, data: supportFile.data } : null });
const header = await nip98Header(signer, { url: supportUrl(info, 'messages'), method: 'POST', body });
const sent = await sendSupport(header, body);
if (supportFile && sent.attachments?.[0]) shots.set(sent.attachments[0].id, supportFile.url);
supportDraft = '';
supportFile = null;
field.value = '';
showFile();
show('', '');
await drawThread(thread, info);
} catch (err) {
show('t-neg', AWAY.has(err.key) ? t('license.supportAway') : err.message);
} finally {
e.target.disabled = false;
e.target.textContent = t('license.supportSend');
}
};
}
async function drawThread(area, info, { quiet = false } = {}) {
if (!quiet) area.innerHTML = `<p class="t-body-sm">${say('license.supportThreadLoading')}</p>`;
let list;
try {
const header = await nip98Header(signer, { url: supportUrl(info, 'messages'), method: 'GET' });
list = await supportMessages(header);
} catch (err) {
if (quiet) return;
area.innerHTML = `<p class="t-body-sm t-neg">${esc(AWAY.has(err.key) ? t('license.supportAway') : err.message)}</p>`;
return;
}
if (!list.length) { area.innerHTML = `<p class="t-body-sm">${say('license.supportThreadEmpty')}</p>`; return; }
const when = (ms) => new Date(ms).toLocaleString(localeTag(), { dateStyle: 'short', timeStyle: 'short' });
const who = (m) => t(m.from === 'buyer' ? 'license.supportYou' : m.ai ? 'license.supportAi' : 'license.supportSeller');
const shot = (a) => (shots.has(a.id)
? `<img class="lic-shot" src="${esc(shots.get(a.id))}" alt="${esc(t('license.supportScreenshot'))}">`
: `<button class="btn btn-ghost btn-sm" type="button" data-shot="${esc(a.id)}">${say('license.supportShowScreenshot')}</button>`);
area.innerHTML = list.map(m => `<div class="lic-msg ${m.from === 'buyer' ? 'is-mine' : 'is-theirs'}">
<div class="lic-bubble">${m.text ? `<p class="t-body-sm">${esc(m.text)}</p>` : ''}${m.attachments.map(shot).join('')}</div>
<span class="t-caption">${esc(who(m))} · ${esc(when(m.at))}</span>
</div>`).join('');
const toEnd = () => { area.scrollTop = area.scrollHeight; };
toEnd();
area.querySelectorAll('img').forEach(img => img.addEventListener('load', toEnd, { once: true }));
const load = async (button) => {
const id = button.dataset.shot;
try {
const header = await nip98Header(signer, { url: supportUrl(info, `attachments/${id}`), method: 'GET' });
const { mime, data } = await supportAttachment(header, id);
if (!info.attachmentTypes.includes(mime) || !/^[A-Za-z0-9+/=]+$/.test(data)) return;
shots.set(id, `data:${mime};base64,${data}`);
const img = document.createElement('img');
img.className = 'lic-shot';
img.alt = t('license.supportScreenshot');
img.addEventListener('load', toEnd, { once: true });
img.src = shots.get(id);
button.replaceWith(img);
} catch (err) {
button.textContent = err.message;
}
};
area.querySelectorAll('[data-shot]').forEach(button => {
if (signer.kind === 'key') load(button);
else button.onclick = () => load(button);
});
}
async function drawLicenses(area, msg, shop) {
area.innerHTML = `<p class="t-body-sm">${say('license.licensesLoading')}</p>`;
let list;
try {
const header = await nip98Header(signer, { url: `${shop.serviceUrl}/v1/license`, method: 'GET' });
list = await buyerLicenses(header);
} catch (err) {
area.innerHTML = '';
msg.className = 't-body-sm t-neg';
msg.textContent = err.message;
return;
}
if (!list.length) { area.innerHTML = `<p class="t-body-sm">${say('license.licensesNone')}</p>`; return; }
area.innerHTML = list.map((l, i) => {
if (l.unreadable) return `<p class="t-body-sm">${say('license.licenseNotValid')}</p>`;
const text = say('license.licenseLine', {
level: t(`license.level_${l.level}`), from: l.majorFrom,
to: l.majorTo === 'unlimited' ? t('license.unlimited') : l.majorTo, issued: l.issued,
});
const state = !l.valid ? say('license.licenseNotValid')
: l.installed ? `<span class="badge badge-neutral">${say('license.licenseInstalled')}</span>`
: `<button class="btn btn-secondary btn-sm" type="button" data-install="${i}">${say('license.licenseInstall')}</button>`
+ (l.covers ? '' : ` <span class="t-caption">${say('license.licenseNotCovered')}</span>`);
return `<div class="flex-between gap-sm mt-sm"><span class="t-body-sm">${text}</span><span>${state}</span></div>`;
}).join('');
area.querySelectorAll('[data-install]').forEach(b => { b.onclick = () => installAndReload(list[Number(b.dataset.install)].file, msg); });
}
async function copyText(text, field) {
try { field.select(); if (document.execCommand('copy')) return true; } catch { }
try { await navigator.clipboard.writeText(text); return true; } catch { return false; }
}
function download(name, text) {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([text], { type: 'text/plain' }));
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
const mark = (v) => {
if (v === true) return `<span class="lic-feat-yes" title="${esc(t('license.featIncluded'))}">${CHECK}</span>`;
if (v === false) return `<span class="lic-feat-no" title="${esc(t('license.featExcluded'))}">-</span>`;
if (v === 'partial') return `<span class="lic-feat-part" title="${esc(t('license.featPartialTitle'))}">${esc(t('license.featPartial'))}</span>`;
return `<span class="lic-feat-open" title="${esc(t('license.featUndecided'))}">?</span>`;
};
const inFree = f => (f.has ? CONFIG.LICENSE.FREE.HAS[f.has] : f.free);
function groupFree(items) {
const v = items.map(inFree);
if (v.every(x => x === true)) return true;
if (v.every(x => x === false)) return false;
return v.some(x => x === true) ? 'partial' : null;
}
const groupKey = g => `feat_${g}`;
function featuresHtml(expand) {
const groups = CONFIG.LICENSE.FEATURE_GROUPS.map((g) => {
const items = CONFIG.LICENSE.FEATURES.filter(f => f.group === g);
const sub = items.map(f => `
<li class="lic-feat-row" tabindex="0" data-feature="${esc(f.id)}"><span>${esc(t(`license.feat_${f.id}`))}</span>${mark(true)}${mark(inFree(f))}</li>`).join('');
return `
<details class="lic-group" ${expand.attr(groupKey(g))}>
<summary class="lic-feat-row lic-group-row" data-feature="${esc(items[0].id)}">
<span class="lic-group-name"><span class="chev-btn">${CHEVRON}</span>${esc(t(`license.featGroup_${g}`))}</span>${mark(true)}${mark(groupFree(items))}
</summary>
<ul class="lic-feat-sub">${sub}</ul>
</details>`;
}).join('');
const open = CONFIG.LICENSE.FEATURES.some(f => inFree(f) === null);
return `
<div class="lic-hero">${splashMarkHtml()}<div class="t-label">${esc(t('license.featTitle'))}</div></div>
<div class="lic-feat">
<div class="lic-feat-row lic-feat-cols"><span></span><span>${esc(t('license.featFull'))}</span><span>${esc(t('license.featFree'))}</span></div>
${groups}
</div>
${open ? `<p class="t-caption">${esc(t('license.featFreeOpen'))}</p>` : ''}`;
}
function placeShot(shot, row) {
const gap = 8, pad = 8;
const r = row.getBoundingClientRect();
const w = shot.offsetWidth, h = shot.offsetHeight;
let top = r.top - gap - h;
if (top < pad) top = r.bottom + gap;
const left = Math.min(Math.max(pad, r.left + 24), window.innerWidth - pad - w);
shot.style.left = `${left}px`;
shot.style.top = `${Math.min(Math.max(pad, top), window.innerHeight - pad - h)}px`;
}
function bindFeatures(root) {
document.querySelector('body > .lic-feat-shot')?.remove();
const shot = document.createElement('div');
shot.className = 'lic-feat-shot';
shot.hidden = true;
document.body.appendChild(shot);
let current = null;
let on = null;
const show = (row) => {
if (row === on && !shot.hidden) return;
on = row;
root.querySelectorAll('[data-feature]').forEach(r => r.classList.toggle('is-on', r === row));
shot.hidden = false;
placeShot(shot, row);
const id = row.dataset.feature;
if (id === current) return;
current = id;
shot.innerHTML = '';
const img = new Image();
img.className = 'lic-feat-img';
img.alt = t(`license.feat_${id}`);
img.onload = () => { if (current === id) shot.replaceChildren(img); };
img.onerror = () => { if (current === id) shot.innerHTML = `<p class="t-caption">${esc(t('license.featNoShot'))}</p>`; };
img.src = `${CONFIG.LICENSE.FEATURE_IMAGES}/${id}.webp`;
};
const hide = () => {
on = null;
shot.hidden = true;
root.querySelectorAll('.is-on').forEach(r => r.classList.remove('is-on'));
};
const over = (e) => {
if (!root.isConnected) { stop(); return; }
const row = e.target.closest?.('[data-feature]');
if (row && root.contains(row)) show(row); else hide();
};
const stop = () => {
shot.remove();
document.removeEventListener('pointerover', over);
window.removeEventListener('scroll', hide, true);
window.removeEventListener('hashchange', stop);
};
document.addEventListener('pointerover', over);
window.addEventListener('scroll', hide, { capture: true, passive: true });
window.addEventListener('hashchange', stop);
root.addEventListener('focusin', (e) => { const row = e.target.closest('[data-feature]'); if (row) show(row); });
root.addEventListener('focusout', hide);
root.querySelectorAll('.lic-group').forEach(d => d.addEventListener('toggle', hide));
}
export async function render(el) {
const s = licenseSnapshot() ?? await loadLicense();
const expand = await expanders('license', {
nostr: false, ...Object.fromEntries(CONFIG.LICENSE.FEATURE_GROUPS.map(g => [groupKey(g), false])),
});
el.innerHTML = `
<div class="lic-layout">
<div class="lic-side">
<div class="card lic-card">
<div class="lic-head">
<div class="t-label">${esc(t('license.cardTitle'))}</div>
<span class="badge badge-neutral">${esc(t('license.cardVersion', { version: s.version }))}</span>
</div>
${lines(s).map(r => `<p class="t-body-sm"${r.title ? ` title="${esc(r.title)}"` : ''}>${esc(r.text)}</p>`).join('')}
<div class="lic-drop" id="lic-drop" tabindex="0" role="button">
<span class="t-body-sm">${esc(t('license.dropHere'))}</span>
<span class="t-caption">${esc(t('license.dropOr'))}</span>
<input type="file" id="lic-file" accept=".json,application/json" hidden>
</div>
<span id="lic-msg" class="t-body-sm"></span>
<section class="lic-section" data-signin></section>
<section class="lic-section" data-buy></section>
</div>
<div class="card lic-card">${payHtml(expand)}</div>
</div>
<div class="lic-main">
<div class="card lic-card" data-features>${featuresHtml(expand)}</div>
<div class="card lic-card lic-chat-card" data-support hidden></div>
</div>
</div>`;
expand.bind(el);
bindFeatures(el.querySelector('[data-features]'));
buyBox = el.querySelector('[data-buy]');
supportBox = el.querySelector('[data-support]');
drawSignIn(el.querySelector('[data-signin]'), s);
markLicenseSeen().then(() => refreshAttention()).catch(() => { });
const message = el.querySelector('#lic-msg');
const install = (text) => installAndReload(text, message);
const MAX = 64 * 1024;
const zone = el.querySelector('#lic-drop');
const picker = el.querySelector('#lic-file');
async function fromFile(file) {
if (!file) return;
if (file.size > MAX) {
message.className = 't-body-sm t-neg';
message.textContent = t('license.dropTooBig');
return;
}
await install(await file.text());
}
zone.onclick = () => picker.click();
zone.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); picker.click(); } };
picker.onchange = () => fromFile(picker.files?.[0]);
zone.ondragover = (e) => { e.preventDefault(); zone.classList.add('is-over'); };
zone.ondragleave = () => zone.classList.remove('is-over');
zone.ondrop = (e) => {
e.preventDefault();
zone.classList.remove('is-over');
fromFile(e.dataTransfer?.files?.[0]);
};
}
+87
View File
@@ -0,0 +1,87 @@
// Generated, do not edit. sha256:ae09cffb6bcb813ac103c0c39e8227c38347409ffb83175ba705ade666f63970
import { listMetalAssets, listMetalProducts, createMetalProduct, updateMetalProduct, deleteMetalProduct } from '../data/metals.js';
import { tableViewCfg } from '../data/preferences.js';
import { t, localeTag } from '../i18n/index.js';
import * as dialog from '../ui/dialog.js';
import { ENUMS, CONFIG } from '../../shared/config.js';
import { renderTable } from '../ui/table.js';
import { toBaseByFactor, fromBaseByFactor } from '../ui/format.js';
import { assetLabelHtml } from '../ui/asset-label.js';
import { esc } from '../ui/escape.js';
let tableApi = null;
export async function mount(host) {
await buildTable(host);
}
async function assetOpts() { return (await listMetalAssets()).map(a => ({ value: a.id, label: `${a.symbol}: ${a.name}` })); }
function formOpts() { return ENUMS.METAL_FORM.map(v => ({ value: v, label: t('enums.metal_form.' + v) })); }
const DEC = CONFIG.METALS.DISPLAY_DECIMALS;
const fmtG = grams => grams.toLocaleString(localeTag(), { maximumFractionDigits: DEC });
async function buildTable(el) {
const metalAssets = await listMetalAssets();
const columns = [
{ key: 'assetId', label: t('metalProducts.asset'), sortable: true, sortValue: r => r.asset_symbol,
render: r => assetLabelHtml({ asset_name: r.asset_name, asset_symbol: r.asset_symbol }),
group: { value: r => r.asset_symbol, label: v => v },
edit: { type: 'select', options: assetOpts } },
{ key: 'name', label: t('metalProducts.name'), sortable: true, text: true,
edit: { type: 'text', placeholder: 'Krugerrand 1 oz' } },
{ key: 'form', label: t('metalProducts.form'), sortable: true,
render: r => t('enums.metal_form.' + r.form),
group: { value: r => r.form, label: v => t('enums.metal_form.' + v) },
edit: { type: 'select', options: formOpts } },
{ key: 'fineGrams', label: t('metalProducts.fineWeight'), align: 'num', sortable: true, sortValue: r => r.fineGrams,
render: r => `${fmtG(fromBaseByFactor(r.fineGrams, 'g'))} g <span class="t-muted-cell">· ${fmtG(fromBaseByFactor(r.fineGrams, 'ozt'))} ozt</span>`,
edit: {
render: vals => {
const grams = Number.isFinite(Number(vals.fineGrams)) && vals.fineGrams !== '' && vals.fineGrams != null
? fromBaseByFactor(vals.fineGrams, 'g') : '';
const opt = u => `<option value="${u}">${u}</option>`;
return `<div data-field="fineGrams" style="display:flex;gap:.4rem;align-items:center">
<input class="input" data-mp-weight type="number" min="0" step="any" value="${esc(grams)}" style="min-width:90px">
<select class="input" data-mp-unit style="width:auto">${['g', 'ozt', 'kg'].map(opt).join('')}</select>
</div>`;
},
read: host => {
if (!host) return null;
const n = parseFloat(host.querySelector('[data-mp-weight]').value);
const u = host.querySelector('[data-mp-unit]').value;
if (!Number.isFinite(n) || n <= 0) return null;
return toBaseByFactor(n, u);
},
} },
];
function toPayload(v) {
const assetId = Number(v.assetId);
const name = String(v.name || '').trim();
const fineGrams = v.fineGrams;
if (!Number.isInteger(assetId) || !name) throw new Error(t('metalProducts.pickMetalAndName'));
if (!Number.isInteger(fineGrams) || fineGrams <= 0) throw new Error(t('metalProducts.fillFineGrams'));
return { assetId, name, form: v.form, fineGrams };
}
tableApi = renderTable(el, {
columns, rows: await listMetalProducts(),
title: t('metalProducts.title'),
...(await tableViewCfg('metal-products', { key: 'assetId', dir: 'asc' })),
emptyText: t('metalProducts.none'),
noneLabel: t('table.none'),
collapsibleGroups: true,
newLabel: t('table.new'),
newDefaults: { assetId: metalAssets[0] ? metalAssets[0].id : '', form: 'coin' },
saveLabel: t('metalProducts.save'), cancelLabel: t('metalProducts.cancel'),
onCreate: async v => { const id = await createMetalProduct(toPayload(v)); tableApi.refresh(await listMetalProducts(), { locate: id }); },
onSave: async (id, v) => { await updateMetalProduct(id, toPayload(v)); tableApi.refresh(await listMetalProducts()); },
onDelete: async id => {
if (!(await dialog.confirm({ message: t('metalProducts.confirmDelete'), danger: true }))) return;
try { await deleteMetalProduct(id); tableApi.refresh(await listMetalProducts()); }
catch (e) { dialog.alert({ title: t('dialog.error'), message: e.message }); }
},
});
}
+790
View File
@@ -0,0 +1,790 @@
// Generated, do not edit. sha256:242a49647c9f6c4f00caf9c66c75b5120a0a8d45f2c0c051b6878a5f798ae976
import { tableViewCfg } from '../data/preferences.js';
import {
addManualPricePoint, upsertPriceHistory, listAllPriceHistory, listPriceGaps, listPriceHistory,
ignorePriceGap, listIgnoredPriceGaps, unignorePriceGap, listPriceAbsences, deletePriceAbsences,
} from '../data/price-history.js';
import { listAssets } from '../data/assets.js';
import { deleteTableRow } from '../data/table-viewer.js';
import { t, localeTag } from '../i18n/index.js';
import { fmtBasePrice, priceFromBase, isBaseAsset } from '../ui/format.js';
import { dayNl } from '../../shared/time.js';
import { baseCurrency, appSetting } from '../app-settings.js';
import { serviceCoverage } from '../data/market.js';
import { renderTable } from '../ui/table.js';
import { mountSparkline } from '../ui/sparkline.js';
import { fetchPriceVia, primaryProvider, fillClosesFor, daysToAsk } from '../price/providers.js';
import { getProvider, listProviders } from '../data/providers.js';
import { apiMetricsTotals } from '../data/api-metrics.js';
import { priceSourceChain } from '../data/price-source.js';
import { loadMatrixContext, matrixCardHtml, wireMatrix } from '../ui/source-matrix.js';
import { diagnoseSources } from '../../shared/source-diagnosis.js';
import { attentionDotHtml } from '../ui/attention.js';
import { hygieneReport } from '../data/hygiene.js';
import { dailyProvenance, provenanceCounts, provenanceRuns, PROVENANCE_KINDS } from '../../shared/price-provenance.js';
import { esc } from '../ui/escape.js';
import { assetLabel, assetLabelHtml, byKindAndLabel } from '../ui/asset-label.js';
import { REFRESH, EYE_OFF, EYE, CHEVRON_RIGHT, CLOSE } from '../ui/icons.js';
import { create as createTabs } from '../ui/tabs.js';
import { expanders } from '../ui/expand.js';
import { notesHtml, mountNotes } from '../ui/notes.js';
import * as dialog from '../ui/dialog.js';
import { ENUMS, CONFIG } from '../../shared/config.js';
import { loadCharting, readPalette } from '../ui/chart-setup.js';
import { hexToRgb } from '../ui/charting.js';
import { isMetal, priceUnitLabel } from '../../shared/units.js';
const todayIso = () => new Date().toISOString().slice(0, 10);
export const title = () => t('priceHistory.title');
let phTable = null;
function localDateParts(iso) {
const d = new Date(iso);
const p = n => String(n).padStart(2, '0');
return { date: `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`, time: `${p(d.getHours())}:${p(d.getMinutes())}` };
}
function fmtDateTime(ts) {
return new Intl.DateTimeFormat(localeTag(), { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).format(new Date(ts * 1000));
}
function displayDate(r) { return r.resolution === 'timestamp' ? localDateParts(r.observedAt).date : r.observedAt.slice(0, 10); }
function resLabel(res) { return res === 'timestamp' ? t('priceHistory.resTimestamp') : t('priceHistory.resDay'); }
const dash = '<span class="t-muted">-</span>';
async function fetchDayPoint(sourceKey, asset, dateKey) {
const midnight = Math.floor(Date.parse(`${dateKey}T00:00:00Z`) / 1000);
let pt = null;
try { pt = await fetchPriceVia(sourceKey, asset, 'history_point', dateKey); } catch { pt = null; }
if (!pt) {
try {
const grid = await fetchPriceVia(sourceKey, asset, 'daily_history', dateKey);
if (Array.isArray(grid)) pt = grid.find(x => new Date(x.time * 1000).toISOString().slice(0, 10) === dateKey) || null;
} catch { }
}
if (!pt && dateKey === todayIso()) {
try { const s = await fetchPriceVia(sourceKey, asset, 'spot', dateKey); if (s && Number.isFinite(s.price)) pt = { time: midnight, price: s.price }; } catch { }
}
if (!pt || !Number.isFinite(pt.price)) return null;
return { time: pt.time ?? midnight, price: pt.price };
}
async function fetchSpotPoint(sourceKey, asset) {
let s = null;
try { s = await fetchPriceVia(sourceKey, asset, 'spot', null); } catch { return null; }
if (!s || !Number.isFinite(s.price)) return null;
return { time: Math.floor(Date.now() / 1000), price: s.price };
}
export async function render(el) {
el.innerHTML = `<div id="ph-gaps"></div>`;
alleAssets = await listAssets();
await renderCards(el);
}
const CARD_DAYS = 365;
let kaarten = new Map();
let manualId = null;
let bronNamen = new Map();
async function renderCards(el) {
const host = el.querySelector('#ph-gaps');
if (!host) return;
let gaten, genegeerd, manual, bronnen, controle, afwezig;
try {
[gaten, genegeerd, manual, bronnen, controle, afwezig] = await Promise.all([listPriceGaps(), listIgnoredPriceGaps(),
getProvider('manual').catch(() => null), bronContext().catch(() => null),
hygieneReport({ kinds: CONTROLE_SOORTEN }).catch(() => null), listPriceAbsences().catch(() => [])]);
} catch { host.innerHTML = ''; return; }
const verdachtPerAsset = new Map();
for (const f of (controle && controle.findings) || []) {
for (const r of f.rows) {
const aid = r.row.assetId;
if (!verdachtPerAsset.has(aid)) verdachtPerAsset.set(aid, new Map());
const perSoort = verdachtPerAsset.get(aid);
if (!perSoort.has(f.kind)) perSoort.set(f.kind, []);
perSoort.get(f.kind).push(r);
}
}
manualId = manual ? manual.id : null;
if (bronnen) bronNamen = new Map(bronnen.providers.map(p => [p.id, p.name]));
const groepeer = rijen => {
const m = new Map();
for (const g of rijen) { if (!m.has(g.assetId)) m.set(g.assetId, []); m.get(g.assetId).push(g); }
return m;
};
const perAsset = groepeer(gaten.rows);
const genegeerdPerAsset = groepeer(genegeerd);
const afwezigPerAsset = groepeer(afwezig);
const vandaag = todayIso();
const venster = shiftIso(vandaag, -(CARD_DAYS - 1));
const nieuw = new Map();
for (const asset of [...alleAssets].sort(byKindAndLabel)) {
if (isBaseAsset(asset)) continue;
let rijen = [];
try { rijen = await listPriceHistory(asset.id); } catch { rijen = []; }
if (!rijen.length && !perAsset.has(asset.id) && !genegeerdPerAsset.has(asset.id)) continue;
const eerste = rijen.length ? rijen[rijen.length - 1].observedAt.slice(0, 10) : vandaag;
const dagen = dailyProvenance(rijen, {
assetKind: asset.kind, manualProviderId: manualId, today: vandaag,
from: eerste > venster ? eerste : venster, until: vandaag,
});
let bevindingen = [];
if (bronnen) {
try {
const historyChain = await priceSourceChain(asset.id, 'history');
bevindingen = diagnoseSources(asset, { providers: bronnen.providers, historyChain, usage: bronnen.usage,
serviceCovers: bronnen.serviceCovered.has(asset.id) });
} catch { bevindingen = []; }
}
const verdacht = [...(verdachtPerAsset.get(asset.id) || new Map())].map(([kind, rows]) => ({ kind, rows }));
nieuw.set(asset.id, {
asset, dagen, gaps: perAsset.get(asset.id) || [], ignored: genegeerdPerAsset.get(asset.id) || [],
absent: afwezigPerAsset.get(asset.id) || [], bevindingen, verdacht,
});
}
kaarten = nieuw;
const meer = gaten.total > gaten.rows.length
? `<div class="t-caption" style="margin:-0.6rem 0 1rem">${esc(t('priceHistory.gapsMore', { n: gaten.total - gaten.rows.length }))}</div>` : '';
const html = [...kaarten.values()].map(kaartHtml).join('');
host.innerHTML = `${html ? `<div class="ph-grid">${html}</div>` : ''}${meer}`;
for (const k of kaarten.values()) tekenLijn(host, k);
const grid = host.querySelector('.ph-grid');
grid?.addEventListener('click', e => {
const kaart = e.target.closest('[data-detail]');
if (kaart) openDetail(el, Number(kaart.dataset.detail));
});
grid?.addEventListener('keydown', e => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const kaart = e.target.closest('[data-detail]');
if (kaart && e.target === kaart) { e.preventDefault(); openDetail(el, Number(kaart.dataset.detail)); }
});
}
async function bronContext() {
const month = new Date().toISOString().slice(0, 7);
const providers = (await Promise.all((await listProviders()).map(p => getProvider(p.key)))).filter(Boolean);
const usage = {};
for (const p of providers) {
if (!p.callsPerMonth) continue;
try { usage[p.id] = (await apiMetricsTotals(p.id, `${month}-01T00:00`)).calls; } catch { }
}
return { providers, usage, serviceCovered: await serviceCovered() };
}
async function serviceCovered() {
let mode = 'off';
try { mode = appSetting('market_spot'); } catch { }
if (mode === 'off') return new Set();
try {
const r = await serviceCoverage();
return new Set(r.status === 'ok' ? r.assetIds : []);
} catch { return new Set(); }
}
const CONTROLE_SOORTEN = ['price_unit_swap', 'price_outlier', 'price_sources_disagree',
'price_not_positive', 'price_in_future'];
function meldingenVan({ gaps, bevindingen = [], verdacht = [] }) {
const uit = bevindingen.map(b => ({ severity: b.severity, tekst: t(`priceHistory.diagCard_${b.code}`, { namen: (b.names || []).join(', ') }) }));
const n = verdacht.reduce((s, v) => s + v.rows.length, 0);
if (n) uit.push({ severity: 'attention', tekst: telTekst('suspect', n) });
const dagen = gaps.reduce((s, g) => s + g.days, 0);
if (gaps.length) uit.push({ severity: 'attention', tekst: t('priceHistory.cardGaps', { gaten: telTekst('gapCount', gaps.length), dagen: telTekst('dayCount', dagen) }) });
return uit;
}
const meldingHtml = m => `<span class="ph-notice">${attentionDotHtml([{ severity: m.severity, label: m.tekst, fade: 'full' }])}<span>${esc(m.tekst)}</span></span>`;
const daysIncl = (from, until) => Math.round((Date.parse(`${until}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / 86400000) + 1;
const shiftIso = (day, n) => new Date(Date.parse(`${day}T00:00:00Z`) + n * 86400000).toISOString().slice(0, 10);
const kindLabel = kind => t(`priceHistory.kind_${kind}`);
const telTekst = (stam, n) => t(`priceHistory.${stam}${n === 1 ? 'One' : 'Many'}`, { n });
const soortTelling = (kind, n) => t('priceHistory.kindCount', { n, soort: t(`priceHistory.kindCount_${kind}${n === 1 ? 'One' : 'Many'}`) });
function kaartHtml(k) {
const { asset, dagen } = k;
const counts = provenanceCounts(dagen);
const telling = dagen.length
? PROVENANCE_KINDS.filter(k => counts[k] > 0)
.map(k => `<span class="ph-count"><span class="ph-dot ph-k-${k}"></span>${esc(soortTelling(k, counts[k]))}</span>`)
.join('')
: `<span class="t-muted">${esc(t('priceHistory.cardNoPrices'))}</span>`;
const periode = dagen.length ? `<span class="t-muted">${esc(t('priceHistory.cardPeriod', { dagen: telTekst('dayCount', dagen.length) }))}</span>` : '';
const melding = meldingenVan(k).map(meldingHtml).join('');
return `<div class="card wealth-card ph-card" data-asset="${asset.id}" data-detail="${asset.id}" role="button" tabindex="0"
aria-label="${esc(t('wealth.openDetail'))}: ${esc(asset.name)}">
<div class="wealth-head">
<strong>${assetLabelHtml(asset)}</strong>
<span class="badge">${esc(t('enums.asset_kind.' + asset.kind))}</span>
</div>
<div class="ph-counts t-caption">${periode}${telling}</div>
<div class="ph-notices t-caption">${melding}</div>
<div class="ph-spark-wrap">
<div class="wealth-spark ph-spark" data-ph-spark="${asset.id}"></div>
<div class="ph-strip" data-ph-strip="${asset.id}"></div>
</div>
</div>`;
}
function tekenLijn(host, { asset, dagen }) {
const lijnEl = host.querySelector(`[data-ph-spark="${asset.id}"]`);
const strookEl = host.querySelector(`[data-ph-strip="${asset.id}"]`);
if (!lijnEl || !strookEl || !dagen.length) return;
const perMs = new Map();
const punten = [];
for (const d of dagen) {
if (d.fiatPrice == null) continue;
const ms = Date.parse(`${d.day}T00:00:00Z`);
perMs.set(ms, d);
punten.push([ms, priceFromBase(d.fiatPrice, asset)]);
}
mountSparkline(lijnEl, punten, {
tone: 'accent',
formatValue: (v, ms) => {
const d = perMs.get(ms);
return [fmtBasePrice(v),d ? d.day : null, d ? kindLabel(d.kind) : null].filter(Boolean).join(' · ');
},
});
tekenStrook(strookEl, dagen);
}
function tekenStrook(strookEl, dagen) {
const n = dagen.length;
if (!n) { strookEl.innerHTML = ''; return; }
let x = 0;
const rects = provenanceRuns(dagen).map(r => {
const titel = r.days === 1
? t('priceHistory.stripDay', { dag: r.from, soort: kindLabel(r.kind) })
: t('priceHistory.stripRange', { van: r.from, tot: r.until, soort: kindLabel(r.kind) });
const rect = `<rect x="${x}" y="0" width="${r.days}" height="1" class="ph-k-${r.kind}"><title>${esc(titel)}</title></rect>`;
x += r.days;
return rect;
}).join('');
strookEl.innerHTML = `<svg viewBox="0 0 ${n} 1" preserveAspectRatio="none" aria-hidden="true">${rects}</svg>`;
}
let detailMelding = null;
let detail = null;
let detailChart = null;
async function laadDetail(asset) {
let rijen = [];
try { rijen = await listPriceHistory(asset.id); } catch { rijen = []; }
detail = { assetId: asset.id, dagen: dailyProvenance(rijen, { assetKind: asset.kind, manualProviderId: manualId, today: todayIso() }) };
}
async function openDetail(el, assetId) {
const k = kaarten.get(assetId);
if (!k) return;
await laadDetail(k.asset);
dialog.detail({ title: assetLabel(k.asset), titleHtml: assetLabelHtml(k.asset),
asideHtml: `<span class="badge">${t('enums.asset_kind.' + k.asset.kind)}</span>`,
html: `<div class="wealth-detail ph-detail">
<div class="wealth-detail-tabs" data-detail-tabs></div>
<div class="wealth-detail-spark ph-detail-chart">
<div class="ph-detail-plot"><canvas data-ph-chart></canvas></div>
<div class="ph-strip" data-ph-detail-strip></div>
</div>
</div>` });
mountDetailTabs(el, assetId, 'provenance');
tekenDetailGrafiek(k.asset).catch(() => {});
}
function mountDetailTabs(el, assetId, active) {
const host = document.querySelector('.dialog-overlay [data-detail-tabs]');
const k = kaarten.get(assetId);
if (!host || !k) return;
createTabs(host, {
active,
items: [
{ id: 'provenance', label: t('priceHistory.tabProvenance') },
{ id: 'source', label: t('priceHistory.tabSource') },
{ id: 'check', label: t('priceHistory.tabCheck'), when: () => k.verdacht.length > 0 },
{ id: 'gaps', label: t('priceHistory.tabGaps'), when: () => k.gaps.length > 0 || k.ignored.length > 0 || k.absent.length > 0 },
{ id: 'prices', label: t('priceHistory.tabPrices') },
{ id: 'notes', label: t('notes.tab') },
],
render: (id, panel) => {
const m = detailMelding;
detailMelding = null;
const kop = m ? `<div class="ph-outcome t-body-sm">${esc(m)}</div>` : '';
if (id !== 'prices') { koersFilter = null; koersFocus = null; }
if (id === 'source') bronPaneel(el, panel, k, kop).catch(e => { panel.innerHTML = kop + `<div class="t-body-sm t-neg">${esc(e.message)}</div>`; });
else if (id === 'check') { panel.innerHTML = kop + controleHtml(k); wireControle(el, panel, k); }
else if (id === 'gaps') gatenPaneel(el, panel, k, kop).catch(e => { panel.innerHTML = kop + `<div class="t-body-sm t-neg">${esc(e.message)}</div>`; });
else if (id === 'prices') koersenPaneel(el, panel, k, kop);
else if (id === 'notes') { panel.innerHTML = kop + notesHtml(); mountNotes(panel, k.asset.id); }
else {
panel.innerHTML = kop + herkomstHtml(k, detail && detail.assetId === assetId ? detail.dagen : k.dagen);
panel.querySelectorAll('[data-to-gap]').forEach(b => b.onclick = () => {
const [from, until] = b.dataset.toGap.split('|');
gatFocus = { from, until };
mountDetailTabs(el, assetId, 'gaps');
});
}
},
});
}
async function naWijziging(el, assetId, tab, melding = null) {
document.dispatchEvent(new CustomEvent('price-gaps-changed'));
await renderCards(el);
detailMelding = melding;
const k = kaarten.get(assetId);
if (k) await laadDetail(k.asset);
mountDetailTabs(el, assetId, tab);
if (k) tekenDetailGrafiek(k.asset).catch(() => {});
}
async function tekenDetailGrafiek(asset) {
const canvas = document.querySelector('.dialog-overlay [data-ph-chart]');
const strook = document.querySelector('.dialog-overlay [data-ph-detail-strip]');
if (!canvas || !detail || detail.assetId !== asset.id) return;
if (strook) tekenStrook(strook, detail.dagen);
const Chart = await loadCharting();
const pal = readPalette();
const kleur = { close: pal.accent, manual: pal.blue, live: pal.accent, spot: pal.sigYellow, carried: pal.textSec, none: pal.sigRed };
const punten = detail.dagen.map(d => ({ x: Date.parse(`${d.day}T00:00:00Z`), y: d.fiatPrice == null ? null : priceFromBase(d.fiatPrice, asset), d }));
const soortOp = i => punten[i] && punten[i].d.kind;
const rgb = hexToRgb(pal.accent) || { r: 128, g: 128, b: 128 };
const schaduw = ({ chart }) => {
const a = chart.chartArea;
if (!a) return 'transparent';
const g = chart.ctx.createLinearGradient(0, a.top, 0, a.top + a.height * 0.85);
g.addColorStop(0, `rgba(${rgb.r},${rgb.g},${rgb.b},0.28)`);
g.addColorStop(1, `rgba(${rgb.r},${rgb.g},${rgb.b},0)`);
return g;
};
if (detailChart) detailChart.destroy();
detailChart = new Chart(canvas.getContext('2d'), {
type: 'scatter',
data: { datasets: [{
data: punten, showLine: true, spanGaps: false, borderWidth: 1.5, cubicInterpolationMode: 'monotone',
fill: 'start', backgroundColor: schaduw,
segment: {
borderColor: c => kleur[soortOp(c.p1DataIndex)] || pal.accent,
borderDash: c => (soortOp(c.p1DataIndex) === 'carried' ? [4, 3] : undefined),
},
pointRadius: c => (['spot', 'manual'].includes(soortOp(c.dataIndex)) ? 2.5 : 0),
pointHoverRadius: 4, pointBorderWidth: 0,
pointBackgroundColor: c => kleur[soortOp(c.dataIndex)] || pal.accent,
}] },
options: {
responsive: true, maintainAspectRatio: false, animation: false,
layout: { padding: { top: 6, bottom: 3 } },
interaction: { mode: 'nearest', axis: 'x', intersect: false },
plugins: {
legend: { display: false },
tooltip: {
filter: i => i.parsed.y != null,
backgroundColor: pal.tooltipBg, borderColor: pal.border, borderWidth: 1, titleColor: pal.accent, bodyColor: pal.text,
callbacks: {
title: items => (items[0] ? punten[items[0].dataIndex].d.day : ''),
label: i => tooltipRegels(punten[i.dataIndex].d, i.parsed.y),
},
},
zoom: { zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x' }, pan: { enabled: true, mode: 'x' } },
},
scales: {
x: { type: 'linear', display: false },
y: { display: false },
},
},
});
}
function tooltipRegels(d, prijs) {
const regels = [fmtBasePrice(prijs)];
const bron = d.row ? bronNamen.get(d.row.providerId) : null;
regels.push(bron ? `${kindLabel(d.kind)} · ${bron}` : kindLabel(d.kind));
if (d.kind === 'spot' && d.row) {
const moment = d.row.resolution === 'timestamp' ? d.row.observedAt : (d.row.updatedAt || d.row.createdAt);
if (moment) regels.push(t('priceHistory.tipMoment', { tijd: localDateParts(moment).time }));
const pct = v => (v == null ? '?' : `${v > 0 ? '+' : ''}${v.toLocaleString(localeTag(), { maximumFractionDigits: 2 })}%`);
if (d.deviation) regels.push(t('priceHistory.tipDeviation', { voor: pct(d.deviation.before), na: pct(d.deviation.after) }));
}
if (d.kind === 'carried') regels.push(t('priceHistory.provCarriedNote', { n: d.ageDays }));
return regels;
}
const overlapt = (r, g) => r.from <= (g.until ?? g.untilDay) && r.until >= (g.from ?? g.fromDay);
function herkomstHtml(k, dagen) {
const { asset } = k;
const counts = provenanceCounts(dagen);
const telling = PROVENANCE_KINDS.filter(k => counts[k] > 0)
.map(k => `<span class="ph-count"><span class="ph-dot ph-k-${k}"></span>${esc(soortTelling(k, counts[k]))}</span>`)
.join('');
const kop = dagen.length
? `<div class="ph-counts t-caption"><span class="t-muted">${esc(t('priceHistory.cardPeriod', { dagen: telTekst('dayCount', dagen.length) }))}</span>${telling}</div>`
: `<p class="t-muted">${esc(t('priceHistory.cardNoPrices'))}</p>`;
const dicht = CONFIG.PRICE_HISTORY.CLOSED_MARKET_DAYS[asset.kind] ?? 0;
const zwak = provenanceRuns(dagen)
.filter(r => !['close', 'live', 'manual'].includes(r.kind) && !(r.kind === 'carried' && r.days <= dicht))
.reverse();
if (!dagen.length) return kop;
if (!zwak.length) return `${kop}<p class="t-muted">${esc(t('priceHistory.provNoWeak'))}</p>`;
const toelichting = r => {
const stuk = dagen.filter(d => d.day >= r.from && d.day <= r.until);
if (r.kind === 'carried') return t('priceHistory.provCarriedNote', { n: Math.max(...stuk.map(d => d.ageDays)) });
if (r.kind !== 'spot') return '';
const pcts = stuk.flatMap(d => [d.deviation?.before, d.deviation?.after]).filter(v => v != null);
if (!pcts.length) return '';
const max = pcts.reduce((a, b) => (Math.abs(b) > Math.abs(a) ? b : a));
return t('priceHistory.provSpotNote', { pct: `${max > 0 ? '+' : ''}${max.toLocaleString(localeTag(), { maximumFractionDigits: 2 })}%` });
};
const bronVan = r => {
const namen = [...new Set(dagen.filter(d => d.day >= r.from && d.day <= r.until && d.row)
.map(d => bronNamen.get(d.row.providerId)).filter(Boolean))];
return namen.length ? esc(namen.join(', ')) : dash;
};
const naarGat = r => (r.kind === 'none' && [...k.gaps, ...k.ignored].some(g => overlapt(r, g))
? `<button type="button" class="btn btn-ghost btn-sm gap-fill" data-to-gap="${esc(r.from)}|${esc(r.until)}"
title="${esc(t('priceHistory.provToGaps'))}" aria-label="${esc(t('priceHistory.provToGaps'))}">${CHEVRON_RIGHT}</button>` : '');
const rijen = zwak.map(r => `<tr class="ph-weak-row">
<td class="text">${esc(r.days === 1 ? r.from : t('priceHistory.provRange', { van: r.from, tot: r.until }))}</td>
<td class="text"><span class="ph-count"><span class="ph-dot ph-k-${r.kind}"></span>${esc(kindLabel(r.kind))}</span></td>
<td class="num">${r.days}</td>
<td class="text">${bronVan(r)}</td>
<td class="text t-muted">${esc(toelichting(r))}</td>
<td class="actions-cell">${naarGat(r)}</td>
</tr>`).join('');
return `<div class="ph-prov">${kop}
<div class="table-wrap"><table>
<thead><tr>
<th class="text">${esc(t('priceHistory.provColPeriod'))}</th>
<th class="text">${esc(t('priceHistory.provColKind'))}</th>
<th class="num">${esc(t('priceHistory.provColDays'))}</th>
<th class="text">${esc(t('priceHistory.provColSource'))}</th>
<th class="text">${esc(t('priceHistory.provColNote'))}</th>
<th class="actions-cell"></th>
</tr></thead>
<tbody>${rijen}</tbody>
</table></div></div>`;
}
async function bronPaneel(el, panel, k, kop) {
const { asset } = k;
const regels = k.bevindingen.map(b => {
const tekst = t(`priceHistory.diag_${b.code}${b.code === 'limitReached' && b.severity !== 'problem' ? 'Fallback' : ''}`,
{ namen: (b.names || []).join(', '), symbool: asset.symbol });
const voorstel = b.proposal
? `<div class="ph-proposal t-body-sm">${esc(t('priceHistory.diagProposal', { naam: b.proposal.providerName }))}</div>` : '';
return `<div class="ph-finding">${meldingHtml({ severity: b.severity, tekst })}${voorstel}</div>`;
}).join('');
panel.innerHTML = `${kop}${regels}<div class="mx-grid" data-ph-matrix></div>`;
const host = panel.querySelector('[data-ph-matrix]');
host.innerHTML = matrixCardHtml(asset, await loadMatrixContext(), { title: false });
wireMatrix(host, () => naWijziging(el, asset.id, 'source'));
}
function controleHtml({ verdacht }) {
const maat = c => (c.factor != null ? t('priceHistory.checkFactor', { n: c.factor })
: c.afwijkingProcent != null ? t('priceHistory.checkShare', { n: c.afwijkingProcent })
: c.verschilProcent != null ? t('priceHistory.checkShare', { n: c.verschilProcent }) : '');
const bron = r => (r.provider_name || [r.low_provider_name, r.high_provider_name].filter(Boolean).join(' / '));
return verdacht.map(({ kind, rows }, ki) => `
<div class="ph-finding">
<div class="ph-gaps-head">
<strong class="t-body-sm">${esc(t(`health.kind.${kind}`))}</strong>
<button type="button" class="btn btn-secondary btn-sm" data-check-kind="${ki}">${esc(t('priceHistory.checkShowKind'))}</button>
</div>
<p class="t-caption t-muted" style="margin:0.2rem 0 0.4rem">${esc(t(`health.why.${kind}`))}</p>
<div class="gap-list">${rows.map(({ row, counts }, ri) => `
<div class="gap-row">
<span class="t-body-sm">${esc(dayNl(localDateParts(row.observedAt).date))} · ${esc(bron(row))}</span>
<span class="row-actions">
<span class="t-caption t-muted">${esc(maat(counts || {}))}</span>
<button type="button" class="btn btn-ghost btn-sm gap-fill" data-check-row="${ki}:${ri}"
title="${esc(t('priceHistory.checkOpenRow'))}" aria-label="${esc(t('priceHistory.checkOpenRow'))}">${CHEVRON_RIGHT}</button>
</span>
</div>`).join('')}</div>
</div>`).join('') + `<p class="t-caption t-muted">${esc(t('priceHistory.checkHint'))}</p>`;
}
let koersFilter = null;
let koersFocus = null;
const dagVan = row => row.observedAt.slice(0, 10);
function filterVoor(rows) {
const ids = new Set(rows.filter(r => r.row.priceHistoryId != null).map(r => r.row.priceHistoryId));
const dagen = new Set(rows.filter(r => r.row.priceHistoryId == null).map(r => dagVan(r.row)));
return r => ids.has(r.id) || (r.resolution === 'day' && dagen.has(dagVan(r)));
}
function wireControle(el, panel, k) {
const naarKoersen = () => mountDetailTabs(el, k.asset.id, 'prices');
panel.querySelectorAll('[data-check-kind]').forEach(b => b.onclick = () => {
const { kind, rows } = k.verdacht[Number(b.dataset.checkKind)];
koersFilter = { label: t('priceHistory.checkFiltered', { soort: t(`health.kind.${kind}`), n: rows.length }), match: filterVoor(rows) };
naarKoersen();
});
panel.querySelectorAll('[data-check-row]').forEach(b => b.onclick = () => {
const [ki, ri] = b.dataset.checkRow.split(':').map(Number);
const r = k.verdacht[ki].rows[ri];
if (r.row.priceHistoryId != null) koersFocus = r.row.priceHistoryId;
else koersFilter = { label: t('priceHistory.checkFilteredDay', { dag: dagVan(r.row) }), match: filterVoor([r]) };
naarKoersen();
});
}
function koersenPaneel(el, panel, k, kop) {
const filter = koersFilter
? `<div class="ph-gaps-head ph-filter t-body-sm"><span>${esc(koersFilter.label)}</span>
<button type="button" class="btn btn-ghost btn-sm" data-clear-filter>${esc(t('priceHistory.checkClearFilter'))}</button></div>`
: '';
panel.innerHTML = kop + filter + '<div class="ph-prices" data-ph-table></div>';
panel.querySelector('[data-clear-filter]')?.addEventListener('click', () => {
koersFilter = null;
mountDetailTabs(el, k.asset.id, 'prices');
});
buildTable(el, panel.querySelector('[data-ph-table]'), k.asset);
}
function voortgang(panel) {
const box = panel.querySelector('[data-confirm]');
box.hidden = false;
box.innerHTML = `<div class="t-body-sm">${esc(t('priceHistory.gapBusy'))}</div>
<div class="ph-confirm-actions">
<button type="button" class="btn btn-secondary btn-sm" data-stop>${esc(t('priceHistory.gapStop'))}</button>
</div>`;
return box.querySelector('[data-stop]');
}
const gatKnop = (attr, i, label, icoon) =>
`<button type="button" class="btn btn-ghost btn-sm gap-fill" ${attr}="${i}" title="${esc(label)}" aria-label="${esc(label)}">${icoon}</button>`;
let gatFocus = null;
async function gatenPaneel(el, panel, k, kop) {
const dagen = k.gaps.reduce((s, g) => s + g.days, 0);
const focus = gatFocus;
gatFocus = null;
const inFocus = g => (focus && overlapt(focus, g) ? ' is-focus' : '');
const expand = await expanders('priceHistory', { ignored: false, absent: false });
const kopregel = k.gaps.length ? `
<div class="ph-gaps-head">
<span class="t-caption">${esc(t('priceHistory.gapCardSummary', { gaten: telTekst('gapCount', k.gaps.length), dagen: telTekst('dayCount', dagen) }))}</span>
<span class="row-actions">
<button type="button" class="btn btn-ghost btn-sm" data-gap-ignore-all>${esc(t('priceHistory.gapIgnoreAll'))}</button>
<button type="button" class="btn btn-secondary btn-sm" data-gap-all>${esc(t('priceHistory.gapFillAll'))}</button>
</span>
</div>` : `<p class="t-caption t-muted">${esc(t('priceHistory.gapNone'))}</p>`;
const lijst = `<div class="gap-list">${k.gaps.map((g, i) => `
<div class="gap-row${inFocus(g)}">
<span class="t-body-sm">${esc(t('priceHistory.gapLine', { van: g.from, tot: g.until, n: g.days }))}</span>
<span class="row-actions">${gatKnop('data-gap', i, t('priceHistory.gapFill'), REFRESH)}${gatKnop('data-gap-ignore', i, t('priceHistory.gapIgnore'), EYE_OFF)}</span>
</div>`).join('')}</div>`;
const openVoorFocus = k.ignored.some(g => inFocus(g)) && !expand.open('ignored');
const genegeerd = k.ignored.length ? `
<details class="ph-ignored" ${expand.attr('ignored')}${openVoorFocus ? ' open' : ''}>
<summary class="t-body-sm">${CHEVRON_RIGHT}<span>${esc(t('priceHistory.tabIgnored'))} (${k.ignored.length})</span></summary>
<div class="gap-list">${k.ignored.map(g => `
<div class="gap-row${inFocus(g)}">
<span class="t-body-sm">${esc(t('priceHistory.gapLine', { van: g.fromDay, tot: g.untilDay, n: daysIncl(g.fromDay, g.untilDay) }))}</span>
<span class="row-actions">${gatKnop('data-unignore', g.id, t('priceHistory.gapUnignore'), EYE)}</span>
</div>`).join('')}</div>
</details>` : '';
const afwezig = k.absent.length ? `
<details class="ph-ignored" ${expand.attr('absent')}>
<summary class="t-body-sm">${CHEVRON_RIGHT}<span>${esc(t('priceHistory.tabAbsent'))} (${k.absent.length})</span></summary>
<p class="t-caption t-muted">${esc(t('priceHistory.absentExplain'))}</p>
<div class="gap-list">${k.absent.map((g, i) => `
<div class="gap-row">
<span class="t-body-sm">${esc(g.days === 1 ? t('priceHistory.absentLineOne', { bron: g.providerName, dag: g.fromDay })
: t('priceHistory.absentLine', { bron: g.providerName, van: g.fromDay, tot: g.untilDay, dagen: telTekst('dayCount', g.days) }))}</span>
<span class="row-actions">${gatKnop('data-absent-forget', i, t('priceHistory.absentForget'), CLOSE)}</span>
</div>`).join('')}</div>
</details>` : '';
panel.innerHTML = `${kop}<div class="ph-confirm" data-confirm hidden></div>${kopregel}${lijst}${genegeerd}${afwezig}`;
panel.querySelector('.gap-row.is-focus')?.scrollIntoView({ block: 'center' });
expand.bind(panel);
panel.querySelector('[data-gap-all]')?.addEventListener('click', () => fillGaps(el, panel, k, k.gaps));
panel.querySelectorAll('[data-gap]').forEach(b => b.onclick = () => fillGaps(el, panel, k, [k.gaps[Number(b.dataset.gap)]]));
panel.querySelectorAll('[data-gap-ignore]').forEach(b => b.onclick = () => negeer(el, k, [k.gaps[Number(b.dataset.gapIgnore)]]));
panel.querySelector('[data-gap-ignore-all]')?.addEventListener('click', () => negeer(el, k, k.gaps));
panel.querySelectorAll('[data-unignore]').forEach(b => b.onclick = async () => {
let melding = null;
try { await unignorePriceGap(Number(b.dataset.unignore)); } catch (e) { melding = e.message; }
await naWijziging(el, k.asset.id, 'gaps', melding);
});
panel.querySelectorAll('[data-absent-forget]').forEach(b => b.onclick = async () => {
const g = k.absent[Number(b.dataset.absentForget)];
let melding = null;
try { await deletePriceAbsences({ assetId: g.assetId, providerId: g.providerId, from: g.fromDay, until: g.untilDay }); }
catch (e) { melding = e.message; }
await naWijziging(el, k.asset.id, 'gaps', melding);
});
}
async function negeer(el, k, gaps) {
const vraag = gaps.length === 1 ? t('priceHistory.gapIgnoreConfirm') : t('priceHistory.gapIgnoreAllConfirm', { n: gaps.length });
const label = t(gaps.length === 1 ? 'priceHistory.gapIgnore' : 'priceHistory.gapIgnoreAll');
if (!(await dialog.confirmOver({ title: label, message: vraag, confirmLabel: label }))) return;
let melding = null;
try {
for (const g of gaps) await ignorePriceGap({ assetId: k.asset.id, from: g.from, until: g.until });
} catch (e) { melding = e.message; }
await naWijziging(el, k.asset.id, 'gaps', melding);
}
async function fillGaps(el, panel, k, gaps) {
const { asset } = k;
if (!gaps || !gaps.length) return;
const reeks = await primaryProvider(asset, 'daily_history');
const perDatum = await primaryProvider(asset, 'history_point');
if (!reeks && !perDatum) {
detailMelding = t('priceHistory.gapNoSource', { asset: asset.symbol });
mountDetailTabs(el, asset.id, 'gaps');
return;
}
const dagen = gaps.reduce((s, g) => s + daysToAsk(asset, g.from, g.until), 0);
const vraag = reeks && perDatum ? t('priceHistory.gapConfirmBoth', { reeks: reeks.name, bron: perDatum.name, n: dagen })
: reeks ? t('priceHistory.gapConfirmSeries', { reeks: reeks.name })
: t('priceHistory.gapConfirmPoint', { bron: perDatum.name, n: dagen });
if (!(await dialog.confirmOver({ title: t('priceHistory.gapFill'), message: vraag, confirmLabel: t('priceHistory.gapFill') }))) return;
panel.querySelectorAll('button').forEach(b => { b.disabled = true; });
const btn = voortgang(panel);
const stop = new AbortController();
btn.disabled = false;
btn.onclick = () => { stop.abort(); btn.disabled = true; };
let stopped = false, limitReached = false, fout = null;
try {
for (let i = 0; i < gaps.length && !stop.signal.aborted; i++) {
btn.textContent = gaps.length > 1 ? t('priceHistory.gapStopProgress', { i: i + 1, n: gaps.length }) : t('priceHistory.gapStop');
const uit = await fillClosesFor(asset, gaps[i].from, gaps[i].until, { signal: stop.signal });
if (uit.stopped) stopped = true;
if (uit.limitReached) { limitReached = true; break; }
}
if (stop.signal.aborted) stopped = true;
} catch (e) {
fout = e.message;
}
let resterend = [];
try {
resterend = (await listPriceGaps()).rows
.filter(g => g.assetId === asset.id && gaps.some(h => g.from <= h.until && g.until >= h.from));
} catch { }
const rest = resterend.reduce((s, g) => s + g.days, 0);
let waarom = '';
if (rest && stopped) waarom = t('priceHistory.gapStopped');
else if (rest && limitReached) waarom = t('priceHistory.gapLimit');
else if (rest) {
if (perDatum) waarom = t('priceHistory.gapWhyPoint', { bron: perDatum.name });
else {
let begin = null;
try {
for (const r of await listPriceHistory(asset.id)) {
const dag = r.observedAt.slice(0, 10);
if (r.providerId === reeks.id && r.resolution === 'day' && (!begin || dag < begin)) begin = dag;
}
} catch { }
if (!begin) waarom = t('priceHistory.gapWhySeriesEmpty', { reeks: reeks.name });
else if (resterend.some(g => g.from < begin)) waarom = t('priceHistory.gapWhySeries', { reeks: reeks.name, begin });
else waarom = t('priceHistory.gapWhySeriesHole', { reeks: reeks.name, n: rest });
}
}
const melding = fout || (!rest ? t('priceHistory.gapFilled', { asset: asset.symbol })
: `${t('priceHistory.gapRemaining', { asset: asset.symbol, n: rest })} ${waarom}`);
await naWijziging(el, asset.id, 'gaps', melding);
}
async function tableRows(asset) {
const rijen = await listAllPriceHistory({ assetId: asset.id });
return koersFilter ? rijen.filter(koersFilter.match) : rijen;
}
let alleAssets = [];
const assetVan = id => alleAssets.find(a => a.id === Number(id)) || null;
const eenheidVan = asset => (asset ? priceUnitLabel(asset) : '');
function eenheidOpts(a) {
if (!a || !isMetal(a)) return [];
return [a.unit, a.subunit].filter(Boolean).map(u => ({ value: u, label: u }));
}
function momentVan(vals) {
if (vals.resolution !== 'timestamp') return String(vals.date || '');
const tijd = String(vals.time || '00:00');
return new Date(`${vals.date}T${tijd}`).toISOString();
}
async function buildTable(el, host, asset) {
const tabelAsset = () => asset;
const columns = [
{ key: 'date', label: t('priceHistory.colDate'), sortable: true, sortValue: r => r.observedAt, render: r => r.observedAt ? esc(dayNl(displayDate(r))) : dash, group: { value: r => displayDate(r), label: v => dayNl(v), order: 'desc' },
edit: { type: 'date', sticky: true, value: r => r && r.observedAt ? displayDate(r) : todayIso() } },
{ key: 'time', label: t('priceHistory.colTime'), sortable: true, sortValue: r => r.observedAt, render: r => r.resolution === 'timestamp' ? esc(localDateParts(r.observedAt).time) : dash,
edit: { type: 'time', visible: vals => vals.resolution === 'timestamp',
value: r => r && r.resolution === 'timestamp' ? localDateParts(r.observedAt).time : '00:00' } },
{ key: 'resolution', label: t('priceHistory.colResolution'), sortable: true, sortValue: r => r.resolution, render: r => r.resolution ? esc(resLabel(r.resolution)) : dash, group: { value: r => resLabel(r.resolution), label: v => v },
edit: { type: 'select', reactive: true, sticky: true, value: r => r ? r.resolution : 'day',
options: () => ENUMS.PRICE_RESOLUTION.map(v => ({ value: v, label: resLabel(v) })) } },
{ key: 'source', label: t('priceHistory.colIntegration'), sortable: true, sortValue: r => r.provider_name, render: r => r.provider_name ? esc(r.provider_name) : dash, group: { value: r => r.provider_name, label: v => v } },
{ key: 'fiatPrice', label: baseCurrency(), align: 'num', sortable: true, sortValue: r => r.fiatPrice ?? -1,
render: r => r.fiatPrice != null ? fmtBasePrice(priceFromBase(r.fiatPrice, assetVan(r.assetId))) : dash,
edit: { type: 'number', attrs: { min: 0, step: 'any' },
value: r => (r && r.fiatPrice != null ? priceFromBase(r.fiatPrice, assetVan(r.assetId)) : '') } },
{ key: 'priceUnit', label: t('priceHistory.colUnit'),
render: r => r.assetId ? esc(eenheidVan(assetVan(r.assetId))) : dash,
edit: { type: 'select', sticky: true, visible: () => isMetal(tabelAsset()),
options: () => eenheidOpts(tabelAsset()), value: () => eenheidVan(tabelAsset()) } },
{ key: 'updated', label: t('priceHistory.colUpdated'), sortable: true, sortValue: r => r.updatedAt || '', render: r => r.updatedAt ? esc(fmtDateTime(Math.floor(Date.parse(r.updatedAt) / 1000))) : dash },
];
phTable = renderTable(host, {
columns,
rows: await tableRows(asset),
idKey: 'id',
...(await tableViewCfg('prijsinfo', { key: 'date', dir: 'desc' })),
noneLabel: t('table.none'),
collapsibleGroups: true,
searchLabel: t('priceHistory.search'),
emptyText: t('priceHistory.noPoints'),
onDelete: id => onDeleteRow(el, asset, id),
onCreate: v => onCreateRow(el, asset, v),
extraActions: [{ key: 'refresh', label: t('priceHistory.refresh') }],
onAction: (key, id) => { if (key === 'refresh') onRefreshRow(el, asset, id); },
});
if (koersFocus != null) { phTable.focusRow(koersFocus); koersFocus = null; }
}
async function naRijWijziging(el, asset, locate = null) {
if (phTable) phTable.refresh(await tableRows(asset), locate != null ? { locate } : undefined);
document.dispatchEvent(new CustomEvent('price-gaps-changed'));
await renderCards(el);
await laadDetail(asset);
tekenDetailGrafiek(asset).catch(() => {});
}
async function onCreateRow(el, asset, vals) {
const id = await addManualPricePoint({
assetId: asset.id,
observedAt: momentVan(vals),
fiatPrice: vals.fiatPrice,
resolution: vals.resolution || 'day',
unit: vals.priceUnit || null,
});
await naRijWijziging(el, asset, id);
}
async function onDeleteRow(el, asset, id) {
if (!(await dialog.confirmOver({ message: t('priceHistory.confirmDeletePoint'), danger: true }))) return;
try { await deleteTableRow('PriceHistory', id); } catch { }
await naRijWijziging(el, asset);
}
async function onRefreshRow(el, asset, id) {
const row = (await tableRows(asset)).find(r => r.id === id);
if (!row) return;
const a = { id: row.assetId, symbol: row.asset_symbol };
const dateKey = row.observedAt.slice(0, 10);
try {
const bron = row.provider_key;
if (row.resolution === 'day') {
const pt = await fetchDayPoint(bron, a, dateKey);
if (pt) await upsertPriceHistory(a.id, [pt], bron, 'day');
} else if (dateKey === todayIso()) {
const sp = await fetchSpotPoint(bron, a);
if (sp) await upsertPriceHistory(a.id, [sp], bron, 'timestamp');
}
} catch { }
await naRijWijziging(el, asset);
}
+93
View File
@@ -0,0 +1,93 @@
// Generated, do not edit. sha256:877ef362c70027a6a3b598d45680633fae5e6da42177c2b820d61d28df5d8d58
import { listSelectableAccounts } from '../data/accounts.js';
import { listBookableAssets } from '../data/assets.js';
import { createReservation, deleteReservation, listReservations, listReservationsForAccount, updateReservation } from '../data/reservations.js';
import { tableViewCfg } from '../data/preferences.js';
import { t } from '../i18n/index.js';
import { assetLabelHtml } from '../ui/asset-label.js';
import * as dialog from '../ui/dialog.js';
import { renderTable } from '../ui/table.js';
import { takeRowFocus } from '../ui/row-focus.js';
import { fmtUnit, val, toSubunit, fromSubunit } from '../ui/format.js';
import { esc } from '../ui/escape.js';
import { setPageHint } from '../ui/page-hint.js';
import { localDate, localDateNl, toUtcIso } from '../../shared/time.js';
import { bookReservationFlow, reservationAccount, reservationDir, reservationMeta } from '../actions/reservation-book.js';
export async function render(el, { accountId = null, assetId = null, assetKinds = null } = {}) {
el.innerHTML = `<div id="tbl"></div>`;
const onPage = accountId == null;
if (onPage) setPageHint(el, { title: t('nav.reservations'), rows: [t('reservations.hint')] });
const allowed = a => (assetId == null || a.id === assetId) && (assetKinds == null || assetKinds.includes(a.kind));
const assets = (await listBookableAssets()).filter(allowed);
const assetById = id => assets.find(a => a.id === Number(id));
const rows = async () => (onPage ? await listReservations() : await listReservationsForAccount(accountId))
.filter(r => assetId == null || r.assetId === assetId);
const now = new Date().toISOString();
const dirLabel = d => t(d === 'out' ? 'reservations.dirOut' : 'reservations.dirIn');
const accountOpts = async current => (await listSelectableAccounts({ ookId: Number(current) || null }))
.map(a => ({ value: a.id, label: a.name }));
const columns = [
{ key: 'occurredAt', label: t('reservations.colDate'), sortable: true,
render: r => `${esc(localDateNl(r.occurredAt))}${r.occurredAt <= now
? ` <span class="badge badge-accent">${t('reservations.due')}</span>` : ''}`,
edit: { type: 'date', value: r => (r ? localDate(r.occurredAt) : localDate(now)) } },
...(onPage ? [{ key: '_account', label: t('reservations.colAccount'), sortable: true,
sortValue: r => reservationAccount(r).name || '',
render: r => esc(reservationAccount(r).name || '-'),
group: { value: r => reservationAccount(r).name || '-', label: v => v },
edit: { type: 'select', sticky: true, value: r => (r ? reservationAccount(r).id : ''),
options: vals => accountOpts(vals._account) } }] : []),
{ key: '_dir', label: t('reservations.colDirection'), sortable: true, sortValue: r => reservationDir(r),
render: r => dirLabel(reservationDir(r)),
edit: { type: 'select', sticky: true, value: r => (r ? reservationDir(r) : 'out'),
options: () => [{ value: 'out', label: dirLabel('out') }, { value: 'in', label: dirLabel('in') }] } },
{ key: 'assetId', label: t('reservations.colAsset'), sortable: true, sortValue: r => r.asset_symbol,
render: r => assetLabelHtml(r),
edit: { type: 'select', sticky: true, options: () => assets.map(a => ({ value: a.id, label: `${a.symbol}: ${a.name}` })) } },
{ key: 'amount', label: t('reservations.colAmount'), align: 'num', sortable: true, sortValue: r => r.amount,
render: r => val(fmtUnit(r.amount, reservationMeta(r))),
edit: { type: 'number', attrs: { min: 0, step: 'any' },
value: r => (r ? fromSubunit(r.amount, reservationMeta(r)) : '') } },
{ key: 'note', label: t('reservations.colNote'), text: true,
render: r => `<span class="t-muted-cell">${esc(r.note || '')}</span>`,
edit: { type: 'text', expand: true } },
];
const toPayload = v => {
const asset = assetById(v.assetId);
const qty = parseFloat(v.amount);
const account = onPage ? Number(v._account) || null : accountId;
if (!v.occurredAt || !asset || !account || !Number.isFinite(qty) || qty <= 0) throw new Error(t('reservations.fill'));
return {
accountId: account, assetId: asset.id, direction: v._dir === 'in' ? 'in' : 'out',
amount: toSubunit(qty, asset), occurredAt: toUtcIso(`${v.occurredAt}T00:00:00`),
note: (v.note || '').trim() || null,
};
};
let tableApi = null;
const reload = async () => tableApi.refresh(await rows());
tableApi = renderTable(el.querySelector('#tbl'), {
columns, rows: await rows(),
...(await tableViewCfg('reservations', { key: 'occurredAt', dir: 'asc' })),
emptyText: t(onPage ? 'reservations.noneAll' : 'reservations.none'),
noneLabel: t('table.none'),
newLabel: t('table.new'),
newDefaults: { _dir: 'out', assetId: assets[0]?.id ?? '', note: '' },
saveLabel: t('transfers.save'), cancelLabel: t('transfers.cancel'),
onCreate: async v => { const id = await createReservation(toPayload(v)); tableApi.refresh(await rows(), { locate: id }); },
onSave: async (id, v) => { await updateReservation(id, toPayload(v)); reload(); },
onDelete: async id => {
if (!(await dialog.confirm({ message: t('reservations.confirmDelete'), danger: true }))) return;
await deleteReservation(id); reload();
},
extraActions: [{ key: 'book', label: t('reservations.book') }],
onAction: async (key, id) => {
const r = (await rows()).find(x => x.id === id);
if (key === 'book' && r && await bookReservationFlow(r)) reload();
},
});
tableApi.focusRow(takeRowFocus('reservations'));
}
+208
View File
@@ -0,0 +1,208 @@
// Generated, do not edit. sha256:2c8b0cb9cf7b6e6878eecfd14445f87e0333bfc949df19e5e2747b65137b5411
import { getSettings, updateSettings } from '../data/preferences.js';
import { t, setLocale, LOCALES as CATALOGUES } from '../i18n/index.js';
import { CONFIG, ENUMS, PREFERENCES } from '../../shared/config.js';
import { setPageHint } from '../ui/page-hint.js';
import { esc } from '../ui/escape.js';
import { locked } from '../license.js';
const LOCALES = Object.keys(CATALOGUES);
const GLOW_GLASS = Object.freeze({
off: { glow: 'off' },
still: { glow: 'on', motion: 'off' },
step: { glow: 'on', motion: 'step' },
always: { glow: 'on', motion: 'always' },
});
const GLOW_BUSINESS = Object.freeze({ off: { glow: 'off' }, on: { glow: 'on' } });
export function glowChoice(s) {
if (s.glow === 'off') return 'off';
if (s.profile !== 'glass') return 'on';
return s.motion === 'off' ? 'still' : (s.motion || 'step');
}
export function glowPatch(profile, choice) {
const table = profile === 'glass' ? GLOW_GLASS : GLOW_BUSINESS;
return Object.hasOwn(table, choice) ? { ...table[choice] } : null;
}
const media = q => typeof matchMedia === 'function' && matchMedia(q).matches;
export async function render(el) {
const s = await getSettings();
const glass = s.profile === 'glass';
const business = !glass;
const glowVisible = glass || s.depth === 'deep';
const motionBlocked = glass && s.glow !== 'off' && s.motion !== 'off' && media('(prefers-reduced-motion: reduce)');
const transparencyBlocked = glass && media('(prefers-reduced-transparency: reduce)');
const persona = !locked('signals');
const kiosk = !locked('kiosk');
const glowOptions = glass
? [['off', t('settings.glowOff')], ['still', t('settings.glowStill')], ['step', t('settings.glowStep')], ['always', t('settings.glowAlways')]]
: [['off', t('settings.glowOff')], ['on', t('settings.glowOn')]];
el.innerHTML = `
<div class="settings-cols">
<div class="card">
<div class="t-label" style="margin-bottom:1.2rem">${t('settings.appearance')}</div>
${pillRow('theme', t('settings.theme'), [['dark', t('controls.dark')], ['light', t('controls.light')]], s.theme, { maxWidth: 300 })}
${accentRow(s)}
${pillRow('profile', t('settings.profile'), [['business', t('controls.business')], ['glass', t('controls.glass')]], s.profile,
{ maxWidth: 300, notice: transparencyBlocked ? t('settings.transparencyBlocked') : '' })}
${business ? pillRow('depth', t('settings.depth'), [['flat', t('controls.flat')], ['deep', t('controls.deep')]], s.depth, { maxWidth: 300 }) : ''}
${glowVisible ? pillRow('glowmix', t(glass ? 'settings.glowBgMotion' : 'settings.glowBg'), glowOptions, glowChoice(s),
{ maxWidth: glass ? 420 : 300, notice: motionBlocked ? t('settings.motionBlocked') : '' }) : ''}
${glass && s.glow !== 'off' ? pillRow('glowshape', t('settings.glowShape'),
[['blobs', t('settings.glowShapeBlobs')], ['bands', t('settings.glowShapeBands')]], s.glowshape, { maxWidth: 300 }) : ''}
${glass ? pillRow('rowsel', t('settings.rowsel'),
[['light', t('settings.rowselLight')], ['tint', t('settings.rowselTint')], ['pill', t('settings.rowselPill')]], s.rowsel, { maxWidth: 360 }) : ''}
${glass ? pillRow('glass_render', t('settings.glassRender'),
[['mimic', t('settings.glassRenderMimic')], ['real', t('settings.glassRenderReal')]], s.glass_render, { maxWidth: 300 }) : ''}
${glass ? glassRows(s) : ''}
</div>
<div class="card">
<div class="t-label" style="margin-bottom:1.2rem">${t('settings.screen')}</div>
<div class="form-group" style="margin-bottom:1rem">
<label class="form-label">${t('settings.assetDisplay')}</label>
<div class="pill-toggle" data-asset-display style="max-width:360px">
<button data-toggle="asset_show_name" class="${s.asset_show_name ? 'on' : ''}">${t('assets.name')}</button>
<button data-toggle="asset_show_symbol" class="${s.asset_show_symbol ? 'on' : ''}">${t('assets.symbol')}</button>
</div>
</div>
${persona ? pillRow('persona', t('settings.persona'),
[['bitcoin', t('setup.persona_bitcoin')], ['broad', t('setup.persona_broad')]], s.persona, { maxWidth: 300 }) : ''}
${pillRow('show_page_title', t('settings.pageTitle'),
[['1', t('settings.pageTitleShow')], ['0', t('settings.pageTitleHide')]], String(s.show_page_title), { maxWidth: 300 })}
${pillRow('show_page_hint', t('settings.pageHint'),
[['1', t('settings.pageTitleShow')], ['0', t('settings.pageTitleHide')]], String(s.show_page_hint), { maxWidth: 300 })}
${pillRow('result_signs', t('settings.resultSigns'),
[['none', t('settings.resultSignsNone')], ['negative', t('settings.resultSignsNegative')], ['both', t('settings.resultSignsBoth')]],
s.result_signs, { maxWidth: 420 })}
${kiosk ? pillRow('kiosk', t('settings.kiosk'),
[['0', t('settings.kioskWindow')], ['1', t('settings.kioskFull')]], String(s.kiosk), { maxWidth: 300 }) : ''}
${pillRow('auto_blur_min', t('settings.autoBlur'),
[...CONFIG.AUTO_BLUR.PRESETS_MIN.map(m => [String(m), `${m} ${t('settings.minutesShort')}`]),
[String(CONFIG.AUTO_BLUR.NEVER_MIN), t('settings.autoBlurManual')]],
String(s.auto_blur_min), { maxWidth: 420 })}
<div class="form-group" style="margin-bottom:0">
<label class="form-label">${t('settings.language')}</label>
<select class="input" id="f-locale" style="max-width:200px">
${LOCALES.map(l => `<option value="${l}"${l === s.locale ? ' selected' : ''}>${t('settings.' + l)}</option>`).join('')}
</select>
</div>
</div>
</div>`;
setPageHint(el, { title: t('settings.hintTitle'), rows: [
{ heading: t('settings.appearance') },
t('settings.hintProfile'),
t('settings.hintTheme'),
t('settings.depthHint'),
t('settings.hintGlow'),
t('settings.hintGlowShape'),
t('settings.hintRowsel'),
t('settings.hintGlassRender'),
t('settings.hintGlass'),
{ heading: t('settings.hintAiHeading') },
t('settings.hintAiIntro'),
t('settings.hintAiTempo'),
{ heading: t('settings.hintScreenHeading') },
`${t('settings.assetDisplay')}: ${t('settings.assetDisplayHint')}`,
...(persona ? [`${t('settings.persona')}: ${t('settings.personaHint')}`] : []),
`${t('settings.pageTitle')}: ${t('settings.pageTitleHint')}`,
`${t('settings.pageHint')}: ${t('settings.pageHintHint')}`,
`${t('settings.resultSigns')}: ${t('settings.resultSignsHint')}`,
...(kiosk ? [`${t('settings.kiosk')}: ${t('settings.kioskHint')}`] : []),
`${t('settings.autoBlur')}: ${t('settings.autoBlurHint')}`,
] });
el.querySelectorAll('[data-field]').forEach(group => {
group.onclick = e => {
const b = e.target.closest('[data-v]'); if (!b) return;
const field = group.dataset.field;
if (field === 'glowmix') {
const patch = glowPatch(s.profile, b.dataset.v);
if (patch) apply(el, patch);
return;
}
apply(el, { [field]: b.dataset.v });
};
});
el.querySelector('#f-locale').onchange = e => apply(el, { locale: e.target.value });
for (const input of el.querySelectorAll('[data-glass]')) {
syncSlider(input);
input.oninput = () => syncSlider(input);
input.onchange = () => apply(el, { [input.dataset.glass]: Number(input.value) });
}
const reset = el.querySelector('[data-glass-reset]');
if (reset) reset.onclick = () => apply(el, Object.fromEntries(['glass_render', ...GLASS.map(([key]) => key)]
.map(key => [key, PREFERENCES.app[key].initial])));
el.querySelector('[data-asset-display]').onclick = e => {
const b = e.target.closest('[data-toggle]'); if (!b) return;
const field = b.dataset.toggle;
const other = field === 'asset_show_name' ? 'asset_show_symbol' : 'asset_show_name';
const next = s[field] ? 0 : 1;
if (next === 0 && !s[other]) return;
apply(el, { [field]: next });
};
}
async function apply(el, patch) {
await updateSettings(patch);
if (patch.locale) setLocale(patch.locale);
document.dispatchEvent(new Event('settings-changed'));
render(el);
}
function pillRow(field, label, options, current, { maxWidth = 500, notice = '' } = {}) {
const buttons = options.map(([v, lbl]) =>
`<button data-v="${v}" class="${v === current ? 'on' : ''}">${lbl}</button>`).join('');
return `
<div class="form-group" style="margin-bottom:1rem">
<label class="form-label">${label}</label>
<div class="pill-toggle" data-field="${field}" style="max-width:${maxWidth}px">${buttons}</div>
${notice ? `<div class="form-hint">${notice}</div>` : ''}
</div>`;
}
const GLASS = Object.freeze([
['glass_transparency', 'settings.glassTransparency', '--glass-transparency', '%', '%'],
['glass_blur', 'settings.glassDialog', '--dialog-transparency', '%', ''],
['glass_frost', 'settings.glassFrost', '--glass-frost', '%', '%'],
]);
function glassRows(s) {
const rows = GLASS.map(([key, label, token, unit, tokenUnit]) => {
const { min, max } = PREFERENCES.app[key];
return `<label class="slider-row slider-row--wide" for="f-${key}"><span>${t(label)}</span>
<input class="slider" type="range" id="f-${key}" min="${min}" max="${max}" step="1" value="${Number(s[key])}"
data-glass="${key}" data-token="${token}" data-unit="${unit}" data-token-unit="${tokenUnit}"><output for="f-${key}"></output></label>`;
}).join('');
return `
<div class="form-group" style="margin-bottom:1rem; max-width:420px">
<label class="form-label">${t('settings.glass')}</label>
${rows}
<div><button class="btn btn-ghost btn-xs" type="button" data-glass-reset>${t('settings.glassDefault')}</button></div>
</div>`;
}
function syncSlider(input) {
const min = Number(input.min), max = Number(input.max), v = Number(input.value);
input.style.setProperty('--pct', `${((v - min) / (max - min)) * 100}%`);
const out = input.parentElement.querySelector('output');
if (out) out.textContent = `${v}${input.dataset.unit}`;
document.documentElement.style.setProperty(input.dataset.token, `${v}${input.dataset.tokenUnit}`);
}
function accentRow(s) {
const dots = ENUMS.ACCENT.map(a => {
const label = t(`controls.${a}`);
return `<button type="button" data-v="${a}" data-theme="${esc(s.theme)}" data-accent="${a}" class="${a === s.accent ? 'on' : ''}" title="${esc(label)}" aria-label="${esc(label)}"></button>`;
}).join('');
return `
<div class="form-group" style="margin-bottom:1rem">
<label class="form-label">${t('settings.accent')}</label>
<div class="accent-dots" data-field="accent">${dots}</div>
</div>`;
}
+13
View File
@@ -0,0 +1,13 @@
// Generated, do not edit. sha256:3583845b6975a70bf6e3ec60e90764723b09df9714d36916609dadda9746f9e5
import { t } from '../i18n/index.js';
import { alertHtml } from '../ui/alert.js';
export function makeStub(navKey) {
return {
title: () => t(`nav.${navKey}`),
render(el) {
el.innerHTML = `
${alertHtml({ kind: 'neutral', icon: '🚧', title: t('stub.title'), body: t('stub.body') })}`;
},
};
}
+21
View File
@@ -0,0 +1,21 @@
// Generated, do not edit. sha256:fbeb70898a324872c8f2444d8e03beae326a416826348a59cda101932ba82f5c
import { SYSTEM_PAGE } from '../../shared/config.js';
import { createRegistry } from '../../shared/registry.js';
import { locked } from '../license.js';
const tabs = createRegistry('system tab', {
validate: (e) => { if (!Object.values(SYSTEM_PAGE.TABS).includes(e.tab)) throw new Error(`Unknown system tab '${e.tab}'.`); },
});
export function registerSystemTab(tab) {
const feature = tab.feature ?? null;
tabs.register({ ...tab, id: `${tab.id}:${feature ?? ''}`, tab: tab.id, feature, label: tab.label ?? `nav.${tab.id}` });
}
export function systemTabs() {
return Object.values(SYSTEM_PAGE.TABS).flatMap(id => {
const own = tabs.list().filter(r => r.tab === id).map(r => ({ ...r, id }));
const pick = own.find(r => r.feature && !locked(r.feature)) || own.find(r => !r.feature);
return pick ? [pick] : [];
});
}
+63
View File
@@ -0,0 +1,63 @@
// Generated, do not edit. sha256:c54ac534bce5953f6b0bb207fdc1338feba7f454198f189ef3b97c5830760c48
import { t } from '../i18n/index.js';
import { getPref, setPref } from '../data/preferences.js';
import { SYSTEM_PAGE, targetHash } from '../../shared/config.js';
import { create as createTabs } from '../ui/tabs.js';
import { attentionForRoute, attentionDotHtml } from '../ui/attention.js';
import { registerSystemTab, systemTabs } from './system-tabs.js';
import * as settings from './settings.js';
import * as accountsPage from './accounts.js';
import * as assetsPage from './assets.js';
import * as connections from './connections.js';
import * as aiPage from './ai.js';
import * as licensePage from './license.js';
export const title = () => t('nav.system');
const ROUTE = SYSTEM_PAGE.ROUTE;
const TABS = SYSTEM_PAGE.TABS;
const PREF_SCOPE = ROUTE;
registerSystemTab({ id: TABS.SETTINGS, render: panel => settings.render(panel) });
registerSystemTab({ id: TABS.ASSETS, render: panel => assetsPage.render(panel) });
registerSystemTab({ id: TABS.ACCOUNTS, render: panel => accountsPage.render(panel) });
registerSystemTab({ id: TABS.INTEGRATIONS, label: 'nav.connections', subs: ['connections'], render: panel => connections.render(panel) });
registerSystemTab({ id: TABS.AI, render: panel => aiPage.render(panel) });
registerSystemTab({ id: TABS.LICENSE, render: panel => licensePage.render(panel) });
function parseHash() {
const parts = (location.hash || '').replace(/^#\/?/, '').split('/');
return { tab: parts[1] || null, sub: parts[2] || null };
}
const hashFor = tab => targetHash({ route: ROUTE, tab });
function tabDot(tabId) {
return attentionDotHtml(attentionForRoute(ROUTE).filter(a => (a.tab || '').split('/')[0] === tabId));
}
export async function render(el) {
const tabs = systemTabs();
const TAB_IDS = tabs.map(x => x.id);
const { tab: hashTab, sub } = parseHash();
let tab = TAB_IDS.includes(hashTab) ? hashTab : null;
if (!tab) {
const att = attentionForRoute(ROUTE).find(a => TAB_IDS.includes((a.tab || '').split('/')[0]));
tab = att ? att.tab.split('/')[0] : await getPref(PREF_SCOPE, 'tab', TAB_IDS[0]);
if (!TAB_IDS.includes(tab)) tab = TAB_IDS[0];
history.replaceState(null, '', hashFor(tab));
}
el.innerHTML = '<div data-tabs></div>';
let firstSub = sub;
createTabs(el.querySelector('[data-tabs]'), {
size: 'lg',
active: tab,
items: tabs.map(x => ({ id: x.id, label: t(x.label), dot: () => tabDot(x.id) })),
render: (id, panel) => { tabs.find(x => x.id === id).render(panel, firstSub); firstSub = null; },
onChange: (id) => {
setPref(PREF_SCOPE, 'tab', id).catch(() => { });
if (location.hash !== hashFor(id)) history.pushState(null, '', hashFor(id));
},
syncOn: ['attention-changed'],
});
}
+606
View File
@@ -0,0 +1,606 @@
// Generated, do not edit. sha256:fcc1131e05f7794fbe4f303c8c1237f96201e5ee9ed17491fd18d55b64828d40
import { localDate } from '../../shared/time.js';
import { listAccounts } from '../data/accounts.js';
import { listLabels, createLabel } from '../data/labels.js';
import { getPref, setPref } from '../data/preferences.js';
import { walletTxEventMap, onchainAsset, syncAddressLinks } from '../data/tx-links.js';
import { sourceBalance, accountOnchainBalance } from '../data/wallet-addresses.js';
import { listWalletSources } from '../data/wallet-sources.js';
import { listSourceTxs, setTxLabels, labelChannelTxs } from '../data/wallet-txs.js';
import { syncTxProposals } from '../data/proposals.js';
import { t, localeTag } from '../i18n/index.js';
import { fmtInt, fmtUnit, val } from '../ui/format.js';
import { matchDot, openMatchPopover } from '../ui/match-popover.js';
import { copyButton } from '../ui/copy-button.js';
import { CHEVRON, ARROW_DOWN, ARROW_UP } from '../ui/icons.js';
import { isOnchainSource, scanSource } from '../onchain.js';
import { loadFiat, fiatValue, fiatValueOrigin } from '../ui/fiat.js';
import { originHtml } from '../ui/price-origin.js';
import { labelComboMarkup, labelComboMount } from '../ui/label-combobox.js';
import { resolveSelection } from '../ui/filter.js';
import { volgMasonry } from '../ui/masonry.js';
import { latestScanLog } from '../data/scan-logs.js';
import { TARGETS, targetHash } from '../../shared/config.js';
import { esc } from '../ui/escape.js';
import { emptyStateHtml } from '../ui/empty-state.js';
import { holdAttention } from '../ui/attention-ring.js';
import { sourceBadgeText } from '../ui/source-badge.js';
import { isLightningSource, scanLightningSource, lightningAccountSats, countMovements } from '../lightning/lightning-scan.js';
import { memoLine } from '../lightning/memo-line.js';
const ACCOUNTS_HASH = targetHash(TARGETS.ACCOUNTS);
const trunc = s => (s && s.length > 18) ? `${s.slice(0, 8)}…${s.slice(-6)}` : (s || '');
let selectedAccounts = null;
let eventMap = new Map();
let btc = null;
let shownTxIds = new Set();
let newTxIds = new Set();
let scanBaseline = null;
const FILTER_SCOPE = 'table.tracking';
const FILTER_KEY = 'accounts';
function saveFilter() {
setPref(FILTER_SCOPE, FILTER_KEY, [...selectedAccounts]).catch(() => { });
}
const COLLAPSE_KEY = 'collapsed';
let collapsedSet = null;
let stopMasonry = null;
async function loadCollapsed() {
const ids = await getPref(FILTER_SCOPE, COLLAPSE_KEY, null);
return new Set(Array.isArray(ids) ? ids : []);
}
function saveCollapsed() {
setPref(FILTER_SCOPE, COLLAPSE_KEY, [...collapsedSet]).catch(() => { });
}
export const title = () => t('tracking.title');
export const headerInColumn = true;
function btcAsset() {
return btc;
}
async function accountsWithSources() {
const accounts = await listAccounts();
const bronnen = await Promise.all(accounts.map(a => listWalletSources(a.id)));
return accounts.filter((a, i) => bronnen[i].length > 0);
}
function fmtDate(key) {
const d = new Date(key + 'T00:00:00');
return isNaN(d) ? key : d.toLocaleDateString(localeTag(), { day: 'numeric', month: 'short', year: 'numeric' });
}
function fmtDateLong(key) {
const d = new Date(key + 'T00:00:00');
return isNaN(d) ? key : d.toLocaleDateString(localeTag(), { day: 'numeric', month: 'long', year: 'numeric' });
}
function amt(amount) {
const a = btcAsset();
return val(a ? fmtUnit(amount, a) : fmtInt(amount));
}
function fiatSub(amount) {
const f = fiatValue(amount, btcAsset());
return f ? val(f) + originHtml(fiatValueOrigin(btcAsset()), btcAsset()) : '';
}
function amountHtml(amount, numClass = 't-num-md') {
return `<div class="${numClass}">${amt(amount)}</div>
<div class="t-muted-cell po-host">${fiatSub(amount)}</div>`;
}
function asideHtml(items) {
const rows = items.map(i => `
<button type="button" class="trk-acct${selectedAccounts.has(i.id) ? ' is-on' : ''}" data-acct="${i.id}"
aria-pressed="${selectedAccounts.has(i.id)}" title="${esc(i.label)}">
<span class="trk-acct-name">${esc(i.label)}</span>
<span class="trk-acct-line">
<span class="trk-acct-sub">${fmtInt(i.wallets)} ${i.wallets === 1 ? t('tracking.walletOne') : t('tracking.walletMany')}</span>
<span class="trk-acct-amt" data-acct-amt="${i.id}">…</span>
</span>
</button>`).join('');
return `<aside class="page-aside trk-aside">
<div class="trk-block trk-total">
<div class="t-label">${t('tracking.accountTotal')}</div>
<div class="trk-total-amt">
<div class="t-num-lg" data-trk="total">…</div>
<div class="t-muted-cell" data-trk="fiat"></div>
</div>
<div class="trk-stats">
${stat(t('tracking.statWallets'), 'wallets')}
${stat(t('tracking.statTxs'), 'txs')}
${stat(t('tracking.statLinked'), 'linked')}
</div>
</div>
<div class="trk-block trk-accounts">
<div class="trk-acct-list">${rows}</div>
<div class="trk-acct-foot">
<span class="t-muted-cell trk-selection" data-trk="selection"></span>
<button type="button" class="trk-act" data-acct-all>${t('filter.none')}</button>
</div>
</div>
<div class="trk-block trk-scan">
<button class="btn btn-primary btn-sm trk-scan-btn" id="scan-all">${t('tracking.scanAll')}</button>
<div class="t-muted-cell trk-scan-status" data-trk="scanstatus"></div>
<ul class="trk-scan-log" data-trk="scanlog"></ul>
<div class="t-muted-cell trk-scan-when" data-trk="scanned"></div>
</div>
</aside>`;
}
function stat(label, sleutel) {
return `<div class="trk-stat"><div class="t-muted-cell">${label}</div><div class="t-value" data-trk="${sleutel}">…</div></div>`;
}
export async function render(el) {
btc = await onchainAsset();
const accts = await accountsWithSources();
if (accts.length === 0) {
el.innerHTML = `<div class="page-header"><h1 class="page-title">${t('tracking.title')}</h1></div>`
+ emptyStateHtml(t('tracking.noAccountsWithSources'), {
href: ACCOUNTS_HASH, linkText: t('tracking.manageAccounts'),
});
return;
}
const wallets = await Promise.all(accts.map(a => accountWallets(a.id)));
const items = accts.map((a, i) => ({ id: a.id, label: a.name, wallets: wallets[i].length }));
if (!selectedAccounts)
selectedAccounts = resolveSelection({ saved: await getPref(FILTER_SCOPE, FILTER_KEY, null), items });
if (!collapsedSet) collapsedSet = await loadCollapsed();
newTxIds = new Set();
scanBaseline = null;
el.innerHTML = `<div class="page-layout">${asideHtml(items)}`
+ `<div class="page-body">`
+ `<div class="page-header"><h1 class="page-title">${t('tracking.title')}</h1></div>`
+ `<div id="acc-body"></div></div></div>`;
bindAside(el, items);
renderBody(el);
loadFiat().then(() => renderBody(el)).catch(() => {});
}
function bindAside(el, items) {
const aside = el.querySelector('.trk-aside');
const alle = aside.querySelector('[data-acct-all]');
const syncAct = () => {
const allOn = items.every(i => selectedAccounts.has(i.id));
alle.textContent = allOn ? t('filter.none') : t('filter.all');
};
const zet = (sel) => {
selectedAccounts = sel;
for (const b of aside.querySelectorAll('[data-acct]')) {
const on = selectedAccounts.has(Number(b.dataset.acct));
b.classList.toggle('is-on', on);
b.setAttribute('aria-pressed', String(on));
}
syncAct();
saveFilter();
renderBody(el);
};
syncAct();
alle.onclick = () => zet(items.every(i => selectedAccounts.has(i.id))
? new Set() : new Set(items.map(i => i.id)));
aside.onclick = (e) => {
const b = e.target.closest('[data-acct]');
if (!b) return;
const id = Number(b.dataset.acct);
const next = new Set(selectedAccounts);
if (next.has(id)) next.delete(id); else next.add(id);
zet(next);
};
el.querySelector('#scan-all').onclick = () => scanAll(el);
}
async function sourcesOf(accounts) {
const bronnen = await Promise.all(accounts.map(a => listWalletSources(a.id)));
return accounts.flatMap((a, i) => bronnen[i].map(s => ({ ...s, _account: a })));
}
async function selectedSources() {
return sourcesOf((await accountsWithSources()).filter(a => selectedAccounts.has(a.id)));
}
async function allSources() {
return sourcesOf(await accountsWithSources());
}
function groupWallets(sources) {
const byKey = new Map();
for (const s of sources) {
const named = (s.name || '').trim();
const key = named ? `a${s._account.id}:${named}` : `s${s.id}`;
if (!byKey.has(key)) byKey.set(key, { account: s._account, name: named, sources: [] });
byKey.get(key).sources.push(s);
}
const groups = [...byKey.values()];
for (const g of groups) g.id = g.sources[0].id;
return groups;
}
const allWallets = async () => groupWallets(await allSources());
const groupedWallets = async () => groupWallets(await selectedSources());
function sourceBadge(s) {
const { label, title } = sourceBadgeText(s);
return `<span class="badge badge-neutral"${title ? ` title="${esc(title)}"` : ''}>${esc(label)}</span>`;
}
async function accountWallets(accountId) {
const byKey = new Map();
for (const s of await listWalletSources(accountId)) {
const named = (s.name || '').trim();
const key = named || `s${s.id}`;
if (!byKey.has(key)) byKey.set(key, { name: named, sources: [] });
byKey.get(key).sources.push(s);
}
return [...byKey.values()];
}
async function renderBody(el) {
const body = el.querySelector('#acc-body');
if (!body) return;
eventMap = await walletTxEventMap();
const wallets = await allWallets();
const perWallet = await Promise.all(wallets.map(g => walletTxs(g.sources.map(s => s.id))));
const ids = perWallet.flat().map(tx => tx.id);
if (scanBaseline) {
for (const id of ids) if (!scanBaseline.has(id)) newTxIds.add(id);
scanBaseline = null;
}
shownTxIds = new Set(ids);
const shown =wallets.map((g, i) => [g, perWallet[i]]).filter(([g]) => selectedAccounts.has(g.account.id));
body.innerHTML = `<div class="oca-grid">${
(await Promise.all(shown.map(([g, txs]) => walletBlock(g, txs)))).join('')}</div>`;
await vulKolom(el, wallets, perWallet);
if (stopMasonry) stopMasonry();
stopMasonry = volgMasonry(body.querySelector('.oca-grid'), ':scope > .oca-source');
for (const card of body.querySelectorAll('.oca-source[data-new]')) holdAttention(card);
const btc = await btcAsset();
const openMatch = dot => openMatchPopover(dot, {
kind: 'tx', id: Number(dot.dataset.matchId),
assetId: btc ? btc.id : null,
onChange: () => syncAddressLinks().catch(() => 0).then(() => renderBody(el)),
});
body.onclick = e => {
const collapseBtn = e.target.closest('[data-collapse]');
if (collapseBtn) {
const id = Number(collapseBtn.dataset.collapse);
const card = collapseBtn.closest('.oca-source');
const nowCollapsed = card.classList.toggle('is-collapsed');
if (nowCollapsed) collapsedSet.add(id); else collapsedSet.delete(id);
collapseBtn.classList.toggle('is-open', !nowCollapsed);
saveCollapsed();
return;
}
if (e.target.closest('summary') &&
e.target.closest('[data-edit-label],[data-match-trigger],[data-label-combo]')) {
e.preventDefault();
}
const dot = e.target.closest('[data-match-trigger]');
if (dot) { openMatch(dot); return; }
const trigger = e.target.closest('[data-edit-label]');
if (trigger) openLabelEditor(el, trigger);
};
body.onkeydown = e => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const dot = e.target.closest('[data-match-trigger]');
if (dot) { e.preventDefault(); openMatch(dot); }
};
}
async function vulKolom(el, wallets, perWallet) {
const zet = (sleutel, html) => {
const e = el.querySelector(`[data-trk="${sleutel}"]`);
if (e) e.innerHTML = html;
};
const alle = await accountsWithSources();
const bronnen = await sourcesOf(alle);
const bronnenVan = id => bronnen.filter(s => s._account.id === id);
const saldi = new Map();
for (const a of alle) saldi.set(a.id, await accountOnchainBalance(a.id) + lightningAccountSats(bronnenVan(a.id)));
for (const a of alle) {
const cel = el.querySelector(`[data-acct-amt="${a.id}"]`);
if (cel) cel.innerHTML = amt(saldi.get(a.id));
}
const total = alle.reduce((sum, a) => sum + saldi.get(a.id), 0);
zet('total', amt(total));
zet('fiat', fiatSub(total));
const { total: nTx, linked: nLinked } =
countMovements(perWallet.flat(), tx => (eventMap.get(tx.id) || []).length > 0);
zet('wallets', fmtInt(wallets.length));
zet('txs', fmtInt(nTx));
zet('linked', nTx ? `${fmtInt(nLinked)} / ${fmtInt(nTx)}` : '-');
const gekozen = alle.filter(a => selectedAccounts.has(a.id));
const deel = gekozen.reduce((sum, a) => sum + saldi.get(a.id), 0);
zet('selection', gekozen.length === alle.length ? ''
: `${t('tracking.selectedSum')} ${amt(deel)} · ${t('tracking.selectedOf', { n: gekozen.length, m: alle.length })}`);
const logs = await Promise.all(gekozen.map(a => latestScanLog(a.id)));
const tijden = gekozen.map((a, i) => [logs[i]?.scannedAt, ...bronnenVan(a.id).map(s => s.lastScannedAt)]
.filter(Boolean).sort().at(-1)).filter(Boolean);
zet('scanned', (!gekozen.length || tijden.length < gekozen.length)
? t('tracking.scanNever')
: `${t('tracking.scanLast')} ${fmtDateLong(localDate(tijden.sort()[0]))}`);
}
async function walletBlock(g, txs) {
const name = g.name || t('tracking.sourceFallback');
const saldi = await Promise.all(g.sources.filter(s => !isLightningSource(s)).map(s => sourceBalance(s.id)));
const sats = saldi.reduce((sum, b) => sum + b.sats, 0) + lightningAccountSats(g.sources);
const badges = g.sources.map(sourceBadge).join('');
const collapsed = collapsedSet.has(g.id);
const isNew = txs.some(tx => newTxIds.has(tx.id));
return `<div class="oca-source card${collapsed ? ' is-collapsed' : ''}"${isNew ? ' data-new' : ''}>
<button type="button" class="chev-btn oca-collapse${collapsed ? '' : ' is-open'}" data-collapse="${g.id}" title="${esc(t('tracking.collapseToggle'))}" aria-label="${esc(t('tracking.collapseToggle'))}">${CHEVRON}</button>
<div class="oca-source-head">
<div class="oca-source-meta">
<div class="oca-source-name">${esc(name)}</div>
<div class="oca-source-acct">${esc(g.account ? g.account.name : '')}</div>
</div>
<div class="oca-wallet-tags">${badges}</div>
<div class="oca-source-total">${amountHtml(sats)}</div>
</div>
<div class="oca-movements">${walletMovements(txs)}</div>
</div>`;
}
async function walletTxs(sourceIds) {
const seen = new Map();
for (const id of sourceIds) for (const tx of await listSourceTxs(id)) if (!seen.has(tx.id)) seen.set(tx.id, tx);
return [...seen.values()];
}
function walletMovements(txs) {
if (txs.length === 0) {
return `<div class="mv-group"><div class="mv-card"><div class="mv-empty t-body-sm t-muted">${t('tracking.noTxs')}</div></div></div>`;
}
const groups = new Map();
for (const tx of txs) {
const key = tx.blockTime ? localDate(tx.blockTime) : '';
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(tx);
}
const keys = [...groups.keys()].sort((a, b) => {
if (a === '') return -1;
if (b === '') return 1;
return a < b ? 1 : (a > b ? -1 : 0);
});
return keys.map(k => {
const label = k === '' ? t('tracking.unconfirmed') : fmtDate(k);
return `<div class="mv-group">
<div class="mv-card"><div class="mv-date">${label}</div>${dayRows(groups.get(k))}</div>
</div>`;
}).join('');
}
function dayRows(txs) {
const byEvent = new Map();
for (const tx of txs) {
for (const ev of (eventMap.get(tx.id) || [])) {
if (!byEvent.has(ev.eventId)) byEvent.set(ev.eventId, { ev, list: [] });
byEvent.get(ev.eventId).list.push(tx);
}
}
const inGroup = new Set();
for (const { list } of byEvent.values()) if (list.length >= 2) for (const tx of list) inGroup.add(tx.id);
const emitted = new Set();
return txs.map(tx => {
const groups = (eventMap.get(tx.id) || []).filter(ev => byEvent.get(ev.eventId).list.length >= 2);
if (groups.length === 0) return inGroup.has(tx.id) ? '' : txRow(tx);
return groups.map(ev => {
if (emitted.has(ev.eventId)) return '';
emitted.add(ev.eventId);
const g = byEvent.get(ev.eventId);
return eventGroupRow(g.ev, g.list);
}).join('');
}).join('');
}
function eventGroupRow(ev, list) {
const out = ev.fromAccountId === list[0].accountId;
const dirTitle = out ? t('tracking.dirOut') : t('tracking.dirIn');
const arrow = out ? ARROW_UP : ARROW_DOWN;
const typeLabel = t('enums.event_type.' + ev.type);
const other = out ? ev.to_name : ev.from_name;
const desc = ev.type === 'transfer' && other ? `${typeLabel} ${out ? '→' : '←'} ${other}` : typeLabel;
const allSame = list.every(x => x.labelId && x.labelId === list[0].labelId);
const shown = allSame ? list[0].label_name : desc;
const idsAttr = esc(JSON.stringify(list.map(x => x.id)));
const feeBlock = ev.unitFee
? `<div class="mv-fee">
<div class="num"><span class="mv-feecap">${t('tracking.fee')}</span> ${amt(ev.unitFee)}</div>
<div class="t-muted-cell">${fiatSub(ev.unitFee)}</div>
</div>`
: '';
return `<details class="mv-grp">
<summary class="mv-row">
<div class="mv-dir" title="${dirTitle}" aria-label="${dirTitle}">${arrow}</div>
<div class="mv-mid" data-cell-tx="${list[0].id}">
<span class="mv-chev">›</span>
<span class="mv-label" data-edit-label data-tx-ids="${idsAttr}" data-label-id="${allSame ? list[0].labelId : ''}" title="${esc(`${desc} · ${list.length} ${t('tracking.txCount')}`)}">${esc(shown)}</span>
${matchDot({ matched: true, method: ev.method, kind: 'tx', id: list[0].id })}
</div>
<div class="mv-amounts">${feeBlock}
<div class="mv-amt"><div class="num">${amt(ev.amount)}</div><div class="t-muted-cell">${fiatSub(ev.amount)}</div></div>
</div>
</summary>
<div class="mv-sub">${list.map(txRow).join('')}</div>
</details>`;
}
function txRow(tx) {
const dirTitle = tx.direction === 'in' ? t('tracking.dirIn') : t('tracking.dirOut');
const arrow = tx.direction === 'in' ? ARROW_DOWN : ARROW_UP;
const links = eventMap.get(tx.id) || [];
const memo = !tx.address ? memoLine(tx.memo, tx.direction) : null;
const leeg = memo ? esc(memo.text) : t('tracking.addLabel');
const mid = tx.labelId
? `<span class="mv-label" data-edit-label data-tx-id="${tx.id}" data-label-id="${tx.labelId}" title="${esc(tx.address || memo?.title || '')}">${esc(tx.label_name)}</span>`
: `<span class="mv-addr" data-edit-label data-tx-id="${tx.id}" data-label-id="" title="${esc(tx.address || memo?.title || '')}">${tx.address ? trunc(tx.address) : leeg}</span>`;
const copyBtn = tx.address ? copyButton(tx.address, t('tracking.copyAddr')) : '';
const feeBlock = (tx.feeSats && tx.feeSats > 0)
? `<div class="mv-fee">
<div class="num"><span class="mv-feecap">${t('tracking.fee')}</span> ${amt(tx.feeSats)}</div>
<div class="t-muted-cell">${fiatSub(tx.feeSats)}</div>
</div>`
: '';
return `<div class="mv-row">
<div class="mv-dir" title="${dirTitle}" aria-label="${dirTitle}">${arrow}</div>
<div class="mv-mid" data-cell-tx="${tx.id}">${mid}${copyBtn}${matchDot({ matched: links.length > 0, method: links.length ? links[0].method : undefined, kind: 'tx', id: tx.id })}</div>
<div class="mv-amounts">
${feeBlock}
<div class="mv-amt">
<div class="num">${amt(tx.valueSats)}</div>
<div class="t-muted-cell">${fiatSub(tx.valueSats)}</div>
</div>
</div>
</div>`;
}
function openLabelEditor(el, trigger) {
const ids = trigger.dataset.txIds ? JSON.parse(trigger.dataset.txIds) : [Number(trigger.dataset.txId)];
const cur = trigger.dataset.labelId ? Number(trigger.dataset.labelId) : '';
const cell = trigger.closest('[data-cell-tx]');
if (!cell) return;
cell.innerHTML = `<span class="tx-label" data-label-combo>${labelComboMarkup('labelId', cur)}</span>`;
const host = cell.querySelector('[data-label-combo]');
labelComboMount(host, { listLabels, createLabel }, async id => {
await setTxLabels(ids, id);
renderBody(el);
});
const input = host.querySelector('[data-label-input]');
if (input) input.focus();
}
const logRow = (el, id) => el.querySelector(`[data-trk="scanlog"] [data-log="${id}"] .trk-scan-out`);
function logGroup(lijst, account) {
const key = String(account ? account.id : '');
const bestaand = [...lijst.children].find(e => e.dataset.logAcct === key);
if (bestaand) return bestaand.querySelector('.trk-scan-wallets');
const groep = document.createElement('li');
groep.className = 'trk-scan-group';
groep.dataset.logAcct = key;
const kop = document.createElement('div');
kop.className = 't-label t-label--ter trk-scan-acct';
kop.textContent = account ? account.name : '';
const wallets = document.createElement('ul');
wallets.className = 'trk-scan-wallets';
groep.append(kop, wallets);
lijst.append(groep);
return wallets;
}
function addLogRow(el, g) {
const lijst = el.querySelector('[data-trk="scanlog"]');
if (!lijst) return;
const li = document.createElement('li');
li.className = 'trk-scan-row';
li.dataset.log = g.id;
const naam = document.createElement('span');
naam.className = 'trk-scan-name';
naam.textContent = g.name || t('tracking.sourceFallback');
const uit = document.createElement('span');
uit.className = 'trk-scan-out t-muted';
uit.textContent = t('tracking.scanning');
li.append(naam, uit);
logGroup(lijst, g.account).append(li);
}
function setLogRow(el, id, cls, txt) {
const e = logRow(el, id);
if (e) { e.className = `trk-scan-out ${cls}`; e.textContent = txt; }
}
async function scanOne(el, source, logId = source.id) {
return scanSource(source, txt => { const e = logRow(el, logId); if (e) e.textContent = txt; });
}
async function scanAll(el) {
const wallets = await groupedWallets();
if (!wallets.length) return;
const knop = el.querySelector('#scan-all');
const zetStatus = txt => {
const e = el.querySelector('[data-trk="scanstatus"]');
if (e) e.textContent = txt;
};
const results = {};
const kanalen = [];
const voorstellen = new Map();
const lnAccounts = new Set();
let nWallets = 0, nMislukt = 0;
if (knop) knop.disabled = true;
const lijst = el.querySelector('[data-trk="scanlog"]');
if (lijst) lijst.replaceChildren();
const voor = shownTxIds;
newTxIds = new Set();
try {
for (const [i, g] of wallets.entries()) {
zetStatus(t('tracking.scanProgress', { i: i + 1, n: wallets.length }));
addLogRow(el, g);
let count = 0, failed = 0, error = false, geslagen = 0, ln = 0, lnBronnen = 0;
for (const s of g.sources) {
if (isLightningSource(s)) {
lnBronnen++;
lnAccounts.add(s.accountId);
try {
const r = await scanLightningSource(s);
ln += r.count;
if (r.channelTxids.length) kanalen.push([s.accountId, r.channelTxids]);
} catch (err) { console.error('[lightning-scan]', err); error = true; }
continue;
}
if (!isOnchainSource(s)) { geslagen++; continue; }
try {
const r = await scanOne(el, s, g.id);
count += r.count; failed += r.failed; voorstellen.set(s.accountId, r.proposals || 0);
} catch (err) { console.error('[onchain-scan-all]', err); error = true; }
}
const skipped = geslagen && !lnBronnen && !count && !failed;
if (error) nMislukt++; else if (!skipped) nWallets++;
results[g.id] = error ? { error: true } : (skipped ? { skipped: true } : { count, failed, ln, lnBronnen });
}
if (Object.values(results).some(r => r.lnBronnen)) await syncAddressLinks().catch(err => console.error('[lightning-links]', err));
for (const [accountId, txids] of kanalen) {
await labelChannelTxs(accountId, txids).catch(err => console.error('[lightning-channel-label]', err));
}
const btc = lnAccounts.size ? await btcAsset() : null;
if (btc) for (const accountId of lnAccounts) {
try { voorstellen.set(accountId, await syncTxProposals(accountId, btc.id)); }
catch (err) { console.error('[lightning-proposals]', err); }
}
} finally {
if (knop) knop.disabled = false;
}
const nVoorstellen = [...voorstellen.values()].reduce((s, n) => s + n, 0);
zetStatus(scanSummary(nWallets, nVoorstellen, nMislukt));
for (const id in results) {
const r = results[id];
if (r.error) setLogRow(el, id, 't-neg', t('tracking.scanError'));
else if (r.skipped) setLogRow(el, id, 't-muted', t('tracking.scanNotOnchain'));
else setLogRow(el, id, r.failed ? 't-neg' : 't-pos', scanDoneText(r));
}
scanBaseline = voor;
await renderBody(el);
}
function scanSummary(nWallets, nVoorstellen, nMislukt) {
const deel = [`${fmtInt(nWallets)} ${nWallets === 1 ? t('tracking.walletOne') : t('tracking.walletMany')} ${t('tracking.scanUpdated')}`];
if (nVoorstellen) deel.push(`${fmtInt(nVoorstellen)} ${nVoorstellen === 1 ? t('tracking.proposalOne') : t('tracking.proposalMany')}`);
if (nMislukt) deel.push(`${fmtInt(nMislukt)} ${t('tracking.scanFailedN')}`);
return deel.join(' · ');
}
function scanDoneText(r) {
const delen = [];
if (r.count || r.failed || !r.lnBronnen) delen.push(`${r.count} ${t('tracking.addressesScanned')}`);
if (r.failed) delen.push(`${r.failed} ${t('tracking.addressesFailed')}`);
if (r.lnBronnen) delen.push(`${fmtInt(r.ln)} ${t('tracking.lightningMovements')}`);
return delen.join(' · ');
}
+375
View File
@@ -0,0 +1,375 @@
// Generated, do not edit. sha256:d029e4f661a0e4d29fa7970f6f2aff7a15cdf1a9757dd86f22ff6a54e12c7c53
import { listSelectableAccounts } from '../data/accounts.js';
import { localDate, localDateNl, localTime, toUtcIso } from '../../shared/time.js';
import { listAssets, listBookableAssets } from '../data/assets.js';
import { listTrades, createEvent, updateEvent, deleteEvent } from '../data/events.js';
import { listMetalProducts, listMetalProductsForAsset } from '../data/metals.js';
import { tableViewCfg } from '../data/preferences.js';
import { eventLinkMap, onchainAsset } from '../data/tx-links.js';
import { t } from '../i18n/index.js';
import { assetLabelHtml } from '../ui/asset-label.js';
import * as dialog from '../ui/dialog.js';
import { ENUMS, CONFIG, TARGETS, targetHash } from '../../shared/config.js';
import { renderTable } from '../ui/table.js';
import { takeRowFocus } from '../ui/row-focus.js';
import { fmtBase, fmtBasePrice, fmtUnit, val, toSubunit, fromSubunit, priceToBase, priceFromBase, isMetal } from '../ui/format.js';
import { totalFromPrice, priceFromTotal, amountFromTotal } from '../../shared/units.js';
import { priceUnitLabel } from '../../shared/units.js';
import { matchDot, wireMatchTriggers } from '../ui/match-popover.js';
import { spotOnDate } from '../ui/fiat.js';
import { ARROW_DOWN, ARROW_UP } from '../ui/icons.js';
import { esc } from '../ui/escape.js';
import { emptyStateHtml } from '../ui/empty-state.js';
import { setPageHint } from '../ui/page-hint.js';
import { bookingOriginHtml } from '../ui/price-origin.js';
import { listPriceHistory } from '../data/price-history.js';
import { herkomstContext } from '../wealth/data.js';
import { provenanceOn, originFrom } from '../../shared/price-provenance.js';
let tableApi = null;
let onlyAccount = null;
let onlyAsset = null;
let onlyKinds = null;
const assetAllowed = a => (onlyAsset == null || a.id === onlyAsset) && (onlyKinds == null || onlyKinds.includes(a.kind));
export async function render(el, { accountId = null, assetId = null, assetKinds = null } = {}) {
onlyAccount = accountId;
onlyAsset = assetId;
onlyKinds = assetKinds;
if ((await listAssets()).length === 0) {
el.innerHTML = emptyStateHtml(t('trades.none'), {
why: t('trades.needAsset'), href: targetHash(TARGETS.ASSETS), linkText: t('common.manageAssets'),
});
return;
}
el.innerHTML = `<div id="tbl"></div>`;
if (onlyAccount == null) setPageHint(el, { title: t('nav.trades'), rows: [t('trades.fiatNote')] });
buildTable(el);
}
function typeOpts() {
return ENUMS.EVENT_TYPE.filter(x => x === 'buy' || x === 'sell')
.map(x => ({ value: x, label: t('enums.event_type.' + x) }));
}
async function assetOptsOfKind(kind) {
return (await listBookableAssets()).filter(a => (!kind || a.kind === kind) && assetAllowed(a))
.map(a => ({ value: a.id, label: `${a.symbol}: ${a.name}` }));
}
async function accountOpts(huidigeId) {
const accounts = (await listSelectableAccounts({ ookId: Number(huidigeId) || null }))
.filter(a => onlyAccount == null || a.id === onlyAccount);
return [...(onlyAccount == null ? [{ value: '', label: '-' }] : []), ...accounts.map(a => ({ value: a.id, label: a.name }))];
}
async function productOpts(assetId) {
const id = Number(assetId);
const opts = [{ value: '', label: t('trades.looseWeight') }];
if (Number.isInteger(id)) for (const p of await listMetalProductsForAsset(id)) opts.push({ value: p.id, label: p.name });
return opts;
}
async function dagHerkomsten(trades) {
const uit = new Map();
const perAsset = new Map();
for (const tr of trades) {
if (tr.priceOrigin !== 'market_day' || tr.fiatPrice == null) continue;
if (!perAsset.has(tr.assetId)) perAsset.set(tr.assetId, []);
perAsset.get(tr.assetId).push(tr);
}
if (!perAsset.size) return uit;
const ctx = await herkomstContext();
for (const [assetId, lijst] of perAsset) {
let rijen = [];
try { rijen = await listPriceHistory(assetId); } catch { continue; }
for (const tr of lijst) {
const d = provenanceOn(rijen, tr.occurredAt.slice(0, 10),
{ assetKind: tr.asset_kind, manualProviderId: ctx.manualId, usedPrice: tr.fiatPrice });
if (d && d.fiatPrice != null && Math.abs(d.fiatPrice - tr.fiatPrice) <= 1e-9 * Math.max(1, Math.abs(tr.fiatPrice))) {
uit.set(tr.id, originFrom(d, ctx.names));
}
}
}
return uit;
}
async function buildTable(el) {
let dagHerkomst = new Map();
const rows = async () => {
const lijst = (await listTrades())
.filter(tr => (onlyAccount == null || (tr.type === 'buy' ? tr.toAccountId : tr.fromAccountId) === onlyAccount)
&& (onlyAsset == null || tr.assetId === onlyAsset))
.map(tr => ({ ...tr, loc: tr.type === 'buy' ? tr.to_name : tr.from_name }));
try { dagHerkomst = await dagHerkomsten(lijst); } catch { dagHerkomst = new Map(); }
return lijst;
};
const meta = r => ({ kind: r.asset_kind, decimals: r.asset_decimals, unit: r.asset_unit, subunit: r.asset_subunit, displayUnit: r.asset_display_unit });
const btc = await onchainAsset();
let linkMap = await eventLinkMap();
const today = new Date().toISOString().slice(0, 10);
const alleAssets = await listAssets();
const alleProducten = await listMetalProducts();
const assetById = id => alleAssets.find(a => a.id === Number(id));
const firstOf = kind => { const a = alleAssets.find(x => x.kind === kind && assetAllowed(x)); return a ? a.id : ''; };
async function resolvePrice(v) {
const asset = alleAssets.find(a => a.id === Number(v.assetId));
if (!asset || !v.occurredAt) return;
if (v.priceOrigin === 'none') { v.fiatPrice = 0; return; }
const spot = await spotOnDate(asset, v.occurredAt);
if (v.fiatPrice === '' || v.fiatPrice == null) {
if (spot == null) throw new Error(t('trades.noSpotForDate'));
v.fiatPrice = spot;
v.priceOrigin = 'market_day';
return;
}
const entered = parseFloat(v.fiatPrice);
if (spot != null && spot > 0 && Number.isFinite(entered)) {
const dev = Math.abs(entered - spot) / spot;
if (dev > CONFIG.METALS.SPOT_DEVIATION_WARN) {
const ok = await dialog.confirm({
message: `De ingevoerde prijs (${fmtBasePrice(entered)}) wijkt ${Math.round(dev * 100)}% af van de spot op ${v.occurredAt} (${fmtBasePrice(spot)}). Toch opslaan?`,
});
if (!ok) throw new Error(t('trades.saveCancelled'));
}
}
}
const convertAsset = vals => (vals.productId !== '' && vals.productId != null) ? null : (assetById(vals.assetId) || null);
function productCell(r) {
if (r.productId && r.product_name) return `${esc(r.product_name)} <span class="t-muted-cell">×${r.productCount}</span>`;
if (r.asset_kind === 'metal') return `<span class="t-muted-cell">${t('trades.looseWeight')}</span>`;
return '';
}
function herkomstCel(r) {
const hints = {
paid: 'trades.originPaidHint', market_day: 'trades.originMarketDay',
market_moment: 'trades.originMarketMoment', none: 'trades.originNoneHint',
};
if (!r.priceOrigin) {
return `<span class="t-muted-cell" title="${esc(t('trades.originUnknownHint'))}">?</span>`;
}
if (r.priceOrigin === 'paid') return '';
return `<span class="t-muted-cell" title="${esc(t(hints[r.priceOrigin]))}">${esc(t('enums.event_price_origin.' + r.priceOrigin))}</span>`;
}
function herkomstOpts(vals) {
const opts = [
{ value: '', label: t('trades.originPick') },
{ value: 'paid', label: t('trades.originPickPaid') },
{ value: 'none', label: t('trades.originPickGift') },
];
const huidig = vals && vals.priceOrigin;
if (huidig && !opts.some(o => o.value === huidig)) {
opts.push({ value: huidig, label: t('enums.event_price_origin.' + huidig) });
}
return opts;
}
const columns = [
{ key: 'occurredAt', label: t('trades.colDate'), sortable: true,
render: r => `${btc && r.assetId === btc.id ? matchDot({ matched: linkMap.has(r.id), method: linkMap.get(r.id), kind: 'event', id: r.id }) : ''}${localDateNl(r.occurredAt)}`,
group: { value: r => localDate(r.occurredAt).slice(0, 4), label: v => v, order: 'desc' },
edit: { type: 'date', sticky: true, attrs: { max: today }, value: r => r ? localDate(r.occurredAt) : today } },
{ key: '_time', label: t('trades.time'),
render: r => localTime(r.occurredAt),
edit: { type: 'time', value: r => r ? localTime(r.occurredAt) : '12:00' } },
{ key: 'loc', label: t('trades.colAccount'), sortable: true,
render: r => r.loc || '-', group: { value: r => r.loc || '-', label: v => v },
edit: { type: 'select', sticky: true, options: vals => accountOpts(vals.loc),
value: r => r ? (r.type === 'sell' ? r.fromAccountId : r.toAccountId) ?? '' : '' } },
{ key: 'assetId', label: t('trades.colAsset'), sortable: true, sortValue: r => r.asset_symbol,
render: r => assetLabelHtml(r), group: { value: r => r.asset_symbol, label: v => v },
edit: { type: 'select', sticky: true, reactive: true, options: vals => assetOptsOfKind(vals.asset_kind) } },
{ key: 'asset_kind', label: t('trades.colKind'), sortable: true,
render: r => t('enums.asset_kind.' + r.asset_kind),
group: { value: r => r.asset_kind, label: v => t('enums.asset_kind.' + v) },
edit: { value: r => r ? r.asset_kind : '', visible: () => false } },
{ key: 'type', label: t('trades.colType'), sortable: true,
render: r => r.type === 'buy'
? `<span class="badge badge-accent badge-icon" title="${t('enums.event_type.buy')}" aria-label="${t('enums.event_type.buy')}">${ARROW_DOWN}</span>`
: `<span class="badge badge-neutral badge-icon" title="${t('enums.event_type.sell')}" aria-label="${t('enums.event_type.sell')}">${ARROW_UP}</span>`,
group: { value: r => r.type, label: v => t('enums.event_type.' + v) },
edit: { type: 'select', reactive: true, options: typeOpts } },
{ key: 'productId', label: t('trades.colProduct'), sortable: true, sortValue: r => r.product_name || '',
render: productCell,
edit: { type: 'select', sticky: true, reactive: true, visible: vals => vals.asset_kind === 'metal',
options: vals => productOpts(vals.assetId),
value: r => r ? (r.productId ?? '') : '' } },
{ key: 'amount', label: t('trades.colAmount'), align: 'num', sortable: true, sortValue: r => r.amount,
render: r => val(fmtUnit(r.amount, meta(r))),
edit: { type: 'number', reactive: true, attrs: { min: 0, step: 'any' },
value: r => r ? (r.productCount != null ? r.productCount : fromSubunit(r.amount, meta(r))) : '',
convert: convertAsset } },
{ key: 'priceOrigin', label: t('trades.colOrigin'), sortable: true,
sortValue: r => r.priceOrigin || '',
render: herkomstCel,
group: { value: r => r.priceOrigin || '', label: v => (v ? t('enums.event_price_origin.' + v) : t('trades.originUnknown')) },
edit: { type: 'select', reactive: true, options: herkomstOpts,
value: r => r ? (r.priceOrigin ?? '') : '' } },
{ key: 'fiatTotal', label: t('trades.colTotal'), align: 'num', sortable: true,
sortValue: r => totalFromPrice(r.amount, meta(r).decimals, r.fiatPrice, r.fiatFee, r.type) ?? -1,
render: r => {
const tot = totalFromPrice(r.amount, meta(r).decimals, r.fiatPrice, r.fiatFee, r.type);
return tot == null ? '-' : val(fmtBase(tot));
},
edit: { type: 'number', reactive: true, attrs: { min: 0, step: 'any' },
visible: vals => vals.priceOrigin !== 'none',
value: r => {
const tot = r ? totalFromPrice(r.amount, meta(r).decimals, r.fiatPrice, r.fiatFee, r.type) : null;
return tot == null ? '' : Math.round(tot * 100) / 100;
} } },
{ key: 'fiatPrice', label: t('trades.colPrice'), align: 'num', sortable: true, sortValue: r => r.fiatPrice,
render: r => `${val(fmtBasePrice(priceFromBase(r.fiatPrice, meta(r))))} <span class="t-muted-cell">/${priceUnitLabel(meta(r))}</span>`
+ bookingOriginHtml(r, { ...meta(r), symbol: r.asset_symbol }, dagHerkomst.get(r.id) || null),
edit: { type: 'number', reactive: true, attrs: { min: 0, step: 'any' },
visible: vals => vals.priceOrigin !== 'none',
value: r => r ? priceFromBase(r.fiatPrice, meta(r)) : '' } },
{ key: 'fiatFee', label: t('trades.colFee'), align: 'num', sortable: true, sortValue: r => r.fiatFee ?? -1,
render: r => r.fiatFee != null ? val(fmtBase(r.fiatFee)) : '-',
edit: { type: 'number', reactive: true, attrs: { min: 0, step: 'any' },
visible: vals => vals.priceOrigin !== 'none',
value: r => r && r.fiatFee != null ? r.fiatFee : '' } },
{ key: 'note', label: t('trades.colNote'), text: true,
render: r => `<span class="t-muted-cell">${esc(r.note || '')}</span>`,
edit: { type: 'textarea', expand: true } },
{ key: 'source', label: t('trades.colSource'), sortable: true,
render: r => `<span class="t-muted-cell">${r.source ? t('enums.event_source.' + r.source) : '-'}</span>`,
group: { value: r => r.source || 'manual', label: v => t('enums.event_source.' + v) } },
].filter(c => onlyAccount == null || c.key !== 'loc');
function hoeveelheidBasis(v, asset) {
if (!asset) return null;
const wantProduct = isMetal(asset) && v.productId !== '' && v.productId != null;
if (wantProduct) {
const product = alleProducten.find(p => p.id === Number(v.productId) && p.assetId === asset.id);
const cnt = parseInt(v.amount, 10);
if (!product || !Number.isInteger(cnt) || cnt <= 0) return null;
return product.fineGrams * cnt;
}
const qty = parseFloat(v.amount);
if (!Number.isFinite(qty) || qty <= 0) return null;
return toSubunit(qty, asset);
}
function afgeleid(v, sleutel, aangeraakt = new Set()) {
if (v.priceOrigin === 'none') return { fiatPrice: '0', fiatTotal: '0', fiatFee: '' };
const asset = assetById(v.assetId);
if (!asset) return null;
const dec = asset.decimals;
const rond = n => (n == null ? '' : String(Math.round(n * 100) / 100));
const gevuld = x => x !== '' && x != null && Number.isFinite(Number(x));
const opProduct = isMetal(asset) && v.productId !== '' && v.productId != null;
if (!opProduct && !aangeraakt.has('amount') && gevuld(v.fiatTotal) && gevuld(v.fiatPrice)) {
const basisUitGeld = amountFromTotal(
v.fiatTotal, priceToBase(parseFloat(v.fiatPrice), asset), dec, v.fiatFee, v.type);
if (basisUitGeld !== null) {
const getoond = fromSubunit(Math.round(basisUitGeld), asset);
return { amount: getoond === null ? '' : String(getoond) };
}
}
const basis = hoeveelheidBasis(v, asset);
if (basis === null) return null;
const naarPrijs = sleutel === 'fiatTotal'
|| (gevuld(v.fiatTotal) && (sleutel === 'fiatFee' || !gevuld(v.fiatPrice)));
if (naarPrijs) {
const prijs = priceFromTotal(v.fiatTotal, basis, dec, v.fiatFee, v.type);
return prijs === null ? null : { fiatPrice: rond(priceFromBase(prijs, asset)) };
}
const totaal = totalFromPrice(basis, dec, priceToBase(parseFloat(v.fiatPrice), asset), v.fiatFee, v.type);
return totaal === null ? null : { fiatTotal: rond(totaal) };
}
function herkomst(v, existing, priceBasis) {
const gekozen = v.priceOrigin ?? '';
const opgeslagen = existing ? (existing.priceOrigin ?? '') : '';
if (gekozen !== opgeslagen) return gekozen === '' ? null : gekozen;
if (existing && existing.fiatPrice != null) {
const zelfde = Math.abs(priceBasis - existing.fiatPrice) <= 1e-9 * Math.max(1, Math.abs(existing.fiatPrice));
if (zelfde) return existing.priceOrigin ?? null;
}
return gekozen === '' ? 'paid' : gekozen;
}
async function toPayload(v, existing) {
const asset = alleAssets.find(a => a.id === Number(v.assetId));
const price = parseFloat(v.fiatPrice);
if (!v.occurredAt || !asset || !Number.isFinite(price)) throw new Error(t('trades.fillDateAssetPrice'));
let productId = null, productCount = null, amount;
const wantProduct = isMetal(asset) && v.productId !== '' && v.productId != null;
if (wantProduct) {
const product = (await listMetalProducts()).find(p => p.id === Number(v.productId) && p.assetId === asset.id);
if (!product) throw new Error(t('trades.pickValidProduct'));
const cnt = parseInt(v.amount, 10);
if (!Number.isInteger(cnt) || cnt <= 0) throw new Error(t('trades.fillCount'));
productId = product.id; productCount = cnt;
amount = product.fineGrams * cnt;
} else {
const qty = parseFloat(v.amount);
if (!Number.isFinite(qty) || qty <= 0) throw new Error(t('trades.fillQuantity'));
amount = toSubunit(qty, asset);
}
const fiatPrice = priceToBase(price, asset);
const locVal = v.loc ?? onlyAccount;
const loc = locVal !== '' && locVal != null ? Number(locVal) : null;
return {
priceOrigin: herkomst(v, existing, fiatPrice),
assetId: asset.id, type: v.type,
occurredAt: toUtcIso(`${v.occurredAt}T${v._time || '00:00'}:00`),
amount, fiatPrice,
fiatFee: v.fiatFee === '' || v.fiatFee == null ? null : parseFloat(v.fiatFee),
unitFee: existing ? existing.unitFee ?? null : null,
productId, productCount,
fromAccountId: v.type === 'sell' ? loc : null,
toAccountId: v.type === 'buy' ? loc : null,
source: existing ? existing.source : 'manual',
note: (v.note || '').trim() || null,
};
}
const reload = async () => tableApi.refresh(await rows());
tableApi = renderTable(el.querySelector('#tbl'), {
columns, rows: await rows(),
onEditChange: (vals, sleutel, aangeraakt) => afgeleid(vals, sleutel, aangeraakt),
...(await tableViewCfg('trades', { key: 'occurredAt', dir: 'desc' }, onlyAccount != null ? 'assetId' : null)),
emptyText: t('trades.none'),
noneLabel: t('table.none'),
collapsibleGroups: true,
groupSummary: items => {
const first = items[0]; if (!first) return '';
if (!items.every(r => r.assetId === first.assetId)) return '';
const net = items.reduce((s, r) => s + (r.type === 'buy' ? r.amount : -r.amount), 0);
return `${net < 0 ? '\u2212' : ''}${val(fmtUnit(Math.abs(net), meta(first)))}`;
},
newLabel: t('table.new'),
convertLabel: t('convert.title'),
newDefaults: { type: 'buy', loc: onlyAccount ?? '', fiatFee: '' },
newOptions: [
{ key: 'crypto', label: t('trades.addCrypto'), defaults: { asset_kind: 'crypto', type: 'buy', assetId: firstOf('crypto') } },
{ key: 'metal', label: t('trades.addMetal'), defaults: { asset_kind: 'metal', type: 'buy', assetId: firstOf('metal') } },
{ key: 'fiat', label: t('trades.addFiat'), defaults: { asset_kind: 'fiat', type: 'buy', assetId: firstOf('fiat') } },
].filter(o => o.defaults.assetId !== ''),
saveLabel: t('trades.save'), cancelLabel: t('trades.cancel'),
onCreate: async v => { await resolvePrice(v); const id = await createEvent(await toPayload(v, null)); tableApi.refresh(await rows(), { locate: id }); },
onSave: async (id, v) => {
await resolvePrice(v);
const ex = (await listTrades()).find(x => x.id === id);
await updateEvent(id, await toPayload(v, ex)); reload();
},
onDelete: async id => {
if (!(await dialog.confirm({ message: t('trades.confirmDelete'), danger: true }))) return;
await deleteEvent(id); reload();
},
});
wireMatchTriggers(el.querySelector('#tbl'), (kind, id) => ({
kind, id, assetId: btc ? btc.id : null,
onChange: async () => { linkMap = await eventLinkMap(); tableApi.refresh(await rows()); },
}));
tableApi.focusRow(takeRowFocus('trades'));
}
+238
View File
@@ -0,0 +1,238 @@
// Generated, do not edit. sha256:691f92a0fa91d8a9f654cd1214fd7139438481faeed67130424bba664831b55c
import { listSelectableAccounts } from '../data/accounts.js';
import { localDate, localDateNl, localTime, toUtcIso } from '../../shared/time.js';
import { listAssets, listBookableAssets } from '../data/assets.js';
import { listTransfers, createEvent, updateEvent, deleteEvent } from '../data/events.js';
import { listMetalProducts, listMetalProductsForAsset } from '../data/metals.js';
import { tableViewCfg } from '../data/preferences.js';
import { eventLinkMap, onchainAsset } from '../data/tx-links.js';
import { t } from '../i18n/index.js';
import { assetLabelHtml } from '../ui/asset-label.js';
import * as dialog from '../ui/dialog.js';
import { renderTable } from '../ui/table.js';
import { takeRowFocus } from '../ui/row-focus.js';
import { fmtUnit, val, subline, toSubunit, fromSubunit, isMetal } from '../ui/format.js';
import { matchDot, wireMatchTriggers } from '../ui/match-popover.js';
import { loadHistorical, fiatHistorical, fiatHistoricalOrigin } from '../ui/fiat.js';
import { originHtml } from '../ui/price-origin.js';
import { copyButton } from '../ui/copy-button.js';
import { TARGETS, targetHash } from '../../shared/config.js';
import { esc } from '../ui/escape.js';
import { emptyStateHtml } from '../ui/empty-state.js';
import { setPageHint } from '../ui/page-hint.js';
let tableApi = null;
let onlyAccount = null;
let onlyAsset = null;
let onlyKinds = null;
const assetAllowed = a => (onlyAsset == null || a.id === onlyAsset) && (onlyKinds == null || onlyKinds.includes(a.kind));
const shortAddr = a => (a && a.length > 16) ? `${a.slice(0, 8)}…${a.slice(-6)}` : (a || '');
export async function render(el, { accountId = null, assetId = null, assetKinds = null } = {}) {
onlyAccount = accountId;
onlyAsset = assetId;
onlyKinds = assetKinds;
if ((await listAssets()).length === 0) {
el.innerHTML = `
${emptyStateHtml(t('transfers.none'), {
why: t('transfers.needAsset'), href: targetHash(TARGETS.ASSETS), linkText: t('common.manageAssets'),
})}`;
return;
}
el.innerHTML = `<div id="tbl"></div>`;
if (onlyAccount == null) setPageHint(el, { title: t('nav.transfers'), rows: [t('transfers.fiatNote')] });
buildTable(el);
}
async function assetOptsOfKind(kind) {
return (await listBookableAssets()).filter(a => (!kind || a.kind === kind) && assetAllowed(a))
.map(a => ({ value: a.id, label: `${a.symbol}: ${a.name}` }));
}
async function accountOpts(huidigeId) {
return [{ value: '', label: '-' },
...(await listSelectableAccounts({ ookId: Number(huidigeId) || null })).map(a => ({ value: a.id, label: a.name }))];
}
async function productOpts(assetId) {
const id = Number(assetId);
const opts = [{ value: '', label: t('transfers.looseWeight') }];
if (Number.isInteger(id)) for (const p of await listMetalProductsForAsset(id)) opts.push({ value: p.id, label: p.name });
return opts;
}
async function buildTable(el) {
const rows = async () => (await listTransfers())
.filter(r => (onlyAccount == null || r.fromAccountId === onlyAccount || r.toAccountId === onlyAccount)
&& (onlyAsset == null || r.assetId === onlyAsset));
const meta = r => ({ id: r.assetId, kind: r.asset_kind, decimals: r.asset_decimals, unit: r.asset_unit, subunit: r.asset_subunit, displayUnit: r.asset_display_unit });
const herkomst = r => originHtml(fiatHistoricalOrigin(meta(r), localDate(r.occurredAt)), { ...meta(r), symbol: r.asset_symbol });
const btc = await onchainAsset();
let linkMap = await eventLinkMap();
const today = new Date().toISOString().slice(0, 10);
const alleAssets = await listAssets();
const assetById = id => alleAssets.find(a => a.id === Number(id));
const firstOf = kind => { const a = alleAssets.find(x => x.kind === kind && assetAllowed(x)); return a ? a.id : ''; };
const convertAsset = vals => (vals.productId !== '' && vals.productId != null) ? null : (assetById(vals.assetId) || null);
const dirOf = r => (r.toAccountId === onlyAccount && r.fromAccountId !== onlyAccount ? 'in' : 'out');
const otherOf = r => (dirOf(r) === 'in'
? { id: r.fromAccountId, name: r.from_name }
: { id: r.toAccountId, name: r.to_name });
function productCell(r) {
if (r.productId && r.product_name) return `${esc(r.product_name)} <span class="t-muted-cell">×${r.productCount}</span>`;
if (r.asset_kind === 'metal') return `<span class="t-muted-cell">${t('transfers.looseWeight')}</span>`;
return '';
}
const columns = [
{ key: 'occurredAt', label: t('transfers.colDate'), sortable: true,
render: r => `${btc && r.assetId === btc.id ? matchDot({ matched: linkMap.has(r.id), method: linkMap.get(r.id), kind: 'event', id: r.id }) : ''}${localDateNl(r.occurredAt)}`,
group: { value: r => localDate(r.occurredAt).slice(0, 4), label: v => v, order: 'desc' },
edit: { type: 'date', sticky: true, attrs: { max: today }, value: r => r ? localDate(r.occurredAt) : today } },
{ key: '_time', label: t('transfers.time'),
render: r => localTime(r.occurredAt),
edit: { type: 'time', value: r => r ? localTime(r.occurredAt) : '12:00' } },
{ key: 'assetId', label: t('transfers.colAsset'), sortable: true, sortValue: r => r.asset_symbol,
render: r => assetLabelHtml(r), group: { value: r => r.asset_symbol, label: v => v },
edit: { type: 'select', sticky: true, reactive: true, options: vals => assetOptsOfKind(vals.asset_kind) } },
{ key: 'asset_kind', label: t('transfers.colKind'), sortable: true,
render: r => t('enums.asset_kind.' + r.asset_kind),
group: { value: r => r.asset_kind, label: v => t('enums.asset_kind.' + v) },
edit: { value: r => r ? r.asset_kind : '', visible: () => false } },
{ key: 'productId', label: t('transfers.colProduct'), sortable: true, sortValue: r => r.product_name || '',
render: productCell,
edit: { type: 'select', sticky: true, reactive: true, visible: vals => vals.asset_kind === 'metal',
options: vals => productOpts(vals.assetId),
value: r => r ? (r.productId ?? '') : '' } },
{ key: 'amount', label: t('transfers.colAmount'), align: 'num', sortable: true, sortValue: r => r.amount,
render: r => { const fiat = fiatHistorical(r.amount, meta(r), localDate(r.occurredAt)); return `${val(fmtUnit(r.amount, meta(r)))}${subline(fiat ? val(fiat) + herkomst(r) : '')}`; },
edit: { type: 'number', attrs: { min: 0, step: 'any' },
value: r => r ? (r.productCount != null ? r.productCount : fromSubunit(r.amount, meta(r))) : '',
convert: convertAsset } },
...(onlyAccount == null ? [
{ key: 'fromAccountId', label: t('transfers.colFrom'), sortable: true, sortValue: r => r.from_name || '',
render: r => r.from_name || '-', group: { value: r => r.from_name || '-', label: v => v },
edit: { type: 'select', sticky: true, options: vals => accountOpts(vals.fromAccountId) } },
{ key: 'toAccountId', label: t('transfers.colTo'), sortable: true, sortValue: r => r.to_name || '',
render: r => r.to_name || '-', group: { value: r => r.to_name || '-', label: v => v },
edit: { type: 'select', sticky: true, options: vals => accountOpts(vals.toAccountId) } },
] : [
{ key: '_dir', label: t('transfers.colDirection'), sortable: true, sortValue: r => dirOf(r),
render: r => t(dirOf(r) === 'in' ? 'transfers.dirIn' : 'transfers.dirOut'),
group: { value: r => dirOf(r), label: v => t(v === 'in' ? 'transfers.dirIn' : 'transfers.dirOut') },
edit: { type: 'select', sticky: true, value: r => (r ? dirOf(r) : 'in'),
options: () => [{ value: 'in', label: t('transfers.dirIn') }, { value: 'out', label: t('transfers.dirOut') }] } },
{ key: '_other', label: t('transfers.colOther'), sortable: true, sortValue: r => otherOf(r).name || '',
render: r => esc(otherOf(r).name || '-'),
group: { value: r => otherOf(r).name || '-', label: v => v },
edit: { type: 'select', sticky: true, options: vals => accountOpts(vals._other),
value: r => (r ? otherOf(r).id ?? '' : '') } },
]),
{ key: 'unitFee', label: t('transfers.colFee'), align: 'num', sortable: true, sortValue: r => r.unitFee ?? -1,
render: r => { if (r.unitFee == null) return `-${subline('')}`; const fiat = fiatHistorical(r.unitFee, meta(r), localDate(r.occurredAt)); return `${val(fmtUnit(r.unitFee, meta(r)))}${subline(fiat ? val(fiat) + herkomst(r) : '')}`; },
edit: { type: 'number', attrs: { min: 0, step: 'any' }, value: r => r && r.unitFee != null ? fromSubunit(r.unitFee, meta(r)) : '', convert: convertAsset } },
{ key: 'priceOrigin', label: t('transfers.colOrigin'),
render: r => (r.priceOrigin === 'none'
? `<span class="badge badge-neutral">${t('transfers.originNone')}</span>`
: `<span class="t-muted-cell">-</span>`),
group: { value: r => (r.priceOrigin === 'none' ? t('transfers.originNone') : '-'), label: v => v },
edit: { type: 'select', value: r => (r && r.priceOrigin === 'none' ? 'none' : ''),
options: () => ([
{ value: '', label: t('transfers.originPick') },
{ value: 'none', label: t('transfers.originNonePick') },
]) } },
{ key: 'note', label: t('transfers.colNote'), text: true,
render: r => `<span class="t-muted-cell">${esc(r.note || '')}</span>`,
edit: { type: 'textarea', expand: true } },
{ key: 'externalAddress', label: t('transfers.colAddress'),
render: r => r.externalAddress ? `<span class="t-muted-cell" title="${esc(r.externalAddress)}">${esc(shortAddr(r.externalAddress))}</span>${copyButton(r.externalAddress)}` : '',
edit: { type: 'text', expand: true, placeholder: t('transfers.addressPlaceholder') } },
{ key: 'source', label: t('transfers.colSource'), sortable: true,
render: r => `<span class="t-muted-cell">${r.source ? t('enums.event_source.' + r.source) : '-'}</span>`,
group: { value: r => r.source || 'manual', label: v => t('enums.event_source.' + v) } },
];
async function toPayload(v, existing) {
const asset = alleAssets.find(a => a.id === Number(v.assetId));
if (!v.occurredAt || !asset) throw new Error(t('transfers.fillDateAsset'));
let productId = null, productCount = null, amount;
const wantProduct = isMetal(asset) && v.productId !== '' && v.productId != null;
if (wantProduct) {
const product = (await listMetalProducts()).find(p => p.id === Number(v.productId) && p.assetId === asset.id);
if (!product) throw new Error(t('transfers.pickValidProduct'));
const cnt = parseInt(v.amount, 10);
if (!Number.isInteger(cnt) || cnt <= 0) throw new Error(t('transfers.fillCount'));
productId = product.id; productCount = cnt;
amount = product.fineGrams * cnt;
} else {
const qty = parseFloat(v.amount);
if (!Number.isFinite(qty) || qty <= 0) throw new Error(t('transfers.fillDateAssetQuantity'));
amount = toSubunit(qty, asset);
}
const idOf = x => (x !== '' && x != null ? Number(x) : null);
let from = idOf(v.fromAccountId), to = idOf(v.toAccountId);
if (onlyAccount != null) {
const other = idOf(v._other);
[from, to] = v._dir === 'in' ? [other, onlyAccount] : [onlyAccount, other];
}
if (!from && !to) throw new Error(t('transfers.fillOneAccount'));
if (onlyAccount != null && from !== onlyAccount && to !== onlyAccount) throw new Error(t('transfers.fillThisAccount'));
return {
assetId: asset.id, type: 'transfer',
occurredAt: toUtcIso(`${v.occurredAt}T${v._time || '00:00'}:00`),
amount, fiatPrice: null,
fiatFee: existing ? existing.fiatFee ?? null : null,
unitFee: v.unitFee === '' || v.unitFee == null ? null : toSubunit(parseFloat(v.unitFee), asset),
productId, productCount,
fromAccountId: from, toAccountId: to,
source: existing ? existing.source : 'manual',
note: (v.note || '').trim() || null,
externalAddress: (v.externalAddress || '').trim() || null,
priceOrigin: v.priceOrigin === 'none' ? 'none' : null,
};
}
const reload = async () => tableApi.refresh(await rows());
const historieRijen = await rows();
loadHistorical(historieRijen.map(r => ({ assetId: r.assetId, dateKey: localDate(r.occurredAt) })))
.then(async () => { if (tableApi) tableApi.refresh(await rows()); }).catch(() => {});
tableApi = renderTable(el.querySelector('#tbl'), {
columns, rows: await rows(),
...(await tableViewCfg('transfers', { key: 'occurredAt', dir: 'desc' }, onlyAccount != null ? 'assetId' : null)),
emptyText: t('transfers.none'),
noneLabel: t('table.none'),
collapsibleGroups: true,
groupSummary: items => {
const first = items[0]; if (!first) return '';
if (!items.every(r => r.assetId === first.assetId)) return '';
const tot = items.reduce((s, r) => s + (r.amount || 0), 0);
return val(fmtUnit(tot, meta(first)));
},
newLabel: t('table.new'),
convertLabel: t('convert.title'),
newDefaults: { fromAccountId: '', toAccountId: '', unitFee: '' },
newOptions: [
{ key: 'crypto', label: t('transfers.addCrypto'), defaults: { asset_kind: 'crypto', assetId: firstOf('crypto') } },
{ key: 'metal', label: t('transfers.addMetal'), defaults: { asset_kind: 'metal', assetId: firstOf('metal') } },
{ key: 'fiat', label: t('transfers.addFiat'), defaults: { asset_kind: 'fiat', assetId: firstOf('fiat') } },
].filter(o => o.defaults.assetId !== ''),
saveLabel: t('transfers.save'), cancelLabel: t('transfers.cancel'),
onCreate: async v => { const id = await createEvent(await toPayload(v, null)); tableApi.refresh(await rows(), { locate: id }); },
onSave: async (id, v) => {
const ex = (await listTransfers()).find(x => x.id === id);
await updateEvent(id, await toPayload(v, ex)); reload();
},
onDelete: async id => {
if (!(await dialog.confirm({ message: t('transfers.confirmDelete'), danger: true }))) return;
await deleteEvent(id); reload();
},
});
wireMatchTriggers(el.querySelector('#tbl'), (kind, id) => ({
kind, id, assetId: btc ? btc.id : null,
onChange: async () => { linkMap = await eventLinkMap(); tableApi.refresh(await rows()); },
}));
tableApi.focusRow(takeRowFocus('transfers'));
}
+200
View File
@@ -0,0 +1,200 @@
// Generated, do not edit. sha256:84e73291cc02df3176d4ba8acbc3d3f1b844080d320978a75a2af8c8bde70e30
import { listSelectableAccounts } from '../data/accounts.js';
import { dayNl, localTime, toUtcIso } from '../../shared/time.js';
import { listAssets, listBookableAssets } from '../data/assets.js';
import { tableViewCfg } from '../data/preferences.js';
import { getPriceAt } from '../data/price-history.js';
import { listValuations, valuationDiffs, updateValuation, deleteValuation, createValuation,
acceptValuationGap } from '../data/valuations.js';
import { t } from '../i18n/index.js';
import { assetLabelHtml } from '../ui/asset-label.js';
import * as dialog from '../ui/dialog.js';
import { esc } from '../ui/escape.js';
import { renderTable } from '../ui/table.js';
import { takeRowFocus } from '../ui/row-focus.js';
import { fmtBase, fmtBasePrice, fmtUnit, isBaseAsset, val, toSubunit, fromSubunit } from '../ui/format.js';
import { baseCurrency } from '../app-settings.js';
import { ensurePriceHistoryFor, historicalPointForVia, primarySourceKey } from '../price/providers.js';
import { bookDifference, acceptDifference } from '../actions/valuation-widget.js';
import { TARGETS, targetHash } from '../../shared/config.js';
import { emptyStateHtml } from '../ui/empty-state.js';
import { capacityCfg } from '../ui/lock.js';
import { setPageHint } from '../ui/page-hint.js';
let tableApi = null;
let onlyAccount = null;
let onlyAsset = null;
let onlyKinds = null;
const assetAllowed = a => (onlyAsset == null || a.id === onlyAsset) && (onlyKinds == null || onlyKinds.includes(a.kind));
export async function render(el, { accountId = null, assetId = null, assetKinds = null } = {}) {
onlyAccount = accountId;
onlyAsset = assetId;
onlyKinds = assetKinds;
if ((await listAssets()).length === 0) {
el.innerHTML = `
${emptyStateHtml(t('valuations.none'), {
why: t('valuations.needAsset'), href: targetHash(TARGETS.ASSETS), linkText: t('common.manageAssets'),
})}`;
return;
}
el.innerHTML = `<div id="tbl"></div>`;
if (onlyAccount == null) setPageHint(el, { title: t('nav.valuations'), rows: [t('valuations.rateNote')] });
buildTable(el);
}
async function assetOpts() { return (await listBookableAssets()).filter(assetAllowed).map(a => ({ value: a.id, label: `${a.symbol}: ${a.name}` })); }
async function accountOpts(huidigeId) {
const accounts = (await listSelectableAccounts({ ookId: Number(huidigeId) || null }))
.filter(a => onlyAccount == null || a.id === onlyAccount);
return [...(onlyAccount == null ? [{ value: '', label: '-' }] : []), ...accounts.map(a => ({ value: a.id, label: a.name }))];
}
const meta = r => ({ kind: r.asset_kind, decimals: r.asset_decimals, unit: r.asset_unit, subunit: r.asset_subunit, displayUnit: r.asset_display_unit });
const isRekenmunt = r => isBaseAsset({ symbol: r.asset_symbol });
async function valuationPayload(v, existing) {
const asset = (await listAssets()).find(a => a.id === Number(v.assetId));
const qty = parseFloat(v.balance);
if (!v.valuedOn || !asset || !Number.isFinite(qty) || qty < 0) {
throw new Error(t('valuations.invalid'));
}
const balance = toSubunit(qty, asset);
const rateAt = toUtcIso(`${v.valuedOn}T${v._time || '00:00'}:00`);
let fiatRate = v.fiatRate === '' || v.fiatRate == null ? null : parseFloat(v.fiatRate);
if (fiatRate == null) {
try { await ensurePriceHistoryFor(asset); } catch { }
const readSrc = (await primarySourceKey(asset, 'daily_history'))
|| (await primarySourceKey(asset, 'history_point'));
const at = await getPriceAt(asset.id, Math.floor(Date.parse(rateAt) / 1000), readSrc);
fiatRate = at ? at.fiatPrice : null;
if (fiatRate == null) {
const p = await historicalPointForVia(asset, v.valuedOn).catch(() => null);
fiatRate = p ? p.price : null;
}
if (fiatRate == null) throw new Error(t('valuations.noRate'));
}
const accountId = v.accountId ?? onlyAccount;
return {
assetId: Number(v.assetId), valuedOn: v.valuedOn,
accountId: accountId !== '' && accountId != null ? Number(accountId) : null,
balance, fiatRate, rateAt,
source: existing ? existing.source : 'manual',
};
}
export async function recordBalance(v) {
return createValuation(await valuationPayload({ fiatRate: '', ...v }, null));
}
async function buildTable(el) {
const alleAssets = await listAssets();
const verschillen = new Map();
const rows = async () => {
const rijen = (await listValuations()).filter(r => (onlyAccount == null || r.accountId === onlyAccount)
&& (onlyAsset == null || r.assetId === onlyAsset));
verschillen.clear();
for (const s of await valuationDiffs()) verschillen.set(s.id, s);
return rijen;
};
const today = new Date().toISOString().slice(0, 10);
const columns = [
{ key: 'valuedOn', label: t('valuations.colDate'), sortable: true,
render: r => dayNl(r.valuedOn),
group: { value: r => r.valuedOn.slice(0, 4), label: v => v, order: 'desc' },
edit: { type: 'date', sticky: true, value: r => r ? r.valuedOn : today } },
{ key: '_time', label: t('valuations.colTime'),
render: r => localTime(r.rateAt),
edit: { type: 'time', value: r => r && r.rateAt ? localTime(r.rateAt) : '00:00' } },
{ key: 'accountId', label: t('valuations.colAccount'), sortable: true, sortValue: r => r.account_name || '',
render: r => r.account_name || '-', group: { value: r => r.account_name || '-', label: v => v },
edit: { type: 'select', sticky: true, options: vals => accountOpts(vals.accountId), value: r => r ? r.accountId ?? '' : '' } },
{ key: 'assetId', label: t('valuations.colAsset'), sortable: true, sortValue: r => r.asset_symbol,
render: r => assetLabelHtml(r), group: { value: r => r.asset_symbol, label: v => v },
edit: { type: 'select', sticky: true, reactive: true, options: assetOpts } },
{ key: 'balance', label: t('valuations.colBalance'), align: 'num', sortable: true, sortValue: r => r.balance,
render: r => {
const fiat = (r.fiatRate && !isRekenmunt(r)) ? fmtBase(r.balance / Math.pow(10, r.asset_decimals) * r.fiatRate) : '';
return `${val(fmtUnit(r.balance, meta(r)))}${fiat ? `<div class="t-muted-cell">${val(fiat)}</div>` : ''}`;
},
edit: { type: 'number', attrs: { min: 0, step: 'any' }, value: r => r ? fromSubunit(r.balance, meta(r)) : '', convert: vals => alleAssets.find(a => a.id === Number(vals.assetId)) } },
{ key: 'fiatRate', label: t('valuations.colRate', { munt: baseCurrency() }), align: 'num', sortable: true, sortValue: r => r.fiatRate,
render: r => (r.fiatRate && !isRekenmunt(r)) ? val(fmtBasePrice(r.fiatRate)) : '-',
edit: { type: 'number', attrs: { min: 0, step: 'any' }, placeholder: t('valuations.ratePlaceholder'),
visible: vals => !isBaseAsset(alleAssets.find(a => a.id === Number(vals.assetId))),
value: r => r && r.fiatRate ? r.fiatRate : '' } },
{ key: 'source', label: t('valuations.colSource'),
render: r => r.source ? `<span class="badge badge-neutral">${t('enums.valuation_source.' + r.source)}</span>` : '-' },
{ key: '_diff', label: t('valuations.colDiff'), align: 'num',
render: r => {
const s = verschillen.get(r.id);
const d = s ? s.gap : null;
if (d == null) return '<span class="t-muted-cell">-</span>';
if (s.future) return `<span class="t-muted-cell" title="${t('valuations.futureHint')}">⏳</span>`;
if (!s.reconcilable) return `<span class="t-muted-cell" title="${t('valuations.anchorHint')}">⚓</span>`;
if (d === 0) return `<span class="t-pos">✓</span>`;
if (s.accepted) {
return `<button class="btn btn-ghost btn-xs" data-unaccept="${r.id}"
title="${esc(`${t('valuations.acceptedHint')} ${fmtUnit(d, meta(r))}`)}">${t('valuations.acceptedMark')}</button>`;
}
return `<span class="${d < 0 ? 't-neg' : 't-pos'}">${val(fmtUnit(d, meta(r)))}</span>
<button class="btn btn-secondary btn-xs" data-book="${r.id}">${t('valuations.book')}</button>
<button class="btn btn-ghost btn-xs" data-accept="${r.id}"
title="${esc(t('valuations.acceptHint'))}">${t('valuations.accept')}</button>`;
} },
].filter(c => onlyAccount == null || c.key !== 'accountId');
const toPayload = valuationPayload;
const rijen = await rows();
let used = rijen.length;
const reload = async () => { const r = await rows(); used = r.length; tableApi.refresh(r); };
tableApi = renderTable(el.querySelector('#tbl'), {
columns, rows: rijen,
...capacityCfg('valuations', () => used),
...(await tableViewCfg('valuations', { key: 'valuedOn', dir: 'desc' }, onlyAccount != null ? 'assetId' : null)),
emptyText: t('valuations.none'),
noneLabel: t('table.none'),
collapsibleGroups: true,
newLabel: t('table.new'),
convertLabel: t('convert.title'),
newDefaults: { accountId: onlyAccount ?? '', fiatRate: '', ...(onlyAsset != null ? { assetId: onlyAsset } : {}) },
saveLabel: t('trades.save'), cancelLabel: t('trades.cancel'),
onCreate: async v => {
const id = await createValuation(await toPayload(v, null));
const r = await rows();
used = r.length;
tableApi.refresh(r, { locate: id });
},
onSave: async (id, v) => {
const ex = (await listValuations()).find(x => x.id === id);
await updateValuation(id, await toPayload(v, ex)); reload();
},
onDelete: async id => {
if (!(await dialog.confirm({ message: t('valuations.confirmDelete'), danger: true }))) return;
await deleteValuation(id); reload();
},
});
el.querySelector('#tbl').addEventListener('click', async e => {
const b = e.target.closest('[data-book]');
if (b) {
e.preventDefault(); e.stopPropagation();
bookDifference((await rows()).find(x => x.id === Number(b.dataset.book)), reload);
return;
}
const a = e.target.closest('[data-accept]');
if (a) {
e.preventDefault(); e.stopPropagation();
acceptDifference(a.dataset.accept, reload);
return;
}
const u = e.target.closest('[data-unaccept]');
if (u) {
e.preventDefault(); e.stopPropagation();
await acceptValuationGap(Number(u.dataset.unaccept), false);
reload();
}
}, true);
tableApi.focusRow(takeRowFocus('valuations'));
}
File diff suppressed because it is too large. Load diff