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

+241
View File
@@ -0,0 +1,241 @@
// Generated, do not edit. sha256:7498a98f290e59aca597eb4a57338d467525bf6c2933429d69f5e8474930a0ac
import { aiProposalsStatus, acceptAiProposal, acceptAiProposals, rejectAiProposal, rejectAiProposals }
from '../data/ai-proposals.js';
import { CONFIG, PREFERENCES } from '../../shared/config.js';
import { appSetting, timerSetting, loadAppSettings } from '../app-settings.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 { fmtBase, fmtBasePrice, fmtUnit, priceFromBase, val, currencyName } from '../ui/format.js';
import { taskCardHtml, nothingPendingHtml } from '../ui/task-card.js';
import { priceUnitLabel } from '../../shared/units.js';
import { localDateNl } from '../../shared/time.js';
const ONOMKEERBAAR = new Set(CONFIG.AI.DESTRUCTIVE_PROPOSAL_KINDS);
const assetVan = p => ({ asset_name: p.asset_name, asset_symbol: p.asset_symbol });
function detailTekst(detail) {
if (typeof detail !== 'string' || !detail.startsWith('{')) return detail;
try {
const o = JSON.parse(detail);
return o && o.key ? t(o.key, o.params) : detail;
} catch { return detail; }
}
const meta = p => ({
kind: p.asset_kind, decimals: p.asset_decimals, unit: p.asset_unit,
subunit: p.asset_subunit, displayUnit: p.asset_display_unit,
});
const eenheidIsSymbool = p => {
const asset = meta(p);
const getoond = asset.displayUnit === 'subunit' && asset.subunit ? asset.subunit : asset.unit;
return String(getoond || '').toUpperCase() === String(p.asset_symbol || '').toUpperCase();
};
const koers = p => {
const asset = meta(p);
return `${fmtBasePrice(priceFromBase(p.fiatPrice, asset))} ${t('aiProposals.perUnit')} ${priceUnitLabel(asset)}`;
};
const leesbareWaarde = (p, json) => {
try { return toon(p, JSON.parse(json)); }
catch { return String(json); }
};
const toon = (p, v) => {
const spec = PREFERENCES[p.scope]?.[p.key];
if (spec?.type === 'bool') return t(Number(v) ? 'aiProposals.preferenceOn' : 'aiProposals.preferenceOff');
return String(v);
};
const huidigeVoorkeur = (p) => {
try {
const v = p.scope === 'timers' ? timerSetting(p.key) : appSetting(p.key);
return v == null ? null : toon(p, v);
} catch { return null; }
};
const OMSCHRIJVING = {
price_point: p =>
`${t('aiProposals.kindPricePoint')}: ${assetLabelHtml(assetVan(p))} · ${val(koers(p))} `
+ `<span class="t-muted-cell">${esc(localDateNl(p.observedAt))}</span>`,
account: p =>
`${t('aiProposals.kindAccount')}: <strong>${esc(p.name)}</strong> `
+ `<span class="t-muted-cell">${esc(t('enums.account_kind.' + p.accountKind))} · `
+ `${esc(p.isOwnCustody ? t('aiProposals.ownCustody') : t('aiProposals.thirdParty'))}</span>`,
account_delete: p =>
`${t('aiProposals.kindAccountDelete')}: <strong>${esc(p.account_name)}</strong> `
+ `<span class="t-muted-cell">${esc(t('enums.account_kind.' + p.account_kind))}</span>`,
portfolio: p =>
`${t('aiProposals.kindPortfolio')}: <strong>${esc(p.name)}</strong> `
+ `<span class="t-muted-cell">${esc(t('aiProposals.portfolioIn'))} ${esc(currencyName(p.currency))} (${esc(p.currency)})</span><br>`
+ `<span class="t-muted-cell">${esc(t('aiProposals.portfolioNote'))}</span>`,
preference: p => {
const nu = huidigeVoorkeur(p);
return `${t('aiProposals.kindPreference')}: <strong>${esc(p.scope)}.${esc(p.key)}</strong> `
+ `→ ${val(leesbareWaarde(p, p.value))}`
+ (nu === null ? '' : ` <span class="t-muted-cell">(${esc(t('aiProposals.preferenceCurrent'))} ${esc(nu)})</span>`)
+ `<br><span class="t-muted-cell">${esc(t('aiProposals.preferenceNote'))}</span>`;
},
source_choice: p =>
`${t('aiProposals.kindSourceChoice')}: ${assetLabelHtml(assetVan(p))} · `
+ `${esc(t('integrations.sourceCap.' + p.scope))} · <strong>${esc(p.provider_name || p.key)}</strong> `
+ `${esc(t(p.value === 'first' ? 'aiProposals.sourceFirst' : 'aiProposals.sourceRemove'))}<br>`
+ `<span class="t-muted-cell">${esc(t('aiProposals.sourceChoiceNote'))}</span>`,
valuation: p => {
const asset = meta(p);
const grootboek = p.ledger_balance == null ? null : Number(p.ledger_balance);
const verschil = grootboek == null ? null : Number(p.balance) - grootboek;
const naast = grootboek == null ? '' :
` · ${t('aiProposals.ledger')} ${val(fmtUnit(grootboek, asset))}`
+ (verschil === 0 ? '' : ` · ${t('aiProposals.diff')} ${verschil > 0 ? '+' : '−'}${val(fmtUnit(Math.abs(verschil), asset))}`);
return `${t('aiProposals.kindValuation')}: ${val(fmtUnit(p.balance, asset))} `
+ `${assetLabelHtml(assetVan(p), { withoutSymbol: eenheidIsSymbool(p) })} `
+ `· <strong>${esc(p.account_name || '')}</strong>${naast}<br>`
+ `<span class="t-muted-cell">${esc(localDateNl(p.observedAt))}</span>`;
},
event: p => {
const waar = [p.from_name, p.to_name].filter(Boolean).map(esc).join(' → ');
const prijs = p.fiatPrice == null ? '' : ` · ${val(koers(p))}`;
const bedragen = [
p.fiatFee == null ? null : val(fmtBase(p.fiatFee)),
p.unitFee == null ? null : val(fmtUnit(p.unitFee, meta(p))),
].filter(Boolean);
const kosten = bedragen.length ? ` · ${t('aiProposals.fee')} ${bedragen.join(' + ')}` : '';
return `${esc(t('enums.event_type.' + p.eventType))}: ${val(fmtUnit(p.amount, meta(p)))} `
+ `${assetLabelHtml(assetVan(p), { withoutSymbol: eenheidIsSymbool(p) })}${prijs}${kosten}<br>`
+ `<span class="t-muted-cell">${waar} · ${esc(localDateNl(p.observedAt))}</span>`;
},
};
function regel(p) {
const maak = OMSCHRIJVING[p.kind];
const tekst = maak ? maak(p) : esc(t('aiProposals.unknownKind') + ` (${p.kind})`);
const notitie = p.note ? `<div class="t-body-sm t-muted">${esc(p.note)}</div>` : '';
const mislukt = p.detail ? `<div class="t-body-sm t-neg">${esc(t('aiProposals.failed'))}: ${esc(detailTekst(p.detail))}</div>` : '';
const weg = ONOMKEERBAAR.has(p.kind);
const waarschuwing = weg
? `<div class="t-body-sm t-neg">${esc(t('aiProposals.irreversible'))}</div>` : '';
const opening = p.kind === 'valuation' && p.events_before === 0
? `<div class="t-body-sm t-neg">${esc(t('aiProposals.openingBalance'))}</div>` : '';
return `<div class="rec-item" data-aiprop="${p.id}" data-aidanger="${weg ? 1 : 0}" data-aikind="${esc(p.kind)}">
<div class="rec-item-row">
<div class="rec-item-meta">${tekst}${notitie}${waarschuwing}${opening}${mislukt}</div>
<div class="row-actions rec-hover">
<button class="btn ${weg ? 'btn-danger' : 'btn-primary'} btn-xs" data-aiact="accept">${weg ? t('aiProposals.acceptDelete') : t('aiProposals.accept')}</button>
<button class="btn btn-ghost btn-xs" data-aiact="reject">${t('aiProposals.reject')}</button>
</div>
</div>
</div>`;
}
function redenTekst(u) {
if (!u.key) return u.reden;
try { return t(u.key, u.params); } catch { return u.reden; }
}
async function meldMislukt(uitkomsten) {
const stuk = uitkomsten.filter(u => !u.ok);
if (!stuk.length) return;
await dialog.alert({
title: t('aiProposals.partialTitle'),
message: `${t('aiProposals.partialBody')}\n\n${stuk.map(u => `- ${redenTekst(u)}`).join('\n')}`,
});
}
let live = null;
let liveGebonden = false;
function bindLive() {
if (liveGebonden) return;
liveGebonden = true;
document.addEventListener('ai-proposals-changed', async () => {
if (!live || !live.host || !live.host.isConnected) { live = null; return; }
try { await renderAiProposals(live.host, live.refresh); } catch { }
});
}
export async function renderAiProposals(host, refresh) {
live = { host, refresh };
bindLive();
const { enabled, pending } = await aiProposalsStatus();
if (!enabled) { host.innerHTML = ''; return null; }
if (!pending.length) {
host.innerHTML = taskCardHtml({ title: t('aiProposals.title'), body: nothingPendingHtml() });
return 0;
}
const teVerwijderen = pending.filter(p => ONOMKEERBAAR.has(p.kind)).length;
const bulkUitleg = teVerwijderen
? `<div class="t-body-sm t-neg" style="margin-bottom:0.6rem">${esc(t('aiProposals.bulkSkips').replace('{n}', teVerwijderen))}</div>`
: '';
host.innerHTML = taskCardHtml({
title: t('aiProposals.title'),
actions: `<div class="row-actions">
<button class="btn btn-secondary btn-xs" id="aiprop-accept-all">${t('aiProposals.acceptAll')}</button>
<button class="btn btn-ghost btn-xs" id="aiprop-reject-all">${t('aiProposals.rejectAll')}</button>
</div>`,
body: `<div class="t-body-sm t-muted" style="margin-bottom:0.6rem">${t('aiProposals.intro')}</div>
${bulkUitleg}
${pending.map(regel).join('')}`,
});
const ids = pending.filter(p => !ONOMKEERBAAR.has(p.kind)).map(p => p.id);
const alleIds = pending.map(p => p.id);
host.querySelector('#aiprop-accept-all').onclick = async () => {
if (!ids.length) {
await dialog.alert({ title: t('aiProposals.title'), message: t('aiProposals.bulkNothing') });
return;
}
if (!(await dialog.confirm({ message: t('aiProposals.acceptAllConfirm').replace('{n}', ids.length) }))) return;
await meldMislukt(await acceptAiProposals(ids));
if (pending.some(p => ids.includes(p.id) && p.kind === 'preference')) {
await loadAppSettings();
location.reload();
return;
}
refresh();
};
host.querySelector('#aiprop-reject-all').onclick = async () => {
if (!(await dialog.confirm({ message: t('aiProposals.rejectAllConfirm').replace('{n}', alleIds.length), danger: true }))) return;
await meldMislukt(await rejectAiProposals(alleIds, t('aiProposals.rejectedByUser')));
refresh();
};
host.querySelectorAll('[data-aiprop]').forEach(rij => {
const id = Number(rij.dataset.aiprop);
rij.querySelectorAll('[data-aiact]').forEach(knop => knop.onclick = async () => {
try {
if (knop.dataset.aiact === 'accept') {
if (rij.dataset.aidanger === '1'
&& !(await dialog.confirm({ message: t('aiProposals.acceptDeleteConfirm'), danger: true }))) return;
await acceptAiProposal(id);
if (rij.dataset.aikind === 'preference') {
await loadAppSettings();
location.reload();
return;
}
} else await rejectAiProposal(id, t('aiProposals.rejectedByUser'));
} catch (e) {
await dialog.alert({ title: t('dialog.error'), message: e.message });
}
refresh();
});
});
return pending.length;
}
+11
View File
@@ -0,0 +1,11 @@
// Generated, do not edit. sha256:030fb9043ec0bb393242bec50f13de9146004cd70907c5230c0bfc59d06b39b7
const empty = v => v == null || String(v).trim() === '';
export function compoundsViaBalance(s) {
return s.rateKind === 'fixed' && empty(s.principalAmount)
&& !empty(s.principalAssetId) && Number(s.principalAssetId) === Number(s.payoutAssetId);
}
export function looksLikeDepositWithoutPrincipal(s) {
return compoundsViaBalance(s) && !empty(s.endsOn);
}
+20
View File
@@ -0,0 +1,20 @@
// Generated, do not edit. sha256:dee49c9ff6e746770628ebfbeb55d9732c23407998d26306fb4c07dd8cc4e1ec
export const dueKey = d => `${d.scheduleId}|${d.periodEnd}`;
export function byPeriod(due) {
return [...due].sort((a, b) =>
a.periodEnd.localeCompare(b.periodEnd)
|| a.periodStart.localeCompare(b.periodStart)
|| a.scheduleId - b.scheduleId);
}
export function bookableDues(due) {
const oudstePerAccount = new Map();
for (const d of due) {
const huidig = oudstePerAccount.get(d.accountId);
if (huidig == null || d.periodEnd < huidig) oudstePerAccount.set(d.accountId, d.periodEnd);
}
return new Set(due
.filter(d => d.periodEnd === oudstePerAccount.get(d.accountId))
.map(dueKey));
}
+110
View File
@@ -0,0 +1,110 @@
// Generated, do not edit. sha256:6eef7930e5d81c104d37fffaf0d8f5c9109485ed27fb20ffaaecdf946a306931
import { scheduleForecast, dueCreditsForAccount, dueCreditsAll, bookDueCredit, dismissDueCredit } from '../data/interest-credits.js';
import { byPeriod, bookableDues, dueKey } from './interest-order.js';
import { listSchedulesForAccount } from '../data/interest-schedules.js';
import { t } from '../i18n/index.js';
import * as dialog from '../ui/dialog.js';
import { fmtUnit, val, pct } from '../ui/format.js';
import { taskCardHtml, nothingPendingHtml } from '../ui/task-card.js';
import { esc } from '../ui/escape.js';
import { CHEVRON } from '../ui/icons.js';
import { expanders } from '../ui/expand.js';
const dueMeta = d => ({ decimals: d.payout_decimals, unit: d.payout_unit, subunit: d.payout_subunit, displayUnit: d.payout_display_unit });
const dueKnoppen = (i, kan) =>
`<button class="btn btn-primary btn-sm" data-book="${i}"${kan ? '' : ` disabled title="${esc(t('accounts.bookOldestFirst'))}"`}>${t('accounts.book')}</button>`
+ `<button class="btn btn-ghost btn-sm" data-dismiss="${i}">${t('accounts.dismiss')}</button>`;
const dueUitleg = (due, boekbaar) => due.some(d => !boekbaar.has(dueKey(d)))
? `<div class="t-body-sm t-muted mt-sm">${t('accounts.bookOldestFirst')}</div>` : '';
function wireDue(scopeEl, due, refresh) {
scopeEl.querySelectorAll('[data-book]').forEach(b => b.onclick = async () => {
const d = due[Number(b.dataset.book)];
const amt = `${fmtUnit(d.projected_sub, dueMeta(d))}`;
if (!(await dialog.confirm({ message: `${t('accounts.confirmBook')} ${amt} (${d.periodStart} → ${d.periodEnd})?` }))) return;
try { await bookDueCredit(d, d.projected_sub); refresh(); }
catch (e) { dialog.alert({ title: t('dialog.error'), message: e.message }); }
});
scopeEl.querySelectorAll('[data-dismiss]').forEach(b => b.onclick = async () => {
const d = due[Number(b.dataset.dismiss)];
try { await dismissDueCredit(d); refresh(); }
catch (e) { dialog.alert({ title: t('dialog.error'), message: e.message }); }
});
}
const fig = (label, value, sub = '') => `<div class="ip-fig"><div class="t-label">${label}</div>`
+ `<div class="t-value ip-fig-value">${value}</div>${sub ? `<div class="t-body-sm t-muted">${sub}</div>` : ''}</div>`;
export async function renderScheduleStatus(statusHost, accountId, refresh) {
if (!statusHost) return false;
const today = new Date().toISOString().slice(0, 10);
const scheds = (await listSchedulesForAccount(accountId)).filter(s => s.rateKind === 'fixed' && (!s.endsOn || s.endsOn >= today));
const due = byPeriod(await dueCreditsForAccount(accountId));
const boekbaar = bookableDues(due);
const prognoses = await Promise.all(scheds.map(s => scheduleForecast(s)));
const expand = await expanders('interest', { upcoming: false });
const projHtml = scheds.map((s, i) => {
const f = prognoses[i];
const m = { decimals: s.payout_decimals, unit: s.payout_unit, subunit: s.payout_subunit, displayUnit: s.payout_display_unit };
const pm = { decimals: s.principal_decimals, unit: s.principal_unit, subunit: s.principal_subunit, displayUnit: s.principal_display_unit };
const asset = s.principal_symbol === s.payout_symbol ? esc(s.payout_symbol) : `${esc(s.principal_symbol)} → ${esc(s.payout_symbol)}`;
const naam = s.note ? esc(s.note) : asset;
const termen = [
...(s.note ? [asset] : []),
pct(s.annualRate),
s.principalAmount != null ? t('accounts.projOver', { bedrag: val(fmtUnit(s.principalAmount, pm)) }) : t('interest.principalFollowsBalance'),
t('enums.interest_cadence.' + s.creditCadence),
s.endsOn ? t('accounts.projUntil', { datum: esc(s.endsOn) }) : t('interest.endsOnOpen'),
].join(' · ');
const volgende = f.upcoming[0];
const cijfers = [
fig(t('accounts.projReceived'), val(fmtUnit(f.realizedSub, m))),
...(f.termTotalSub != null ? [
fig(t('accounts.projExpectRemain'), val(fmtUnit(f.remainingSub, m)), t('accounts.projCount', { n: f.remainingCount })),
fig(t('accounts.projTermTotal'), val(fmtUnit(f.termTotalSub, m))),
] : []),
...(f.termTotalSub != null && f.sameAsset ? [
fig(t('accounts.projEndBalance', { datum: esc(s.endsOn) }), val(fmtUnit(s.principalAmount + f.termTotalSub, m)), t('accounts.projEndBalanceSub')),
] : []),
...(volgende ? [fig(t('accounts.projNext'), val(fmtUnit(volgende.projectedSub, m)), esc(volgende.periodEnd))] : []),
].join('');
const komend = f.upcoming.length > 1
? `<details class="ip-upcoming" ${expand.attr('upcoming')}><summary class="t-body-sm">${t('accounts.projAllUpcoming', { n: f.upcoming.length })}`
+ `<span class="chev-btn" aria-hidden="true">${CHEVRON}</span></summary>`
+ `<div class="ip-upcoming-list">${f.upcoming.map(p => `<div class="ip-up"><span class="t-muted">${esc(p.periodEnd)}</span>`
+ `<span class="ip-num">${val(fmtUnit(p.projectedSub, m))}</span></div>`).join('')}</div></details>`
: '';
return `<div class="ip-sched"><div class="ip-sched-head"><span class="ip-sched-name">${naam}</span>`
+ `<span class="t-body-sm t-muted">${termen}</span></div><div class="ip-figs">${cijfers}</div>${komend}</div>`;
}).join('');
const dueHtml = due.length ? `<div class="ip-due"><div class="t-label">${t('accounts.dueTitle')}</div>`
+ due.map((d, i) => `<div class="ip-due-row">`
+ `<span class="t-body-sm">${d.periodStart} → ${d.periodEnd}</span><span class="t-body-sm ip-num">${val(fmtUnit(d.projected_sub, dueMeta(d)))}</span>`
+ `<span class="ip-due-btns">${dueKnoppen(i, boekbaar.has(dueKey(d)))}</span></div>`).join('')
+ dueUitleg(due, boekbaar) + '</div>' : '';
statusHost.innerHTML = projHtml + dueHtml;
expand.bind(statusHost);
statusHost.querySelectorAll('.ip-upcoming').forEach(d => d.addEventListener('toggle', () => {
document.querySelectorAll('.ip-upcoming').forEach(o => { if (o.open !== d.open) o.open = d.open; });
}));
wireDue(statusHost, due, refresh);
return Boolean(projHtml || dueHtml);
}
export async function renderInterestWidget(host, refresh) {
const due = byPeriod(await dueCreditsAll());
const boekbaar = bookableDues(due);
const body = due.length === 0
? nothingPendingHtml()
: due.map((d, i) => `<div class="flex gap-md" style="align-items:center;flex-wrap:wrap;margin-top:0.4rem">`
+ `<span class="t-body-sm">${esc(d.account_name)} · ${d.periodStart} → ${d.periodEnd}: ${val(fmtUnit(d.projected_sub, dueMeta(d)))}</span>`
+ `${dueKnoppen(i, boekbaar.has(dueKey(d)))}</div>`).join('') + dueUitleg(due, boekbaar);
host.innerHTML = taskCardHtml({ title: t('actions.interestTitle'), body });
wireDue(host, due, refresh);
return due.length;
}
+62
View File
@@ -0,0 +1,62 @@
// Generated, do not edit. sha256:ce839cfe04c94b47ff548f20a8992356bcbc456e5e878e1829f94a38a663c5f1
import { currentNotifications, notificationDetail, dismissNotification } from '../data/notifications.js';
import { localDateNl } from '../../shared/time.js';
import { t } from '../i18n/index.js';
import { TARGETS, NOTIFICATION_TARGETS, NOTIFICATION_PRIORITY, targetHash } from '../../shared/config.js';
import { CHECK, CHEVRON_RIGHT } from '../ui/icons.js';
import { esc } from '../ui/escape.js';
import { taskCardHtml, nothingPendingHtml } from '../ui/task-card.js';
const targetFor = kind => targetHash(NOTIFICATION_TARGETS[kind] || TARGETS.ACTIONS);
const rank = kind => NOTIFICATION_PRIORITY.indexOf(kind);
async function detailFor(n) {
if (n.kind === 'schedule_ended') {
const d = await notificationDetail(n);
if (!d) return '';
return `${d.account} · ${d.asset}${d.extra ? ` +${d.extra}` : ''}`;
}
return t('notify.hint.' + n.kind);
}
async function rowHtml(n) {
const target = targetFor(n.kind);
const detail = await detailFor(n);
const date = localDateNl(n.createdAt);
const info = n.kind === 'schedule_ended' ? ' is-info' : '';
return `<div class="notify-row${info}" data-target="${target}" data-kind="${n.kind}" tabindex="0">
<button type="button" class="notify-count" data-dismiss="${n.id}" title="${t('notify.dismiss')}" aria-label="${t('notify.dismiss')}">`
+ `<span class="notify-n">${n.count}</span><span class="notify-check" aria-hidden="true">${CHECK}</span></button>
<div class="notify-kind">${t('notify.' + n.kind)}</div>
${detail ? `<div class="notify-detail" title="${esc(detail)}">${esc(detail)}</div>` : ''}
<div class="notify-side">
<span class="notify-date">${date || ''}</span>
<span class="notify-go" aria-hidden="true">${CHEVRON_RIGHT}</span>
</div>
</div>`;
}
export async function renderNotifications(host, onChanged) {
const rerender = () => renderNotifications(host, onChanged);
const items = await currentNotifications();
const sorted = [...items].sort((a, b) => rank(a.kind) - rank(b.kind));
const body = sorted.length === 0
? nothingPendingHtml()
: `<div class="notify-list">${(await Promise.all(sorted.map(rowHtml))).join('')}</div>`;
host.innerHTML = taskCardHtml({ title: t('notify.title'), body });
host.querySelectorAll('[data-dismiss]').forEach(b => b.onclick = async e => {
e.stopPropagation();
await dismissNotification(Number(b.dataset.dismiss));
(onChanged || rerender)();
});
host.querySelectorAll('.notify-row').forEach(row => {
const go = () => { location.hash = row.dataset.target; };
row.onclick = e => { if (e.target.closest('[data-dismiss]') || e.target.closest('a')) return; go(); };
row.onkeydown = e => { if (e.key === 'Enter' && e.target === row) go(); };
});
}
+21
View File
@@ -0,0 +1,21 @@
// Generated, do not edit. sha256:7e67d01e5ccf6ce7a103e0d0647ba030da33d566237ec5c657ed27cb9e070f30
export const isForward = p => p.tx_layer === 'lightning' && typeof p.transactionHash === 'string'
&& p.transactionHash.startsWith('fwd:');
export function bulkPlan(pending) {
return (pending || []).map(p => {
if (p.tx_kind) return { id: p.id, action: 'internal', proposal: p };
if (p.tx_layer !== 'lightning') return { id: p.id, action: 'skip', reason: 'onchain', proposal: p };
if (isForward(p)) return { id: p.id, action: 'interest', proposal: p };
return { id: p.id, action: 'trade', proposal: p };
});
}
export function bulkCounts(plan) {
const n = { internal: 0, interest: 0, trade: 0, onchain: 0 };
for (const s of plan || []) {
if (s.action === 'skip') n[s.reason]++;
else n[s.action]++;
}
return n;
}
+363
View File
@@ -0,0 +1,363 @@
// Generated, do not edit. sha256:405d455ffaf6423b7f59a7abc882b3d6e788cead63eb4b179c23c015dd404720
import { listSelectableAccounts } from '../data/accounts.js';
import { dayNl, localDate, localDateTimeNl } from '../../shared/time.js';
import {
listPendingProposals, resolveProposalAsTrade, resolveProposalAsTransfer, resolveProposalAsInternal,
resolveProposalAsInterest, ignoreProposal,
deleteProposal, ignoreAllPendingProposals, deleteAllPendingProposals, revertProposal,
listHandledProposals
} from '../data/proposals.js';
import { syncAddressLinks } from '../data/tx-links.js';
import { listAllWalletSources } from '../data/wallet-sources.js';
import { t } from '../i18n/index.js';
import { assetLabelHtml } from '../ui/asset-label.js';
import * as dialog from '../ui/dialog.js';
import { fmtInt, val } from '../ui/format.js';
import { baseCurrency } from '../app-settings.js';
import { esc } from '../ui/escape.js';
import { taskCardHtml, nothingPendingHtml } from '../ui/task-card.js';
import { isOnchainSource, scanSource } from '../onchain.js';
import { openMatchPopover } from '../ui/match-popover.js';
import { onchainAsset } from '../data/tx-links.js';
import { spotOnDate, spotAtMoment } from '../ui/fiat.js';
import { priceUnitLabel, totalFromPrice, priceFromTotal, priceToBase, priceFromBase } from '../../shared/units.js';
import { expanders } from '../ui/expand.js';
import { bulkPlan, bulkCounts, isForward } from './reconcile-bulk.js';
import { memoLine } from '../lightning/memo-line.js';
let wizIdx = 0;
export async function renderReconcile(host, onChanged) {
const rerender = () => renderReconcile(host, onChanged);
const refresh = () => (onChanged ? onChanged() : rerender());
await syncAddressLinks();
const pending = await listPendingProposals();
const handled = await listHandledProposals();
const expand = await expanders('reconcile', { drawer: false });
if (wizIdx >= pending.length) wizIdx = Math.max(0, pending.length - 1);
const current = pending[wizIdx] || null;
const actions = `<button class="btn btn-primary btn-sm" id="rec-rematch">${t('home.rematch')}</button>`;
const wizard = pending.length === 0
? nothingPendingHtml()
: `<div class="rec-wiz-nav">
<button class="btn btn-ghost btn-xs" id="rec-prev" ${wizIdx === 0 ? 'disabled' : ''}>‹ ${t('home.recPrev')}</button>
<span class="t-body-sm t-muted">${wizIdx + 1} / ${pending.length}</span>
<button class="btn btn-ghost btn-xs" id="rec-next" ${wizIdx >= pending.length - 1 ? 'disabled' : ''}>${t('home.recNext')} ›</button>
</div>
${recRow(current, true, current.id)}`;
host.innerHTML = taskCardHtml({
title: t('home.reconcile'),
actions,
body: `<div id="rec-progress" class="t-body-sm t-muted" style="margin-bottom:0.6rem"></div>
${wizard}
${drawerSection(pending, handled, current, expand)}`,
});
const prev = host.querySelector('#rec-prev');
if (prev) prev.onclick = () => { wizIdx = Math.max(0, wizIdx - 1); rerender(); };
const next = host.querySelector('#rec-next');
if (next) next.onclick = () => { wizIdx = Math.min(pending.length - 1, wizIdx + 1); rerender(); };
expand.bind(host);
const igAll = host.querySelector('#rec-ignore-all');
if (igAll) igAll.onclick = async () => {
if (!(await dialog.confirm({ message: t('home.recIgnoreAllConfirm') }))) return;
await ignoreAllPendingProposals(); refresh();
};
const bookAll = host.querySelector('#rec-book-all');
if (bookAll) bookAll.onclick = () => bookAllLightning(host, pending, refresh);
const delAll = host.querySelector('#rec-delete-all');
if (delAll) delAll.onclick = async () => {
if (!(await dialog.confirm({ message: t('home.recDeleteAllConfirm'), danger: true }))) return;
await deleteAllPendingProposals(); refresh();
};
host.querySelectorAll('[data-prop]').forEach(row => {
const id = Number(row.dataset.prop);
const form = row.querySelector('.prop-form');
row.querySelectorAll('[data-act]').forEach(btn => btn.onclick = async () => {
const act = btn.dataset.act;
if (act === 'ignore') { await ignoreProposal(id); refresh(); return; }
if (act === 'internal' || act === 'interest') {
try { await (act === 'internal' ? resolveProposalAsInternal(id) : resolveProposalAsInterest(id)); refresh(); }
catch (e) { dialog.alert({ title: t('dialog.error'), message: e.message }); }
return;
}
if (act === 'link') {
openMatchPopover(btn, { kind: 'tx', id: Number(row.dataset.wtx), assetId: Number(row.dataset.asset), onChange: refresh });
return;
}
if (act === 'trade') { showTradeForm(refresh, pending.find(x => x.id === id), form); return; }
await showTransferForm(refresh, id, form);
});
});
host.querySelectorAll('[data-del-prop]').forEach(btn => btn.onclick = async () => {
if (!(await dialog.confirm({ message: t('home.recDeleteConfirm'), danger: true }))) return;
await deleteProposal(Number(btn.dataset.delProp)); refresh();
});
host.querySelectorAll('[data-revert]').forEach(btn => btn.onclick = async () => {
if (!(await dialog.confirm({ message: t('home.recRevertConfirm') }))) return;
try { await revertProposal(Number(btn.dataset.revert)); refresh(); }
catch (e) { dialog.alert({ title: t('dialog.error'), message: e.message }); }
});
const rematch = host.querySelector('#rec-rematch');
if (rematch) rematch.onclick = () => runUpdate(host, refresh);
return pending.length;
}
function shortTx(h) { return h ? `${h.slice(0, 8)}…${h.slice(-4)}` : ''; }
function momentCel(p) {
const bevestigd = !!p.tx_block_time;
const wanneer = bevestigd ? p.tx_block_time : p.detectedAt;
const uitleg = bevestigd ? t('home.recTxAt') : t('home.recDetectedAt');
return `<span class="t-muted-cell" title="${esc(uitleg)}">(${esc(localDateTimeNl(wanneer))})</span>`;
}
function recRow(p, wiz, currentId) {
const dir = p.direction === 'in' ? t('home.recIn') : t('home.recOut');
const wtxAttrs = p.walletTransactionId ? ` data-wtx="${p.walletTransactionId}" data-asset="${p.assetId}"` : '';
const linkBtn = p.walletTransactionId ? `<button class="btn btn-secondary btn-xs" data-act="link">${t('home.recLink')}</button>` : '';
const cls = wiz ? 'rec-item rec-wiz-item' : `rec-item${p.id === currentId ? ' is-current' : ''}`;
const laag = p.tx_layer === 'lightning'
? ` · ${t('home.recLightning')}${p.via_wallet != null
? ` ${t('home.recVia', { wallet: p.via_wallet || t('home.recViaNwc') })}` : ''}`
: '';
const intern = p.tx_kind ? ` · ${t(`home.recInternal_${p.tx_kind}`)}` : '';
const internLabel = p.tx_node_account
? t('home.recAsChannelTransfer', { account: p.tx_node_account }) : t('home.recAsInternal');
const internBtn = p.tx_kind
? `<button class="btn btn-primary btn-xs" data-act="internal" title="${esc(t('home.recInternalHint'))}">${esc(internLabel)}</button>` : '';
const forward = isForward(p);
const renteBtn = forward
? `<button class="btn btn-primary btn-xs" data-act="interest" title="${esc(t('home.recInterestHint'))}">${t('home.recAsInterest')}</button>` : '';
const eerste = p.tx_kind || forward;
const memo = memoLine(p.tx_memo, p.direction, 120);
const memoHtml = memo ? `<div class="t-body-sm t-muted" title="${esc(memo.title)}">${esc(memo.text)}</div>` : '';
return `<div class="${cls}" data-prop="${p.id}"${wtxAttrs}>
<div class="rec-item-row">
<div class="rec-item-meta"><strong>${val(`${fmtInt(p.amount)} sat`)}</strong> ${assetLabelHtml(p)} ${dir}${esc(laag)}${esc(intern)} · ${esc(p.account_name)}
${momentCel(p)}${p.transactionHash ? ` <span class="t-muted-cell" title="${esc(p.transactionHash)}">· ${esc(shortTx(p.transactionHash))}</span>` : ''}${memoHtml}</div>
<div class="row-actions rec-hover">
${linkBtn}
${internBtn}${renteBtn}
<button class="btn ${eerste ? 'btn-secondary' : 'btn-primary'} btn-xs" data-act="trade">${t('home.recAsTrade')}</button>
<button class="btn btn-secondary btn-xs" data-act="transfer">${t('home.recAsTransfer')}</button>
<button class="btn btn-ghost btn-xs" data-act="ignore">${t('home.recIgnore')}</button>
<button class="btn btn-ghost btn-xs" data-del-prop="${p.id}" title="${t('home.recDelete')}">✕</button>
</div>
</div>
<div class="prop-form mt-sm hidden"></div>
</div>`;
}
function drawerSection(pending, handled, current, expand) {
const total = pending.length + handled.length;
const teBoeken = bulkPlan(pending).filter(s => s.action !== 'skip').length;
const bulk = `<div class="rec-bulk">
${teBoeken ? `<button class="btn btn-primary btn-xs" id="rec-book-all">${t('home.recBookAll', { n: teBoeken })}</button>` : ''}
<button class="btn btn-secondary btn-xs" id="rec-ignore-all" ${pending.length ? '' : 'disabled'}>${t('home.recIgnoreAll')}</button>
<button class="btn btn-danger btn-xs" id="rec-delete-all" ${pending.length ? '' : 'disabled'}>${t('home.recDeleteAll')}</button>
</div>`;
const rows = total === 0
? `<div class="t-body-sm t-muted" style="padding:0.5rem 0">${t('home.recHandledNone')}</div>`
: pending.map(p => recRow(p, false, current ? current.id : 0)).join('') + handled.map(handledRow).join('');
return `<details class="mt-lg" ${expand.attr('drawer')}>
<summary class="t-label" style="cursor:pointer">${t('home.recAll')} (${total})</summary>
${bulk}
<div class="mt-sm">${rows}</div>
</details>`;
}
const STATUS_BADGE = {
accepted_trade: 'badge-pos',
accepted_transfer: 'badge-pos',
accepted_interest: 'badge-pos',
ignored: 'badge-neutral',
auto_linked: 'badge-outline',
};
const REVERTIBLE = new Set(['accepted_trade', 'accepted_transfer', 'accepted_interest', 'ignored']);
function handledRow(p) {
const dir = p.direction === 'in' ? t('home.recIn') : t('home.recOut');
const badge = `<span class="badge ${STATUS_BADGE[p.status] || 'badge-neutral'}">${t('enums.reconcile_status.' + p.status)}</span>`;
const revert = REVERTIBLE.has(p.status)
? `<button class="btn btn-ghost btn-xs" data-revert="${p.id}">${t('home.recRevert')}</button>` : '';
const del = p.status !== 'auto_linked'
? `<button class="btn btn-ghost btn-xs" data-del-prop="${p.id}" title="${t('home.recDelete')}">✕</button>` : '';
return `<div class="rec-item rec-handled">
<div class="rec-item-row">
<div class="rec-item-meta"><strong>${val(`${fmtInt(p.amount)} sat`)}</strong> ${assetLabelHtml(p)} ${dir} · ${esc(p.account_name)}
${momentCel(p)}${p.transactionHash ? ` <span class="t-muted-cell" title="${esc(p.transactionHash)}">· ${esc(shortTx(p.transactionHash))}</span>` : ''}</div>
<div class="row-actions"><span class="row-actions rec-hover">${revert}${del}</span>${badge}</div>
</div>
</div>`;
}
let updating = false;
async function runUpdate(host, refresh) {
if (updating) return;
updating = true;
const btn = host.querySelector('#rec-rematch');
const prog = host.querySelector('#rec-progress');
if (btn) btn.disabled = true;
try {
for (const s of (await listAllWalletSources()).filter(isOnchainSource)) {
const label = s.name ? `${s.account_name} · ${s.name}` : s.account_name;
if (prog) prog.textContent = `${t('home.updating')} ${label}`;
try { await scanSource(s, txt => { if (prog) prog.textContent = `${label}: ${txt}`; }); }
catch (e) { console.error('[reconcile-update]', e); }
}
await syncAddressLinks();
} finally {
updating = false;
}
refresh();
}
async function bookAllLightning(host, pending, refresh) {
const plan = bulkPlan(pending);
const n = bulkCounts(plan);
const ok = await dialog.confirm({
message: t('home.recBookAllConfirm', { trade: n.trade, internal: n.internal, interest: n.interest, skipped: n.onchain }),
});
if (!ok) return;
const prog = host.querySelector('#rec-progress');
const teller = { booked: 0, noPrice: 0, failed: 0 };
const werk = plan.filter(s => s.action !== 'skip');
for (const [i, s] of werk.entries()) {
if (prog) prog.textContent = t('home.recBookAllProgress', { i: i + 1, n: werk.length });
try {
if (s.action === 'internal') {
await resolveProposalAsInternal(s.id);
} else if (s.action === 'interest') {
await resolveProposalAsInterest(s.id);
} else {
const k = await koersVoor(s.proposal);
if (!k) { teller.noPrice++; continue; }
await resolveProposalAsTrade(s.id, { fiatPrice: k.price, priceOrigin: k.origin });
}
teller.booked++;
} catch (e) {
teller.failed++;
console.error('[reconcile-book-all]', e);
}
}
await dialog.alert({ title: t('home.recBookAllDoneTitle'), message: t('home.recBookAllDone', teller) });
refresh();
}
async function koersVoor(p) {
const asset = await onchainAsset();
if (!asset || asset.id !== p.assetId) return null;
const dag = localDate(p.tx_block_time || p.detectedAt);
const rond = n => Math.round(n * 100) / 100;
const moment = p.tx_block_time ? await spotAtMoment(asset, p.tx_block_time) : null;
if (moment) return { price: rond(moment.price), origin: 'market_moment', at: moment.at, dag, asset };
const koers = await spotOnDate(asset, dag);
return koers == null ? null : { price: rond(koers), origin: 'market_day', dag, asset };
}
async function vulKoers(p, form) {
const input = form.querySelector('[data-price]');
const hint = form.querySelector('[data-spot]');
hint.textContent = t('home.recPriceLoading');
try {
const k = await koersVoor(p);
if (!k) { hint.textContent = t('home.recPriceUnknown'); return null; }
input.value = String(k.price);
input.dataset.filled = input.value;
input.dataset.origin = k.origin;
input.dispatchEvent(new Event('input', { bubbles: true }));
hint.textContent = k.origin === 'market_moment'
? t('home.recPriceAt', { time: localDateTimeNl(new Date(k.at).toISOString()), unit: priceUnitLabel(k.asset) })
: t('home.recPriceFrom', { date: dayNl(k.dag), unit: priceUnitLabel(k.asset) });
return k.price;
} catch {
hint.textContent = t('home.recPriceUnknown');
return null;
}
}
function koppelBedragEnPrijs(p, form) {
const prijsEl = form.querySelector('[data-price]');
const totaalEl = form.querySelector('[data-total]');
onchainAsset().then(asset => {
if (!asset || asset.id !== p.assetId) return;
const dec = asset.decimals;
const rond = n => (n == null ? '' : String(Math.round(n * 100) / 100));
const uitPrijs = () => {
const basis = totalFromPrice(p.amount, dec, priceToBase(prijsEl.value, asset));
totaalEl.value = rond(basis);
};
const uitTotaal = () => {
const prijs = priceFromTotal(totaalEl.value, p.amount, dec);
prijsEl.value = prijs === null ? '' : rond(priceFromBase(prijs, asset));
};
prijsEl.addEventListener('input', uitPrijs);
totaalEl.addEventListener('input', uitTotaal);
if (prijsEl.value !== '') uitPrijs();
}).catch(() => { });
}
function showTradeForm(refresh, p, form) {
if (!p) return;
form.classList.remove('hidden');
form.innerHTML = `
<div class="flex gap-md" style="flex-wrap:wrap;align-items:flex-end">
<div class="form-group" style="margin-bottom:0">
<label class="form-label">${t('home.recTotal', { munt: baseCurrency() })}</label>
<input type="number" class="input" data-total min="0" step="any" style="min-width:140px">
</div>
<div class="form-group" style="margin-bottom:0">
<label class="form-label">${t('home.recPrice', { munt: baseCurrency() })}</label>
<input type="number" class="input" data-price min="0" step="any" style="min-width:160px">
<div class="form-hint" data-spot></div>
</div>
<button class="btn btn-primary btn-sm" data-confirm>${t('home.recConfirm')}</button>
</div>
<div class="form-error" data-err></div>`;
vulKoers(p, form);
koppelBedragEnPrijs(p, form);
form.querySelector('[data-confirm]').onclick = async () => {
const err = form.querySelector('[data-err]'); err.textContent = '';
const prijsEl = form.querySelector('[data-price]');
const veld = prijsEl.value;
const price = veld === '' ? await vulKoers(p, form) : parseFloat(veld);
if (!Number.isFinite(price)) { err.textContent = t('home.recPriceRequired'); return; }
const gevuld = prijsEl.dataset.filled;
const priceOrigin = gevuld !== undefined && Number(gevuld) === price ? (prijsEl.dataset.origin || 'market_day') : 'paid';
try { await resolveProposalAsTrade(p.id, { fiatPrice: price, priceOrigin }); refresh(); }
catch (e) { err.textContent = e.message; }
};
}
async function showTransferForm(refresh, id, form) {
form.classList.remove('hidden');
const opts = (await listSelectableAccounts()).map(a => `<option value="${a.id}">${esc(a.name)}</option>`).join('');
form.innerHTML = `
<div class="flex gap-md" style="flex-wrap:wrap;align-items:flex-end">
<div class="form-group" style="margin-bottom:0">
<label class="form-label">${t('home.recCounter')}</label>
<select class="input" data-counter style="min-width:180px">${opts}</select>
</div>
<button class="btn btn-primary btn-sm" data-confirm>${t('home.recConfirm')}</button>
</div>
<div class="form-error" data-err></div>`;
form.querySelector('[data-confirm]').onclick = async () => {
const err = form.querySelector('[data-err]'); err.textContent = '';
const other = Number(form.querySelector('[data-counter]').value);
try { await resolveProposalAsTransfer(id, { other_account_id: other }); refresh(); }
catch (e) { err.textContent = e.message; }
};
}
+58
View File
@@ -0,0 +1,58 @@
// Generated, do not edit. sha256:a607e071a277540b2c65e9bb3a8807791f7326ce16b2766440a5c537f44ad532
import { bookReservation, deleteReservation } from '../data/reservations.js';
import { listSelectableAccounts } from '../data/accounts.js';
import { t } from '../i18n/index.js';
import * as dialog from '../ui/dialog.js';
import { fromSubunit, toSubunit } from '../ui/format.js';
import { localDate, toUtcIso } from '../../shared/time.js';
export const reservationMeta = 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 });
export const reservationDir = r => (r.fromAccountId != null ? 'out' : 'in');
export const reservationAccount = r => (r.fromAccountId != null
? { id: r.fromAccountId, name: r.from_name } : { id: r.toAccountId, name: r.to_name });
export async function bookReservationFlow(r) {
const own = reservationAccount(r).id;
const accounts = (await listSelectableAccounts({ ookId: null })).filter(a => a.id !== own);
const res = await dialog.form({
title: t('reservations.bookTitle'),
fields: [
{ key: 'date', label: t('reservations.colDate'), type: 'date', value: localDate(r.occurredAt) },
{ key: 'amount', label: `${t('reservations.colAmount')} (${r.asset_symbol})`, type: 'number',
value: String(fromSubunit(r.amount, reservationMeta(r))) },
{ key: 'other', label: t(reservationDir(r) === 'out' ? 'reservations.otherTo' : 'reservations.otherFrom'),
type: 'select', value: '',
options: [{ value: '', label: t('reservations.outside') }, ...accounts.map(a => ({ value: a.id, label: a.name }))] },
],
confirmLabel: t('reservations.book'),
});
if (!res) return false;
const qty = parseFloat(res.amount);
if (!res.date || !Number.isFinite(qty) || qty <= 0) {
await dialog.alert({ title: t('dialog.error'), message: t('reservations.fill') });
return false;
}
try {
await bookReservation(r.id, {
occurredAt: toUtcIso(`${res.date}T12:00:00`),
amount: toSubunit(qty, reservationMeta(r)),
otherAccountId: res.other ? Number(res.other) : null,
});
return true;
} catch (err) {
await dialog.alert({ title: t('dialog.error'), message: err.message });
return false;
}
}
export async function settleReservationFlow(r) {
if (!(await dialog.confirm({ message: t('reservations.confirmSettle') }))) return false;
try {
await deleteReservation(r.id);
return true;
} catch (err) {
await dialog.alert({ title: t('dialog.error'), message: err.message });
return false;
}
}
+28
View File
@@ -0,0 +1,28 @@
// Generated, do not edit. sha256:d20de9a177bb432b6c8f10236c35149637d208ed9ad948a3457ff3eea687978a
import { listDueReservations } from '../data/reservations.js';
import { t } from '../i18n/index.js';
import { fmtUnit, val } from '../ui/format.js';
import { taskCardHtml, nothingPendingHtml } from '../ui/task-card.js';
import { esc } from '../ui/escape.js';
import { localDateNl } from '../../shared/time.js';
import { bookReservationFlow, reservationAccount, reservationDir, reservationMeta, settleReservationFlow } from './reservation-book.js';
export async function renderReservationsWidget(host, refresh) {
const due = await listDueReservations();
const body = due.length === 0
? nothingPendingHtml()
: due.map((r, i) => `<div class="flex gap-md" style="align-items:center;flex-wrap:wrap;margin-top:0.4rem">`
+ `<span class="t-body-sm">${esc(reservationAccount(r).name)} · ${esc(localDateNl(r.occurredAt))} · `
+ `${t(reservationDir(r) === 'out' ? 'reservations.dirOut' : 'reservations.dirIn')} `
+ `${val(fmtUnit(r.amount, reservationMeta(r)))}${r.note ? ` · ${esc(r.note)}` : ''}</span>`
+ `<button class="btn btn-primary btn-sm" data-res-book="${i}">${t('reservations.book')}</button>`
+ `<button class="btn btn-ghost btn-sm" data-res-settle="${i}">${t('reservations.settle')}</button></div>`).join('');
host.innerHTML = taskCardHtml({ title: t('reservations.title'), body });
host.querySelectorAll('[data-res-book]').forEach(b => b.onclick = async () => {
if (await bookReservationFlow(due[Number(b.dataset.resBook)])) refresh();
});
host.querySelectorAll('[data-res-settle]').forEach(b => b.onclick = async () => {
if (await settleReservationFlow(due[Number(b.dataset.resSettle)])) refresh();
});
return due.length;
}
+23
View File
@@ -0,0 +1,23 @@
// Generated, do not edit. sha256:8cf51d1c864566621979207e78f8179f742e2fdc07a34188bb58149450665be4
export function feeCandidates({ events = [], valuations = [], v }) {
if (!v || v.accountId == null) return [];
const tot = Date.parse(v.rateAt);
const vorige = valuations
.filter(x => x.id !== v.id && x.accountId === v.accountId && x.assetId === v.assetId
&& Date.parse(x.rateAt) < tot)
.reduce((max, x) => Math.max(max, Date.parse(x.rateAt)), -Infinity);
return events
.filter(e => e.assetId === v.assetId && e.fromAccountId === v.accountId
&& (e.type === 'sell' || e.type === 'transfer'))
.filter(e => { const op = Date.parse(e.occurredAt); return op > vorige && op <= tot; })
.sort((a, b) => Date.parse(b.occurredAt) - Date.parse(a.occurredAt) || b.id - a.id);
}
export function withFee(event, units, note) {
const fee = Math.abs(Number(units) || 0);
return {
...event,
unitFee: (Number(event.unitFee) || 0) + fee,
note: note ? (event.note ? `${event.note} · ${note}` : note) : event.note,
};
}
+144
View File
@@ -0,0 +1,144 @@
// Generated, do not edit. sha256:d42fa49545ab26fee8488ce3ebd3d684d5394b406ae802e38e6b624c87d49f71
import { listSelectableAccounts } from '../data/accounts.js';
import { createEvent, findEventByRef, deleteEvent, listEvents, updateEvent } from '../data/events.js';
import { valuationGap, listOpenValuationDiffs, acceptValuationGap, listValuations } from '../data/valuations.js';
import { feeCandidates, withFee } from './valuation-fee.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 { fmtUnit, val } from '../ui/format.js';
import { taskCardHtml, nothingPendingHtml } from '../ui/task-card.js';
import { lightningBalanceDiffs, recordLightningBalance } from '../data/positions.js';
import { onchainAsset } from '../data/tx-links.js';
import { spotOnDate } from '../ui/fiat.js';
import { priceToBase } from '../../shared/units.js';
import { dayNl, localDate, localDateNl } from '../../shared/time.js';
const meta = v => ({ decimals: v.asset_decimals, unit: v.asset_unit, subunit: v.asset_subunit, displayUnit: v.asset_display_unit });
const ADJUST_REF_PREFIX = 'val:';
export async function bookDifference(v, refresh) {
if (!v || !v.accountId) return;
const gap = await valuationGap(v);
if (!gap) return;
const refVan = (tegen) => (tegen ? `${ADJUST_REF_PREFIX}${v.id}:${tegen}` : `${ADJUST_REF_PREFIX}${v.id}`);
const netVan = (e) => (e ? (e.toAccountId === v.accountId ? e.amount : -e.amount) : 0);
const anderen = (await listSelectableAccounts({ ookId: null })).filter(a => a.id !== v.accountId);
const kandidaten = [{ tegen: null, naam: null }, ...anderen.map(a => ({ tegen: a.id, naam: a.name }))];
const priors = new Map();
for (const k of kandidaten) {
const e = await findEventByRef(refVan(k.tegen));
if (e) priors.set(k.tegen, e);
}
const bedragVan = (k) => gap + netVan(priors.get(k.tegen));
const keuzes = kandidaten.map(k => {
const bedrag = bedragVan(k);
const uit = bedrag < 0;
const basis = k.tegen === null
? (uit ? t('valuations.bookAsSell') : t('valuations.bookAsBuy'))
: `${uit ? t('valuations.bookAsTransferTo') : t('valuations.bookAsTransferFrom')} ${k.naam}`;
const extra = !priors.has(k.tegen) ? ''
: bedrag === 0 ? ` (${t('valuations.bookRemoves')})`
: ` (${t('valuations.bookReplaces')} ${fmtUnit(Math.abs(bedrag), meta(v))})`;
return { value: k.tegen === null ? 'trade' : `acc:${k.tegen}`, label: basis + extra };
});
const kandidatenFee = gap < 0
? feeCandidates({ events: await listEvents(), valuations: await listValuations(), v }).slice(0, 10)
: [];
for (const e of kandidatenFee) {
keuzes.push({ value: `fee:${e.id}`, label: t('valuations.bookAsFeeOn',
{ soort: t(`enums.event_type.${e.type}`), datum: localDateNl(e.occurredAt) }) });
}
const res = await dialog.form({
title: t('valuations.bookTitle'),
fields: [{ key: 'hoe', type: 'select', value: 'trade', options: keuzes,
label: `${fmtUnit(Math.abs(gap), meta(v))} ${t('valuations.bookAsSuffix')}` }],
confirmLabel: t('valuations.book'),
});
if (!res) return;
if (res.hoe.startsWith('fee:')) {
const e = kandidatenFee.find(x => x.id === Number(res.hoe.slice(4)));
if (e) await updateEvent(e.id, withFee(e, gap, t('valuations.bookFeeNote')));
refresh();
return;
}
const tegen = res.hoe.startsWith('acc:') ? Number(res.hoe.slice(4)) : null;
const prior = priors.get(tegen) || null;
const bedrag = gap + netVan(prior);
const uit = bedrag < 0;
if (prior) await deleteEvent(prior.id);
if (!bedrag) { refresh(); return; }
const gemeen = {
assetId: v.assetId, occurredAt: v.rateAt, amount: Math.abs(bedrag),
fiatFee: null, unitFee: null, source: 'reconcile',
note: t('valuations.bookNote'), importReference: refVan(tegen),
};
await createEvent(tegen
? { ...gemeen, type: 'transfer', fiatPrice: null,
fromAccountId: uit ? v.accountId : tegen, toAccountId: uit ? tegen : v.accountId }
: { ...gemeen, type: uit ? 'sell' : 'buy', fiatPrice: v.fiatRate,
fromAccountId: uit ? v.accountId : null, toAccountId: uit ? null : v.accountId });
refresh();
}
export async function acceptDifference(id, refresh) {
if (!(await dialog.confirm({ message: t('valuations.confirmAccept') }))) return;
await acceptValuationGap(Number(id));
refresh();
}
async function recordMeasured(d, refresh) {
const asset = await onchainAsset();
const dag = localDate(d.scannedAt);
let koers = null;
try { koers = asset ? await spotOnDate(asset, dag) : null; } catch { koers = null; }
const res = await dialog.form({
title: t('valuations.recordTitle'),
fields: [{ key: 'rate', type: 'number', value: koers != null ? String(Math.round(koers * 100) / 100) : '',
label: t('valuations.recordRate', { date: dayNl(dag) }), attrs: { min: 0, step: 'any' } }],
confirmLabel: t('valuations.recordBalance'),
});
if (!res) return;
const rate = parseFloat(String(res.rate).replace(',', '.'));
if (!(rate > 0)) return;
try {
await recordLightningBalance(d.accountId, { valuedOn: dag, fiatRate: priceToBase(rate, asset) });
} catch (err) {
await dialog.alert({ message: err.message });
return;
}
refresh();
}
export async function renderValuationWidget(host, refresh) {
const [rows, gemeten] = await Promise.all([listOpenValuationDiffs(), lightningBalanceDiffs()]);
const btc = gemeten.length ? await onchainAsset() : null;
const btcMeta = btc ? { decimals: btc.decimals, unit: btc.unit, subunit: btc.subunit, displayUnit: btc.displayUnit } : null;
const nodeRijen = gemeten.map((d, i) => `<div class="flex gap-md" style="align-items:center;flex-wrap:wrap;margin-top:0.4rem">
<span class="t-body-sm" title="${esc(t('valuations.lightningHint'))}">${esc(localDateNl(d.scannedAt))} · ${esc(d.account_name)} · ${esc(t('valuations.lightningRow'))}:
<span class="${d.gap < 0 ? 't-neg' : 't-pos'}">${val(fmtUnit(d.gap, btcMeta))}</span></span>
<button class="btn btn-secondary btn-sm" data-vrecord="${i}" title="${esc(t('valuations.recordBalanceHint'))}">${t('valuations.recordBalance')}</button>
</div>`).join('');
const balansRijen = rows.map((v, i) => `<div class="flex gap-md" style="align-items:center;flex-wrap:wrap;margin-top:0.4rem">
<span class="t-body-sm">${esc(v.valuedOn)} · ${esc(v.account_name)} · ${assetLabelHtml(v)}:
<span class="${v.gap < 0 ? 't-neg' : 't-pos'}">${val(fmtUnit(v.gap, meta(v)))}</span></span>
<button class="btn btn-secondary btn-sm" data-vbook="${i}">${t('valuations.book')}</button>
<button class="btn btn-ghost btn-sm" data-vaccept="${i}" title="${esc(t('valuations.acceptHint'))}">${t('valuations.accept')}</button>
</div>`).join('');
const body = rows.length + gemeten.length === 0 ? nothingPendingHtml() : nodeRijen + balansRijen;
host.innerHTML = taskCardHtml({ title: t('actions.valuationTitle'), body });
host.querySelectorAll('[data-vrecord]').forEach(b =>
b.onclick = () => recordMeasured(gemeten[Number(b.dataset.vrecord)], refresh));
host.querySelectorAll('[data-vbook]').forEach(b =>
b.onclick = () => bookDifference(rows[Number(b.dataset.vbook)], refresh));
host.querySelectorAll('[data-vaccept]').forEach(b =>
b.onclick = () => acceptDifference(rows[Number(b.dataset.vaccept)].id, refresh));
return rows.length + gemeten.length;
}
+44
View File
@@ -0,0 +1,44 @@
// Generated, do not edit. sha256:06a40e9a9b07fffcb17863260da0fc8eb04737e1ff80b20eaa9403c734f9d87d
import { PREFERENCES } from '../shared/config.js';
import { getPref, getSettings, setPref } from './data/preferences.js';
let app = null;
const timers = new Map();
export async function loadAppSettings() {
app = await getSettings();
for (const key of Object.keys(PREFERENCES.timers)) timers.set(key, await getPref('timers', key));
return app;
}
function geladen() {
if (app === null) {
throw new Error('app-settings: not loaded yet. loadAppSettings() belongs to the boot, before the first render.');
}
return app;
}
export function appSetting(key) {
const s = geladen();
if (!Object.hasOwn(PREFERENCES.app, key)) throw new Error(`Unknown app preference: ${key}.`);
return s[key];
}
export function baseCurrency() { return geladen().baseCurrency; }
export function appSettings() { return { ...geladen() }; }
export function timerSetting(key) {
geladen();
return timers.get(key);
}
export async function setAppSetting(key, value) {
await setPref('app', key, value);
if (app) app[key] = value;
}
export async function setTimerSetting(key, value) {
await setPref('timers', key, value);
timers.set(key, value);
}
+272
View File
@@ -0,0 +1,272 @@
// Generated, do not edit. sha256:8874091aa075a54dd49f5d1aad9244d40ef61ac47ce7c83d7d4e346d8133207a
import { configureSharedUi } from './ui/shared-setup.js';
import './edition.js';
import { assertConfig, ROUTE_KEYS, hashFromLocation } from '../shared/config.js';
import { listAssets } from './data/assets.js';
import { refreshNotifications } from './data/notifications.js';
import { initCommands } from './commands.js';
import { loadAppSettings, appSetting } from './app-settings.js';
import { loadLicense } from './license.js';
import { t, setLocale } from './i18n/index.js';
import { initStatusBar } from './ui/status-bar.js';
import { renderErrorPanel } from './ui/error-panel.js';
import { createRenderTurns } from './ui/render-turn.js';
import { finishSplash, initSplashReplay } from './ui/splash.js';
import { refreshActivePortfolio, watchPortfolioChanges, cacheSleutels } from './portfolio.js';
import { applyAurora } from './ui/aurora.js';
import { initScheduler } from './ui/scheduler.js';
import { refreshAttention, attentionForRoute, attentionDotHtml } from './ui/attention.js';
import { registerAttentionSources } from './ui/attention-sources.js';
import { clearPageHint } from './ui/page-hint.js';
import { refreshRecentCloses } from './price/providers.js';
import * as home from './pages/home.js';
import * as conversionBitcoin from './pages/conversion-bitcoin.js';
import * as priceHistory from './pages/price-history.js';
import * as wealth from './pages/wealth.js';
import * as events from './pages/events.js';
import * as tracking from './pages/tracking.js';
import * as system from './pages/system.js';
import * as actions from './pages/actions.js';
import * as guide from './pages/guide.js';
import { makeStub } from './pages/stub.js';
import { ADJUST } from './ui/icons.js';
import { esc } from './ui/escape.js';
import * as dialog from './ui/dialog.js';
import { maybeStartSetup } from './onboarding/setup.js';
import { registerRoute, routePage } from './routes.js';
registerRoute(ROUTE_KEYS.HOME, home);
registerRoute(ROUTE_KEYS.WEALTH, wealth);
registerRoute(ROUTE_KEYS.EVENTS, events);
registerRoute(ROUTE_KEYS.VALIDATION, makeStub('validation'));
registerRoute(ROUTE_KEYS.TRACKING, tracking);
registerRoute(ROUTE_KEYS.CONVERSION_BITCOIN, conversionBitcoin);
registerRoute(ROUTE_KEYS.PRICE_HISTORY, priceHistory);
registerRoute(ROUTE_KEYS.SYSTEM, system);
registerRoute(ROUTE_KEYS.ACTIONS, actions);
registerRoute(ROUTE_KEYS.GUIDE, guide);
const NAV = [
{ items: ['home', 'actions', 'events'] },
{ section: 'onchain', items: ['tracking'] },
{ section: 'tax', items: ['wealth', 'taxes'] },
{ section: 'observe', items: ['conversion/bitcoin', 'price-history', 'charts'] },
{ items: ['guide', 'system'], bottom: true },
];
function currentRoute() {
const key = hashFromLocation(location.hash).slice(2);
if (routePage(key)) return key;
const head = key.split('/')[0];
return routePage(head) ? head : 'home';
}
const LOOK_ATTRS = Object.freeze({ profile: 'profile', glow: 'glow', glowshape: 'glowshape', motion: 'motion', rowsel: 'rowsel', glass_render: 'render' });
const LOOK_TOKENS = Object.freeze({
glass_transparency: ['--glass-transparency', '%'],
glass_frost: ['--glass-frost', '%'],
glass_blur: ['--dialog-transparency', ''],
});
function applyTheme(s) {
const { theme, accent, depth } = s;
document.documentElement.dataset.theme = theme;
document.documentElement.dataset.accent = accent;
document.documentElement.dataset.depth = depth;
const look = {};
for (const [key, attr] of Object.entries(LOOK_ATTRS)) {
document.documentElement.dataset[attr] = s[key];
look[attr] = s[key];
}
for (const [key, [token, unit]] of Object.entries(LOOK_TOKENS)) {
document.documentElement.style.setProperty(token, `${s[key]}${unit}`);
look[token] = `${s[key]}${unit}`;
}
applyAurora(s);
try {
const per = cacheSleutels();
localStorage.setItem(per.theme, theme);
localStorage.setItem(per.accent, accent);
localStorage.setItem(per.depth, depth);
localStorage.setItem(per.look, JSON.stringify(look));
} catch { }
}
function applyPageTitle(show) {
document.documentElement.dataset.pagetitle = show ? 'on' : 'off';
}
function applyPageHint(show) {
document.documentElement.dataset.pagehint = show ? 'on' : 'off';
}
const hasBitcoin = async () => (await listAssets()).some(a => a.symbol === 'BTC');
const NAV_WHEN = {
[ROUTE_KEYS.WEALTH]: async () => (await listAssets()).length > 0,
[ROUTE_KEYS.CHARTS]: async () => appSetting('persona') !== 'broad',
[ROUTE_KEYS.CONVERSION_BITCOIN]: hasBitcoin,
[ROUTE_KEYS.TRACKING]: hasBitcoin,
};
const NAV_CUSTOMIZABLE = new Set(['home']);
const editModeRoutes = new Set();
function toggleEdit(key) {
if (editModeRoutes.has(key)) editModeRoutes.delete(key); else editModeRoutes.add(key);
if (currentRoute() === key) renderPage();
else location.hash = `#/${key}`;
}
function setEditMode(key, on) {
if (editModeRoutes.has(key) === !!on) return;
toggleEdit(key);
}
let kanSluiten = false;
async function onNavCloseClick(e) {
if (!e.target.closest('[data-app-close]')) return;
e.preventDefault();
if (!await dialog.confirm({ title: t('appWindow.confirmTitle'), message: t('appWindow.confirmClose'),
confirmLabel: t('appWindow.close') })) return;
const r = await fetch('/api/app/close', { method: 'POST' }).catch(() => null);
if (r && !r.ok) dialog.alert({ title: t('appWindow.confirmTitle'), message: t('appWindow.closeUnavailable') });
}
function onNavEditClick(e) {
const tog = e.target.closest('[data-edit]');
if (!tog) return;
e.preventDefault();
e.stopPropagation();
toggleEdit(tog.dataset.edit);
}
async function renderSidebar() {
const nav = document.getElementById('nav');
const active = currentRoute();
const navToolsSlot = !appSetting('show_page_title');
const zichtbaar = new Set();
for (const [route, voorwaarde] of Object.entries(NAV_WHEN)) {
if (await voorwaarde()) zichtbaar.add(route);
}
const keepTools = nav.querySelector('#nav-tools');
nav.innerHTML = NAV.map(group => {
const title = group.section ? `<div class="t-label t-label--ter sidebar-section-title">${t(`nav.${group.section}`)}</div>` : '';
const links = group.items.filter(k => routePage(k) && (!NAV_WHEN[k] || zichtbaar.has(k))).map(k => {
const dot = attentionDotHtml(attentionForRoute(k));
const editTog = NAV_CUSTOMIZABLE.has(k)
? `<span class="nav-edit-toggle${editModeRoutes.has(k) ? ' is-on' : ''}" data-edit="${k}" role="button" tabindex="-1" title="${t('nav.customize')}" aria-label="${t('nav.customize')}">${ADJUST}</span>`
: '';
const tools = navToolsSlot && k === active && editModeRoutes.has(k) ? '<div class="nav-tools" id="nav-tools"></div>' : '';
const link = `<a href="#/${k}" class="${k === active ? 'active' : ''}${dot ? ' has-attention' : ''}${NAV_CUSTOMIZABLE.has(k) ? ' is-customizable' : ''}"><span class="nav-label">${t(`nav.${k}`)}</span>${dot}${editTog}</a>${tools}`;
return tools ? `<div class="nav-edit-group">${link}</div>` : link;
}).join('');
const sluiten = group.bottom && kanSluiten
? `<a href="#" class="nav-close" data-app-close><span class="nav-label">${esc(t('appWindow.close'))}</span></a>` : '';
return links ? (group.bottom ? '<div class="nav-spacer"></div>' : '') + title + links + sluiten : '';
}).join('');
const slot = nav.querySelector('#nav-tools');
if (slot && keepTools && keepTools.firstChild) slot.replaceWith(keepTools);
}
function renderPageHeader(key) {
const host = document.getElementById('page-header');
if (!host) return;
const mod = routePage(key);
const naam = mod?.headerInColumn || typeof mod?.title !== 'function' ? '' : mod.title();
if (!naam) { host.innerHTML = ''; return; }
const icoon = typeof mod.titleIcon === 'function' ? mod.titleIcon() : '';
host.innerHTML = `<div class="page-header">`
+ `<h1 class="page-title">${icoon}${esc(naam)}</h1><div class="page-tools" id="page-tools"></div></div>`;
}
const renderTurns = createRenderTurns();
async function renderPage() {
const turn = renderTurns.take();
const key = currentRoute();
for (const k of [...editModeRoutes]) if (k !== key) editModeRoutes.delete(k);
document.getElementById('sidebar').classList.remove('open');
const view = document.getElementById('view');
renderPageHeader(key);
clearPageHint();
try {
await renderSidebar();
await routePage(key).render(view, { edit: editModeRoutes.has(key), setEdit: on => setEditMode(key, on) });
} catch (err) {
if (!renderTurns.isCurrent(turn)) { console.warn(`[render] ${key} overtaken:`, err); return; }
renderErrorPanel(view, err, key);
}
}
async function boot() {
try {
assertConfig();
const s = await loadAppSettings();
setLocale(s.locale);
configureSharedUi();
await loadLicense();
await refreshNotifications();
applyTheme(s);
applyPageTitle(s.show_page_title);
applyPageHint(s.show_page_hint);
document.getElementById('logo-text').textContent = t('app.name');
document.getElementById('mobile-logo-text').textContent = t('app.name');
initSplashReplay();
refreshActivePortfolio();
watchPortfolioChanges();
document.getElementById('menu-btn').onclick =
() => document.getElementById('sidebar').classList.toggle('open');
document.addEventListener('settings-changed', async () => {
const s2 = await loadAppSettings();
setLocale(s2.locale);
configureSharedUi();
applyTheme(s2);
applyPageTitle(s2.show_page_title);
applyPageHint(s2.show_page_hint);
renderPage();
});
window.addEventListener('hashchange', renderPage);
document.getElementById('nav').addEventListener('click', onNavEditClick);
document.getElementById('nav').addEventListener('click', onNavCloseClick);
kanSluiten = await fetch('/api/app/window').then(r => (r.ok ? r.json() : {}))
.then(j => j.canClose === true).catch(() => false);
registerAttentionSources();
document.addEventListener('attention-changed',
() => renderSidebar().catch(e => console.error('[app] refreshing the sidebar failed:', e)));
for (const signaal of ['providers-changed', 'connections-changed', 'ai-activity-changed',
'ai-proposals-changed', 'price-gaps-changed']) {
document.addEventListener(signaal, () => refreshAttention());
}
window.addEventListener('hashchange', () => refreshAttention());
document.addEventListener('app-refresh', () => {
refreshAttention();
renderPage();
});
initStatusBar();
initScheduler();
initCommands();
await renderPage();
document.documentElement.dataset.app = 'ready';
finishSplash();
maybeStartSetup();
refreshAttention();
listAssets()
.then(assets => refreshRecentCloses(assets))
.then(({ written }) => { if (written) document.dispatchEvent(new CustomEvent('price-gaps-changed')); })
.catch(e => console.error('[app] refreshing the recent closes failed:', e));
} catch (err) {
document.body.innerHTML =
`<div style="padding:3rem;font-family:sans-serif;color:#ff4545">
<h2>Start afgebroken</h2><pre>${esc(err.message)}</pre></div>`;
throw err;
}
}
boot();
+52
View File
@@ -0,0 +1,52 @@
// Generated, do not edit. sha256:2c49affa1ec432e4d1ea7c40091c250afc875d5da6a7e1f18da77761d64a9168
(function () {
var show = false;
var gewisseld = false;
try {
gewisseld = localStorage.getItem('portfolio_switching') === '1';
if (gewisseld) localStorage.removeItem('portfolio_switching');
} catch (e0) { }
try {
var KEY = 'splash_last_shown';
var d = new Date();
var today = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
show = gewisseld
|| location.search.indexOf('splash') !== -1
|| location.hash.indexOf('splash') !== -1
|| localStorage.getItem(KEY) !== today;
if (show) localStorage.setItem(KEY, today);
} catch (e) { show = true; }
if (show) document.documentElement.dataset.splash = 'on';
try {
var naam = localStorage.getItem('portfolio_active');
if (naam) {
document.documentElement.dataset.portfolio = naam;
document.documentElement.style.setProperty('--portfolio-name', JSON.stringify(naam));
}
} catch (e1) { }
try {
var achtervoegsel = document.documentElement.dataset.portfolio
? ':' + document.documentElement.dataset.portfolio : '';
var theme = localStorage.getItem('ui_theme' + achtervoegsel);
var accent = localStorage.getItem('ui_accent' + achtervoegsel);
var depth = localStorage.getItem('ui_depth' + achtervoegsel);
if (theme === 'dark' || theme === 'light') document.documentElement.dataset.theme = theme;
if (accent) document.documentElement.dataset.accent = accent;
if (depth === 'flat' || depth === 'deep') document.documentElement.dataset.depth = depth;
} catch (e2) { }
try {
var look = JSON.parse(localStorage.getItem('ui_look' + achtervoegsel) || '{}');
var LOOK_ATTRS = ['profile', 'glow', 'glowshape', 'motion', 'rowsel', 'render'];
for (var i = 0; i < LOOK_ATTRS.length; i++) {
var v = look && look[LOOK_ATTRS[i]];
if (typeof v === 'string' && /^[a-z]+$/.test(v)) document.documentElement.dataset[LOOK_ATTRS[i]] = v;
}
var LOOK_TOKENS = ['--glass-transparency', '--glass-frost', '--dialog-transparency'];
for (var j = 0; j < LOOK_TOKENS.length; j++) {
var w = look && look[LOOK_TOKENS[j]];
if (typeof w === 'string' && /^\d{1,3}(%|px)?$/.test(w)) document.documentElement.style.setProperty(LOOK_TOKENS[j], w);
}
} catch (e3) { }
})();
+18
View File
@@ -0,0 +1,18 @@
// Generated, do not edit. sha256:671ad1d61da792d482cd1cb3577e28a415b868ce12182a75eeb1a59b5fd96682
import { changesSince } from './data/bus.js';
import { dispatchChanges } from './data/_rpc.js';
let cursor = null;
export async function pollChanges() {
let uitslag;
try {
uitslag = await changesSince(cursor);
} catch {
return [];
}
if (!uitslag || typeof uitslag !== 'object') return [];
cursor = Number.isInteger(uitslag.cursor) ? uitslag.cursor : cursor;
if (uitslag.reset) return dispatchChanges(['app-refresh']);
return dispatchChanges(uitslag.names);
}
+123
View File
@@ -0,0 +1,123 @@
// Generated, do not edit. sha256:a469a46a90e6bcc2ab27a831978cd788d57e02abb6e3347f99750cb9fb53c286
import { claimNextCommand, resolveCommand } from './data/commands.js';
import { CONFIG, PLACE_LABELS, hashFromLocation, commandTooLate } from '../shared/config.js';
import { t } from './i18n/index.js';
import { esc } from './ui/escape.js';
const TAB = (globalThis.crypto?.randomUUID?.() || `tab-${Date.now()}-${Math.random()}`).slice(0, 40);
const KAN = Object.freeze(['navigate', 'locate']);
let bezig = false;
export function initCommands() {
document.addEventListener('commands-changed', () => { pakOp(); });
pakOp();
}
export async function pakOp() {
if (bezig) return;
bezig = true;
try {
const opdracht = await claimNextCommand(TAB, KAN);
if (opdracht) await voerUit(opdracht);
} catch { }
finally { bezig = false; }
}
async function voerUit(opdracht) {
if (opdracht.kind === 'locate') {
await resolveCommand(opdracht.id, TAB, 'done', hashFromLocation(location.hash));
return;
}
if (opdracht.kind !== 'navigate') {
await resolveCommand(opdracht.id, TAB, 'rejected', t('commands.unknownKind'));
return;
}
const hash = opdracht.payload && typeof opdracht.payload.hash === 'string' ? opdracht.payload.hash : '';
if (!hash.startsWith('#/')) {
await resolveCommand(opdracht.id, TAB, 'rejected', t('commands.badTarget'));
return;
}
if (commandTooLate(opdracht.expiresAt)) {
await resolveCommand(opdracht.id, TAB, 'expired', t('commands.tooLate'));
return;
}
const doorgaan = await aftellen(plekNaam(opdracht.payload));
if (!doorgaan) {
await resolveCommand(opdracht.id, TAB, 'rejected', t('commands.stopped'));
return;
}
location.hash = hash;
await resolveCommand(opdracht.id, TAB, 'done');
}
function plekNaam({ route, tab, sub }) {
const label = (id) => { try { return t(PLACE_LABELS[id] || `nav.${id}`); } catch { return id; } };
return [label(route), tab && label(tab), sub && label(sub)].filter(Boolean).join(' → ');
}
let directVoortaan = false;
function toonBalk(inhoud) {
const bar = document.createElement('div');
bar.className = 'cmd-bar';
bar.setAttribute('role', 'alert');
bar.innerHTML = inhoud;
document.body.appendChild(bar);
return bar;
}
const knop = (data, tekst, soort = 'secondary') =>
`<button type="button" class="btn btn-${soort} btn-sm" ${data}>${tekst}</button>`;
const knoppen = (...items) => `<div class="cmd-bar-buttons">${items.join('')}</div>`;
function meldDirect(plek) {
const bar = toonBalk(`<span class="cmd-bar-text">${t('commands.movedTo')} <strong>${esc(plek)}</strong></span>`
+ knoppen(knop('data-countdown-back', t('commands.countdownBack'))));
const weg = setTimeout(() => bar.remove(), CONFIG.COMMANDS.NOTICE_SECONDS * 1000);
bar.querySelector('[data-countdown-back]').addEventListener('click', () => {
directVoortaan = false;
clearTimeout(weg);
bar.remove();
});
}
function aftellen(plek) {
if (directVoortaan) { meldDirect(plek); return Promise.resolve(true); }
const seconden = CONFIG.COMMANDS.COUNTDOWN_SECONDS;
return new Promise((klaar) => {
let over = seconden;
const tekst = () => `${t('commands.goingTo')} <strong>${esc(plek)}</strong> (${over})`;
const bar = toonBalk(`<span class="cmd-bar-text">${tekst()}</span>`
+ knoppen(
knop('data-now', t('commands.goNow'), 'primary'),
knop('data-always', t('commands.goAlways')),
knop('data-stop', t('commands.stop')),
));
const op = (doorgaan) => {
clearInterval(tik);
document.removeEventListener('keydown', opEscape);
bar.remove();
klaar(doorgaan);
};
const opEscape = (e) => { if (e.key === 'Escape') op(false); };
bar.querySelector('[data-stop]').addEventListener('click', () => op(false));
const nu = bar.querySelector('[data-now]');
nu.addEventListener('click', () => op(true));
nu.focus();
bar.querySelector('[data-always]').addEventListener('click', () => { directVoortaan = true; op(true); });
document.addEventListener('keydown', opEscape);
const tik = setInterval(() => {
over -= 1;
if (over <= 0) { op(true); return; }
bar.querySelector('.cmd-bar-text').innerHTML = tekst();
}, 1000);
});
}
File diff suppressed because it is too large. Load diff
+805
View File
@@ -0,0 +1,805 @@
// Generated, do not edit. sha256:890b0e46a8d4907e79564933d64186ce26573ba305bedb6fd4fa2f911700fadc
import { getPref, setPref } from '../data/preferences.js';
import {
activateProfile, activeProfile, createProfile, deleteProfile, listProfiles, listWidgets,
renameProfile, saveWidgets, widgetParams,
} from '../data/dashboard.js';
import * as dialog from '../ui/dialog.js';
import { DASHBOARD } from '../../shared/config.js';
import { t } from '../i18n/index.js';
import { CLOSE, PLUS, GRIP, ADJUST, PENCIL, TRASH } from '../ui/icons.js';
import { esc } from '../ui/escape.js';
import * as loading from '../ui/loading.js';
import { freeRunAt, fittingCatalog, placeInRun } from './free-space.js';
const LAYOUT_SCOPE = 'dashboard';
const MODEL_KEY = 'heightModel';
const LAYOUT_MODEL_KEY = 'layoutModel';
let masonryRO = null;
let masonryResizeBound = false;
let masonrySettleTimer = null;
let masonryRevealTimer = null;
let masonryMO = null;
let growCheckTimer = null;
function clampSpan(w) {
const n = Number(w);
if (!Number.isFinite(n)) return DASHBOARD.MIN_SPAN;
return Math.max(DASHBOARD.MIN_SPAN, Math.min(DASHBOARD.MAX_SPAN, Math.round(n)));
}
function clampH(h) {
const n = Number(h);
if (!Number.isFinite(n)) return DASHBOARD.MIN_H;
return Math.max(DASHBOARD.MIN_H, Math.min(DASHBOARD.MAX_H, Math.round(n)));
}
function stepPx() {
const rootPx = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
return DASHBOARD.HEIGHT_STEP_REM * rootPx;
}
function pxForSteps(n, step, gap) { return n > 0 ? n * step + (n - 1) * gap : 0; }
function stepsForPx(px, step, gap) { return Math.max(1, Math.ceil((px + gap) / (step + gap))); }
function newInstance(c) {
const def = DASHBOARD.WIDGETS[c.type];
return { instId: c.instId, type: c.type, params: c.params || null, w: def.defaultSpan, h: 0, x: 0, y: 0 };
}
function cellSteps(it) {
if (it.h > 0) return clampH(it.h);
const def = DASHBOARD.WIDGETS[it.type];
return def && def.defaultH != null ? clampH(def.defaultH) : DASHBOARD.SKELETON_STEPS;
}
function spanSteps(it) { return Math.max(DASHBOARD.MIN_H, cellSteps(it)); }
function packLayout(list) {
const cols = DASHBOARD.GRID_COLUMNS;
const bottom = new Array(cols).fill(0);
for (const it of list) {
const w = clampSpan(it.w);
const h = Math.max(DASHBOARD.MIN_H, cellSteps(it));
const own = Math.max(0, Math.min(cols - w, Math.round(Number(it.x) || 0)));
let bestX = 0, bestY = Infinity;
for (let x = 0; x + w <= cols; x++) {
let y = 0;
for (let c = x; c < x + w; c++) if (bottom[c] > y) y = bottom[c];
if (y < bestY || (y === bestY && x === own)) { bestY = y; bestX = x; }
}
it.x = bestX; it.y = bestY;
for (let c = bestX; c < bestX + w; c++) bottom[c] = bestY + h;
}
return list;
}
function rectsOverlap(a, b) {
return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + spanSteps(b) && b.y < a.y + spanSteps(a);
}
function makeRoom(list, anchors) {
const anchorSet = new Set(anchors);
const queue = anchors.slice();
let guard = 0;
while (queue.length && guard++ < 2000) {
const b = list[queue.shift()];
const bBottom = b.y + spanSteps(b);
for (let i = 0; i < list.length; i++) {
if (anchorSet.has(i)) continue;
const a = list[i];
if (a === b) continue;
if (rectsOverlap(a, b) && bBottom > a.y) { a.y = bBottom; queue.push(i); }
}
}
}
function layoutBottom(list) {
let bottom = 0;
for (const it of list) {
const b = (Math.max(0, Math.round(Number(it.y) || 0))) + Math.max(DASHBOARD.MIN_H, cellSteps(it));
if (b > bottom) bottom = b;
}
return bottom;
}
export async function readLayout() {
const list = await listWidgets((await activeProfile()).id);
const model = Number(await getPref(LAYOUT_SCOPE, MODEL_KEY, 0)) || 0;
const hinted = model >= DASHBOARD.HEIGHT_MODEL;
const layoutModel = Number(await getPref(LAYOUT_SCOPE, LAYOUT_MODEL_KEY, 0)) || 0;
const placed = layoutModel >= DASHBOARD.LAYOUT_MODEL;
const out = list
.filter(r => DASHBOARD.WIDGETS[r.type])
.map(r => {
const def = DASHBOARD.WIDGETS[r.type];
const h = (hinted && r.spanHeight > 0) ? clampH(r.spanHeight) : 0;
const w = clampSpan(r.spanWidth != null ? r.spanWidth : def.defaultSpan);
return {
instId: String(r.instanceKey || r.type),
type: r.type,
params: widgetParams(r),
w,
h,
x: Math.max(0, Math.min(DASHBOARD.GRID_COLUMNS - w, Math.round(Number(r.gridX) || 0))),
y: placed ? Math.max(0, Math.round(Number(r.gridY) || 0)) : 0,
};
});
if (!placed) packLayout(out);
return out;
}
async function writeLayout(list) {
try {
const profileId = (await activeProfile()).id;
await saveWidgets(profileId, list.map(it => ({
instanceKey: it.instId, type: it.type, params: it.params || null,
gridX: it.x, gridY: it.y, spanWidth: it.w,
spanHeight: it.h > 0 ? it.h : null,
})));
await setPref(LAYOUT_SCOPE, LAYOUT_MODEL_KEY, DASHBOARD.LAYOUT_MODEL);
} catch { }
}
function errCard(e) {
return `<div class="card"><div class="t-body-sm t-muted">\u26A0 ${esc((e && e.message) || e)}</div></div>`;
}
function editBarHtml(it, i, hasSettings) {
let opts = '';
for (let n = DASHBOARD.MIN_SPAN; n <= DASHBOARD.MAX_SPAN; n++) opts += `<option value="${n}"${n === it.w ? ' selected' : ''}>${n}</option>`;
const widthSel = `<label class="dash-edit-wsel-wrap" title="${t('dashboard.width')}"><select class="dash-edit-wsel" data-act="width" data-i="${i}" aria-label="${t('dashboard.width')}">${opts}</select></label>`;
const cfgBtn = hasSettings
? `<button type="button" class="dash-edit-cfg" data-act="settings" data-i="${i}" title="${t('dashboard.settings')}" aria-label="${t('dashboard.settings')}">${ADJUST}</button>`
: '';
return `<div class="dash-edit-bar">
<span class="dash-edit-grip" data-drag="${i}" role="button" tabindex="-1" title="${t('dashboard.reorder')}" aria-label="${t('dashboard.reorder')}">${GRIP}</span>
${widthSel}
${cfgBtn}
<button type="button" class="dash-edit-x" data-act="remove" data-i="${i}" title="${t('dashboard.remove')}" aria-label="${t('dashboard.remove')}">${CLOSE}</button>
</div>`;
}
function cellHtml(it, i, edit, hasSettings) {
const x = Math.max(0, Math.round(Number(it.x) || 0));
const y = Math.max(0, Math.round(Number(it.y) || 0));
const span = spanSteps(it);
const cls = `dashboard-cell${it.h > 0 ? ' is-sized' : ''}`;
const place = `grid-column: ${x + 1} / span ${it.w}; grid-row: ${y + 1} / span ${span};`;
const settingsPanel = edit && hasSettings ? '<div class="dash-settings" data-settings hidden></div>' : '';
return `<div class="${cls}" data-cell="${i}" data-inst="${esc(it.instId)}" data-w="${it.w}" style="${place}">${edit ? editBarHtml(it, i, hasSettings) : ''}${settingsPanel}<div class="dashboard-body"></div></div>`;
}
async function profileListHtml() {
const profielen = await listProfiles();
const actief = profielen.find(p => p.isActive);
const systeem = !!(actief && actief.isSystem);
const items = profielen.map(p => `<button type="button" role="option" class="dash-profile-item${p.isActive ? ' is-active' : ''}"
aria-selected="${p.isActive ? 'true' : 'false'}" data-act="profile-pick" data-id="${p.id}">${esc(p.name)}</button>`).join('');
const knop = (act, icoon, label, uit = false) =>
`<button type="button" class="dash-profile-btn" data-act="${act}" title="${esc(label)}" aria-label="${esc(label)}"${uit ? ' disabled' : ''}>${icoon}</button>`;
return `<div class="dash-profiles">
<div class="dash-profile-list" role="listbox" aria-label="${esc(t('dashboard.profile'))}">${items}</div>
<div class="dash-profile-actions">
${knop('profile-new', PLUS, t('dashboard.profileNew'))}
${knop('profile-rename', PENCIL, systeem ? t('dashboard.profileSystemHint') : t('dashboard.profileRename'), systeem)}
${knop('profile-delete', TRASH, systeem ? t('dashboard.profileRestart') : t('dashboard.profileDelete'))}
</div>
</div>`;
}
const itemLabelHtml = c => c.labelHtml || esc(c.label);
async function toolsHtml() {
return profileListHtml();
}
function mobileAddHtml(catalog) {
const items = catalog.length
? catalog.map((c, i) => `<button type="button" class="dashboard-add-item" data-act="add" data-idx="${i}">${itemLabelHtml(c)}</button>`).join('')
: `<div class="dashboard-add-empty">${t('dashboard.addNone')}</div>`;
return `<div class="dash-add-mobile"><div class="t-label t-label--ter">${t('dashboard.add')}</div>${items}</div>`;
}
export async function renderDashboard(host, opts = {}) {
if (!host) return;
latestOpts.set(host, opts);
return draw(host, opts);
}
const latestOpts = new WeakMap();
const drawGen = new WeakMap();
const rerender = host => draw(host, latestOpts.get(host) || {});
async function draw(host, opts) {
const gen = (drawGen.get(host) || 0) + 1;
drawGen.set(host, gen);
const stale = () => drawGen.get(host) !== gen;
const renderers = opts.renderers || {};
const edit = !!opts.edit;
const toolbarHost = opts.toolbarHost || null;
const layout = await readLayout();
const catalog = edit && typeof opts.catalog === 'function' ? await opts.catalog(layout) : [];
if (stale()) return;
const hasSettings = type => !!(opts.settings && typeof opts.settings[type] === 'function');
teardownSettingsOutside();
const prevBodies = new Map();
host.querySelectorAll('.dashboard-grid > .dashboard-cell').forEach(c => {
const b = c.querySelector('.dashboard-body');
if (b && c.dataset.inst) prevBodies.set(c.dataset.inst, b);
});
const freshMount = prevBodies.size === 0 && layout.length > 0;
const holdHeight = host.getBoundingClientRect().height;
if (holdHeight > 0) host.style.minHeight = holdHeight + 'px';
const order = layout.map((_, i) => i).sort((a, b) => (layout[a].y - layout[b].y) || (layout[a].x - layout[b].x));
const room = edit ? `<div class="dash-add-room" aria-hidden="true" style="grid-column: 1 / -1; grid-row: ${layoutBottom(layout) + 1} / span 2;"></div>` : '';
closeAddPop();
host.innerHTML = `<div class="dashboard-grid${edit ? ' is-editing' : ''}">${order.map(i => cellHtml(layout[i], i, edit, hasSettings(layout[i].type))).join('')}${room}</div>${edit ? mobileAddHtml(catalog) : ''}`;
const clickHandler = edit ? editClickHandler(host, layout, catalog, opts) : null;
if (toolbarHost) {
const tools = edit ? await toolsHtml() : '';
if (stale()) return;
toolbarHost.innerHTML = tools;
toolbarHost.onclick = edit ? clickHandler : null;
}
host.querySelectorAll('.dashboard-grid > .dashboard-cell').forEach(cell => {
const it = layout[Number(cell.dataset.cell)];
if (!it) return;
const render = renderers[it.type];
if (typeof render !== 'function') { cell.remove(); return; }
const prev = prevBodies.get(it.instId);
if (prev) { cell.querySelector('.dashboard-body').replaceWith(prev); prevBodies.delete(it.instId); return; }
const body = cell.querySelector('.dashboard-body');
try { render(body, it); }
catch (e) { body.innerHTML = errCard(e); }
});
host.onclick = clickHandler;
host.onchange = edit ? editChangeHandler(host, layout, opts) : null;
if (edit) setupDrag(host, layout, opts);
if (edit) setupAddBar(host, layout, catalog, opts);
teardownEditExit();
if (typeof opts.onEdit === 'function') {
if (edit) setupEditExit(host, opts);
else setupLongPress(host, opts);
}
if (masonryRevealTimer) { clearTimeout(masonryRevealTimer); masonryRevealTimer = null; }
if (freshMount) {
const g = host.querySelector('.dashboard-grid');
if (g) {
g.__freshSettle = true;
g.classList.add('is-loading');
g.insertAdjacentHTML('beforebegin', skeletonHtml(layout));
loading.sizeSkeleton(g.parentElement);
masonryRevealTimer = setTimeout(() => revealGrid(g), DASHBOARD.REVEAL_MAX_MS);
}
}
setupMasonry(host, layout, opts);
if (holdHeight > 0) host.style.minHeight = '';
}
let settingsOutsideHandler = null;
function teardownSettingsOutside() {
if (settingsOutsideHandler) { document.removeEventListener('mousedown', settingsOutsideHandler, true); settingsOutsideHandler = null; }
}
function closeSettingsPanel(panel) {
if (!panel) return;
panel.setAttribute('hidden', '');
panel.innerHTML = '';
const cell = panel.closest('.dashboard-cell');
if (cell) cell.classList.remove('is-settings-open');
teardownSettingsOutside();
}
function editClickHandler(host, layout, catalog, opts) {
return async e => {
const btn = e.target.closest('[data-act]');
if (!btn) return;
const act = btn.dataset.act;
if (act === 'settings') {
const cell = btn.closest('.dashboard-cell');
const panel = cell && cell.querySelector('.dash-settings');
if (!panel) return;
const wasOpen = !panel.hasAttribute('hidden');
host.querySelectorAll('.dash-settings:not([hidden])').forEach(p => { if (p !== panel) closeSettingsPanel(p); });
if (wasOpen) { closeSettingsPanel(panel); return; }
const it = layout[Number(btn.dataset.i)];
const fn = it && opts.settings && opts.settings[it.type];
if (typeof fn !== 'function') return;
const commit = (o = {}) => {
if (o && o.resize) it.h = 0;
writeLayout(layout);
const body = cell.querySelector('.dashboard-body');
const render = (opts.renderers || {})[it.type];
if (body && typeof render === 'function') { try { render(body, it); } catch (e) { body.innerHTML = errCard(e); } }
if (o && o.resize) {
cell.classList.remove('is-sized');
const grid = host.querySelector('.dashboard-grid');
if (grid) armPersist(host, grid, layout, opts);
}
};
panel.innerHTML = '';
fn(panel, it, commit);
panel.removeAttribute('hidden');
cell.classList.add('is-settings-open');
teardownSettingsOutside();
settingsOutsideHandler = ev => {
if (panel.contains(ev.target)) return;
if (ev.target.closest('[data-act="settings"]')) return;
closeSettingsPanel(panel);
};
document.addEventListener('mousedown', settingsOutsideHandler, true);
return;
}
if (act === 'add') {
const c = catalog[Number(btn.dataset.idx)];
if (!c || !DASHBOARD.WIDGETS[c.type]) return;
const inst = newInstance(c); inst.x = 0; inst.y = layoutBottom(layout);
layout.push(inst);
await writeLayout(layout); await rerender(host); return;
}
if (act.startsWith('profile-')) { await profileAction(host, opts, act, btn); return; }
const i = Number(btn.dataset.i);
const it = layout[i];
if (!it) return;
if (act === 'remove') layout.splice(i, 1);
else return;
await writeLayout(layout);
await rerender(host);
};
}
async function profileAction(host, opts, act, btn) {
const huidig = await activeProfile();
try {
if (act === 'profile-pick') {
const id = Number(btn.dataset.id);
if (!Number.isInteger(id) || id === huidig.id) return;
await activateProfile(id);
} else if (act === 'profile-new') {
const res = await dialog.form({
title: t('dashboard.profileNew'),
fields: [{ key: 'name', label: t('dashboard.profileNewPrompt'), value: '' }],
});
if (!res || !String(res.name || '').trim()) return;
await activateProfile(await createProfile({ name: res.name }));
} else if (act === 'profile-rename') {
if (huidig.isSystem) return;
const res = await dialog.form({
title: t('dashboard.profileRename'),
fields: [{ key: 'name', label: t('dashboard.profileRenamePrompt'), value: huidig.name }],
});
if (!res || !String(res.name || '').trim()) return;
await renameProfile(huidig.id, { name: res.name });
} else if (act === 'profile-delete') {
const vraag = huidig.isSystem ? t('dashboard.profileRestartConfirm') : t('dashboard.profileDeleteConfirm');
if (!(await dialog.confirm({ message: vraag, danger: true }))) return;
await deleteProfile(huidig.id);
} else return;
} catch (err) {
dialog.alert({ title: t('dialog.error'), message: err.message });
}
host.innerHTML = '';
await rerender(host);
}
function editChangeHandler(host, layout, opts) {
return async e => {
const sel = e.target.closest('select[data-act="width"]');
if (!sel || !host.contains(sel)) return;
const it = layout[Number(sel.dataset.i)];
if (!it) return;
it.w = clampSpan(sel.value);
it.h = 0;
await writeLayout(layout);
await rerender(host);
};
}
const MOBILE_QUERY = '(max-width:768px)';
let addPop = null;
function addPopOutside(e) {
if (addPop && !addPop.contains(e.target) && !e.target.closest('.dash-add-bar')) closeAddPop();
}
function closeAddPop() {
if (!addPop) return;
addPop.remove();
addPop = null;
document.removeEventListener('mousedown', addPopOutside, true);
}
function setupAddBar(host, layout, catalog, opts) {
const grid = host.querySelector('.dashboard-grid');
if (!grid) return;
const bar = document.createElement('div');
bar.className = 'dash-add-bar';
bar.hidden = true;
bar.innerHTML = `<span class="dash-add-bar-plus" role="button" title="${esc(t('dashboard.add'))}" aria-label="${esc(t('dashboard.add'))}">${PLUS}</span>`;
grid.appendChild(bar);
let run = null;
const cellAt = (clientX, clientY) => {
const gr = grid.getBoundingClientRect();
const cs = getComputedStyle(grid);
const colGap = parseFloat(cs.columnGap) || 0;
const rowGap = parseFloat(cs.rowGap) || 0;
const colW = (gr.width - (DASHBOARD.GRID_COLUMNS - 1) * colGap) / DASHBOARD.GRID_COLUMNS;
return {
col: Math.floor((clientX - gr.left) / (colW + colGap)),
row: Math.floor((clientY - gr.top) / (stepPx() + rowGap)),
};
};
const hide = () => { if (!addPop) { bar.hidden = true; run = null; } };
const follow = (target, clientX, clientY) => {
if (addPop || grid.classList.contains('is-dragging-active') || matchMedia(MOBILE_QUERY).matches) return;
if (target.closest('.dash-add-bar')) return;
if (target.closest('.dashboard-cell')) { hide(); return; }
const { col, row } = cellAt(clientX, clientY);
const r = freeRunAt(layout, col, row, spanSteps);
if (!r) { hide(); return; }
run = r;
bar.style.gridColumn = `${r.x + 1} / span ${r.w}`;
bar.style.gridRow = `${r.y + 1} / span 1`;
bar.hidden = false;
};
grid.addEventListener('pointermove', e => follow(e.target, e.clientX, e.clientY));
grid.addEventListener('pointerdown', e => { if (e.pointerType !== 'mouse') follow(e.target, e.clientX, e.clientY); });
grid.addEventListener('pointerleave', hide);
bar.addEventListener('click', () => {
if (run) openAddPop(host, layout, catalog, opts, run, bar);
});
}
function openAddPop(host, layout, catalog, opts, run, bar) {
closeAddPop();
const items = fittingCatalog(catalog, run);
const pop = document.createElement('div');
pop.className = 'dashboard-add-menu dash-add-pop is-open';
pop.innerHTML = items.length
? items.map((c, i) => `<button type="button" class="dashboard-add-item" data-idx="${i}">${itemLabelHtml(c)}</button>`).join('')
: `<div class="dashboard-add-empty">${esc(catalog.length ? t('dashboard.addNoFit') : t('dashboard.addNone'))}</div>`;
document.body.appendChild(pop);
addPop = pop;
const r = bar.getBoundingClientRect();
const w = pop.offsetWidth, h = pop.offsetHeight;
const left = Math.max(8, Math.min(window.innerWidth - w - 8, r.left + r.width / 2 - w / 2));
const top = r.bottom + 6 + h > window.innerHeight ? Math.max(8, r.top - 6 - h) : r.bottom + 6;
pop.style.left = left + 'px';
pop.style.top = top + 'px';
document.addEventListener('mousedown', addPopOutside, true);
pop.addEventListener('click', async e => {
const btn = e.target.closest('[data-idx]');
const c = btn && items[Number(btn.dataset.idx)];
if (!c) return;
closeAddPop();
const inst = newInstance(c);
Object.assign(inst, placeInRun(run, inst.w));
layout.push(inst);
makeRoom(layout, [layout.length - 1]);
await writeLayout(layout);
await rerender(host);
});
}
let swallowClick = false;
let swallowBound = false;
function setupLongPress(host, opts) {
const grid = host.querySelector('.dashboard-grid');
if (!grid) return;
if (!swallowBound) {
swallowBound = true;
window.addEventListener('click', e => {
if (!swallowClick) return;
swallowClick = false;
e.preventDefault();
e.stopImmediatePropagation();
}, true);
window.addEventListener('pointerdown', () => { swallowClick = false; }, true);
}
grid.addEventListener('pointerdown', e => {
swallowClick = false;
if (e.pointerType !== 'mouse' || e.button !== 0) return;
if (!e.target.closest('.dashboard-cell')) return;
const x0 = e.clientX, y0 = e.clientY;
const stop = () => {
clearTimeout(timer);
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', stop);
window.removeEventListener('pointercancel', stop);
};
const onMove = ev => {
if (Math.hypot(ev.clientX - x0, ev.clientY - y0) > DASHBOARD.LONG_PRESS_SLOP_PX) stop();
};
const timer = setTimeout(() => {
stop();
swallowClick = true;
opts.onEdit(true);
}, DASHBOARD.LONG_PRESS_MS);
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', stop);
window.addEventListener('pointercancel', stop);
});
}
let editExitHandlers = null;
function teardownEditExit() {
if (!editExitHandlers) return;
document.removeEventListener('keydown', editExitHandlers.key);
document.removeEventListener('click', editExitHandlers.click);
editExitHandlers = null;
}
const EDIT_INSIDE = '.dashboard-cell, .dash-add-bar,.dashboard-add-menu, .dash-add-mobile, #page-tools, #nav-tools, #sidebar, .dialog-overlay';
function setupEditExit(host, opts) {
const done = () => {
teardownEditExit();
if (host.isConnected) opts.onEdit(false);
};
const key = e => {
if (e.key !== 'Escape') return;
if (!host.isConnected) { teardownEditExit(); return; }
if (e.defaultPrevented || !e.target.isConnected || document.querySelector('.dialog-overlay')) return;
const panel = host.querySelector('.dash-settings:not([hidden])');
if (panel) { closeSettingsPanel(panel); return; }
if (addPop) { closeAddPop(); return; }
done();
};
const click = e => {
if (!e.target.isConnected) return;
if (e.target.closest(EDIT_INSIDE)) return;
done();
};
editExitHandlers = { key, click };
document.addEventListener('keydown', key);
document.addEventListener('click', click);
}
function setupDrag(host, layout, opts) {
const grid = host.querySelector('.dashboard-grid');
if (!grid) return;
grid.addEventListener('pointerdown', e => {
if (e.button != null && e.button !== 0) return;
const handle = e.target.closest('.dash-edit-grip');
if (!handle || !grid.contains(handle)) return;
const fromCell = handle.closest('.dashboard-cell');
if (!fromCell) return;
e.preventDefault();
startDrag({ host, grid, layout, opts, fromCell, fromIdx: Number(fromCell.dataset.cell), startX: e.clientX, startY: e.clientY });
});
}
function swallowNextClick() {
const eat = e => { e.preventDefault(); e.stopImmediatePropagation(); off(); };
const off = () => {
clearTimeout(timer);
window.removeEventListener('click', eat, true);
window.removeEventListener('pointerdown', off, true);
};
const timer = setTimeout(off, 300);
window.addEventListener('click', eat, true);
window.addEventListener('pointerdown', off, true);
}
function startDrag({ host, grid, layout, opts, fromCell, fromIdx, startX, startY }) {
const it = layout[fromIdx];
if (!it) return;
const w = clampSpan(it.w);
const h = spanSteps(it);
grid.classList.add('is-dragging-active');
fromCell.classList.add('is-dragging');
const srcRect = fromCell.getBoundingClientRect();
const grabX = startX - srcRect.left, grabY = startY - srcRect.top;
const srcInner = fromCell.querySelector('.dashboard-body > *');
const ghost = document.createElement('div');
ghost.className = 'dashboard-drag-ghost';
ghost.style.width = srcRect.width + 'px';
ghost.style.left = srcRect.left + 'px';
ghost.style.top = srcRect.top + 'px';
if (srcInner) ghost.appendChild(srcInner.cloneNode(true));
document.body.appendChild(ghost);
const moveGhost = (x, y) => { ghost.style.left = (x - grabX) + 'px'; ghost.style.top = (y - grabY) + 'px'; };
const preview = document.createElement('div');
preview.className = 'dashboard-drop-preview';
grid.appendChild(preview);
let target = null;
let swapCell = null;
const cellByIdx = idx => grid.querySelector('.dashboard-cell[data-cell="' + idx + '"]');
const clearSwap = () => { if (swapCell) { swapCell.classList.remove('is-drop-swap'); swapCell = null; } };
const snapCell = (clientX, clientY) => {
const gr = grid.getBoundingClientRect();
const cs = getComputedStyle(grid);
const colGap = parseFloat(cs.columnGap) || 0;
const rowGap = parseFloat(cs.rowGap) || 0;
const step = stepPx();
const colW = (gr.width - (DASHBOARD.GRID_COLUMNS - 1) * colGap) / DASHBOARD.GRID_COLUMNS;
let x = Math.round(((clientX - grabX) - gr.left) / (colW + colGap));
x = Math.max(0, Math.min(DASHBOARD.GRID_COLUMNS - w, x));
const y = Math.max(0, Math.round(((clientY - grabY) - gr.top) / (step + rowGap)));
return { x, y };
};
const computeTarget = (clientX, clientY) => {
let over = document.elementFromPoint(clientX, clientY);
over = over && over.closest ? over.closest('.dashboard-cell') : null;
if (over && grid.contains(over) && Number(over.dataset.cell) !== fromIdx) {
return { mode: 'swap', overIdx: Number(over.dataset.cell) };
}
const s = snapCell(clientX, clientY);
return { mode: 'insert', x: s.x, y: s.y };
};
const showTarget = tg => {
if (tg.mode === 'swap') {
preview.style.display = 'none';
const cell = cellByIdx(tg.overIdx);
if (swapCell && swapCell !== cell) clearSwap();
if (cell) { swapCell = cell; cell.classList.add('is-drop-swap'); }
} else {
clearSwap();
preview.style.display = '';
preview.style.gridColumn = (tg.x + 1) + ' / span ' + w;
preview.style.gridRow = (tg.y + 1) + ' / span ' + h;
}
};
const onMove = ev => { moveGhost(ev.clientX, ev.clientY); target = computeTarget(ev.clientX, ev.clientY); showTarget(target); };
moveGhost(startX, startY);
target = computeTarget(startX, startY);
showTarget(target);
const finish = async commit => {
if (commit) swallowNextClick();
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onCancel);
clearSwap();
preview.remove();
ghost.remove();
grid.classList.remove('is-dragging-active');
fromCell.classList.remove('is-dragging');
if (!commit || !target) return;
if (target.mode === 'swap') {
const b = layout[target.overIdx];
if (!b || target.overIdx === fromIdx) return;
const ax = it.x, ay = it.y;
it.x = b.x; it.y = b.y;
b.x = ax; b.y = ay;
if (rectsOverlap(it, b)) makeRoom(layout, [fromIdx]);
else makeRoom(layout, [fromIdx, target.overIdx]);
} else {
if (target.x === it.x && target.y === it.y) return;
it.x = target.x; it.y = target.y;
makeRoom(layout, [fromIdx]);
}
await writeLayout(layout);
await rerender(host);
};
const onUp = () => finish(true);
const onCancel = () => finish(false);
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onCancel);
}
function packGrid(grid) {
if (!grid) return;
const gap = parseFloat(getComputedStyle(grid).columnGap) || 0;
const rows = Math.round(stepPx()) + 'px';
const rowGap = Math.round(gap) + 'px';
if (grid.style.gridAutoRows !== rows) grid.style.gridAutoRows = rows;
if (grid.style.rowGap !== rowGap) grid.style.rowGap = rowGap;
}
function scheduleMasonry(grid) {
if (!grid || grid.__packQueued) return;
grid.__packQueued = true;
requestAnimationFrame(() => { grid.__packQueued = false; packGrid(grid); });
}
function setupMasonry(host, layout, opts) {
const grid = host.querySelector('.dashboard-grid');
if (!grid) return;
packGrid(grid);
if (typeof ResizeObserver !== 'undefined') {
if (masonryRO) masonryRO.disconnect();
masonryRO = new ResizeObserver(() => { scheduleMasonry(grid); armPersist(host, grid, layout, opts); });
grid.querySelectorAll(':scope > .dashboard-cell').forEach(c => masonryRO.observe(c));
}
if (typeof MutationObserver !== 'undefined') {
if (masonryMO) masonryMO.disconnect();
if (growCheckTimer) { clearTimeout(growCheckTimer); growCheckTimer = null; }
masonryMO = new MutationObserver(() => scheduleGrowCheck(host, grid, layout, opts));
grid.querySelectorAll(':scope > .dashboard-cell > .dashboard-body')
.forEach(b => masonryMO.observe(b, { childList: true, subtree: true, characterData: true }));
}
if (!masonryResizeBound) {
masonryResizeBound = true;
window.addEventListener('resize', () => {
const g = document.querySelector('.dashboard-grid');
if (g) scheduleMasonry(g);
});
}
armPersist(host, grid, layout, opts);
}
function armPersist(host, grid, layout, opts) {
if (masonrySettleTimer) clearTimeout(masonrySettleTimer);
masonrySettleTimer = setTimeout(() => persistSettled(host, grid, layout, opts), DASHBOARD.SETTLE_MS);
}
function scheduleGrowCheck(host, grid, layout, opts) {
if (masonrySettleTimer || growCheckTimer) return;
growCheckTimer = setTimeout(() => { growCheckTimer = null; persistSettled(host, grid, layout, opts); }, DASHBOARD.SETTLE_MS);
}
async function persistSettled(host, grid, layout, opts) {
masonrySettleTimer = null;
if (!grid.isConnected) return;
if (!grid.getBoundingClientRect().height) {
if (grid.querySelector(':scope > .dashboard-cell')) armPersist(host, grid, layout, opts);
return;
}
const step = stepPx();
const gap = parseFloat(getComputedStyle(grid).columnGap) || 0;
const minSteps = DASHBOARD.MIN_H;
const GROW_TOL = 4;
const grown = [];
grid.querySelectorAll(':scope > .dashboard-cell').forEach(cell => {
const idx = Number(cell.dataset.cell);
const it = layout[idx];
if (!it) return;
const bodyEl = cell.querySelector('.dashboard-body');
const content = Math.round((bodyEl || cell).scrollHeight);
const wanted = Math.max(minSteps, stepsForPx(content, step, gap));
if (it.h <= 0) { if (wanted !== it.h) { it.h = wanted; grown.push(idx); } }
else if (content > pxForSteps(it.h, step, gap) + GROW_TOL && wanted > it.h) { it.h = wanted; grown.push(idx); }
});
if (!grown.length) { revealGrid(grid); return; }
const fresh = grid.__freshSettle; grid.__freshSettle = false;
if (fresh) packLayout(layout); else makeRoom(layout, grown);
await writeLayout(layout);
try { await setPref(LAYOUT_SCOPE, MODEL_KEY, DASHBOARD.HEIGHT_MODEL); } catch { }
await rerender(host);
}
function revealGrid(grid) {
if (masonryRevealTimer) { clearTimeout(masonryRevealTimer); masonryRevealTimer = null; }
if (!grid || !grid.classList.contains('is-loading')) return;
const host = grid.parentElement;
const skel = host && host.querySelector(':scope > .dashboard-skeleton');
if (skel) skel.remove();
grid.classList.remove('is-loading');
}
function skeletonHtml(layout) {
const step = stepPx();
const cells = layout.map(it => {
const steps = it.h > 0 ? it.h : DASHBOARD.SKELETON_STEPS;
const h = Math.round(steps * step);
return `<div class="dashboard-skel-cell" style="grid-column: span ${it.w}; min-height:${h}px">${loading.card()}</div>`;
}).join('');
return `<div class="dashboard-skeleton" aria-hidden="true"><div class="dashboard-skel-grid">${cells}</div></div>`;
}
+24
View File
@@ -0,0 +1,24 @@
// Generated, do not edit. sha256:0de0d7d8274e0483bbf9a739f8fbd821c59be413f9db532018d5a830258c7352
import { DASHBOARD } from '../../shared/config.js';
export function freeRunAt(layout, col, row, stepsOf) {
const cols = DASHBOARD.GRID_COLUMNS;
if (!Number.isInteger(col) || !Number.isInteger(row) || col < 0 || col >= cols || row < 0) return null;
const taken = x => layout.some(it => x >= it.x && x < it.x + it.w && row >= it.y && row < it.y + stepsOf(it));
if (taken(col)) return null;
let x0 = col, x1 = col + 1;
while (x0 > 0 && !taken(x0 - 1)) x0--;
while (x1 < cols && !taken(x1)) x1++;
return { x: x0, y: row, w: x1 - x0, col };
}
export function fittingCatalog(catalog, run) {
return catalog.filter(c => {
const def = DASHBOARD.WIDGETS[c.type];
return def && run && def.defaultSpan <= run.w;
});
}
export function placeInRun(run, w) {
return { x: Math.max(run.x, Math.min(run.col, run.x + run.w - w)), y: run.y };
}
+87
View File
@@ -0,0 +1,87 @@
// Generated, do not edit. sha256:f8b7d4cfde53f5e36967f7e1ae667dea2e048f4cbedb5088c2d2e8af98709896
import { CONFIG } from '../../shared/config.js';
import { t } from '../i18n/index.js';
const DOORLATEN = new Set(['then', 'toJSON', 'constructor', 'hasOwnProperty', 'nodeType', 'length',
'inspect', 'valueOf', 'toString']);
function bewaakteRij(rij) {
return new Proxy(rij, {
get(doel, naam) {
if (typeof naam === 'symbol' || naam in doel || DOORLATEN.has(naam)) return Reflect.get(doel, naam);
throw new Error(
`Onbekende kolom '${String(naam)}' op een rij met: ${Object.keys(doel).join(', ')}. ` +
`Waarschijnlijk een naam van vóór de baseline.`);
},
});
}
function bewaak(waarde) {
if (waarde instanceof Map) return new Map([...waarde].map(([k, v]) => [k, bewaak(v)]));
if (Array.isArray(waarde)) return waarde.map(bewaak);
if (waarde && typeof waarde === 'object') return bewaakteRij(waarde);
return waarde;
}
export function decodeer(waarde) {
if (Array.isArray(waarde)) return waarde.map(decodeer);
if (waarde && typeof waarde === 'object') {
if (waarde.__t === 'Map') return new Map(waarde.v.map(([k, v]) => [k, decodeer(v)]));
if (waarde.__t === 'Set') return new Set(waarde.v.map(decodeer));
return Object.fromEntries(Object.entries(waarde).map(([k, v]) => [k, decodeer(v)]));
}
return waarde;
}
let guardAan = CONFIG.ROW_GUARD;
export function setRowGuard(aan) { guardAan = !!aan; }
export function rowGuardAan() { return guardAan; }
const DISPATCH_VENSTER_MS = 500;
const laatstGedispatcht = new Map();
export function dispatchChanges(namen) {
if (!Array.isArray(namen) || !namen.length) return [];
const nu = Date.now();
const uit = [];
for (const naam of namen) {
if (typeof naam !== 'string' || !naam) continue;
const vorige = laatstGedispatcht.get(naam);
if (vorige != null && nu - vorige < DISPATCH_VENSTER_MS) continue;
laatstGedispatcht.set(naam, nu);
uit.push(naam);
document.dispatchEvent(new CustomEvent(naam));
}
return uit;
}
export function serverFoutTekst(json) {
if (!json) return '';
if (json.key) {
try { return t(json.key, json.params); } catch { }
}
return json.error || '';
}
export async function rpc(module, functie, ...args) {
let res;
try {
res = await fetch(`/api/data/${module}/${functie}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ args }),
});
} catch (e) {
throw new Error(`No connection to the server (${module}/${functie}): ${e.message}`);
}
if (res.status === 401) {
location.replace(CONFIG.AUTH.LOGIN_PATH);
throw new Error(t('common.sessionExpired'));
}
const json = await res.json().catch(() => null);
if (!res.ok) throw Object.assign(new Error(serverFoutTekst(json) || `HTTP ${res.status} bij ${module}/${functie}`), { key: json?.key ?? null });
if (json && json.changes) dispatchChanges(json.changes);
const result = decodeer(json ? json.result : null);
return guardAan ? bewaak(result) : result;
}
+5
View File
@@ -0,0 +1,5 @@
// Generated, do not edit. sha256:1b478fd1c705f49efb007e47d687e8d3c3e16944ad6127dd9fb3f6aff7cd2f9e
import { rpc } from './_rpc.js';
export const mergeAccounts = (...args) => rpc('accounts-merge', 'mergeAccounts', ...args);
export const mergeCheck = (...args) => rpc('accounts-merge', 'mergeCheck', ...args);
+11
View File
@@ -0,0 +1,11 @@
// Generated, do not edit. sha256:a76e42b4b17445ed23c0a5b34d484c1fa3c93c95b182f816b35b9e874d53a6c1
import { rpc } from './_rpc.js';
export const createAccount = (...args) => rpc('accounts', 'createAccount', ...args);
export const deactivateAccount = (...args) => rpc('accounts', 'deactivateAccount', ...args);
export const deactivateCheck = (...args) => rpc('accounts', 'deactivateCheck', ...args);
export const deleteAccount = (...args) => rpc('accounts', 'deleteAccount', ...args);
export const listAccounts = (...args) => rpc('accounts', 'listAccounts', ...args);
export const listSelectableAccounts = (...args) => rpc('accounts', 'listSelectableAccounts', ...args);
export const reactivateAccount = (...args) => rpc('accounts', 'reactivateAccount', ...args);
export const updateAccount = (...args) => rpc('accounts', 'updateAccount', ...args);
+9
View File
@@ -0,0 +1,9 @@
// Generated, do not edit. sha256:80b585a42433b6d90a3e5c2a9924d92c130d30f48331c7ff7a52c9498a9688c3
import { rpc } from './_rpc.js';
export const aiSessions = (...args) => rpc('ai-audit', 'aiSessions', ...args);
export const listAiToolCalls = (...args) => rpc('ai-audit', 'listAiToolCalls', ...args);
export const countAiToolCalls = (...args) => rpc('ai-audit', 'countAiToolCalls', ...args);
export const lastAiToolCallAt = (...args) => rpc('ai-audit', 'lastAiToolCallAt', ...args);
export const aiToolCallMessages = (...args) => rpc('ai-audit', 'aiToolCallMessages', ...args);
export const countAiToolCallsSince = (...args) => rpc('ai-audit', 'countAiToolCallsSince', ...args);
+10
View File
@@ -0,0 +1,10 @@
// Generated, do not edit. sha256:c54529dca3707ad988dd577ff2218f700b511b00652dc60ab704cd18c7e5d14b
import { rpc } from './_rpc.js';
export const aiProposalsStatus = (...args) => rpc('ai-proposals', 'aiProposalsStatus', ...args);
export const listPendingAiProposals = (...args) => rpc('ai-proposals', 'listPendingAiProposals', ...args);
export const getAiProposal = (...args) => rpc('ai-proposals', 'getAiProposal', ...args);
export const acceptAiProposal = (...args) => rpc('ai-proposals', 'acceptAiProposal', ...args);
export const acceptAiProposals = (...args) => rpc('ai-proposals', 'acceptAiProposals', ...args);
export const rejectAiProposal = (...args) => rpc('ai-proposals', 'rejectAiProposal', ...args);
export const rejectAiProposals = (...args) => rpc('ai-proposals', 'rejectAiProposals', ...args);
+9
View File
@@ -0,0 +1,9 @@
// Generated, do not edit. sha256:0774745d5baf5d1ac279b3fd841bf393edb924718cb10edb23e5f72d43b20b77
import { rpc } from './_rpc.js';
export const apiCallsByProvider = (...args) => rpc('api-metrics', 'apiCallsByProvider', ...args);
export const apiMetricsByCapability = (...args) => rpc('api-metrics', 'apiMetricsByCapability', ...args);
export const apiMetricsGlobal = (...args) => rpc('api-metrics', 'apiMetricsGlobal', ...args);
export const apiMetricsSeries = (...args) => rpc('api-metrics', 'apiMetricsSeries', ...args);
export const apiMetricsTotals = (...args) => rpc('api-metrics', 'apiMetricsTotals', ...args);
export const recordApiCall = (...args) => rpc('api-metrics', 'recordApiCall', ...args);
+5
View File
@@ -0,0 +1,5 @@
// Generated, do not edit. sha256:9c2f2feef6a443ca4b9b2a030dfe347691b73b07fee08484ac2bb9a1afdbc90c
import { rpc } from './_rpc.js';
export const getAssetNote = (...args) => rpc('asset-notes', 'getAssetNote', ...args);
export const setAssetNote = (...args) => rpc('asset-notes', 'setAssetNote', ...args);
+8
View File
@@ -0,0 +1,8 @@
// Generated, do not edit. sha256:b6febc102fa1a8f3cb1bd92d3bd9c51308fdd07a2b8841c36c26aee2e0d2a986
import { rpc } from './_rpc.js';
export const createAsset = (...args) => rpc('assets', 'createAsset', ...args);
export const deleteAsset = (...args) => rpc('assets', 'deleteAsset', ...args);
export const listAssets = (...args) => rpc('assets', 'listAssets', ...args);
export const listBookableAssets = (...args) => rpc('assets', 'listBookableAssets', ...args);
export const updateAsset = (...args) => rpc('assets', 'updateAsset', ...args);
+4
View File
@@ -0,0 +1,4 @@
// Generated, do not edit. sha256:7a9b757076981126008fa611b32466b0c4ea84c9740dc8db3223b7668aa909c2
import { rpc } from './_rpc.js';
export const changesSince = (...args) => rpc('bus', 'changesSince', ...args);
+5
View File
@@ -0,0 +1,5 @@
// Generated, do not edit. sha256:b4a6d5eaaa00e5f6844a5d032a45b92244fec713cce2d7b6906b9bbf12a9a0ec
import { rpc } from './_rpc.js';
export const claimNextCommand = (...args) => rpc('commands', 'claimNextCommand', ...args);
export const resolveCommand = (...args) => rpc('commands', 'resolveCommand', ...args);
+9
View File
@@ -0,0 +1,9 @@
// Generated, do not edit. sha256:fcdff1a53c7e1919f381d8176d33f6f88b3620b864306002867bca5768bececc
import { rpc } from './_rpc.js';
export const activeConnection = (...args) => rpc('connections', 'activeConnection', ...args);
export const deleteConnection = (...args) => rpc('connections', 'deleteConnection', ...args);
export const listConnections = (...args) => rpc('connections', 'listConnections', ...args);
export const recordConnectionStatus = (...args) => rpc('connections', 'recordConnectionStatus', ...args);
export const saveConnection = (...args) => rpc('connections', 'saveConnection', ...args);
export const setActiveConnection = (...args) => rpc('connections', 'setActiveConnection', ...args);
+24
View File
@@ -0,0 +1,24 @@
// Generated, do not edit. sha256:defb7eb8725729bc4363e332e3b27f668e5593fc5ad9d4cdb73dde97b1c913b3
import { rpc } from './_rpc.js';
export const activateProfile = (...args) => rpc('dashboard', 'activateProfile', ...args);
export const activeProfile = (...args) => rpc('dashboard', 'activeProfile', ...args);
export const createProfile = (...args) => rpc('dashboard', 'createProfile', ...args);
export const deleteProfile = (...args) => rpc('dashboard', 'deleteProfile', ...args);
export const listProfiles = (...args) => rpc('dashboard', 'listProfiles', ...args);
export const listWidgets = (...args) => rpc('dashboard', 'listWidgets', ...args);
export const renameProfile = (...args) => rpc('dashboard', 'renameProfile', ...args);
export const saveWidgets = (...args) => rpc('dashboard', 'saveWidgets', ...args);
export function widgetParams(rij) {
if (!rij) return null;
let rest = null;
if (rij.settings != null && rij.settings !== '') {
try {
const v = JSON.parse(rij.settings);
if (v && typeof v === 'object' && !Array.isArray(v)) rest = v;
} catch { }
}
if (rij.assetId == null && !rest) return null;
return { ...(rest || {}), ...(rij.assetId == null ? {} : { assetId: rij.assetId }) };
}
+17
View File
@@ -0,0 +1,17 @@
// Generated, do not edit. sha256:7ddb93aead45334f8213e5120543e2ce9c2fdbc49c3493dc936e72a7e07fb3b7
import { rpc } from './_rpc.js';
export const assetPositions = (...args) => rpc('events', 'assetPositions', ...args);
export const createEvent = (...args) => rpc('events', 'createEvent', ...args);
export const deleteEvent = (...args) => rpc('events', 'deleteEvent', ...args);
export const eventsNetByAccount = (...args) => rpc('events', 'eventsNetByAccount', ...args);
export const eventsWithoutImportRef = (...args) => rpc('events', 'eventsWithoutImportRef', ...args);
export const findEventByRef = (...args) => rpc('events', 'findEventByRef', ...args);
export const firstTaxEventDate = (...args) => rpc('events', 'firstTaxEventDate', ...args);
export const listAssetTrades = (...args) => rpc('events', 'listAssetTrades', ...args);
export const listEvents = (...args) => rpc('events', 'listEvents', ...args);
export const listFlowLegs = (...args) => rpc('events', 'listFlowLegs', ...args);
export const listTradeEvents = (...args) => rpc('events', 'listTradeEvents', ...args);
export const listTrades = (...args) => rpc('events', 'listTrades', ...args);
export const listTransfers = (...args) => rpc('events', 'listTransfers', ...args);
export const updateEvent = (...args) => rpc('events', 'updateEvent', ...args);
+10
View File
@@ -0,0 +1,10 @@
// Generated, do not edit. sha256:3d2479ecc3a9b127fd96f6f5e0ff246e37273a0096ff4f6b4225e400506ea204
import { rpc } from './_rpc.js';
export const acceptHygieneFinding = (...args) => rpc('hygiene', 'acceptHygieneFinding', ...args);
export const fillDerivablePriceOrigins = (...args) => rpc('hygiene', 'fillDerivablePriceOrigins', ...args);
export const hygieneFingerprint = (...args) => rpc('hygiene', 'hygieneFingerprint', ...args);
export const hygieneKinds = (...args) => rpc('hygiene', 'hygieneKinds', ...args);
export const hygieneReport = (...args) => rpc('hygiene', 'hygieneReport', ...args);
export const listHygieneAcceptances = (...args) => rpc('hygiene', 'listHygieneAcceptances', ...args);
export const unacceptHygieneFinding = (...args) => rpc('hygiene', 'unacceptHygieneFinding', ...args);
+6
View File
@@ -0,0 +1,6 @@
// Generated, do not edit. sha256:9e3f0b7599208ec0351dd06de329a368ee8b0aec5e374f17900cb84afb65c3ca
import { rpc } from './_rpc.js';
export const lastIntegrationFailures = (...args) => rpc('integration-log', 'lastIntegrationFailures', ...args);
export const listIntegrationCalls = (...args) => rpc('integration-log', 'listIntegrationCalls', ...args);
export const serviceState = (...args) => rpc('integration-log', 'serviceState', ...args);
+9
View File
@@ -0,0 +1,9 @@
// Generated, do not edit. sha256:abab16d475f5e1157c1883f4362f2317dbe5f56059e56c4a310f27132a6f26ee
import { rpc } from './_rpc.js';
export const bookDueCredit = (...args) => rpc('interest-credits', 'bookDueCredit', ...args);
export const dismissDueCredit = (...args) => rpc('interest-credits', 'dismissDueCredit', ...args);
export const dueCreditsAll = (...args) => rpc('interest-credits', 'dueCreditsAll', ...args);
export const dueCreditsForAccount = (...args) => rpc('interest-credits', 'dueCreditsForAccount', ...args);
export const realizedInterestSub = (...args) => rpc('interest-credits', 'realizedInterestSub', ...args);
export const scheduleForecast = (...args) => rpc('interest-credits', 'scheduleForecast', ...args);
+15
View File
@@ -0,0 +1,15 @@
// Generated, do not edit. sha256:49d76c7bdfff8e21aacf25ba4615ebc3e4c44908244991363de89f4b5302883d
import { rpc } from './_rpc.js';
export const createSchedule = (...args) => rpc('interest-schedules', 'createSchedule', ...args);
export const deleteSchedule = (...args) => rpc('interest-schedules', 'deleteSchedule', ...args);
export const listAllSchedules = (...args) => rpc('interest-schedules', 'listAllSchedules', ...args);
export const createPeriod = (...args) => rpc('interest-schedules', 'createPeriod', ...args);
export const deletePeriod = (...args) => rpc('interest-schedules', 'deletePeriod', ...args);
export const listInterest = (...args) => rpc('interest-schedules', 'listInterest', ...args);
export const listPeriods = (...args) => rpc('interest-schedules', 'listPeriods', ...args);
export const listInterestForAccount = (...args) => rpc('interest-schedules', 'listInterestForAccount', ...args);
export const listSchedulesForAccount = (...args) => rpc('interest-schedules', 'listSchedulesForAccount', ...args);
export const scheduleLabel = (...args) => rpc('interest-schedules', 'scheduleLabel', ...args);
export const updateSchedule = (...args) => rpc('interest-schedules', 'updateSchedule', ...args);
export const updatePeriod = (...args) => rpc('interest-schedules', 'updatePeriod', ...args);
+6
View File
@@ -0,0 +1,6 @@
// Generated, do not edit. sha256:957d597a4bfb509e38384b0a56f933177ad98da3654a8091e1d096d93809f347
import { rpc } from './_rpc.js';
export const createLabel = (...args) => rpc('labels', 'createLabel', ...args);
export const deleteLabel = (...args) => rpc('labels', 'deleteLabel', ...args);
export const listLabels = (...args) => rpc('labels', 'listLabels', ...args);
+9
View File
@@ -0,0 +1,9 @@
// Generated, do not edit. sha256:a456f8dc9c28fdd063403c4c2eef8d6e41c477c6c093ce7ffcb31be14c5e6235
import { rpc } from './_rpc.js';
export const licenseShop = (...args) => rpc('license-buyer', 'licenseShop', ...args);
export const buyerLicenses = (...args) => rpc('license-buyer', 'buyerLicenses', ...args);
export const forgetBuyer = (...args) => rpc('license-buyer', 'forgetBuyer', ...args);
export const saleInfo = (...args) => rpc('license-buyer', 'saleInfo', ...args);
export const placeOrder = (...args) => rpc('license-buyer', 'placeOrder', ...args);
export const orderStatus = (...args) => rpc('license-buyer', 'orderStatus', ...args);
+4
View File
@@ -0,0 +1,4 @@
// Generated, do not edit. sha256:ab6b0bca1cf0d6f7ad3bf3740b3ce12c351a567f2d0e027243b33a65ff31aa10
import { rpc } from './_rpc.js';
export const installLicense = (...args) => rpc('license-install', 'installLicense', ...args);
+4
View File
@@ -0,0 +1,4 @@
// Generated, do not edit. sha256:d90a48e296cc770018512015bf31cae94c3aae0aec94589114777077dee41473
import { rpc } from './_rpc.js';
export const licenseStatus = (...args) => rpc('license-status', 'licenseStatus', ...args);
+8
View File
@@ -0,0 +1,8 @@
// Generated, do not edit. sha256:c8ee3ea7594922b4374e1a96cd8ac1eb601542bf346bd3e985122a2cbe72d1f9
import { rpc } from './_rpc.js';
export const intradaySeries = (...args) => rpc('market', 'intradaySeries', ...args);
export const priceAtMoment = (...args) => rpc('market', 'priceAtMoment', ...args);
export const serviceSpot = (...args) => rpc('market', 'serviceSpot', ...args);
export const serviceCoverage = (...args) => rpc('market', 'serviceCoverage', ...args);
export const serviceDayHistory = (...args) => rpc('market', 'serviceDayHistory', ...args);
+11
View File
@@ -0,0 +1,11 @@
// Generated, do not edit. sha256:32f05b529e2424e1bc6f485748255e6c9c0bc3181418d5b3715c4e68840342e4
import { rpc } from './_rpc.js';
export const createMetalProduct = (...args) => rpc('metals', 'createMetalProduct', ...args);
export const deleteMetalProduct = (...args) => rpc('metals', 'deleteMetalProduct', ...args);
export const hasMetalAssets = (...args) => rpc('metals', 'hasMetalAssets', ...args);
export const listMetalAssets = (...args) => rpc('metals', 'listMetalAssets', ...args);
export const listMetalProducts = (...args) => rpc('metals', 'listMetalProducts', ...args);
export const listMetalProductsForAsset = (...args) => rpc('metals', 'listMetalProductsForAsset', ...args);
export const metalHoldings = (...args) => rpc('metals', 'metalHoldings', ...args);
export const updateMetalProduct = (...args) => rpc('metals', 'updateMetalProduct', ...args);
+8
View File
@@ -0,0 +1,8 @@
// Generated, do not edit. sha256:05a6b3f4ff4f47c46307c9ac80ccc27088deb87eaaf1db6f2988deb654616faa
import { rpc } from './_rpc.js';
export const dismissNotification = (...args) => rpc('notifications', 'dismissNotification', ...args);
export const listActiveNotifications = (...args) => rpc('notifications', 'listActiveNotifications', ...args);
export const notificationDetail = (...args) => rpc('notifications', 'notificationDetail', ...args);
export const currentNotifications = (...args) => rpc('notifications', 'currentNotifications', ...args);
export const refreshNotifications = (...args) => rpc('notifications', 'refreshNotifications', ...args);
+5
View File
@@ -0,0 +1,5 @@
// Generated, do not edit. sha256:191f1aef7a5233f2e6d621a68c738dece7c6834a3df68adccdd082cdcc7521b7
import { rpc } from './_rpc.js';
export const completeSetup = (...args) => rpc('onboarding', 'completeSetup', ...args);
export const setupStatus = (...args) => rpc('onboarding', 'setupStatus', ...args);
+13
View File
@@ -0,0 +1,13 @@
// Generated, do not edit. sha256:aadd4a27f8975ab5f4228e6f75932f3b6c20c61c52e3836e29c9ace64bade8ce
import { rpc } from './_rpc.js';
export const accountHoldings = (...args) => rpc('positions', 'accountHoldings', ...args);
export const accountPositions =(...args) => rpc('positions', 'accountPositions', ...args);
export const assetPositionsByAccount = (...args) => rpc('positions', 'assetPositionsByAccount', ...args);
export const firstPositionDate = (...args) => rpc('positions', 'firstPositionDate', ...args);
export const lightningBalanceDiffs = (...args) => rpc('positions', 'lightningBalanceDiffs', ...args);
export const positionAt = (...args) => rpc('positions', 'positionAt', ...args);
export const positionsByAsset = (...args) => rpc('positions', 'positionsByAsset', ...args);
export const recordLightningBalance = (...args) => rpc('positions', 'recordLightningBalance', ...args);
export const unexplainedByPair = (...args) => rpc('positions', 'unexplainedByPair', ...args);
export const unexplainedChange = (...args) => rpc('positions', 'unexplainedChange', ...args);
+35
View File
@@ -0,0 +1,35 @@
// Generated, do not edit. sha256:b4ad8c5f7165b15620e02825794b0dddb66d0170a5147e3133d866b2a23f0721
import { rpc } from './_rpc.js';
export const deletePref = (...args) => rpc('preferences', 'deletePref', ...args);
export const getPref = (...args) => rpc('preferences', 'getPref', ...args);
export const getSettings = (...args) => rpc('preferences', 'getSettings', ...args);
export const setPref = (...args) => rpc('preferences', 'setPref', ...args);
export const updateSettings = (...args) => rpc('preferences', 'updateSettings', ...args);
export async function tableViewCfg(scope, defaultSort = null, fixedGroup = null) {
const k = `table.${scope}`;
if (fixedGroup) {
const sticky = await getPref(k, 'sticky', {});
return {
sticky,
onStickyChange: v => setPref(k, 'sticky', v).catch(() => { }),
sort: defaultSort, defaultSort, group: fixedGroup, groupChoice: false,
};
}
const view = await getPref(k, 'view',
{ sort: defaultSort, group: null, group2: null, group3: null, groupFilter: {}, groupCollapsed: {} });
const sticky = await getPref(k, 'sticky', {});
return {
sticky,
onStickyChange: v => setPref(k, 'sticky', v).catch(() => { }),
sort: view.sort,
group: view.group,
group2: view.group2 ?? null,
group3: view.group3 ?? null,
groupFilter: view.groupFilter ?? {},
groupCollapsed: view.groupCollapsed ?? {},
defaultSort,
onStateChange: v => setPref(k, 'view', v).catch(() => { }),
};
}
+21
View File
@@ -0,0 +1,21 @@
// Generated, do not edit. sha256:9c96669397f06ce2f157ce7eee34dbe23fdd2da353c005dbfe8d12b9dd299240
import { rpc } from './_rpc.js';
export const addManualPricePoint = (...args) => rpc('price-history', 'addManualPricePoint', ...args);
export const getPriceAt = (...args) => rpc('price-history', 'getPriceAt', ...args);
export const getPricePoint = (...args) => rpc('price-history', 'getPricePoint', ...args);
export const listAllPriceHistory = (...args) => rpc('price-history', 'listAllPriceHistory', ...args);
export const listPriceGaps = (...args) => rpc('price-history', 'listPriceGaps', ...args);
export const ignorePriceGap = (...args) => rpc('price-history', 'ignorePriceGap', ...args);
export const listIgnoredPriceGaps = (...args) => rpc('price-history', 'listIgnoredPriceGaps', ...args);
export const unignorePriceGap = (...args) => rpc('price-history', 'unignorePriceGap', ...args);
export const latestPricePoint = (...args) => rpc('price-history', 'latestPricePoint', ...args);
export const latestSpotPoint = (...args) => rpc('price-history', 'latestSpotPoint', ...args);
export const listPriceHistory = (...args) => rpc('price-history', 'listPriceHistory', ...args);
export const priceHistoryCount = (...args) => rpc('price-history', 'priceHistoryCount', ...args);
export const recordCurrentSpot = (...args) => rpc('price-history', 'recordCurrentSpot', ...args);
export const upsertPriceHistory = (...args) => rpc('price-history', 'upsertPriceHistory', ...args);
export const recordNoPriceDays = (...args) => rpc('price-history', 'recordNoPriceDays', ...args);
export const listNoPriceDays = (...args) => rpc('price-history', 'listNoPriceDays', ...args);
export const listPriceAbsences = (...args) => rpc('price-history', 'listPriceAbsences', ...args);
export const deletePriceAbsences = (...args) => rpc('price-history', 'deletePriceAbsences', ...args);
+9
View File
@@ -0,0 +1,9 @@
// Generated, do not edit. sha256:97443fa6467b58882d3c847667f7ec5feefc100a996ce271a6573e15a4cf3910
import { rpc } from './_rpc.js';
export const clearPriceSourceCell = (...args) => rpc('price-source', 'clearPriceSourceCell', ...args);
export const confirmPriceSourceCell = (...args) => rpc('price-source', 'confirmPriceSourceCell', ...args);
export const listPriceSource = (...args) => rpc('price-source', 'listPriceSource', ...args);
export const ownPriceSourceChain = (...args) => rpc('price-source', 'ownPriceSourceChain', ...args);
export const priceSourceChain = (...args) => rpc('price-source', 'priceSourceChain', ...args);
export const setPriceSourceRank = (...args) => rpc('price-source', 'setPriceSourceRank', ...args);
+15
View File
@@ -0,0 +1,15 @@
// Generated, do not edit. sha256:5e4ef4bc2a2565b1c329aabff8462d3a4406176dfc8e63fc7080facce90cb72f
import { rpc } from './_rpc.js';
export const deleteAllPendingProposals = (...args) => rpc('proposals', 'deleteAllPendingProposals', ...args);
export const deleteProposal = (...args) => rpc('proposals', 'deleteProposal', ...args);
export const ignoreAllPendingProposals = (...args) => rpc('proposals', 'ignoreAllPendingProposals', ...args);
export const ignoreProposal = (...args) => rpc('proposals', 'ignoreProposal', ...args);
export const listHandledProposals = (...args) => rpc('proposals', 'listHandledProposals', ...args);
export const listPendingProposals = (...args) => rpc('proposals', 'listPendingProposals', ...args);
export const resolveProposalAsInterest = (...args) => rpc('proposals', 'resolveProposalAsInterest', ...args);
export const resolveProposalAsInternal =(...args) => rpc('proposals', 'resolveProposalAsInternal', ...args);
export const resolveProposalAsTrade = (...args) => rpc('proposals', 'resolveProposalAsTrade', ...args);
export const resolveProposalAsTransfer = (...args) => rpc('proposals', 'resolveProposalAsTransfer', ...args);
export const revertProposal = (...args) => rpc('proposals', 'revertProposal', ...args);
export const syncTxProposals = (...args) => rpc('proposals', 'syncTxProposals', ...args);
+22
View File
@@ -0,0 +1,22 @@
// Generated, do not edit. sha256:72e387af4ed3d4acd30a530057d9d83c411b2f51d88b3b96f79d51dce9ab149c
import { rpc } from './_rpc.js';
export const clearProviderHmac = (...args) => rpc('providers', 'clearProviderHmac', ...args);
export const createProvider = (...args) => rpc('providers', 'createProvider', ...args);
export const createProviderEndpoint = (...args) => rpc('providers', 'createProviderEndpoint', ...args);
export const createProviderOutput = (...args) => rpc('providers', 'createProviderOutput', ...args);
export const createProviderSymbol = (...args) => rpc('providers', 'createProviderSymbol', ...args);
export const deleteProvider = (...args) => rpc('providers', 'deleteProvider', ...args);
export const deleteProviderEndpoint = (...args) => rpc('providers', 'deleteProviderEndpoint', ...args);
export const deleteProviderOutput = (...args) => rpc('providers', 'deleteProviderOutput', ...args);
export const deleteProviderSymbol = (...args) => rpc('providers', 'deleteProviderSymbol', ...args);
export const getProvider = (...args) => rpc('providers', 'getProvider', ...args);
export const listProviders = (...args) => rpc('providers', 'listProviders', ...args);
export const providerCapabilityMap = (...args) => rpc('providers', 'providerCapabilityMap', ...args);
export const setProviderActive = (...args) => rpc('providers', 'setProviderActive', ...args);
export const setProviderCallLimit = (...args) => rpc('providers', 'setProviderCallLimit', ...args);
export const setProviderHmac = (...args) => rpc('providers', 'setProviderHmac', ...args);
export const updateProvider = (...args) => rpc('providers', 'updateProvider', ...args);
export const updateProviderEndpoint = (...args) => rpc('providers', 'updateProviderEndpoint', ...args);
export const updateProviderOutput = (...args) => rpc('providers', 'updateProviderOutput', ...args);
export const updateProviderSymbol = (...args) => rpc('providers', 'updateProviderSymbol', ...args);
+10
View File
@@ -0,0 +1,10 @@
// Generated, do not edit. sha256:d4dd5293de13bcb4d450a08d8edd7724a2cb3ee1a5ec520a57a9d5a196dc7d80
import { rpc } from './_rpc.js';
export const bookReservation = (...args) => rpc('reservations', 'bookReservation', ...args);
export const createReservation = (...args) => rpc('reservations', 'createReservation', ...args);
export const deleteReservation = (...args) => rpc('reservations', 'deleteReservation', ...args);
export const listDueReservations = (...args) => rpc('reservations', 'listDueReservations', ...args);
export const listReservations = (...args) => rpc('reservations', 'listReservations', ...args);
export const listReservationsForAccount = (...args) => rpc('reservations', 'listReservationsForAccount', ...args);
export const updateReservation = (...args) => rpc('reservations', 'updateReservation', ...args);
+6
View File
@@ -0,0 +1,6 @@
// Generated, do not edit. sha256:2f7e0226173eb1df58f29ec0390b99a4181cee3c0805900be698ecb645960d1a
import { rpc } from './_rpc.js';
export const createScanLog = (...args) => rpc('scan-logs', 'createScanLog', ...args);
export const latestScanLog = (...args) => rpc('scan-logs', 'latestScanLog', ...args);
export const listScanLogs = (...args) => rpc('scan-logs', 'listScanLogs', ...args);
+7
View File
@@ -0,0 +1,7 @@
// Generated, do not edit. sha256:53e150ac657ad243c1234b99306c5a7b09f6eb4fcb4a6de8c2d3d374da23ed45
import { rpc } from './_rpc.js';
export const supportInfo = (...args) => rpc('support', 'supportInfo', ...args);
export const supportMessages = (...args) => rpc('support', 'supportMessages', ...args);
export const sendSupport = (...args) => rpc('support', 'sendSupport', ...args);
export const supportAttachment = (...args) => rpc('support', 'supportAttachment', ...args);
+7
View File
@@ -0,0 +1,7 @@
// Generated, do not edit. sha256:c0d23ea37ddc6695c69eb1b6531c8b951887f5c30452dd318610fcfcb868477c
import { rpc } from './_rpc.js';
export const clearTable = (...args) => rpc('table-viewer', 'clearTable', ...args);
export const deleteTableRow = (...args) => rpc('table-viewer', 'deleteTableRow', ...args);
export const listTableRaw = (...args) => rpc('table-viewer', 'listTableRaw', ...args);
export const tableColumns = (...args) => rpc('table-viewer', 'tableColumns', ...args);
+13
View File
@@ -0,0 +1,13 @@
// Generated, do not edit. sha256:2be827f5b7855361ec06bd60ca60c5d976f355c3f8bb57e1356ee04e368af34b
import { rpc } from './_rpc.js';
export const eventCandidatesForTx = (...args) => rpc('tx-links', 'eventCandidatesForTx', ...args);
export const eventLinkMap = (...args) => rpc('tx-links', 'eventLinkMap', ...args);
export const linkTxEvent = (...args) => rpc('tx-links', 'linkTxEvent', ...args);
export const linksForEvent = (...args) => rpc('tx-links', 'linksForEvent', ...args);
export const linksForTx = (...args) => rpc('tx-links', 'linksForTx', ...args);
export const onchainAsset = (...args) => rpc('tx-links', 'onchainAsset', ...args);
export const syncAddressLinks = (...args) => rpc('tx-links', 'syncAddressLinks', ...args);
export const txCandidatesForEvent = (...args) => rpc('tx-links', 'txCandidatesForEvent', ...args);
export const unlinkTxEvent = (...args) => rpc('tx-links', 'unlinkTxEvent', ...args);
export const walletTxEventMap = (...args) => rpc('tx-links', 'walletTxEventMap', ...args);
+13
View File
@@ -0,0 +1,13 @@
// Generated, do not edit. sha256:b8cbf600ab36d03c83b239c9ff1dd49bbb558bd66a0c9ffc89f3e59150f41929
import { rpc } from './_rpc.js';
export const acceptValuationGap = (...args) => rpc('valuations', 'acceptValuationGap', ...args);
export const createValuation = (...args) => rpc('valuations', 'createValuation', ...args);
export const deleteValuation = (...args) => rpc('valuations', 'deleteValuation', ...args);
export const listOpenValuationDiffs = (...args) => rpc('valuations', 'listOpenValuationDiffs', ...args);
export const listValuations = (...args) => rpc('valuations', 'listValuations', ...args);
export const updateValuation = (...args) => rpc('valuations', 'updateValuation', ...args);
export const valuationDiff = (...args) => rpc('valuations', 'valuationDiff', ...args);
export const valuationGap = (...args) => rpc('valuations', 'valuationGap', ...args);
export const valuationDiffs = (...args) => rpc('valuations', 'valuationDiffs', ...args);
export const latestAnchors = (...args) => rpc('valuations', 'latestAnchors', ...args);
+7
View File
@@ -0,0 +1,7 @@
// Generated, do not edit. sha256:4729896ea697d903a66a55b2e3c47015d5d91f3d507871983d51e85d1dfe5bc2
import { rpc } from './_rpc.js';
export const accountOnchainBalance = (...args) => rpc('wallet-addresses', 'accountOnchainBalance', ...args);
export const listWalletAddresses = (...args) => rpc('wallet-addresses', 'listWalletAddresses', ...args);
export const sourceBalance = (...args) => rpc('wallet-addresses', 'sourceBalance', ...args);
export const upsertWalletAddresses = (...args) => rpc('wallet-addresses', 'upsertWalletAddresses', ...args);
+9
View File
@@ -0,0 +1,9 @@
// Generated, do not edit. sha256:10c2762ed4edc728c9bb085ae65408b05698da3cb759b6e0d83ec573a6f31da5
import { rpc } from './_rpc.js';
export const createWalletSource = (...args) => rpc('wallet-sources', 'createWalletSource', ...args);
export const deleteWalletSource = (...args) => rpc('wallet-sources', 'deleteWalletSource', ...args);
export const listAllWalletSources = (...args) => rpc('wallet-sources', 'listAllWalletSources', ...args);
export const listWalletSources = (...args) => rpc('wallet-sources', 'listWalletSources', ...args);
export const recordSourceBalance = (...args) => rpc('wallet-sources', 'recordSourceBalance', ...args);
export const updateWalletSource = (...args) => rpc('wallet-sources', 'updateWalletSource', ...args);
+7
View File
@@ -0,0 +1,7 @@
// Generated, do not edit. sha256:0e229496c38eb1f378e94fa6b93fdbc8268a8edc7a91fbc647e628f80ad40ad5
import { rpc } from './_rpc.js';
export const labelChannelTxs = (...args) => rpc('wallet-txs', 'labelChannelTxs', ...args);
export const listSourceTxs = (...args) => rpc('wallet-txs', 'listSourceTxs', ...args);
export const setTxLabels = (...args) => rpc('wallet-txs', 'setTxLabels', ...args);
export const upsertWalletTxs = (...args) => rpc('wallet-txs', 'upsertWalletTxs', ...args);
+1
View File
@@ -0,0 +1 @@
// Generated, do not edit. sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
+2466
View File
File diff suppressed because it is too large. Load diff
+28
View File
@@ -0,0 +1,28 @@
// Generated, do not edit. sha256:ce8fbbb506834f134cab273f778af5fb72846a7d201d4112d3740ac9125ead32
import { CONFIG } from '../../shared/config.js';
import { setDateLocale } from '../../shared/time.js';
import { nl } from './nl.js';
import { en } from './en.js';
export const LOCALES = Object.freeze({ nl, en });
let code = 'nl';
let active = nl;
export function setLocale(next) {
if (!LOCALES[next] || !CONFIG.LOCALE_TAGS[next]) throw new Error(`Unknown locale: '${next}'.`);
code = next;
active = LOCALES[next];
setDateLocale(CONFIG.LOCALE_TAGS[next]);
}
export const locale = () => code;
export const localeTag = () => CONFIG.LOCALE_TAGS[code];
export function t(path, params) {
const val = path.split('.').reduce((o, k) => (o == null ? o : o[k]), active);
if (val === undefined) throw new Error(`Missing translation key: '${path}'.`);
if (!params || typeof val !== 'string') return val;
return Object.entries(params).reduce((s, [k, v]) => s.replaceAll(`{${k}}`, String(v)), val);
}
+2538
View File
File diff suppressed because it is too large. Load diff
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160">
<path d="M 46.108 37.099 L 79.826 68.032 L 113.068 37.288 L 142.067 37.244 L 92.564 85.773 L 92.367 123.544 L 80.04 135.851 L 67.262 123.517 L 67.425 85.773 L 17.176 37.092 L 46.108 37.099 Z" style="fill: rgb(0, 0, 0); stroke-width: 1;"></path>
<path d="M 49.901 91.273 L 0 43.722 L 0 24.666 L 64.78 87.731 L 64.689 118.25 L 50.234 105.711 L 49.901 91.273 Z" style="fill: rgb(0, 0, 0); stroke-width: 1;"></path>
<path d="M 95.221 87.779 L 160 24.149 L 160 43.383 L 109.554 93.918 L 109.584 105.756 L 94.924 118.584 L 95.221 87.779 Z" style="fill: rgb(0, 0, 0); stroke-width: 1;"></path>
</svg>

After

Width:  |  Height:  |  Size: 663 B

+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160">
<!-- The tab icon: the same bull as the sidebar logo and the splash, on a tile.
A TILE AND NOT A BARE DRAWING, and a fixed pair of colours: a favicon has no theme and no accent
to follow, so it has to read on a light AND on a dark tab strip. A near-black tile with a light
drawing does that in both, and it is the app's own dark default. The sidebar tile keeps the accent
colour; here that would be a colour the user can change and the browser caches.
The transform is the same 25% corner as .sidebar-logo-icon in styles.css, with a ~85% inset:
the sidebar tile leaves more room around the drawing, but a tab icon at 16px needs every pixel.
THE PATHS ARE THE BULL, verbatim from client/img/bull.svg; tests/structuur/logo.test.mjs
compares the three copies. -->
<rect width="160" height="160" rx="40" fill="#0a0a0a"/>
<g transform="translate(12,12) scale(0.85)" fill="#f2f2f2">
<path d="M 46.108 37.099 L 79.826 68.032 L 113.068 37.288 L 142.067 37.244 L 92.564 85.773 L 92.367 123.544 L 80.04 135.851 L 67.262 123.517 L 67.425 85.773 L 17.176 37.092 L 46.108 37.099 Z"></path>
<path d="M 49.901 91.273 L 0 43.722 L 0 24.666 L 64.78 87.731 L 64.689 118.25 L 50.234 105.711 L 49.901 91.273 Z"></path>
<path d="M 95.221 87.779 L 160 24.149 L 160 43.383 L 109.554 93.918 L 109.584 105.756 L 94.924 118.584 L 95.221 87.779 Z"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160">
<path d="M 0 24.271 L 27.421 24.128 L 80.09 76.255 L 131.907 24.382 L 160 24.271 L 91.288 91.851 L 91.114 125.053 L 80.279 135.872 L 69.046 125.029 L 69.189 91.851 L 0 24.271 Z" style="fill: rgb(0, 0, 0); stroke-width: 1;"></path>
<path d="M 67.716 60.508 L 80.719 47.885 L 92.494 46.814 L 94.411 50.037 L 94.411 53.483 L 92.753 50.771 L 91.022 50.906 L 87.64 55.311 L 92.46 60.516 L 80.106 72.997 L 67.716 60.508 Z" style="fill: rgb(0, 0, 0); stroke-width: 1;"></path>
<path d="M 53.923 95.921 L 10.058 54.121 L 10.058 37.369 L 67.003 92.807 L 66.923 119.635 L 54.217 108.613 L 53.923 95.921 Z" style="fill: rgb(0, 0, 0); stroke-width: 1;"></path>
<path d="M 93.606 92.937 L 150.55 37.003 L 150.55 53.911 L 106.206 98.334 L 106.231 108.739 L 93.344 120.016 L 93.606 92.937 Z" style="fill: rgb(0, 0, 0); stroke-width: 1;"></path>
</svg>

After

Width:  |  Height:  |  Size: 900 B

+68
View File
@@ -0,0 +1,68 @@
<!DOCTYPE html>
<!-- Generated, do not edit. sha256:48674ab7c540cc3e2e1c5aa88fb897c278ec547c2fac611aaa346d1cfb79eb21 -->
<html lang="nl" data-theme="dark" data-accent="neutral">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Atreides Assets</title>
<link rel="icon" href="img/favicon.svg" type="image/svg+xml">
<script src="boot-gate.js"></script>
<link rel="stylesheet" href="vendor/inter/inter.css">
<link rel="stylesheet" href="css/styles.css">
</head>
<body>
<div id="splash" aria-hidden="true">
<div class="splash-stage">
<div class="splash-band">
<div class="splash-wing splash-wing--l"><span></span><span></span><span></span></div>
<div class="splash-logo"></div>
<div class="splash-wing splash-wing--r"><span></span><span></span><span></span></div>
</div>
<div class="splash-title">Atreides Assets</div>
<div class="splash-portfolio" id="splash-portfolio"></div>
<div class="splash-quote">
<span class="splash-quote-text"></span><span class="splash-quote-caret" aria-hidden="true"></span>
</div>
</div>
</div>
<div class="aurora" aria-hidden="true"><div class="blob blob-a"></div><div class="blob blob-b"></div><div class="blob blob-c"></div><div class="bands"><div class="band"></div><div class="band"></div><div class="band"></div></div></div>
<div class="mobile-topbar">
<div class="mobile-topbar-logo">
<span class="sidebar-logo-icon"></span>
<span class="mobile-topbar-names">
<span id="mobile-logo-text">Atreides Assets</span>
<span class="mobile-topbar-portfolio"></span>
</span>
</div>
<div class="statusbar-slot" id="sb-slot-mobile"></div>
<button class="mobile-menu-btn" id="menu-btn">☰</button>
</div>
<div class="page">
<aside class="sidebar" id="sidebar">
<div class="sidebar-logo">
<span class="sidebar-logo-icon"></span><span id="logo-text">Atreides Assets</span>
</div>
<div class="sidebar-portfolio" id="sidebar-portfolio"></div>
<div class="statusbar-slot" id="sb-slot-desktop"></div>
<nav id="nav"></nav>
</aside>
<main class="main"><div id="page-header"></div><div id="view"></div></main>
<div id="page-hint"></div>
</div>
<script type="module" src="app.js"></script>
</body>
</html>
+72
View File
@@ -0,0 +1,72 @@
// Generated, do not edit. sha256:4da5cbe36b669344f62663079e6a6414b09f2f6d56a746c2f3daa2989f1e621a
import { t } from '../i18n/index.js';
import { listIntegrationCalls, lastIntegrationFailures } from '../data/integration-log.js';
import { listProviders } from '../data/providers.js';
import { esc } from '../ui/escape.js';
import * as dialog from '../ui/dialog.js';
import { selectLabel } from '../ui/select-label.js';
import { localDateTimeNl } from '../../shared/time.js';
import { CONFIG } from '../../shared/config.js';
const DAYS = CONFIG.INTEGRATION_LOG.WINDOW_DAYS;
const sourceOf = row => (row.target === 'service' ? 'service' : row.providerKey);
const reasonOf = row => row.detail || row.errorKey || t(`integrationLog.outcome_${row.outcome}`);
export async function lastFailureHtml(source) {
const row = (await lastIntegrationFailures()).find(r => sourceOf(r) === source);
if (!row) return '';
const status = row.httpStatus ? ` (${row.httpStatus})` : '';
return `<div class="t-body-sm ilog-last">
<span class="t-neg">${esc(t('integrationLog.lastFailure'))}</span>
${esc(localDateTimeNl(row.occurredAt))} · ${esc(reasonOf(row))}${esc(status)}
<button class="btn btn-ghost btn-sm" data-ilog-open="${esc(source)}" title="${esc(t('integrationLog.openHint', { n: DAYS }))}">${esc(t('integrationLog.open'))}</button>
</div>`;
}
export function wireFailureLog(host) {
host.querySelectorAll('[data-ilog-open]').forEach(b => {
b.onclick = (e) => { e.stopPropagation(); openFailureLog(b.dataset.ilogOpen); };
});
}
function tableHtml(rows, names) {
if (!rows.length) return `<div class="t-body-sm t-muted">${esc(t('integrationLog.noFailures', { n: DAYS }))}</div>`;
const head = ['colTime', 'colSource', 'colOperation', 'colSubject', 'colOutcome', 'colStatus', 'colKey', 'colDetail', 'colRequestId']
.map(k => `<th${k === 'colRequestId' ? ` title="${esc(t('integrationLog.requestIdHint'))}"` : ''}>${esc(t(`integrationLog.${k}`))}</th>`).join('');
const body = rows.map(r => `<tr>
<td class="ilog-nowrap">${esc(localDateTimeNl(r.occurredAt))}</td>
<td>${esc(names.get(sourceOf(r)) || sourceOf(r))}</td>
<td><code>${esc(r.operation)}</code></td>
<td>${esc(r.subject ?? '')}</td>
<td class="${r.outcome === 'fail' ? 't-neg' : 't-muted'}">${esc(t(`integrationLog.outcome_${r.outcome}`))}</td>
<td class="num">${esc(r.httpStatus ?? '')}</td>
<td><code>${esc(r.errorKey ?? '')}</code></td>
<td>${esc(r.detail ?? '')}</td>
<td><code class="ilog-id">${esc(r.requestId ?? '')}</code></td>
</tr>`).join('');
return `<div class="ilog-scroll"><table class="ilog-table"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`;
}
export async function openFailureLog(source = '') {
const [rows, providers] = await Promise.all([listIntegrationCalls({ days: DAYS }), listProviders()]);
const names = new Map([['service', t('integrationLog.service')], ...providers.map(p => [p.key, p.name])]);
const sources = [...new Set([...rows.map(sourceOf), ...(source ? [source] : [])])]
.sort((a, b) => (a === 'service' ? -1 : b === 'service' ? 1 : String(names.get(a) || a).localeCompare(String(names.get(b) || b))));
const options = [`<option value="">${esc(t('integrationLog.allSources'))}</option>`,
...sources.map(s => `<option value="${esc(s)}"${s === source ? ' selected' : ''}>${esc(names.get(s) || s)}</option>`)].join('');
dialog.detail({
title: t('integrationLog.title'), size: 'xl',
asideHtml: selectLabel(`<select class="ilog-filter" data-ilog-filter>${options}</select>`),
html: '<div data-ilog-body></div>',
});
const overlays = document.querySelectorAll('.dialog-overlay');
const d = overlays[overlays.length - 1];
if (!d) return;
const bodyEl = d.querySelector('[data-ilog-body]');
const filter = d.querySelector('[data-ilog-filter]');
const paint = () => { bodyEl.innerHTML = tableHtml(rows.filter(r => !filter.value || sourceOf(r) === filter.value), names); };
filter.onchange = paint;
paint();
}
+50
View File
@@ -0,0 +1,50 @@
// Generated, do not edit. sha256:e2056779c9285b8b0c7aa5d702fc44b067694e358ed2b791b9b58447551ece12
import { licenseStatus } from './data/license-status.js';
import { getPref, setPref } from './data/preferences.js';
import { t } from './i18n/index.js';
let snapshot = null;
export async function loadLicense() {
snapshot = await licenseStatus();
return snapshot;
}
export function licenseSnapshot() { return snapshot; }
export function locked(feature) {
const f = snapshot?.features;
return f ? f[feature] === false : false;
}
export function limitFor(kind) {
const m = snapshot?.max;
return m && kind in m ? m[kind] : null;
}
export function counterText(kind, current) {
const max = limitFor(kind);
return max === null ? null : t('license.counter', { n: current, max });
}
const PREF_SCOPE = 'license';
const PREF_SEEN = 'seen';
export function licenseFingerprint(s) {
if (!s) return '';
const i = s.installed;
return [s.level, s.why, s.buyer ?? '', s.issued ?? '',
i ? `${i.majorFrom}-${i.majorTo}` : ''].join('|');
}
export async function markLicenseSeen() {
await setPref(PREF_SCOPE, PREF_SEEN, licenseFingerprint(snapshot));
}
export async function licenseChangedSinceSeen() {
if (!snapshot) return false;
const now = licenseFingerprint(snapshot);
const seen = await getPref(PREF_SCOPE, PREF_SEEN, null);
if (seen === null || seen === undefined) { await setPref(PREF_SCOPE, PREF_SEEN, now); return false; }
return seen !== now;
}
+62
View File
@@ -0,0 +1,62 @@
// Generated, do not edit. sha256:437d88ce2721c02e69b9a2fbd8a4429ac52560b575699f0f5ce5e41e7cc24369
import { normalizeMovements, normalizeNwcMovements, nodeBalance, channelTxids } from './lightning-tracker.js';
import { upsertWalletTxs, labelChannelTxs } from '../data/wallet-txs.js';
import { recordSourceBalance } from '../data/wallet-sources.js';
export const isLightningSource = s => s?.kind === 'lnd' || s?.kind === 'nwc';
export function walletTxRows(movements, source) {
const rows = [];
let skipped = 0;
for (const m of movements || []) {
const txid = m.hash || m.ref || null;
if (!txid || !m.at) { skipped++; continue; }
const direction = m.direction === 'out' ? 'out' : 'in';
rows.push({
accountId: source.accountId, sourceId: source.id, layer: 'lightning', txid, direction,
valueSats: Math.max(0, Math.round(Number(m.sats) || 0)),
feeSats: direction === 'out' ? Math.max(0, Math.round(Number(m.fee_sats) || 0)) : null,
blockTime: m.at, blockHeight: null, address: null, kind: m.self ? 'self' : null, memo: m.memo ?? null,
});
}
return { rows, skipped };
}
const msatToSats = msat => Math.max(0, Math.floor(Number(msat || 0) / 1000));
export async function scanLightningSource(source, { tracker = null, nwcClient = null } = {}) {
const tr = tracker || { normalizeMovements, normalizeNwcMovements, nodeBalance, channelTxids };
let movements, balanceSats, kanaalTxids = [];
if (source.kind === 'lnd') {
if (!source.connectionId) throw new Error('A LND source without a connection.');
movements = await tr.normalizeMovements(source.connectionId);
balanceSats = (await tr.nodeBalance(source.connectionId)).channelLocal;
kanaalTxids = [...await tr.channelTxids(source.connectionId)];
} else if (source.kind === 'nwc') {
movements = await tr.normalizeNwcMovements(source.id);
const nwc = nwcClient || await import('./nwc.js');
balanceSats = msatToSats((await nwc.getBalance(source.id))?.balance);
} else {
throw new Error(`Not a lightning source: '${source.kind}'.`);
}
const { rows, skipped } = walletTxRows(movements, source);
if (rows.length) await upsertWalletTxs(rows);
await recordSourceBalance(source.id, balanceSats);
const channelLabels = kanaalTxids.length ? await labelChannelTxs(source.accountId, kanaalTxids) : 0;
return { count: rows.length, skipped, balanceSats, channelLabels, channelTxids: kanaalTxids };
}
export function countMovements(txs, isLinked) {
const groups = new Map();
for (const tx of txs || []) {
const key = tx.layer === 'lightning' ? `ln:${tx.accountId}:${tx.txid}` : `id:${tx.id}`;
groups.set(key, groups.get(key) || isLinked(tx));
}
return { total: groups.size, linked: [...groups.values()].filter(Boolean).length };
}
export function lightningAccountSats(sources) {
const ln = (sources || []).filter(isLightningSource);
const heeftLnd = ln.some(s => s.kind === 'lnd');
return ln.filter(s => s.kind === 'lnd' || !heeftLnd).reduce((sum, s) => sum + (Number(s.balanceSats) || 0), 0);
}
+198
View File
@@ -0,0 +1,198 @@
// Generated, do not edit. sha256:20b466379c38e65d9cee77881aaa8890de723421f475561a2047de7bd3f03d95
import * as lnd from './lnd.js';
function toHex(v) {
const s = String(v || '');
if (!s) return '';
if (/^[0-9a-fA-F]+$/.test(s) && s.length % 2 === 0) return s.toLowerCase();
const bin = atob(s.replace(/-/g, '+').replace(/_/g, '/'));
let h = '';
for (let i = 0; i < bin.length; i++) h += bin.charCodeAt(i).toString(16).padStart(2, '0');
return h;
}
const toSats = v => Math.round(Number(v || 0));
const secToIso = s => (Number(s) ? new Date(Number(s) * 1000).toISOString() : null);
const nsToIso = ns => (Number(ns) ? new Date(Math.floor(Number(ns) / 1e6)).toISOString() : null);
const PAGE = 200;
const MAX_PAGES = 50;
async function fetchPayments(connId) {
const out = [];
let offset = 0;
for (let p = 0; p < MAX_PAGES; p++) {
const res = await lnd.listPayments(connId, { max_payments: PAGE, index_offset: offset, include_incomplete: false });
const batch = (res && res.payments) || [];
out.push(...batch);
const next = Number(res && res.last_index_offset) || 0;
if (!batch.length || next <= offset) break;
offset = next;
}
return out;
}
async function fetchInvoices(connId) {
const out = [];
let offset = 0;
for (let p = 0; p < MAX_PAGES; p++) {
const res = await lnd.listInvoices(connId, { num_max_invoices: PAGE, index_offset: offset });
const batch = (res && res.invoices) || [];
out.push(...batch);
const next = Number(res && res.last_index_offset) || 0;
if (!batch.length || next <= offset) break;
offset = next;
}
return out;
}
async function fetchForwarding(connId) {
const out = [];
let offset = 0;
for (let p = 0; p < MAX_PAGES; p++) {
const res = await lnd.forwardingHistory(connId, { num_max_events: PAGE, index_offset: offset });
const batch = (res && res.forwarding_events) || [];
out.push(...batch);
const next = Number(res && res.last_offset_index) || 0;
if (!batch.length || next <= offset) break;
offset = next;
}
return out;
}
export async function normalizeMovements(connId) {
const [payments, invoices, forwards] = await Promise.all([
fetchPayments(connId), fetchInvoices(connId), fetchForwarding(connId),
]);
const okPayments = payments.filter(p => p.status === 'SUCCEEDED');
const okInvoices = invoices.filter(i => i.state === 'SETTLED' || i.settled === true);
const invoiceHashes = new Set(okInvoices.map(i => toHex(i.r_hash)));
const selfHashes = new Set(okPayments.map(p => toHex(p.payment_hash)).filter(h => invoiceHashes.has(h)));
const movements = [];
for (const p of okPayments) {
const hash = toHex(p.payment_hash);
const isSelf = selfHashes.has(hash);
movements.push({
kind: 'payment',
type: isSelf ? 'transfer' : 'sell',
direction: 'out',
hash,
at: nsToIso(p.creation_time_ns) || secToIso(p.creation_date),
sats: toSats(p.value_sat),
fee_sats: toSats(p.fee_sat),
self: isSelf,
memo: null,
});
}
for (const i of okInvoices) {
const hash = toHex(i.r_hash);
if (selfHashes.has(hash)) continue;
movements.push({
kind: 'invoice',
type: 'buy',
direction: 'in',
hash,
at: secToIso(i.settle_date) || secToIso(i.creation_date),
sats: toSats(i.amt_paid_sat || i.value),
fee_sats: 0,
self: false,
memo: i.memo || null,
});
}
for (const f of forwards) {
const fee = toSats(f.fee);
if (!fee) continue;
movements.push({
kind: 'forward',
type: 'interest',
direction: 'route',
hash: null,
ref: `fwd:${f.timestamp_ns || f.timestamp || ''}:${f.chan_id_in || ''}:${f.chan_id_out || ''}`,
at: nsToIso(f.timestamp_ns) || secToIso(f.timestamp),
sats: fee,
fee_sats: 0,
self: false,
memo: null,
});
}
movements.sort((a, b) => (a.at || '').localeCompare(b.at || ''));
return movements;
}
export function nwcMemo(x) {
const beschrijving = typeof x?.description === 'string' ? x.description : '';
const zap = x?.metadata && typeof x.metadata.nostr === 'object' ? x.metadata.nostr : null;
if (zap && !beschrijving.trim().startsWith('{')) return { kind: 9734, ...zap };
return beschrijving || (typeof x?.metadata?.comment === 'string' ? x.metadata.comment : null) || null;
}
export async function normalizeNwcMovements(sourceId) {
const nwc = await import('./nwc.js');
const all = [];
let offset = 0;
for (let p = 0; p < MAX_PAGES; p++) {
const res = await nwc.listTransactions(sourceId, { limit: PAGE, offset, unpaid: false });
const batch = (res && res.transactions) || [];
all.push(...batch);
if (batch.length < PAGE) break;
offset += batch.length;
}
const inHashes = new Set(all.filter(x => x.type === 'incoming').map(x => String(x.payment_hash || '')).filter(Boolean));
const selfHashes = new Set(all.filter(x => x.type === 'outgoing').map(x => String(x.payment_hash || '')).filter(h => inHashes.has(h)));
const movements = [];
for (const x of all) {
const hash = String(x.payment_hash || '');
const isSelf = !!hash && selfHashes.has(hash);
if (x.type === 'incoming' && isSelf) continue;
movements.push({
kind: 'nwc',
type: isSelf ? 'transfer' : (x.type === 'incoming' ? 'buy' : 'sell'),
direction: x.type === 'incoming' ? 'in' : 'out',
hash: hash || null,
at: secToIso(x.settled_at) || secToIso(x.created_at),
sats: Math.round(Number(x.amount || 0) / 1000),
fee_sats: Math.round(Number(x.fees_paid || 0) / 1000),
self: isSelf,
memo: nwcMemo(x),
});
}
movements.sort((a, b) => (a.at || '').localeCompare(b.at || ''));
return movements;
}
export async function nodeBalance(connId) {
const [chain, chan, chans] = await Promise.all([
lnd.blockchainBalance(connId), lnd.channelBalance(connId), lnd.listChannels(connId),
]);
const amt = a => toSats(a && typeof a === 'object' ? a.sat : a);
const onchainConfirmed = toSats(chain && chain.confirmed_balance);
const onchainUnconfirmed = toSats(chain && chain.unconfirmed_balance);
const channelLocal = (chans && Array.isArray(chans.channels) ? chans.channels : [])
.reduce((s, ch) => s + toSats(ch && ch.local_balance) + (ch && ch.initiator ? toSats(ch.commit_fee) : 0), 0);
const unsettledLocal = amt(chan && chan.unsettled_local_balance);
const pendingOpen = amt(chan && chan.pending_open_local_balance) || toSats(chan && chan.pending_open_balance);
const anchor = onchainConfirmed + channelLocal;
const inflight = onchainUnconfirmed + unsettledLocal + pendingOpen;
return { onchainConfirmed, onchainUnconfirmed, channelLocal, unsettledLocal, pendingOpen, anchor, inflight };
}
const ZERO_TXID = /^0+$/;
export async function channelTxids(connId) {
const [open, closed] = await Promise.all([lnd.listChannels(connId), lnd.listClosedChannels(connId)]);
const out = new Set();
const funding = point => String(point || '').split(':')[0].toLowerCase();
for (const ch of (open && open.channels) || []) if (funding(ch.channel_point)) out.add(funding(ch.channel_point));
for (const ch of (closed && closed.channels) || []) {
if (funding(ch.channel_point)) out.add(funding(ch.channel_point));
const sluit = String(ch.closing_tx_hash || '').toLowerCase();
if (sluit && !ZERO_TXID.test(sluit)) out.add(sluit);
}
return out;
}
+48
View File
@@ -0,0 +1,48 @@
// Generated, do not edit. sha256:bf0605d21f5259254ae5ab85694c9ae241adda242e38f4332cc0db1ae9405d9d
import { CONFIG } from '../../shared/config.js';
import { t } from '../i18n/index.js';
import { serverFoutTekst } from '../data/_rpc.js';
async function api(path, { method = 'GET', body = null } = {}) {
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), CONFIG.LND.CLIENT_TIMEOUT_MS);
try {
const res = await fetch(path, {
method,
signal: ctl.signal,
...(body ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {}),
});
const json = await res.json().catch(() => null);
if (!res.ok) throw new Error(serverFoutTekst(json) || `HTTP ${res.status}`);
return json;
} catch (e) {
if (e.name === 'AbortError') throw new Error(t('common.backendSilent', { s: CONFIG.LND.CLIENT_TIMEOUT_MS / 1000 }));
throw e;
} finally { clearTimeout(timer); }
}
export const status = id => api(`/api/lnd/status?connection=${encodeURIComponent(id)}`);
export const setConfig = (id, { url, macaroon, tls_cert }) =>
api('/api/lnd/config', { method: 'POST', body: { connection: id, url, macaroon, tls_cert } });
export const purgeConfig = id => api('/api/lnd/delete', { method: 'POST', body: { connection: id } });
export const call = (id, endpoint, { query = null, body = null } = {}) =>
api('/api/lnd/call', { method: 'POST', body: { connection: id, endpoint, query, body } });
export const getInfo = id => call(id, 'getinfo');
export const channelBalance = id => call(id, 'balance_channels');
export const blockchainBalance = id => call(id, 'balance_blockchain');
export const listChannels = id => call(id, 'channels');
export const listClosedChannels = id => call(id, 'channels_closed');
export const listPayments = (id, query = {}) => call(id, 'payments', { query });
export const listInvoices = (id, query = {}) => call(id, 'invoices', { query });
export const forwardingHistory = (id, body = {}) => call(id, 'forwarding', { body });
export async function testLnd(id) {
const info = await getInfo(id);
return {
alias: info.alias || null,
synced: !!info.synced_to_chain,
blockHeight: Number(info.block_height) || 0,
};
}
+19
View File
@@ -0,0 +1,19 @@
// Generated, do not edit. sha256:c864b8912c6f9fb6e1f43a0e7802dcf91da8cc548250568950b56366eecfca04
import { zapContext, npubShort, compactMemo } from '../../shared/zap.js';
import { t } from '../i18n/index.js';
export function memoLine(memo, direction, max = 80) {
const z = zapContext(memo);
let text;
if (z) {
const wie = npubShort(direction === 'in' ? z.sender : z.recipient);
const kop = direction === 'in'
? (wie ? t('tracking.zapFrom', { who: wie }) : t('tracking.zapIn'))
: (wie ? t('tracking.zapTo', { who: wie }) : t('tracking.zapOut'));
text = z.message ? `${kop}: "${z.message}"` : kop;
} else {
text = compactMemo(memo);
}
if (!text) return null;
return { text: text.length > max ? `${text.slice(0, max - 1)}…` : text, title: text };
}
+36
View File
@@ -0,0 +1,36 @@
// Generated, do not edit. sha256:5e5e2f16e143c15a74105363f7c477f9e5d2936d0860e2793eadda36b591c7e6
import { CONFIG } from '../../shared/config.js';
import { t } from '../i18n/index.js';
import { serverFoutTekst } from '../data/_rpc.js';
async function api(path, { method = 'GET', body = null } = {}) {
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), CONFIG.NWC.CLIENT_TIMEOUT_MS);
try {
const res = await fetch(path, {
method,
signal: ctl.signal,
...(body ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {}),
});
const json = await res.json().catch(() => null);
if (!res.ok) throw new Error(serverFoutTekst(json) || `HTTP ${res.status}`);
return json;
} catch (e) {
if (e.name === 'AbortError') throw new Error(t('common.backendSilent', { s: CONFIG.NWC.CLIENT_TIMEOUT_MS / 1000 }));
throw e;
} finally { clearTimeout(timer); }
}
export const validateUri = uri => api('/api/nwc/validate', { method: 'POST', body: { uri } });
export const status = sourceId => api(`/api/nwc/status?source=${encodeURIComponent(sourceId)}`);
export const setUri = (sourceId, uri) => api('/api/nwc/config', { method: 'POST', body: { source: sourceId, uri } });
export const purgeUri = sourceId => api('/api/nwc/delete', { method: 'POST', body: { source: sourceId } });
export const call = (sourceId, method, params = {}) =>
api('/api/nwc/call', { method: 'POST', body: { source: sourceId, method, params } });
export const getInfo = sourceId => call(sourceId, 'get_info');
export const getBalance = sourceId => call(sourceId, 'get_balance');
export const listTransactions = (sourceId, params = {}) => call(sourceId, 'list_transactions', params);
export const testNwc = sourceId => api('/api/nwc/test', { method: 'POST', body: { source: sourceId } });
+41
View File
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<!-- Generated, do not edit. sha256:058d0c2c7bb9fb3a4c45ec211e5d538d2fa3782cb66efad906c9f66be2a0094d -->
<html lang="nl" data-theme="dark" data-accent="neutral">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Atreides Assets</title>
<script src="login.js"></script>
<link rel="icon" href="img/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="vendor/inter/inter.css">
<link rel="stylesheet" href="css/styles.css">
</head>
<body>
<main class="login-page">
<div class="splash-stage">
<div class="splash-band">
<div class="splash-wing splash-wing--l"><span></span><span></span><span></span></div>
<div class="splash-logo"></div>
<div class="splash-wing splash-wing--r"><span></span><span></span><span></span></div>
</div>
<div class="splash-title">Atreides Assets</div>
<div class="splash-portfolio"></div>
<form class="login-form" id="login-form" method="post" action="/api/login" autocomplete="on">
<div class="login-row">
<input class="input login-input" id="login-password" name="password" type="password"
placeholder="Wachtwoord" aria-label="Wachtwoord"
autocomplete="current-password" autofocus>
<button class="btn btn-primary btn-md" id="login-submit" type="submit">Openen</button>
</div>
<p class="form-error login-error" id="login-error" role="alert"></p>
</form>
</div>
</main>
</body>
</html>
+78
View File
@@ -0,0 +1,78 @@
// Generated, do not edit. sha256:57b15ebfb354db8adff68add2532edc1829f9c3b7ed8450a9de6ff4be9df9286
(function () {
try {
var naam = localStorage.getItem('portfolio_active');
if (naam) {
document.documentElement.dataset.portfolio = naam;
document.documentElement.style.setProperty('--portfolio-name', JSON.stringify(naam));
}
} catch (e0) { }
try {
var achtervoegsel = document.documentElement.dataset.portfolio
? ':' + document.documentElement.dataset.portfolio : '';
var theme = localStorage.getItem('ui_theme' + achtervoegsel);
var accent = localStorage.getItem('ui_accent' + achtervoegsel);
var depth = localStorage.getItem('ui_depth' + achtervoegsel);
if (theme === 'dark' || theme === 'light') document.documentElement.dataset.theme = theme;
if (accent) document.documentElement.dataset.accent = accent;
if (depth === 'flat' || depth === 'deep') document.documentElement.dataset.depth = depth;
} catch (e1) { }
try {
var look = JSON.parse(localStorage.getItem('ui_look' + achtervoegsel) || '{}');
var LOOK_ATTRS = ['profile', 'glow', 'glowshape', 'motion', 'rowsel', 'render'];
for (var i = 0; i < LOOK_ATTRS.length; i++) {
var v = look && look[LOOK_ATTRS[i]];
if (typeof v === 'string' && /^[a-z]+$/.test(v)) document.documentElement.dataset[LOOK_ATTRS[i]] = v;
}
var LOOK_TOKENS = ['--glass-transparency', '--glass-frost', '--dialog-transparency'];
for (var j = 0; j < LOOK_TOKENS.length; j++) {
var w = look && look[LOOK_TOKENS[j]];
if (typeof w === 'string' && /^\d{1,3}(%|px)?$/.test(w)) document.documentElement.style.setProperty(LOOK_TOKENS[j], w);
}
} catch (e2) { }
document.addEventListener('DOMContentLoaded', function () {
var form = document.getElementById('login-form');
var veld = document.getElementById('login-password');
var knop = document.getElementById('login-submit');
var fout = document.getElementById('login-error');
if (!form || !veld || !knop || !fout) return;
function melding(antwoord) {
var wacht = antwoord && antwoord.retryAfterMs;
if (wacht > 0) {
var seconden = Math.ceil(wacht / 1000);
return 'Te veel pogingen. Probeer het over ' + seconden
+ (seconden === 1 ? ' seconde' : ' seconden') + ' opnieuw.';
}
return 'Onjuist wachtwoord.';
}
form.addEventListener('submit', function (e) {
e.preventDefault();
fout.textContent = '';
knop.disabled = true;
fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: veld.value }),
}).then(function (res) {
return res.json().catch(function () { return null; }).then(function (json) {
return { ok: res.ok, json: json };
});
}).then(function (uit) {
if (uit.ok) {
location.replace('/');
return;
}
knop.disabled = false;
fout.textContent = melding(uit.json);
veld.select();
}).catch(function () {
knop.disabled = false;
fout.textContent = 'Geen verbinding met de server.';
});
});
});
})();
+25
View File
@@ -0,0 +1,25 @@
// Generated, do not edit. sha256:2d07cc9fa8245d2a586ce4b51503f9ca7d40676b66802c6c06d44ad790d35373
import { CONFIG } from '../shared/config.js';
import { t } from './i18n/index.js';
import { serverFoutTekst } from './data/_rpc.js';
async function api(path, { method = 'GET', body = null } = {}) {
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), CONFIG.MARKET_SERVICE.CLIENT_TIMEOUT_MS);
try {
const res = await fetch(path, {
method,
signal: ctl.signal,
...(body ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {}),
});
const json = await res.json().catch(() => null);
if (!res.ok) throw new Error(serverFoutTekst(json) || `HTTP ${res.status}`);
return json;
} catch (e) {
if (e.name === 'AbortError') throw new Error(t('common.backendSilent', { s: CONFIG.MARKET_SERVICE.CLIENT_TIMEOUT_MS / 1000 }));
throw e;
} finally { clearTimeout(timer); }
}
export const status = () => api('/api/market/status');
export const test = () => api('/api/market/test', { method: 'POST', body: {} });
+44
View File
@@ -0,0 +1,44 @@
// Generated, do not edit. sha256:48d09dbb5cf2116c2b2c3990d8d1b662451d072fe40458fddcaa483e74bb3100
import { bech32, hexToBytes, bytesToHex, schnorr } from '../vendor/crypto/noble-scure.js';
const HEX64 = /^[0-9a-f]{64}$/;
export function npubFromHex(hex) {
const v = String(hex ?? '').toLowerCase();
if (!HEX64.test(v)) return null;
return bech32.encode('npub', bech32.toWords(hexToBytes(v)));
}
export function shortKey(key, keep = 8) {
const k = String(key ?? '');
return k.length > keep * 2 + 1 ? `${k.slice(0, keep)}…${k.slice(-keep)}` : k;
}
function validSecret(bytes) {
try { schnorr.getPublicKey(bytes); return true; } catch { return false; }
}
export function secretFromText(text) {
const v = String(text ?? '').trim();
if (v.startsWith('npub1')) return { error: 'npub' };
let bytes = null;
if (HEX64.test(v.toLowerCase())) bytes = hexToBytes(v.toLowerCase());
else if (v.startsWith('nsec1')) {
try {
const { prefix, words } = bech32.decode(v);
if (prefix === 'nsec') bytes = Uint8Array.from(bech32.fromWords(words));
} catch { bytes = null; }
}
return bytes && bytes.length === 32 && validSecret(bytes) ? { secret: bytes } : { error: 'invalid' };
}
export const publicKeyOf = (secret) => bytesToHex(schnorr.getPublicKey(secret));
export const nsecFromSecret = (secret) => bech32.encode('nsec', bech32.toWords(secret));
export function newSecret(random = (n) => crypto.getRandomValues(new Uint8Array(n))) {
for (;;) {
const bytes = random(32);
if (validSecret(bytes)) return bytes;
}
}
+74
View File
@@ -0,0 +1,74 @@
// Generated, do not edit. sha256:c9e44c618374bc6218029ec68095d9d8a62c6e5839ffe1dd3084b46600ccb118
import {
secp256k1, sha256, chacha20, hkdfExtract, hkdfExpand, hmac, base64,
hexToBytes, utf8ToBytes, concatBytes,
} from '../vendor/crypto/noble-scure.js';
const VERSION = 2;
const SALT = utf8ToBytes('nip44-v2');
const MIN = 1;
const MAX = 65535;
const asBytes = (x) => (typeof x === 'string' ? hexToBytes(x) : x);
export function conversationKey(secret, pubkey) {
const shared = secp256k1.getSharedSecret(asBytes(secret), concatBytes(Uint8Array.of(2), asBytes(pubkey)));
return hkdfExtract(sha256, shared.subarray(1, 33), SALT);
}
export function messageKeys(convKey, nonce) {
const keys = hkdfExpand(sha256, asBytes(convKey), asBytes(nonce), 76);
return { chachaKey: keys.subarray(0, 32), chachaNonce: keys.subarray(32, 44), hmacKey: keys.subarray(44, 76) };
}
export function paddedLength(len) {
if (len <= 32) return 32;
const next = 1 << (Math.floor(Math.log2(len - 1)) + 1);
const chunk = next <= 256 ? 32 : next / 8;
return chunk * (Math.floor((len - 1) / chunk) + 1);
}
function pad(text) {
const bytes = utf8ToBytes(text);
if (bytes.length < MIN || bytes.length > MAX) throw new Error(`A NIP-44 message is ${MIN} to ${MAX} bytes.`);
const out = new Uint8Array(2 + paddedLength(bytes.length));
out[0] = bytes.length >> 8;
out[1] = bytes.length & 0xff;
out.set(bytes, 2);
return out;
}
function unpad(padded) {
const len = (padded[0] << 8) | padded[1];
if (len < MIN || padded.length !== 2 + paddedLength(len)) throw new Error('Invalid padding.');
return new TextDecoder('utf-8', { fatal: true }).decode(padded.subarray(2, 2 + len));
}
function sameBytes(a, b) {
if (a.length !== b.length) return false;
let d = 0;
for (let i = 0; i < a.length; i++) d |= a[i] ^ b[i];
return d === 0;
}
export function encrypt(text, convKey, nonce = crypto.getRandomValues(new Uint8Array(32))) {
const n = asBytes(nonce);
const { chachaKey, chachaNonce, hmacKey } = messageKeys(convKey, n);
const ciphertext = chacha20(chachaKey, chachaNonce, pad(text));
const mac = hmac(sha256, hmacKey, concatBytes(n, ciphertext));
return base64.encode(concatBytes(Uint8Array.of(VERSION), n, ciphertext, mac));
}
export function decrypt(payload, convKey) {
const p = String(payload ?? '');
if (!p || p[0] === '#') throw new Error('Unknown NIP-44 version.');
if (p.length < 132 || p.length > 87472) throw new Error('Invalid NIP-44 payload length.');
const data = base64.decode(p);
if (data[0] !== VERSION) throw new Error(`Unknown NIP-44 version ${data[0]}.`);
const nonce = data.subarray(1, 33);
const ciphertext = data.subarray(33, data.length - 32);
const mac = data.subarray(data.length - 32);
const { chachaKey, chachaNonce, hmacKey } = messageKeys(convKey, nonce);
if (!sameBytes(mac, hmac(sha256, hmacKey, concatBytes(nonce, ciphertext)))) throw new Error('Invalid MAC.');
return unpad(chacha20(chachaKey, chachaNonce, ciphertext));
}
+58
View File
@@ -0,0 +1,58 @@
// Generated, do not edit. sha256:f29f3a90c87550fd7890bda37eec12ad08ada3b62d0dc1ae18995512314d74ce
import { schnorr, sha256, bytesToHex, hexToBytes, utf8ToBytes } from '../vendor/crypto/noble-scure.js';
import { publicKeyOf } from './keys.js';
import { encrypt as nip44, decrypt as nip44Open, conversationKey } from './nip44.js';
const KIND = 27235;
export const eventId = (e) => bytesToHex(sha256(utf8ToBytes(JSON.stringify([0, e.pubkey, e.created_at, e.kind, e.tags, e.content]))));
export function nip98Event({ pubkey, url, method, body = '', now = Date.now() }) {
const tags = [['u', url], ['method', method]];
if (body) tags.push(['payload', bytesToHex(sha256(utf8ToBytes(body)))]);
return { pubkey, created_at: Math.floor(now / 1000), kind: KIND, tags, content: '' };
}
export function keySigner(secret) {
const pubkey = publicKeyOf(secret);
return {
kind: 'key',
pubkey,
async sign(event) {
const id = eventId(event);
return { ...event, id, sig: bytesToHex(schnorr.sign(hexToBytes(id), secret)) };
},
async nip44Encrypt(pubkey, text) {
return nip44(text, conversationKey(secret, pubkey));
},
async nip44Decrypt(pubkey, payload) {
return nip44Open(payload, conversationKey(secret, pubkey));
},
};
}
export async function extensionSigner(nostr = globalThis.nostr) {
if (!nostr || typeof nostr.getPublicKey !== 'function' || typeof nostr.signEvent !== 'function') {
throw new Error('No nostr extension (NIP-07) in this browser.');
}
const pubkey = String(await nostr.getPublicKey()).toLowerCase();
return {
kind: 'extension',
pubkey,
sign: (event) => nostr.signEvent(event),
nip44Encrypt: (to, text) => {
if (typeof nostr.nip44?.encrypt !== 'function') throw new Error('This nostr extension cannot encrypt with NIP-44.');
return nostr.nip44.encrypt(to, text);
},
nip44Decrypt: (from, payload) => {
if (typeof nostr.nip44?.decrypt !== 'function') throw new Error('This nostr extension cannot decrypt with NIP-44.');
return nostr.nip44.decrypt(from, payload);
},
};
}
export async function nip98Header(signer, { url, method, body = '', now = Date.now() }) {
const signed = await signer.sign(nip98Event({ pubkey: signer.pubkey, url, method, body, now }));
if (signed.pubkey !== signer.pubkey) throw new Error('The signer signed with another key than it gave.');
return `Nostr ${btoa(JSON.stringify(signed))}`;
}
+70
View File
@@ -0,0 +1,70 @@
// Generated, do not edit. sha256:e845407dddf225ff542fbca232d3d1694ac7796e6550456ebc528cb13c3d79b8
import { t } from '../i18n/index.js';
import { esc } from '../ui/escape.js';
import * as dialog from '../ui/dialog.js';
import { currencyName } from '../ui/format.js';
import { splashMarkHtml } from '../ui/splash.js';
import { completeSetup, setupStatus } from '../data/onboarding.js';
import { serverFoutTekst } from '../data/_rpc.js';
import { appSetting, baseCurrency } from '../app-settings.js';
const ASSETS = ['BTC'];
const CURRENCIES = ['EUR', 'USD'];
const ACCOUNT_KINDS = ['wallet', 'exchange', 'bank'];
const WIDGETS = ['standing', 'spot'];
const tile = ({ attr, key, value, on, name, sub = '' }) => `
<button type="button" class="setup-choice${on ? ' is-on' : ''}" ${attr}="${key}" data-value="${esc(value)}" aria-pressed="${on}">
<span class="setup-choice-name">${esc(name)}</span>${sub ? `<span class="setup-choice-sub">${esc(sub)}</span>` : ''}
</button>`;
const has = (s, key, v) => Array.isArray(s[key]) && s[key].includes(v);
const toggles = (s, key, values, label) => `<div class="setup-choices">${values.map(v => tile({
attr: 'data-step-toggle', key, value: v, on: has(s, key, v), name: label(v) })).join('')}</div>`;
const steps = status => [
{ title: t('setup.currencyTitle'), skip: () => !status.currencyOpen,
html: s => `<div class="setup-choices">${CURRENCIES.map(c => tile({
attr: 'data-step-set', key: 'currency', value: c, on: s.currency === c, name: currencyName(c), sub: c })).join('')}</div>` },
{ title: t('setup.accountsTitle'), html: s => toggles(s, 'accounts', ACCOUNT_KINDS, k => t(`setup.account_${k}`)) },
{ title: t('setup.dashboardTitle'), html: s => toggles(s, 'widgets', WIDGETS, w => t(`setup.widget_${w}`)) },
];
async function chooseCurrency(code) {
const r = await fetch('/api/setup/currency', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ baseCurrency: code }),
});
const uit = await r.json().catch(() => null);
if (!r.ok) throw new Error(serverFoutTekst(uit) || `HTTP ${r.status}`);
return uit.changed;
}
export async function openSetup({ rerun = false } = {}) {
try {
const status = await setupStatus();
const state = await dialog.stepper({
steps: steps(status), hero: splashMarkHtml(), finishLabel: t('setup.finish'),
state: { currency: baseCurrency(), accounts: rerun ? [] : ['bank'], widgets: [...WIDGETS] },
});
if (!state && rerun) return;
const reload = !!state && status.currencyOpen && await chooseCurrency(state.currency);
await completeSetup(state ? {
assets: ASSETS,
persona: rerun ? appSetting('persona') : 'broad',
accounts: state.accounts.map(k => ({ kind: k, name: t(`setup.account_${k}`) })),
widgets: state.widgets,
} : {});
if (reload) location.reload();
else document.dispatchEvent(new CustomEvent('settings-changed'));
} catch (err) {
console.error('[setup]', err);
dialog.alert({ title: t('dialog.error'), message: err.message });
}
}
export async function maybeStartSetup() {
try {
if ((await setupStatus()).needed) await openSetup();
} catch (err) {
console.error('[setup]', err);
}
}
+73
View File
@@ -0,0 +1,73 @@
// Generated, do not edit. sha256:1acae16b8266effbdb3fb96f020db0a5b6e3d06834851544c79f37653dbed396
import { eventsNetByAccount } from './data/events.js';
import { syncTxProposals } from './data/proposals.js';
import { createScanLog } from './data/scan-logs.js';
import { syncAddressLinks, onchainAsset } from './data/tx-links.js';
import { listWalletAddresses, upsertWalletAddresses, accountOnchainBalance } from './data/wallet-addresses.js';
import { upsertWalletTxs } from './data/wallet-txs.js';
import { t } from './i18n/index.js';
import { addressInfo } from './price/btc-explorer.js';
import { ENUMS } from '../shared/config.js';
const nowIso = () => new Date().toISOString();
export const isOnchainSource = (source) => ENUMS.ONCHAIN_SOURCE_KIND.includes(source && source.kind);
export async function scanSource(source, report = () => {}) {
let addrRows;
let failed = 0;
if (!isOnchainSource(source)) {
throw new Error(`A source of kind '${source && source.kind}' is not scanned through the chain; `
+ `only ${ENUMS.ONCHAIN_SOURCE_KIND.join(' and ')} can be.`);
}
if (source.kind === 'address') {
const info = await addressInfo(source.value);
addrRows = [{
accountId: source.accountId, sourceId: source.id, address: source.value,
chainIndex: null, derivationIndex: null, automaticLabel: null,
balanceSats: info.balanceSats, transactionCount: info.transactionCount, lastScannedAt: nowIso(),
}];
} else {
const { scanWallet } = await import('./price/wallet-scanner.js?v=8');
const res = await scanWallet(source.value, source.scriptType, p => {
if (p.fase === 'scan') report(`${t('tracking.scanning')} ${p.chain} #${p.index}`);
});
addrRows = res.alleAdressen.map(a => ({
accountId: source.accountId, sourceId: source.id, address: a.adres,
chainIndex: a.chain, derivationIndex: a.index, automaticLabel: a.chainLabel,
balanceSats: a.saldo_satoshi, transactionCount: a.tx_aantal, lastScannedAt: nowIso(),
}));
}
await upsertWalletAddresses(addrRows);
const actief = addrRows.filter(a => (a.transactionCount || 0) > 0).map(a => a.address);
if (actief.length) {
report(t('tracking.scanningTx'));
const { scanMovements } = await import('./price/wallet-scanner.js?v=8');
const rekening = (await listWalletAddresses(source.accountId))
.filter(a => (a.transactionCount || 0) > 0).map(a => a.address);
const res = await scanMovements(actief, source.accountId, source.id, p => {
if (p.fase === 'tx') report(`${t('tracking.scanningTx')} ${p.index + 1}/${p.total}`);
}, { ownAddresses: rekening });
await upsertWalletTxs(res.rows);
await syncAddressLinks();
failed = res.failed.length;
}
const proposals = await reconcileAccount(source.accountId);
return { count: addrRows.length, failed, proposals };
}
export async function reconcileAccount(accountId) {
const onchain = await accountOnchainBalance(accountId);
const asset = await onchainAsset();
if (!asset) return 0;
const net = await eventsNetByAccount(accountId, asset.id);
const created = await syncTxProposals(accountId, asset.id);
await createScanLog({
accountId: accountId, scannedAt: nowIso(), status: 'success',
addressesFound: (await listWalletAddresses(accountId)).length,
balanceFound: onchain, proposalsCreated: created,
rawData: { onchain, events_net: net, gap: onchain - net },
});
return created;
}
+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] : [];
});
}
Loaded 100 of 495 files, more files were not shown because too many files have changed in this diff. Show more