Versie 0.3.3

This commit is contained in:
Atreides Assets committed 2026-10-30 12:28:48 +01:00
1 parent 1cf6eeed29
commit 6e741a51fe
57 files changed
+1362 -2135

No files matched your search

+3 -2
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:9b4688b90b1d72ebe6c49262c3d23557d132d9066d8379a911157bbf3dae8ae8
// Generated, do not edit. sha256:aff17b8d5d572bccce031cf1d513314ef3a7957125c4d0ff5f5851fdb069432a
import { mergeCheck, mergeAccounts } from '../data/accounts-merge.js';
import { listAccounts, listSelectableAccounts, createAccount, updateAccount, deleteAccount } from '../data/accounts.js';
import { listConnections } from '../data/connections.js';
@@ -121,7 +121,8 @@ async function buildTable(el) {
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') }],
toolbarActions: locked('accountMerge') ? []
: [{ 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),
+2 -2
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:1f57286ac82ef2aadfb0cdfa809fcb1f771cf74bba815b7e579bbb106a986dd4
// Generated, do not edit. sha256:b2711d1eac7d201be0c0b5dbcc1d76495b068fd4828670afac37d02cd756c98f
import { t } from '../i18n/index.js';
import { listConnections, saveConnection, setActiveConnection, deleteConnection, recordConnectionStatus } from '../data/connections.js';
import { localDateTimeNl } from '../../shared/time.js';
@@ -171,9 +171,9 @@ async function marketFrameHtml() {
<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 class="connp-url" title="${esc(stand)}">${esc(stand)}</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>
+18 -25
View File
@@ -1,9 +1,10 @@
// Generated, do not edit. sha256:1c9c0ff79637180d3780a7e1dd1e59dbb03533899d9a66468c9cdf709d9ee0fe
// Generated, do not edit. sha256:29c2ecea7f7203cf2a789f574d5f90a158b4c2ec943bae44c680cc1e970776b5
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';
import { setPageHint } from '../ui/page-hint.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'];
@@ -28,35 +29,27 @@ function formatUnit(unit, value) {
export async function render(el) {
const n = fmtInt;
el.innerHTML = `
<div class="card price-hero">
<div>
<div class="card conv-card">
<div class="conv-head">
<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 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-note">${t('conversion.satNote')}</div>
</div>
<div class="conv-footer">${t('conversion.footer')}</div>`;
</div>`;
setPageHint(el, { title: t('conversion.hierarchy'), rows: [
`1 BTC = ${n(1e3)} mBTC = ${n(1e6)} bits = ${n(1e8)} sats = ${n(1e11)} msats`,
`1 mBTC = ${n(1e3)} bits = ${n(1e5)} sats`,
`1 bit = 100 sats · 1 sat = ${n(1e3)} msats`,
t('conversion.satNote'),
] });
const q = id => el.querySelector('#' + id);
const currency = baseCurrency();
+115
View File
@@ -0,0 +1,115 @@
// Generated, do not edit. sha256:8e7f997881d1fd01e18e1023544e1df65cb2a48e77ba25495de5fe5ba3785bef
import { setAppSetting } from '../app-settings.js';
import { t } from '../i18n/index.js';
import { serverFoutTekst } from '../data/_rpc.js';
import * as dialog from '../ui/dialog.js';
export async function backupDb() {
const res = await dialog.form({
title: t('dbfile.backup'),
fields: [{ key: 'w1', label: t('dbfile.backupPass'), type: 'password' },
{ key: 'w2', label: t('dbfile.backupPassAgain'), type: 'password' }],
confirmLabel: t('dbfile.backupConfirm'),
});
if (!res) return false;
if (res.w1 !== res.w2) { dialog.alert({ title: t('dialog.error'), message: t('dbfile.backupPassMismatch') }); return false; }
try {
const r = await fetch('/api/db/backup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ passphrase: res.w1 }),
});
if (!r.ok) throw new Error(serverFoutTekst(await r.json().catch(() => null)) || `HTTP ${r.status}`);
const blob = await r.blob();
const naam = (r.headers.get('Content-Disposition') || '').match(/filename="([^"]+)"/)?.[1] || 'assetapp.assetbak';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = naam; a.click();
URL.revokeObjectURL(url);
await stempelBackup();
await dialog.alert({ message: t('dbfile.backupDone') });
return true;
} catch (err) {
dialog.alert({ title: t('dialog.error'), message: err.message });
return false;
}
}
export async function exportDb() {
const a = document.createElement('a');
a.href = '/api/db/export';
a.download = '';
a.click();
await stempelBackup();
}
export function restoreDb(onChanged = () => {}) {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.assetbak,.sqlite,.db';
fileInput.onchange = async () => {
const file = fileInput.files[0];
if (!file) return;
try {
const kop = new Uint8Array(await file.slice(0, 15).arrayBuffer());
const isBackup = new TextDecoder('latin1').decode(kop) === 'ASSETAPP-BACKUP';
if (!(await dialog.confirm({
message: isBackup ? t('dbfile.confirmRestoreBackup') : t('dbfile.confirmImport'), danger: true,
}))) return;
const headers = { 'Content-Type': 'application/octet-stream' };
if (isBackup) {
const res = await dialog.form({
title: t('dbfile.import'),
fields: [{ key: 'w', label: t('dbfile.backupPass'), type: 'password' }],
confirmLabel: t('dbfile.import'),
});
if (!res) return;
headers['X-Backup-Passphrase'] = res.w;
}
const res = await fetch('/api/db/import', { method: 'POST', headers, body: await file.arrayBuffer() });
const json = await res.json().catch(() => null);
if (!res.ok) throw new Error(serverFoutTekst(json) || `HTTP ${res.status}`);
const regels = [`${t('dbfile.importDone')} (versie ${json.versie}).`];
if (json.wezen) regels.push(`${t('dbfile.importOrphans')} ${json.wezen}`);
regels.push(json.versleuteld ? `${t('dbfile.restoredSecrets')} ${json.geheimen}` : json.geheimenWaarschuwing);
dialog.alert({ message: regels.join('\n\n') });
onChanged();
} catch (err) {
dialog.alert({ title: t('dialog.error'), message: err.message });
}
};
fileInput.click();
}
export async function startFresh(onChanged = () => {}) {
if (!(await dialog.confirm({ message: t('dbfile.confirmFresh'), danger: true }))) return;
const woord = t('dbfile.freshWord');
const res = await dialog.form({
title: t('dbfile.fresh'),
fields: [{ key: 'w', label: `${t('dbfile.freshTypeLabel')} ${woord}`, type: 'text' }],
confirmLabel: t('dbfile.fresh'),
});
if (!res) return;
if ((res.w || '').trim().toLowerCase() !== woord.toLowerCase()) {
dialog.alert({ title: t('dialog.error'), message: t('dbfile.freshWordMismatch') });
return;
}
try {
const r = await fetch('/api/db/fresh', { method: 'POST' });
const json = await r.json().catch(() => null);
if (!r.ok) throw new Error(serverFoutTekst(json) || `HTTP ${r.status}`);
dialog.alert({ message: `${t('dbfile.freshDone')} (versie ${json.versie}).\n\n${json.geheimenWaarschuwing}` });
onChanged();
} catch (err) {
dialog.alert({ title: t('dialog.error'), message: err.message });
}
}
async function stempelBackup() {
try { await setAppSetting('last_backup_at', new Date().toISOString()); } catch { }
document.dispatchEvent(new CustomEvent('providers-changed'));
}
+4 -3
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:aff33ec09f0738c89d055fc48911d73d2e952b3818493f0f70824b837ab7c4bf
// Generated, do not edit. sha256:ae28972ed451b65f2e773094a56cc2c886f8928f30dfae76134014f65aa7c8e8
import { listAccounts } from '../data/accounts.js';
import { listAssets } from '../data/assets.js';
import { accountHoldings } from '../data/positions.js';
@@ -156,8 +156,9 @@ function mountActivity(host, events, accountIds, { onPick = null, nowText = null
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) });
.map(it => ({ x: it.x, variant: MARK_VARIANT[it.kind] || 'account', label: label(it.x) }))
.sort((a, b) => (a.variant === 'balance') - (b.variant === 'balance'));
if (nowText) marks.unshift({ x: todayX, variant: 'now', label: label(todayX) });
const peak = Math.max(1, ...beat.points.map(p => Math.abs(p[1])));
mountSparkline(host, beat.points, {
yRange: [-peak, peak],
+55 -93
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:5dea810da6d84ed187a1154955f4194498f812771e3ddaa6b6dc7c547a305b8d
// Generated, do not edit. sha256:867f87b26d0b27fe51f4a0cb77d5196bad8cd8b58a6856168c9cad25f5776d99
import { t, localeTag } from '../i18n/index.js';
import { CONFIG } from '../../shared/config.js';
import { esc } from '../ui/escape.js';
@@ -8,7 +8,8 @@ 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 { CHECK, CHEVRON, IMAGE } from '../ui/icons.js';
import { setPageHint } from '../ui/page-hint.js';
import { splashMarkHtml } from '../ui/splash.js';
import { expanders } from '../ui/expand.js';
import { supportInfo, supportMessages, sendSupport, supportAttachment } from '../data/support.js';
@@ -21,24 +22,21 @@ function buyerOf(s) {
return { text: shortKey(npub ?? s.buyer, 10), title: npub ?? s.buyer ?? '' };
}
function lines(s) {
function standHtml(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,
const range = t('license.cardFacts', {
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 }));
});
const [before, after] = t('license.cardFor', { npub: '§' }).split('§');
out.push(`<p class="t-body-sm">${esc(before)}<span class="badge badge-accent" title="${esc(`${buyer.title}\n${range}`)}">${esc(buyer.text)}</span>${esc(after)}</p>`);
} else {
out.push(`<p class="t-body-sm">${esc(t(s.level === 'full' ? 'license.cardFull' : 'license.cardFree'))}</p>`);
}
if (BROKEN.has(s.why)) line(`${t('license.cardProblem')} ${t(`license.why_${s.why}`)}.`);
if (s.level !== 'full') line(t('license.cardBuy'));
return out;
if (BROKEN.has(s.why)) out.push(`<p class="t-body-sm">${esc(`${t('license.cardProblem')} ${t(`license.why_${s.why}`)}.`)}</p>`);
return out.join('');
}
async function installAndReload(text, message) {
@@ -57,17 +55,16 @@ async function installAndReload(text, 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>';
const legendRows = () => [
say('license.payIntro'),
...['easy', 'other'].flatMap(group => [{ heading: say(`license.payGroup_${group}`) },
...CONFIG.LICENSE.PAY_APPS.filter(a => a.group === group)
.map(a => `${link(a.url, `<strong>${esc(a.name)}</strong>`)} ${say(`license.payApp_${a.id}`)}`)]),
say('license.payNote'),
{ heading: say('license.nostrMore') },
...INFO.map(k => `<strong>${say(`license.info${k}Head`)}</strong> ${say(`license.info${k}`)}`),
...CONFIG.LICENSE.INFO_LINKS.map(l => link(l.url, say(`license.link_${l.id}`))),
];
let signer = null;
let newKey = null;
@@ -91,12 +88,12 @@ async function drawSignIn(box, s) {
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>
box.innerHTML = `
<div class="flex-between gap-sm"><span class="t-body-sm">${esc(before)}${npubText(signer.pubkey)}${esc(after)}</span>
<button class="btn btn-ghost btn-sm" type="button" data-signout>${say('license.signOut')}</button></div>
${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>`;
<p class="t-body-sm" data-signin-msg></p>`;
box.querySelector('[data-signout]').onclick = () => { signer = null; drawSignIn(box, s); };
await drawLicenses(box.querySelector('[data-licenses]'), box.querySelector('[data-signin-msg]'), shop);
return;
@@ -147,7 +144,7 @@ async function drawSignIn(box, s) {
<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>`}
${hasExtension ? '' : `<p class="t-caption t-muted lic-status">${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">
@@ -189,6 +186,8 @@ const AWAY = new Set(['marketService.unreachable', 'marketService.timeout', 'mar
async function drawBuy() {
const box = buyBox;
if (!box) return;
box.hidden = !signer && !purchase;
if (box.hidden) return;
const head = `<div class="lic-head"><div class="t-label">${say('license.buyTitle')}</div></div>`;
if (purchase) {
@@ -223,7 +222,6 @@ async function drawBuy() {
: 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) => {
@@ -305,10 +303,10 @@ async function drawSupport() {
<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>
<input type="file" accept="${esc(info.attachmentTypes.join(','))}" data-support-input hidden>
<button class="btn btn-ghost btn-sm lic-attach" type="button" data-support-pick
title="${esc(`${t('license.supportAttach')}: ${t('license.supportAttachHint', { mb })}`)}" aria-label="${esc(t('license.supportAttach'))}">${IMAGE}</button>
</div>
<p class="t-body-sm mt-sm" data-support-msg></p>
${nostr}`;
@@ -474,10 +472,12 @@ function download(name, text) {
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>`;
if (v === 'partial') return `<span class="lic-feat-part" title="${esc(t('license.featPartialTitle'))}">${CHECK}</span>`;
return `<span class="lic-feat-open" title="${esc(t('license.featUndecided'))}">?</span>`;
};
const MARKS = '.lic-feat-yes, .lic-feat-no, .lic-feat-part, .lic-feat-open';
const inFree = f => (f.has ? CONFIG.LICENSE.FREE.HAS[f.has] : f.free);
function groupFree(items) {
@@ -502,23 +502,21 @@ function featuresHtml(expand) {
<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>` : ''}`;
</div>`;
}
function placeShot(shot, row) {
function placeShot(shot, anchor) {
const gap = 8, pad = 8;
const r = row.getBoundingClientRect();
const r = anchor.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);
const left = Math.min(Math.max(pad, r.left + r.width / 2 - w / 2), window.innerWidth - pad - w);
shot.style.left = `${left}px`;
shot.style.top = `${Math.min(Math.max(pad, top), window.innerHeight - pad - h)}px`;
}
@@ -531,12 +529,12 @@ function bindFeatures(root) {
document.body.appendChild(shot);
let current = null;
let on = null;
const show = (row) => {
if (row === on && !shot.hidden) return;
on = row;
const show = (row, anchor = row) => {
if (anchor === on && !shot.hidden) return;
on = anchor;
root.querySelectorAll('[data-feature]').forEach(r => r.classList.toggle('is-on', r === row));
shot.hidden = false;
placeShot(shot, row);
placeShot(shot, anchor);
const id = row.dataset.feature;
if (id === current) return;
current = id;
@@ -555,8 +553,9 @@ function bindFeatures(root) {
};
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 mark = e.target.closest?.(MARKS);
const row = mark?.closest('[data-feature]');
if (row && root.contains(row)) show(row, mark); else hide();
};
const stop = () => {
shot.remove();
@@ -574,35 +573,27 @@ function bindFeatures(root) {
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])),
});
const expand = await expanders('license', Object.fromEntries(CONFIG.LICENSE.FEATURE_GROUPS.map(g => [groupKey(g), false])));
el.innerHTML = `
<div class="lic-layout">
<div class="lic-main">
<div class="card lic-card" data-features>${featuresHtml(expand)}</div>
</div>
<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>
<span class="badge badge-accent">${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>
${standHtml(s)}
<section class="lic-section" data-signin></section>
<section class="lic-section" data-buy></section>
<section class="lic-section" data-buy hidden></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>`;
setPageHint(el, { title: t('license.payTitle'), rows: legendRows() });
expand.bind(el);
bindFeatures(el.querySelector('[data-features]'));
buyBox = el.querySelector('[data-buy]');
@@ -610,33 +601,4 @@ export async function render(el) {
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]);
};
}
+420
View File
@@ -0,0 +1,420 @@
// Generated, do not edit. sha256:86d4e38921922eb14e5463ba3c4d6855cd2e5276d5295930d9126848cf48e05c
import { t, localeTag } from '../i18n/index.js';
import { serverFoutTekst } from '../data/_rpc.js';
import { esc } from '../ui/escape.js';
import * as dialog from '../ui/dialog.js';
import { setPageHint } from '../ui/page-hint.js';
import { getSettings, updateSettings } from '../data/preferences.js';
import { firstPositionDate } from '../data/positions.js';
import { switchPortfolio, reloadWhenBack, refreshActivePortfolio } from '../portfolio.js';
import { backupDb, exportDb, restoreDb, startFresh } from './db-file.js';
import { openPopover, closePopover } from '../ui/popover.js';
import { expanders } from '../ui/expand.js';
import { PLUS, CHEVRON } from '../ui/icons.js';
import { currencyName } from '../ui/format.js';
import { CONFIG, ENUMS } from '../../shared/config.js';
import { counterText, limitFor, locked } from '../license.js';
import { counterHtml } from '../ui/lock.js';
import { baseCurrency } from '../app-settings.js';
import { openSetup } from '../onboarding/setup.js';
async function api(pad, body) {
const opties = body
? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
: {};
const r = await fetch(pad, opties);
if (r.status === 401) {
location.replace(CONFIG.AUTH.LOGIN_PATH);
throw new Error(t('common.sessionExpired'));
}
const uit = await r.json().catch(() => null);
if (!r.ok) throw new Error(serverFoutTekst(uit) || `HTTP ${r.status}`);
return uit;
}
const datum = (iso) => (iso ? new Date(iso).toLocaleDateString(localeTag()) : null);
const DAG_MS = 24 * 60 * 60 * 1000;
const backupTeOud = (iso) => !iso || Date.now() - new Date(iso).getTime() > CONFIG.BACKUP.REMIND_AFTER_DAYS * DAG_MS;
function menuItems(p, { full, beheer }) {
if (!beheer) return [];
const kopie = p.heeftDatabase && !full ? [['kopieer', t('portfolios.copy')]] : [];
return p.actief
? [...kopie, ['exporteer', t('dbfile.export')], ['terugzetten', t('dbfile.import')], ['inrichten', t('setup.rerun')],
['leeg', t('dbfile.fresh')]]
: [...kopie, ['hernoem', t('portfolios.rename')], ['verwijder', t('portfolios.remove')]];
}
function feitHtml(label, waarde, { late = false } = {}) {
return `<div class="pf-fact${late ? ' is-late' : ''}"><dt>${label}</dt><dd>${waarde}</dd></div>`;
}
const soortenVan = (telling, enumNaam) => ENUMS[enumNaam.toUpperCase()].filter(k => telling?.[k]);
function perSoortHtml(label, telling, enumNaam, regels) {
const getal = (n) => esc(Number(n).toLocaleString(localeTag()));
const soorten = soortenVan(telling, enumNaam);
const totaal = soorten.reduce((s, k) => s + telling[k], 0);
const lijst = soorten.map(k => `<li><span>${esc(t(`enums.${enumNaam}.${k}`))}</span><span>${getal(telling[k])}</span></li>`).join('');
return `<div class="pf-fact"><dt>${label}</dt><dd>${getal(totaal)}</dd>
<ul class="pf-kinds" style="--pf-lines:${Number(regels) || 1}">${lijst}</ul></div>`;
}
function kaartHtml(p, { full, beheer, regels }) {
const k = p.kerncijfers;
const getal = (n) => esc(Number(n).toLocaleString(localeTag()));
const sub = !p.heeftDatabase ? t('portfolios.noDatabase')
: k && k.munt ? esc(k.eersteJaar ? t('portfolios.cardSince', { munt: k.munt, jaar: k.eersteJaar }) : k.munt)
: '';
const feiten = !k ? '' : `<dl class="pf-facts">
${perSoortHtml(t('portfolios.factAssets'), k.assets, 'asset_kind', regels)}
${perSoortHtml(t('portfolios.factAccounts'), k.accounts, 'account_kind', regels)}
${feitHtml(t('portfolios.factEvents'), getal(k.boekingen))}
${feitHtml(t('portfolios.factValuations'), getal(k.balansen))}
${feitHtml(t('portfolios.factLastUsed'), esc(datum(p.laatstGebruikt) || t('portfolios.never')))}
${beheer ? feitHtml(t('portfolios.factBackup'), esc(datum(k.laatsteBackup) || t('portfolios.backupNever')),
{ late: backupTeOud(k.laatsteBackup) }) : '<div class="pf-fact"></div>'}
</dl>`;
const backupKlasse = k && backupTeOud(k.laatsteBackup) ? 'btn-primary' : 'btn-secondary';
const hoofd = !p.actief
? `<button class="btn btn-secondary btn-sm" data-actie="open" data-naam="${esc(p.naam)}">${t('portfolios.activate')}</button>`
: `<button class="btn btn-secondary btn-sm" data-actie="instellingen" data-naam="${esc(p.naam)}">${t('portfolios.settingsButton')}</button>
${beheer ? `<button class="btn ${backupKlasse} btn-sm" data-actie="backup">${t('dbfile.backup')}</button>` : ''}`;
const menu = menuItems(p, { full, beheer }).length
? `<button class="btn btn-ghost btn-sm pf-more" data-menu="${esc(p.naam)}" title="${esc(t('portfolios.more'))}" aria-label="${esc(t('portfolios.more'))}">…</button>`
: '';
return `<div class="card pf-card${p.actief ? ' is-open' : ''}">
<div class="pf-head">
<div class="pf-name">${esc(p.naam)}</div>
${p.demo ? `<span class="badge">${t('portfolios.demoBadge')}</span>` : ''}
${p.actief ? `<span class="badge badge-accent pf-head-end">${t('portfolios.openNow')}</span>` : ''}
</div>
<div class="pf-sub t-body-sm t-muted">${sub}</div>
${feiten}
<div class="pf-actions">${hoofd}${menu}</div>
</div>`;
}
function archiefRijHtml(a) {
return `<div class="flex gap-sm" style="align-items:center; flex-wrap:wrap">
<div style="flex:1 1 12rem">
<div class="t-body">${esc(a.naam)}</div>
<div class="t-body-sm t-muted">${esc(datum(a.gearchiveerd) || '')}</div>
</div>
<button class="btn btn-danger btn-sm" data-actie="wis" data-naam="${esc(a.naam)}">${t('portfolios.purge')}</button>
</div>`;
}
function authHtml(auth) {
const stand = auth.bron === 'omgeving' ? t('portfolios.authEnv')
: auth.ingesteld ? t('portfolios.authSet')
: t('portfolios.authNone');
const knoppen = auth.wijzigbaar
? `<button class="btn btn-secondary btn-sm" data-actie="auth-zet">${t('portfolios.authChange')}</button>
${auth.ingesteld ? `<button class="btn btn-ghost btn-sm" data-actie="auth-leeg">${t('portfolios.authClear')}</button>` : ''}`
: '';
const uitloggen = auth.ingesteld
? `<button class="btn btn-ghost btn-sm" data-actie="auth-uit">${t('portfolios.authLogout')}</button>`
: '';
return `<div class="pf-blok">
<div class="t-h4">${t('portfolios.authTitle')}</div>
<div class="t-body-sm mt-sm">${stand}</div>
<div class="flex gap-sm mt-md" style="flex-wrap:wrap">${knoppen}${uitloggen}</div>
</div>`;
}
function startJaarHtml({ gekozen, eersteJaar, huidigJaar }) {
const AUTO = CONFIG.START_YEAR.AUTO;
let inhoud;
if (eersteJaar == null) {
inhoud = `<div class="t-body-sm mt-sm">${t('portfolios.startYearEmpty')}</div>`;
} else {
const eersteKolom = Math.min(Math.max(eersteJaar + 1, gekozen), huidigJaar);
const stand = gekozen === AUTO
? t('portfolios.startYearAutoState', { jaar: eersteKolom })
: t('portfolios.startYearState', { jaar: eersteKolom, eerste: eersteJaar });
const jaren = [];
for (let y = eersteJaar; y <= huidigJaar; y++) jaren.push(y);
if (gekozen !== AUTO && !jaren.includes(gekozen)) jaren.unshift(gekozen);
const opties = [`<option value="${AUTO}"${gekozen === AUTO ? ' selected' : ''}>${t('portfolios.startYearAuto')}</option>`]
.concat(jaren.map(y => `<option value="${y}"${y === gekozen ? ' selected' : ''}>${y}</option>`));
inhoud = `<div class="t-body-sm mt-sm">${stand}</div>
<div class="form-group mt-md" style="margin-bottom:0">
<label class="form-label" for="startjaar">${t('portfolios.startYearLabel')}</label>
<select class="input" id="startjaar" style="max-width:320px">${opties.join('')}</select>
</div>`;
}
return `<div class="pf-blok">
<div class="t-h4">${t('portfolios.startYearTitle')}</div>
${inhoud}
</div>`;
}
function rendementHtml({ gekozen }) {
const opties = ENUMS.MWR_METHOD
.map(m => `<option value="${m}"${m === gekozen ? ' selected' : ''}>${t(`portfolios.mwr_${m}`)}</option>`)
.join('');
return `<div class="pf-blok">
<div class="t-h4">${t('portfolios.mwrTitle')}</div>
<div class="form-group mt-sm" style="margin-bottom:0">
<label class="form-label" for="mwrmethode">${t('portfolios.mwrLabel')}</label>
<select class="input" id="mwrmethode" style="max-width:320px">${opties}</select>
</div>
</div>`;
}
async function instellingenHtml() {
const [auth, instellingen, eerstePositie] = await Promise.all([
api('/api/auth/status'), getSettings(), firstPositionDate(),
]);
return authHtml(auth) + startJaarHtml({
gekozen: Number(instellingen.start_year) || CONFIG.START_YEAR.AUTO,
eersteJaar: eerstePositie ? Number(eerstePositie.slice(0, 4)) : null,
huidigJaar: new Date().getFullYear(),
}) + rendementHtml({ gekozen: String(instellingen.mwr_method || 'dietz') });
}
async function openInstellingen(naam) {
const html = await instellingenHtml();
const klaar = dialog.detail({ title: t('portfolios.settingsOf', { naam }), html: `<div data-pf-settings>${html}</div>` });
const host = document.querySelector('[data-pf-settings]');
host?.closest('.dialog')?.classList.add('pf-settings-dialog');
const ververs = async () => { if (host?.isConnected) host.innerHTML = await instellingenHtml(); };
const fout = (e) => dialog.alert({ title: t('dialog.error'), message: e.message });
if (host) {
host.onclick = async (ev) => {
const knop = ev.target.closest('[data-actie]');
if (!knop) return;
try {
if (knop.dataset.actie === 'auth-zet' && await authZetten()) await ververs();
if (knop.dataset.actie === 'auth-leeg' && await authLeegmaken()) await ververs();
if (knop.dataset.actie === 'auth-uit') await uitloggen();
} catch (e) { fout(e); }
};
host.onchange = async (ev) => {
const veld = ev.target.matches('#mwrmethode') ? { mwr_method: ev.target.value }
: ev.target.matches('#startjaar') ? { start_year: Number(ev.target.value) } : null;
if (!veld) return;
try {
await updateSettings(veld);
document.dispatchEvent(new Event('settings-changed'));
await ververs();
} catch (e) { fout(e); }
};
}
await klaar;
}
export async function render(el) {
const [overzicht, expand] = await Promise.all([
api('/api/portfolios'), expanders('portfolios', { archive: false }),
]);
refreshActivePortfolio();
const beheer = !locked('portfolios');
const max = limitFor('portfolios');
const eigen = overzicht.portefeuilles.filter(p => !p.demo).length;
const full = max !== null && eigen >= max;
const aangemaakt = p => p.kerncijfers?.aangemaakt || '';
const lijst = [...overzicht.portefeuilles]
.sort((a, b) => aangemaakt(a).localeCompare(aangemaakt(b)) || a.naam.localeCompare(b.naam, localeTag()));
const regels = Math.max(1, ...lijst.map(p => Math.max(
soortenVan(p.kerncijfers?.assets, 'asset_kind').length, soortenVan(p.kerncijfers?.accounts, 'account_kind').length)));
const nieuwTegel = full
? `<div class="pf-new is-full">${counterHtml(counterText('portfolios', eigen))}</div>`
: `<div class="pf-new"><button class="btn btn-new pf-new-btn" data-actie="nieuw" title="${esc(t('portfolios.create'))}"
aria-label="${esc(t('portfolios.create'))}">${PLUS}</button></div>`;
const archief = beheer && overzicht.archief.length
? `<details class="card pf-card pf-archive" ${expand.attr('archive')}>
<summary><span class="chev-btn">${CHEVRON}</span><span>${t('portfolios.archiveTitle')}</span>
<span class="badge pf-head-end">${overzicht.archief.length}</span></summary>
<div class="pf-archive-list">${overzicht.archief.map(archiefRijHtml).join('')}</div>
</details>`
: '';
el.innerHTML = `
<div class="pf-grid${beheer ? '' : ' is-stacked'}">
${lijst.map(p => kaartHtml(p, { full, beheer, regels })).join('')}
${nieuwTegel}
</div>
${archief ? `<div class="pf-grid pf-grid-archive">${archief}</div>` : ''}`;
expand.bind(el);
const instellingenUitleg = [
{ heading: t('portfolios.authTitle') }, t('portfolios.authHint'),
{ heading: t('portfolios.startYearTitle') }, t('portfolios.startYearHint'),
{ heading: t('portfolios.mwrTitle') },
t('portfolios.mwrHint'), t('portfolios.mwrState_dietz'), t('portfolios.mwrState_irr'),
];
setPageHint(el, {
title: t('portfolios.hintTitle'),
rows: beheer ? [
{ heading: t('portfolios.title') },
t('portfolios.intro'), t('portfolios.createHint'), t('portfolios.createCurrencyHint'),
{ heading: t('portfolios.backupTitle') }, t('dbfile.hint'), t('portfolios.backupLateHint', { dagen: CONFIG.BACKUP.REMIND_AFTER_DAYS }),
...instellingenUitleg,
{ heading: t('portfolios.archiveTitle') }, t('portfolios.archiveIntro'),
] : [
{ heading: t('portfolios.title') }, t('portfolios.intro'), t('portfolios.demoHint'),
...instellingenUitleg,
],
});
const opnieuw = () => render(el);
const fout = (e) => dialog.alert({ title: t('dialog.error'), message: e.message });
async function doe(actie, naam) {
try {
switch (actie) {
case 'open': await open(naam); break;
case 'nieuw': if (await nieuw()) await opnieuw(); break;
case 'kopieer': if (await kopieer(naam)) await opnieuw(); break;
case 'instellingen': await openInstellingen(naam); break;
case 'backup': if (await backupDb()) await opnieuw(); break;
case 'exporteer': await exportDb(); await opnieuw(); break;
case 'terugzetten': restoreDb(opnieuw); break;
case 'leeg': await startFresh(opnieuw); break;
case 'inrichten': await openSetup({ rerun: true }); await opnieuw(); break;
case 'hernoem': if (await hernoem(naam)) await opnieuw(); break;
case 'verwijder': if (await verwijder(naam)) await opnieuw(); break;
case 'wis': if (await wis(naam)) await opnieuw(); break;
}
} catch (e) { fout(e); }
}
function openMenu(knop) {
const p = lijst.find(x => x.naam === knop.dataset.menu);
if (!p) return;
const { el: pop, position } = openPopover(knop, { className: 'popover new-menu' });
pop.innerHTML = menuItems(p, { full, beheer }).map(([actie, label]) =>
`<button class="btn btn-ghost btn-sm new-menu-item" data-actie="${actie}">${esc(label)}</button>`).join('');
position();
pop.onclick = (ev) => {
const item = ev.target.closest('[data-actie]');
if (!item) return;
closePopover();
doe(item.dataset.actie, p.naam);
};
}
el.onclick = (ev) => {
const menuKnop = ev.target.closest('[data-menu]');
if (menuKnop) { openMenu(menuKnop); return; }
const knop = ev.target.closest('[data-actie]');
if (knop) doe(knop.dataset.actie, knop.dataset.naam);
};
}
async function open(naam) {
if (!await dialog.confirm({ message: t('portfolios.activateConfirm', { naam }) })) return;
const uit = await switchPortfolio(naam);
if (!uit.supervised) {
await dialog.alert({ message: t('portfolios.activateNoSupervisor', { naam }) });
return;
}
dialog.notice({ message: t('portfolios.activateWaiting') });
await reloadWhenBack();
}
async function nieuw() {
const res = await dialog.form({
title: t('portfolios.create'),
fields: [
{ key: 'naam', label: t('portfolios.createName'), placeholder: t('portfolios.createNameHint') },
{
key: 'munt', label: t('portfolios.createCurrency'), type: 'select',
options: CONFIG.PORTFOLIO.BASE_CURRENCIES.map(code => ({ value: code, label: `${currencyName(code)} (${code})` })),
value: baseCurrency(),
},
],
confirmLabel: t('portfolios.createConfirm'),
});
if (!res) return false;
const uit = await api('/api/portfolios/create', { naam: res.naam, kopieVan: null, baseCurrency: res.munt });
await dialog.alert({ message: t('portfolios.createDone', { naam: uit.naam }) });
return true;
}
async function kopieer(bron) {
const res = await dialog.form({
title: t('portfolios.copyTitle', { naam: bron }),
fields: [{ key: 'naam', label: t('portfolios.copyName'), placeholder: t('portfolios.createNameHint') }],
confirmLabel: t('portfolios.copyConfirm'),
});
if (!res) return false;
const uit = await api('/api/portfolios/create', { naam: res.naam, kopieVan: bron, baseCurrency: null });
await dialog.alert({ message: t('portfolios.copyDone', { naam: uit.naam, bron }) });
return true;
}
async function hernoem(naam) {
const res = await dialog.form({
title: t('portfolios.rename'),
fields: [{ key: 'naar', label: t('portfolios.renameTo'), value: naam }],
confirmLabel: t('portfolios.rename'),
});
if (!res) return false;
const uit = await api('/api/portfolios/rename', { van: naam, naar: res.naar });
await dialog.alert({ message: t('portfolios.renameDone', { naam: uit.naar }) });
return true;
}
async function verwijder(naam) {
const res = await dialog.form({
title: t('portfolios.remove'),
fields: [{ key: 'bevestiging', label: t('portfolios.removeConfirm', { naam }) }],
confirmLabel: t('portfolios.remove'),
});
if (!res) return false;
if (res.bevestiging.trim() !== naam) {
await dialog.alert({ title: t('dialog.error'), message: t('portfolios.removeMismatch') });
return false;
}
await api('/api/portfolios/archive', { naam });
await dialog.alert({ message: t('portfolios.removeDone', { naam }) });
return true;
}
async function wis(naam) {
const res = await dialog.form({
title: t('portfolios.purge'),
fields: [{ key: 'bevestiging', label: t('portfolios.purgeConfirm', { naam }) }],
confirmLabel: t('portfolios.purge'),
});
if (!res) return false;
if (res.bevestiging.trim() !== naam) {
await dialog.alert({ title: t('dialog.error'), message: t('portfolios.removeMismatch') });
return false;
}
await api('/api/portfolios/purge', { naam });
await dialog.alert({ message: t('portfolios.purgeDone', { naam }) });
return true;
}
async function authZetten() {
const res = await dialog.form({
title: t('portfolios.authChange'),
fields: [{ key: 'password', label: t('portfolios.authPassword'), type: 'password' }],
confirmLabel: t('portfolios.authSave'),
});
if (!res) return false;
await api('/api/auth/set', { password: res.password });
await dialog.alert({ message: t('portfolios.authDone') });
return true;
}
async function uitloggen() {
if (!await dialog.confirm({ message: t('portfolios.authLogoutConfirm') })) return;
await api('/api/logout', {});
location.replace(CONFIG.AUTH.LOGIN_PATH);
}
async function authLeegmaken() {
if (!await dialog.confirm({ message: t('portfolios.authClearConfirm'), danger: true })) return false;
await api('/api/auth/clear', {});
await dialog.alert({ message: t('portfolios.authClearedDone') });
return true;
}
-790
View File
@@ -1,790 +0,0 @@
// 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);
}
+3 -1
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:c54ac534bce5953f6b0bb207fdc1338feba7f454198f189ef3b97c5830760c48
// Generated, do not edit. sha256:c74d68370e5fcfc4f6009b2cf9bbe65c4f4f227eb216c413ea6ff5daeae0f4fa
import { t } from '../i18n/index.js';
import { getPref, setPref } from '../data/preferences.js';
import { SYSTEM_PAGE, targetHash } from '../../shared/config.js';
@@ -11,6 +11,7 @@ import * as assetsPage from './assets.js';
import * as connections from './connections.js';
import * as aiPage from './ai.js';
import * as licensePage from './license.js';
import * as portfoliosPage from './portfolios.js';
export const title = () => t('nav.system');
@@ -18,6 +19,7 @@ const ROUTE = SYSTEM_PAGE.ROUTE;
const TABS = SYSTEM_PAGE.TABS;
const PREF_SCOPE = ROUTE;
registerSystemTab({ id: TABS.PORTFOLIOS, render: panel => portfoliosPage.render(panel) });
registerSystemTab({ id: TABS.SETTINGS, render: panel => settings.render(panel) });
registerSystemTab({ id: TABS.ASSETS, render: panel => assetsPage.render(panel) });
registerSystemTab({ id: TABS.ACCOUNTS, render: panel => accountsPage.render(panel) });
+3 -8
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:84e73291cc02df3176d4ba8acbc3d3f1b844080d320978a75a2af8c8bde70e30
// Generated, do not edit. sha256:064fe66c277773dbc29669a7a0cdf7abab018774c71a0f8e1d2a5cceea3a4d22
import { listSelectableAccounts } from '../data/accounts.js';
import { dayNl, localTime, toUtcIso } from '../../shared/time.js';
import { listAssets, listBookableAssets } from '../data/assets.js';
@@ -18,7 +18,6 @@ import { ensurePriceHistoryFor, historicalPointForVia, primarySourceKey } from '
import { bookDifference, acceptDifference } from '../actions/valuation-widget.js';
import { TARGETS, targetHash } from '../../shared/config.js';
import { emptyStateHtml } from '../ui/empty-state.js';
import { capacityCfg } from '../ui/lock.js';
import { setPageHint } from '../ui/page-hint.js';
let tableApi = null;
@@ -146,11 +145,9 @@ async function buildTable(el) {
const toPayload = valuationPayload;
const rijen = await rows();
let used = rijen.length;
const reload = async () => { const r = await rows(); used = r.length; tableApi.refresh(r); };
const reload = async () => tableApi.refresh(await rows());
tableApi = renderTable(el.querySelector('#tbl'), {
columns, rows: rijen,
...capacityCfg('valuations', () => used),
...(await tableViewCfg('valuations', { key: 'valuedOn', dir: 'desc' }, onlyAccount != null ? 'assetId' : null)),
emptyText: t('valuations.none'),
noneLabel: t('table.none'),
@@ -161,9 +158,7 @@ async function buildTable(el) {
saveLabel: t('trades.save'), cancelLabel: t('trades.cancel'),
onCreate: async v => {
const id = await createValuation(await toPayload(v, null));
const r = await rows();
used = r.length;
tableApi.refresh(r, { locate: id });
tableApi.refresh(await rows(), { locate: id });
},
onSave: async (id, v) => {
const ex = (await listValuations()).find(x => x.id === id);