1444 lines
66 KiB
JavaScript
1444 lines
66 KiB
JavaScript
// Generated, do not edit. sha256:57bab9aa8f9259dfb0fc3db59e9e7a9ee92e130476263caf08353fbfd938fe6a
|
||||
|
|
import { accountHoldings, assetPositionsByAccount, positionsByAsset } from '../data/positions.js';
|
|||
|
|
import { listAssets } from '../data/assets.js';
|
|||
|
|
import { isOwnWealth } from '../../shared/wealth-scope.js';
|
|||
|
|
import { listAssetTrades } from '../data/events.js';
|
|||
|
|
import { listAccounts } from '../data/accounts.js';
|
|||
|
|
import { metalHoldings } from '../data/metals.js';
|
|||
|
|
import { latestPricePoint } from '../data/price-history.js';
|
|||
|
|
import { herkomstContext } from '../wealth/data.js';
|
|||
|
|
import { lastSpotSource, basePriceOf } from '../price/providers.js';
|
|||
|
|
import { originHtml, originsHtml, infoLinesHtml } from '../ui/price-origin.js';
|
|||
|
|
import { provenanceOfRow, provenanceOn, originFrom, liveOrigin } from '../../shared/price-provenance.js';
|
|||
|
|
import { t } from '../i18n/index.js';
|
|||
|
|
import { assetLabel, assetLabelHtml, byKindAndLabel } from '../ui/asset-label.js';
|
|||
|
|
import { fmtAmount, fmtBase, fmtBasePrice, fmtBaseSigned, colouredSigns, fmtPct, fmtUnit, priceFromBase, subline, val, isMetal, isBaseAsset } from '../ui/format.js';
|
|||
|
|
import { CONFIG, ENUMS } from '../../shared/config.js';
|
|||
|
|
import { loadFiat } from '../ui/fiat.js';
|
|||
|
|
import * as loading from '../ui/loading.js';
|
|||
|
|
import * as dialog from '../ui/dialog.js';
|
|||
|
|
import { tekenVerdeling } from '../wealth/chart.js';
|
|||
|
|
import { accountSeries, dagreeksVanNu, jaarVensters, meetVenster, metSpotVandaag, seriesFor, windowDelta, windowResult } from '../wealth/series.js';
|
|||
|
|
import { accountCards } from '../wealth/account-cards.js';
|
|||
|
|
import { create as createFilter } from '../ui/filter.js';
|
|||
|
|
import { windowTwr } from '../wealth/performance.js';
|
|||
|
|
import { tekenJaarGrafiek } from '../wealth/year-chart.js';
|
|||
|
|
import { mountSparkline } from '../ui/sparkline.js';
|
|||
|
|
import { openReturnDialog, mountReturnPanel, refreshReturn } from '../wealth/return-dialog.js';
|
|||
|
|
import { create as createTabs } from '../ui/tabs.js';
|
|||
|
|
import { notesHtml, mountNotes } from '../ui/notes.js';
|
|||
|
|
import { ARROW_DOWN, ARROW_UP, CHEVRON_RIGHT } from '../ui/icons.js';
|
|||
|
|
import { getPref, setPref } from '../data/preferences.js';
|
|||
|
|
import { esc } from '../ui/escape.js';
|
|||
|
|
import { emptyStateHtml } from '../ui/empty-state.js';
|
|||
|
|
import { ALL_YEARS, choiceToText, choiceFromText, withoutYear, yearsIn } from '../wealth/year-range.js';
|
|||
|
|
import { sameSelection, selectionFromStored, hiddenFromStored, hiddenAfterFilter, splitHidden,
|
|||
|
|
hiddenKindsFromStored, accountKindItems, accountKindOf, NO_ACCOUNT_KIND } from '../wealth/selection.js';
|
|||
|
|
import { setPageHint, SAMPLES } from '../ui/page-hint.js';
|
|||
|
|
import { assetMetrics, combinePositions } from '../../shared/wealth-metrics.js';
|
|||
|
|
import { localDateNl, nowUtcIso } from '../../shared/time.js';
|
|||
|
|
|
|||
|
|
export const title = () => t('wealth.title');
|
|||
|
|
|
|||
|
|
const BASE = CONFIG.METALS.BASE_DECIMALS;
|
|||
|
|
const DASH = '-';
|
|||
|
|
const gekleurd = () => ({ signs: colouredSigns() });
|
|||
|
|
|
|||
|
|
export const headerInColumn = true;
|
|||
|
|
|
|||
|
|
export async function render(el) {
|
|||
|
|
el.innerHTML = `<div id="vm"></div>`;
|
|||
|
|
const host = el.querySelector('#vm');
|
|||
|
|
loading.show(host, 'cards', { count: 3 });
|
|||
|
|
load(host);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function standenOp(peil, koersVan = null, toon = LEEG, herkomstVoor = null, { holdings = null } = {}) {
|
|||
|
|
const [alle, perAccount] = await Promise.all([
|
|||
|
|
positionsByAsset(peil ?? undefined, { ownOnly: ownOnly() }),
|
|||
|
|
holdings ?? accountHoldings(alleAccounts.filter(a => !ownOnly() || isOwnWealth(a)).map(a => a.id), peil ?? undefined),
|
|||
|
|
]);
|
|||
|
|
const positions = alle.filter(p => p.net !== 0 || toon.has(p.id));
|
|||
|
|
const assets = positions.map(p => ({
|
|||
|
|
id: p.id, symbol: p.symbol, name: p.name, kind: p.kind, decimals: p.decimals,
|
|||
|
|
unit: p.unit, subunit: p.subunit, displayUnit: p.displayUnit,
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
let koersen;
|
|||
|
|
let herkomsten = positions.map(() => null);
|
|||
|
|
if (koersVan) {
|
|||
|
|
koersen = positions.map(p => koersVan(p.id));
|
|||
|
|
if (herkomstVoor) herkomsten = positions.map((p, i) => herkomstVoor(p, koersen[i]));
|
|||
|
|
} else {
|
|||
|
|
const spot = await loadFiat(assets);
|
|||
|
|
const ctx = await herkomstContext();
|
|||
|
|
const lastStored = async id => { try { return await latestPricePoint(id); } catch { return null; } };
|
|||
|
|
const priceOf = async id => {
|
|||
|
|
const s = spot.get(id);
|
|||
|
|
const prijs = basePriceOf(s);
|
|||
|
|
if (prijs != null) {
|
|||
|
|
const asset = assets.find(a => a.id === id);
|
|||
|
|
return { fiatPrice: prijs, origin: liveOrigin(prijs, lastSpotSource(asset)) };
|
|||
|
|
}
|
|||
|
|
const rij = await lastStored(id);
|
|||
|
|
if (!rij) return { fiatPrice: null, origin: null };
|
|||
|
|
return { fiatPrice: rij.fiatPrice, origin: originFrom(provenanceOfRow(rij, { manualProviderId: ctx.manualId }), ctx.names) };
|
|||
|
|
};
|
|||
|
|
const uit = await Promise.all(positions.map(p => priceOf(p.id)));
|
|||
|
|
koersen = uit.map(u => u.fiatPrice);
|
|||
|
|
herkomsten = uit.map(u => u.origin);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let total = 0;
|
|||
|
|
const kaarten = positions.map((p, i) => {
|
|||
|
|
const asset = assets.find(a => a.id === p.id);
|
|||
|
|
const fiatPrice = koersen[i];
|
|||
|
|
const k = assetMetrics(p, fiatPrice);
|
|||
|
|
if (k.value != null) total += k.value;
|
|||
|
|
return { asset, p, m: { fiatPrice, value: k.value, avgMain: k.avgPrice, unrealized: k.unrealized,
|
|||
|
|
avgPremium: k.avgPremium, invested: k.invested, origin: herkomsten[i] } };
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!ownOnly() && excludedAccounts.length) await splitsEigenEnBeheerd(kaarten, perAccount, peil);
|
|||
|
|
|
|||
|
|
kaarten.sort((a, b) => byKindAndLabel(a.asset, b.asset));
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
kaarten, total, holdings: perAccount,
|
|||
|
|
invested: kaarten.reduce((s, k) => s + k.m.invested, 0),
|
|||
|
|
unrealized: kaarten.reduce((s, k) => s + (k.m.unrealized ?? 0), 0),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function load(host) {
|
|||
|
|
if (verdeling) { verdeling.destroy(); verdeling = null; }
|
|||
|
|
ringenBelofte = null;
|
|||
|
|
hoverGekoppeld = false;
|
|||
|
|
selectie = null;
|
|||
|
|
reeksBelofte = null;
|
|||
|
|
getekendPeil = null;
|
|||
|
|
nuReeks = null;
|
|||
|
|
getekendVenster = null;
|
|||
|
|
keuze = keuzeUitTekst(await getPref('page.wealth', 'year', String(new Date().getFullYear())));
|
|||
|
|
alleAccounts = await listAccounts();
|
|||
|
|
excludedAccounts = alleAccounts.filter(a => !isOwnWealth(a));
|
|||
|
|
showAll = excludedAccounts.length > 0
|
|||
|
|
&& (await getPref('page.wealth', 'showAll', false).catch(() => false)) === true;
|
|||
|
|
const bewaard = await getPref('page.wealth', 'selection', null).catch(() => null);
|
|||
|
|
verborgen = hiddenFromStored(await getPref('page.wealth', 'hidden', []).catch(() => []));
|
|||
|
|
verborgenSoorten = hiddenKindsFromStored(await getPref('page.wealth', 'hiddenAccountKinds', []).catch(() => []), ENUMS.ACCOUNT_KIND);
|
|||
|
|
groepering = (await getPref('page.wealth', 'grouping', GROEP.ASSET).catch(() => GROEP.ASSET)) === GROEP.ACCOUNT
|
|||
|
|
? GROEP.ACCOUNT : GROEP.ASSET;
|
|||
|
|
|
|||
|
|
const wachtOpReeks = keuze !== ALLE_JAREN && keuze.tot < new Date().getFullYear();
|
|||
|
|
if (wachtOpReeks && !reeksBelofte) reeksBelofte = reeksOphalen();
|
|||
|
|
|
|||
|
|
nuStanden = await standenOp(null);
|
|||
|
|
if (!nuStanden.kaarten.length && ownOnly()) {
|
|||
|
|
showAll = true;
|
|||
|
|
if (reeksBelofte) reeksBelofte = reeksOphalen();
|
|||
|
|
nuStanden = await standenOp(null);
|
|||
|
|
}
|
|||
|
|
if (!nuStanden.kaarten.length) {
|
|||
|
|
host.innerHTML = `<div class="page-header"><h1 class="page-title">${t('wealth.title')}</h1></div>`
|
|||
|
|
+ emptyStateHtml(t('wealth.none'));
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const holdings = await metalHoldings({ ownOnly: ownOnly() });
|
|||
|
|
|
|||
|
|
const kolom = `
|
|||
|
|
<aside class="page-aside">
|
|||
|
|
<div class="wealth-years" data-jaren></div>
|
|||
|
|
<div class="wealth-aside-total">
|
|||
|
|
<div class="wealth-aside-head">
|
|||
|
|
<span class="t-label">${t('wealth.total')}</span>
|
|||
|
|
${excludedAccounts.length ? `<label class="wealth-all" title="${esc(t('wealth.showAllTip'))}"><span class="toggle-switch"><input type="checkbox" data-wealth-all aria-label="${esc(t('wealth.showAllTip'))}"${showAll ? ' checked' : ''}><span class="toggle-track"></span></span></label>` : ''}
|
|||
|
|
<button type="button" class="wealth-detail-btn" data-jaaroverzicht title="${esc(t('wealth.openYearOverview'))}"
|
|||
|
|
aria-label="${esc(t('wealth.openYearOverview'))}">${CHEVRON_RIGHT}</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="t-num-lg" data-som="total">…</div>
|
|||
|
|
<div class="wealth-header-stats">
|
|||
|
|
${stat(t('wealth.invested'), '<span data-som="invested">…</span>')}
|
|||
|
|
${stat(t('wealth.unrealized'), '<span data-som="unrealized">…</span>')}
|
|||
|
|
${stat(t('wealth.result'), '<span data-resultaat="totaal" class="t-muted-cell">…</span>')}
|
|||
|
|
${stat(`${t('wealth.twrTotal')}<button type="button" class="wealth-detail-btn wealth-detail-btn--inline" data-rendement
|
|||
|
|
title="${esc(t('wealth.openReturn'))}" aria-label="${esc(t('wealth.openReturn'))}">${CHEVRON_RIGHT}</button>`,
|
|||
|
|
`<span data-twr="totaal" class="t-muted-cell">…</span>`)}
|
|||
|
|
${statLeeg(t('wealth.unexplained'), 'totaal')}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="wealth-group fbar fbar--chip" role="group" aria-label="${esc(t('wealth.groupBy'))}" data-groepering></div>
|
|||
|
|
<div class="wealth-filter" data-filter></div>
|
|||
|
|
<div class="wealth-header-rings">
|
|||
|
|
<figure class="wealth-ring wealth-ring--kinds">
|
|||
|
|
<figcaption class="t-label">${t('wealth.ringKinds')}</figcaption>
|
|||
|
|
<div class="wealth-ring-plot"><canvas data-ring="kinds" aria-label="${esc(t('wealth.ringKindsLabel'))}"></canvas></div>
|
|||
|
|
</figure>
|
|||
|
|
<figure class="wealth-ring wealth-ring--assets">
|
|||
|
|
<figcaption class="t-label">${t('wealth.ringAssets')}</figcaption>
|
|||
|
|
<div class="wealth-ring-plot"><canvas data-ring="assets" aria-label="${esc(t('wealth.ringAssetsLabel'))}"></canvas></div>
|
|||
|
|
</figure>
|
|||
|
|
</div>
|
|||
|
|
</aside>`;
|
|||
|
|
|
|||
|
|
const reeksVooraf = wachtOpReeks ? await reeksBelofte.catch(() => null) : null;
|
|||
|
|
|
|||
|
|
host.innerHTML = `<div class="page-layout">${kolom}`
|
|||
|
|
+ `<div class="page-body">`
|
|||
|
|
+ `<div class="page-header"><h1 class="page-title">${t('wealth.title')}</h1></div>`
|
|||
|
|
+ `<div class="wealth-grid"></div></div></div>`;
|
|||
|
|
|
|||
|
|
selectie = bewaard ? selectionFromStored(bewaard) : null;
|
|||
|
|
koppelGroepering(host);
|
|||
|
|
|
|||
|
|
legendaAanmelden(host);
|
|||
|
|
buitenRegels()
|
|||
|
|
.then(regels => { if (regels.length && host.isConnected) legendaAanmelden(host, regels); })
|
|||
|
|
.catch(err => console.warn('[vermogen] buiten het eigen vermogen niet gewaardeerd:', err));
|
|||
|
|
|
|||
|
|
let ringenAlGetekend = false;
|
|||
|
|
if (reeksVooraf) {
|
|||
|
|
loading.show(host.querySelector('.wealth-grid'), 'cards', { count: Math.max(1, Math.min(nuStanden.kaarten.length, 6)) });
|
|||
|
|
vulJaarkeuze(host, reeksVooraf);
|
|||
|
|
await toonJaar(host, reeksVooraf);
|
|||
|
|
ringenAlGetekend = getekendPeil !== null;
|
|||
|
|
if (!ringenAlGetekend) tekenKaarten(host, nuStanden, `${PEIL_NU}|`);
|
|||
|
|
} else {
|
|||
|
|
tekenKaarten(host, nuStanden, `${PEIL_NU}|`);
|
|||
|
|
vulTijdreeks(host);
|
|||
|
|
}
|
|||
|
|
if (selectie && !kaartenVoor(host, selectie).length) pasSelectieToe(host, null);
|
|||
|
|
|
|||
|
|
host.addEventListener('click', e => {
|
|||
|
|
if (e.target.closest('[data-jaaroverzicht]')) { openJaaroverzicht(); return; }
|
|||
|
|
if (e.target.closest('[data-rendement]')) { openRendement(); return; }
|
|||
|
|
if (e.composedPath().some(n => n instanceof Element && n.matches('[data-groepering], [data-filter]'))) return;
|
|||
|
|
const detailAccount = e.target.closest('[data-detail-account]');
|
|||
|
|
if (detailAccount) {
|
|||
|
|
const c = getekendeAccounts.find(x => accountSleutel(x) === Number(detailAccount.dataset.detailAccount));
|
|||
|
|
if (c) openAccountDetail(c);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const accountKaart = e.target.closest('[data-account]');
|
|||
|
|
if (accountKaart) {
|
|||
|
|
const c = getekendeAccounts.find(x => accountSleutel(x) === Number(accountKaart.dataset.account));
|
|||
|
|
zetSelectie(host, c ? { account: accountSleutel(c), assetIds: c.assetIds } : null);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const detail = e.target.closest('[data-detail]');
|
|||
|
|
if (detail) {
|
|||
|
|
const id = Number(detail.dataset.detail);
|
|||
|
|
const k = nuStanden.kaarten.find(x => x.asset.id === id)
|
|||
|
|
|| (getekendeStanden?.kaarten || []).find(x => x.asset.id === id);
|
|||
|
|
if (k) openDetail(k, holdings);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const kaart = e.target.closest('[data-asset]');
|
|||
|
|
zetSelectie(host, kaart ? { assetIds: [Number(kaart.dataset.asset)] } : null);
|
|||
|
|
});
|
|||
|
|
host.addEventListener('change', async e => {
|
|||
|
|
const box = e.target.closest('[data-wealth-all]');
|
|||
|
|
if (!box) return;
|
|||
|
|
await setPref('page.wealth', 'showAll', box.checked).catch(() => {});
|
|||
|
|
if (host.isConnected) render(host.parentElement);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!ringenAlGetekend) tekenRingen(host, nuStanden.kaarten, standenGen);
|
|||
|
|
|
|||
|
|
spotHost = host;
|
|||
|
|
if (!spotGebonden) { document.addEventListener('tick:spot', verversSpot); spotGebonden = true; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let spotHost = null;
|
|||
|
|
let spotGebonden = false;
|
|||
|
|
let nuReeks = null;
|
|||
|
|
let getekendVenster = null;
|
|||
|
|
|
|||
|
|
async function verversSpot() {
|
|||
|
|
const host = spotHost;
|
|||
|
|
if (!host || !host.isConnected) { spotHost = null; return; }
|
|||
|
|
if (!nuReeks || !getekendVenster) return;
|
|||
|
|
if (peilVan(nuReeks, getekendVenster) !== PEIL_NU) return;
|
|||
|
|
|
|||
|
|
const gen = ++standenGen;
|
|||
|
|
let standen;
|
|||
|
|
try {
|
|||
|
|
const holdings = nuStanden.holdings;
|
|||
|
|
nuStanden = await standenOp(null, null, LEEG, null, { holdings });
|
|||
|
|
const bewogen = assetsInVenster(nuReeks, getekendVenster);
|
|||
|
|
const nuHeeft = new Set(nuStanden.kaarten.map(k => k.asset.id));
|
|||
|
|
const extra = [...bewogen].some(id => !nuHeeft.has(id));
|
|||
|
|
standen = extra ? await standenOp(null, null, bewogen, null, { holdings }) : nuStanden;
|
|||
|
|
} catch (err) { console.warn('[vermogen] spot niet ververst:', err); return; }
|
|||
|
|
if (gen !== standenGen || !host.isConnected) return;
|
|||
|
|
|
|||
|
|
const prices = new Map();
|
|||
|
|
for (const k of standen.kaarten) if (k.m.fiatPrice != null) prices.set(k.asset.id, k.m.fiatPrice);
|
|||
|
|
metSpotVandaag(nuReeks, standen.kaarten.map(k => k.asset), prices);
|
|||
|
|
|
|||
|
|
tekenKaarten(host, standen, getekendPeil);
|
|||
|
|
vulSparklines(host, nuReeks, getekendVenster);
|
|||
|
|
vulTwr(host, nuReeks, getekendVenster);
|
|||
|
|
refreshReturn();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const ringHaken = host => ({
|
|||
|
|
onHover: sel => { markeerKaarten(host, sel); verdeling?.markeer(sel); },
|
|||
|
|
onSelect: sel => zetSelectie(host, sel),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
function tekenKaarten(host, standen, peil) {
|
|||
|
|
const grid = host.querySelector('.wealth-grid');
|
|||
|
|
if (!grid) return;
|
|||
|
|
if (groepering === GROEP.ACCOUNT) {
|
|||
|
|
getekendeAccounts = accountCards({ kaarten: standen.kaarten, holdings: standen.holdings || [],
|
|||
|
|
accounts: alleAccounts, toon: accountsInVenster(nuReeks, getekendVenster) });
|
|||
|
|
const { shown } = splitHidden(getekendeAccounts, verborgenSoorten, accountKindOf);
|
|||
|
|
grid.innerHTML = shown.map(c => accountCardHtml(c, standen.total)).join('');
|
|||
|
|
const soortLabel = kind => (kind === NO_ACCOUNT_KIND ? t('wealth.noAccount') : t(`enums.account_kind.${kind}`));
|
|||
|
|
tekenFilter(host, accountKindItems(getekendeAccounts).map(kind => ({ id: kind, label: soortLabel(kind) })), verborgenSoorten);
|
|||
|
|
} else {
|
|||
|
|
const { shown } = splitHidden(standen.kaarten, verborgen);
|
|||
|
|
grid.innerHTML = shown.map(k => cardHtml(k.asset, k.p, k.m, standen.total)).join('');
|
|||
|
|
tekenFilter(host, standen.kaarten.map(k => ({ id: k.asset.id, label: k.asset.symbol, title: k.asset.name })), verborgen);
|
|||
|
|
}
|
|||
|
|
const zet = (sleutel, html) => {
|
|||
|
|
const el = host.querySelector(`[data-som="${sleutel}"]`);
|
|||
|
|
if (el) el.innerHTML = html;
|
|||
|
|
};
|
|||
|
|
const herkomsten = standen.kaarten.filter(k => k.m.value != null).map(k => ({ origin: k.m.origin, asset: k.asset }));
|
|||
|
|
zet('total', `<span class="po-host">${val(fmtBase(standen.total))}${originsHtml(herkomsten)}</span>`);
|
|||
|
|
zet('invested', val(fmtBase(standen.invested)));
|
|||
|
|
zet('unrealized', kleurBedrag(standen.unrealized));
|
|||
|
|
getekendeStanden = standen;
|
|||
|
|
getekendPeil = peil;
|
|||
|
|
pasSelectieToe(host, selectie);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let reeksBelofte = null;
|
|||
|
|
|
|||
|
|
let excludedAccounts = [];
|
|||
|
|
let showAll = false;
|
|||
|
|
const ownOnly = () => excludedAccounts.length > 0 && !showAll;
|
|||
|
|
const scopeOf = id => excludedAccounts.find(a => a.id === id)?.wealthScope ?? null;
|
|||
|
|
const reeksOphalen = () => dagreeksVanNu({
|
|||
|
|
excludedAccounts: ownOnly() ? new Set(excludedAccounts.map(a => a.id)) : null,
|
|||
|
|
managedAccounts: new Set(excludedAccounts.map(a => a.id)),
|
|||
|
|
});
|
|||
|
|
const reeksNodig = () => (reeksBelofte ??= reeksOphalen());
|
|||
|
|
|
|||
|
|
async function splitsEigenEnBeheerd(kaarten, perAccount, peil) {
|
|||
|
|
const beheerd = new Set(excludedAccounts.map(a => a.id));
|
|||
|
|
const metBeheerd = kaarten.filter(k => perAccount.some(h => h.assetId === k.asset.id && beheerd.has(h.accountId)));
|
|||
|
|
if (!metBeheerd.length) return;
|
|||
|
|
const [eigenRijen, reeks] = await Promise.all([
|
|||
|
|
positionsByAsset(peil ?? undefined, { ownOnly: true }),
|
|||
|
|
reeksNodig().catch(() => null),
|
|||
|
|
]);
|
|||
|
|
const potten = reeks?.beheerdeKostprijs?.(peil ?? nowUtcIso()) ?? new Map();
|
|||
|
|
const leeg = { buy_amount: 0, buy_spot_raw: 0, buy_fee_total: 0, buy_events: 0, gift_amount: 0 };
|
|||
|
|
for (const k of metBeheerd) {
|
|||
|
|
const eigenRij = eigenRijen.find(r => r.id === k.asset.id) ?? { ...k.p, ...leeg, net: 0 };
|
|||
|
|
const eigen = assetMetrics(eigenRij, k.m.fiatPrice);
|
|||
|
|
const posities = new Map();
|
|||
|
|
const delen = perAccount.filter(h => h.assetId === k.asset.id && beheerd.has(h.accountId)).map(h => {
|
|||
|
|
const pot = potten.get(`${h.accountId}|${k.asset.id}`) ?? leeg;
|
|||
|
|
const positie = { decimals: k.asset.decimals, net: h.saldo, ...pot };
|
|||
|
|
posities.set(h.accountId, positie);
|
|||
|
|
const m = assetMetrics(positie, k.m.fiatPrice);
|
|||
|
|
return { accountId: h.accountId, saldo: h.saldo, cost: m.avgCost != null ? m.units * m.avgCost : null, unrealized: m.unrealized };
|
|||
|
|
});
|
|||
|
|
const alle = [{ saldo: eigenRij.net, unrealized: eigen.unrealized }, ...delen].filter(d => d.saldo);
|
|||
|
|
const vanDerden = delen.filter(d => d.saldo && scopeOf(d.accountId) === 'managed');
|
|||
|
|
const derden = vanDerden.length
|
|||
|
|
? assetMetrics(combinePositions(vanDerden.map(d => posities.get(d.accountId))), k.m.fiatPrice) : null;
|
|||
|
|
k.m = { ...k.m,
|
|||
|
|
unrealized: alle.some(d => d.unrealized == null) ? null : alle.reduce((s, d) => s + d.unrealized, 0),
|
|||
|
|
avgMain: eigen.avgPrice, avgPremium: eigen.avgPremium, invested: eigen.invested,
|
|||
|
|
eigen: { net: eigenRij.net, unrealized: eigen.unrealized }, beheerd: delen,
|
|||
|
|
derden: derden && { avgMain: derden.avgPrice, avgPremium: derden.avgPremium, invested: derden.invested,
|
|||
|
|
unrealized: derden.unrealized } };
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const PEIL_NU = 'nu';
|
|||
|
|
const LEEG = new Set();
|
|||
|
|
const LEEG_MAP = new Map();
|
|||
|
|
let nuStanden = { kaarten: [], total: 0, invested: 0, unrealized: 0 };
|
|||
|
|
let getekendeStanden = null;
|
|||
|
|
let getekendPeil = null;
|
|||
|
|
let standenGen = 0;
|
|||
|
|
const ALLE_JAREN = ALL_YEARS;
|
|||
|
|
const keuzeTekst = choiceToText;
|
|||
|
|
const keuzeUitTekst = choiceFromText;
|
|||
|
|
const zonderJaar = withoutYear;
|
|||
|
|
const jarenIn = yearsIn;
|
|||
|
|
let keuze = { van: new Date().getFullYear(), tot: new Date().getFullYear() };
|
|||
|
|
|
|||
|
|
function vulTijdreeks(host) {
|
|||
|
|
if (!reeksBelofte) reeksBelofte = reeksOphalen();
|
|||
|
|
reeksBelofte
|
|||
|
|
.then(reeks => { vulJaarkeuze(host, reeks); toonJaar(host, reeks); })
|
|||
|
|
.catch(err => console.warn('[vermogen] tijdreeks niet getekend:', err));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function vensterVan(reeks) {
|
|||
|
|
const heel = { van: 0, tot: reeks.dagen.length - 1 };
|
|||
|
|
if (keuze === ALLE_JAREN) return heel;
|
|||
|
|
const vensters = jaarVensters(reeks.dagen);
|
|||
|
|
const gevonden = jarenIn(keuze).map(j => vensters.get(j)).filter(Boolean);
|
|||
|
|
if (!gevonden.length) return heel;
|
|||
|
|
return { van: gevonden[0].van, tot: gevonden[gevonden.length - 1].tot };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function peilVan(reeks, venster) {
|
|||
|
|
const meet = meetVenster(venster, reeks.dagen.length);
|
|||
|
|
if (meet.tot >= reeks.dagen.length - 1) return PEIL_NU;
|
|||
|
|
return `${reeks.dagen[meet.tot]}T23:59:59.999Z`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function assetsInVenster(reeks, venster) {
|
|||
|
|
const uit = new Set();
|
|||
|
|
for (const [id, eenheden] of (reeks.eenhedenPerAsset || LEEG_MAP)) {
|
|||
|
|
for (let i = venster.van; i <= venster.tot; i++) {
|
|||
|
|
if (eenheden[i]) { uit.add(id); break; }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const raakt = dagen => (dagen || []).some(i => i >= venster.van && i <= venster.tot);
|
|||
|
|
for (const [id, dagen] of (reeks.aankopen || LEEG_MAP)) if (raakt(dagen)) uit.add(id);
|
|||
|
|
for (const [id, dagen] of (reeks.verkopen || LEEG_MAP)) if (raakt(dagen)) uit.add(id);
|
|||
|
|
return uit;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function toonJaar(host, reeks) {
|
|||
|
|
const venster = vensterVan(reeks);
|
|||
|
|
nuReeks = reeks;
|
|||
|
|
getekendVenster = venster;
|
|||
|
|
vulSparklines(host, reeks, venster);
|
|||
|
|
vulTwr(host, reeks, venster);
|
|||
|
|
await zetStanden(host, reeks, venster);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function zetStanden(host, reeks, venster) {
|
|||
|
|
const peil = peilVan(reeks, venster);
|
|||
|
|
const bewogen = assetsInVenster(reeks, venster);
|
|||
|
|
const nuHeeft = new Set(nuStanden.kaarten.map(k => k.asset.id));
|
|||
|
|
const extra = [...bewogen].filter(id => !nuHeeft.has(id)).sort((a, b) => a - b);
|
|||
|
|
const sleutel = `${peil}|${extra.join(',')}`;
|
|||
|
|
if (sleutel === getekendPeil) { if (groepering === GROEP.ACCOUNT) hertekenRaster(host); return; }
|
|||
|
|
const gen = ++standenGen;
|
|||
|
|
let standen = nuStanden;
|
|||
|
|
if (peil !== PEIL_NU || extra.length) {
|
|||
|
|
const dagIndex = meetVenster(venster, reeks.dagen.length).tot;
|
|||
|
|
const koersVan = id => reeks.koersPerAsset?.get(id)?.[dagIndex] ?? null;
|
|||
|
|
const herkomstVoor = async () => {
|
|||
|
|
const ctx = await herkomstContext();
|
|||
|
|
const dag = reeks.dagen[dagIndex];
|
|||
|
|
return (p, price) => (price == null ? null : originFrom(provenanceOn(reeks.rijenPerAsset?.get(p.id) || [], dag,
|
|||
|
|
{ assetKind: p.kind, manualProviderId: ctx.manualId, usedPrice: price }), ctx.names));
|
|||
|
|
};
|
|||
|
|
try {
|
|||
|
|
standen = peil === PEIL_NU
|
|||
|
|
? await standenOp(null, null, bewogen)
|
|||
|
|
: await standenOp(peil, koersVan, bewogen, await herkomstVoor());
|
|||
|
|
} catch (err) { console.warn('[vermogen] standen van de periode niet opgehaald:', err); return; }
|
|||
|
|
if (gen !== standenGen || !host.isConnected) return;
|
|||
|
|
}
|
|||
|
|
tekenKaarten(host, standen, sleutel);
|
|||
|
|
vulSparklines(host, reeks, venster);
|
|||
|
|
vulTwr(host, reeks, venster);
|
|||
|
|
await tekenRingen(host, standen.kaarten, gen);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function tekenRingen(host, kaarten, gen) {
|
|||
|
|
const cvSoorten = host.querySelector('[data-ring="kinds"]');
|
|||
|
|
const cvAssets = host.querySelector('[data-ring="assets"]');
|
|||
|
|
if (!cvSoorten && !cvAssets) return;
|
|||
|
|
try { await ringenBelofte; } catch { }
|
|||
|
|
verdeling?.destroy();
|
|||
|
|
verdeling = null;
|
|||
|
|
try {
|
|||
|
|
ringenBelofte = tekenVerdeling(cvSoorten, cvAssets, kaarten, ringHaken(host));
|
|||
|
|
const v = await ringenBelofte;
|
|||
|
|
if (gen !== standenGen || !host.isConnected) { v?.destroy(); return; }
|
|||
|
|
verdeling = v;
|
|||
|
|
if (!v) { if (!hoverGekoppeld) host.querySelector('.wealth-header-rings')?.remove(); return; }
|
|||
|
|
if (!hoverGekoppeld) { hoverGekoppeld = true; koppelKaartHover(host); }
|
|||
|
|
v.selecteer(selectie);
|
|||
|
|
} catch (err) {
|
|||
|
|
console.warn('[vermogen] cirkelgrafieken niet getekend:', err);
|
|||
|
|
if (gen === standenGen && !hoverGekoppeld) host.querySelector('.wealth-header-rings')?.remove();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function vulJaarkeuze(host, reeks) {
|
|||
|
|
const balk = host.querySelector('[data-jaren]');
|
|||
|
|
if (!balk) return;
|
|||
|
|
const vensters = jaarVensters(reeks.dagen);
|
|||
|
|
const heel = { van: 0, tot: reeks.dagen.length - 1 };
|
|||
|
|
const pctVan = j => { const v = vensters.get(j); return v ? rendementVanVenster(reeks, v) : null; };
|
|||
|
|
|
|||
|
|
const bekend = [...vensters.keys()];
|
|||
|
|
if (keuze !== ALLE_JAREN && bekend.length) {
|
|||
|
|
const raak = jarenIn(keuze).filter(j => vensters.has(j));
|
|||
|
|
if (!raak.length) { const laatste = Math.max(...bekend); keuze = { van: laatste, tot: laatste }; }
|
|||
|
|
else keuze = { van: raak[0], tot: raak[raak.length - 1] };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
balk.innerHTML = `
|
|||
|
|
<div class="wealth-year-boxes" data-kaders></div>
|
|||
|
|
<div class="wealth-year-menu" role="group" aria-label="${esc(t('wealth.yearPick'))}">
|
|||
|
|
<div class="wealth-year-grid">
|
|||
|
|
${bekend.slice().sort((a, b) => b - a).map(j => menuKnop(j, String(j), pctVan(j))).join('')}
|
|||
|
|
${menuKnop(ALLE_JAREN, t('wealth.allYears'), rendementVanVenster(reeks, heel))}
|
|||
|
|
</div>
|
|||
|
|
<div class="wealth-year-tip t-caption t-muted">${t('wealth.yearRangeTip')}</div>
|
|||
|
|
</div>`;
|
|||
|
|
tekenKaders(balk, pctVan, rendementVanVenster(reeks, heel));
|
|||
|
|
|
|||
|
|
balk.onclick = e => {
|
|||
|
|
const uit = e.target.closest('[data-jaar-uit]');
|
|||
|
|
if (uit) {
|
|||
|
|
keuze = zonderJaar(keuze, Number(uit.dataset.jaarUit));
|
|||
|
|
setPref('page.wealth', 'year', keuzeTekst(keuze)).catch(() => { });
|
|||
|
|
tekenKaders(balk, pctVan, rendementVanVenster(reeks, heel));
|
|||
|
|
toonJaar(host, reeks);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const b = e.target.closest('[data-jaar]');
|
|||
|
|
if (!b) return;
|
|||
|
|
if (b.dataset.jaar === ALLE_JAREN) keuze = ALLE_JAREN;
|
|||
|
|
else {
|
|||
|
|
const gekozen = Number(b.dataset.jaar);
|
|||
|
|
const anker = e.shiftKey && keuze !== ALLE_JAREN ? keuze.van : gekozen;
|
|||
|
|
keuze = { van: Math.min(anker, gekozen), tot: Math.max(anker, gekozen) };
|
|||
|
|
}
|
|||
|
|
setPref('page.wealth', 'year', keuzeTekst(keuze)).catch(() => { });
|
|||
|
|
tekenKaders(balk, pctVan, rendementVanVenster(reeks, heel));
|
|||
|
|
toonJaar(host, reeks);
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function menuKnop(waarde, label, pct) {
|
|||
|
|
return `<button type="button" class="wealth-year" data-jaar="${esc(String(waarde))}">${jaarInhoud(label, pct)}</button>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function jaarInhoud(label, pct) {
|
|||
|
|
if (pct == null) return esc(label);
|
|||
|
|
return `<span>${esc(label)}</span><span class="wealth-year-pct ${pctToon(pct)}">${fmtPct(pct, gekleurd())}</span>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const pctCel = p => (p == null ? DASH : `<span class="${pctToon(p)}">${fmtPct(p, gekleurd())}</span>`);
|
|||
|
|
const bedragCel = v => (v == null || Math.abs(v) < 0.01 ? DASH : kleurBedrag(v));
|
|||
|
|
|
|||
|
|
function pctToon(pct) {
|
|||
|
|
const zichtbaar = Math.round(pct * 1000);
|
|||
|
|
return zichtbaar > 0 ? 't-pos' : zichtbaar < 0 ? 't-neg' : '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function tekenKaders(balk, pctVan, pctHeel) {
|
|||
|
|
const kader = (label, pct, jaar) => (jaar == null
|
|||
|
|
? `<span class="wealth-year-chip">${jaarInhoud(label, pct)}</span>`
|
|||
|
|
: `<button type="button" class="wealth-year-chip" data-jaar-uit="${jaar}"`
|
|||
|
|
+ ` title="${esc(t('wealth.yearOff'))}">${jaarInhoud(label, pct)}</button>`);
|
|||
|
|
const kaders = keuze === ALLE_JAREN
|
|||
|
|
? kader(t('wealth.allYears'), pctHeel, null)
|
|||
|
|
: jarenIn(keuze).map(j => kader(String(j), pctVan(j), j)).join('');
|
|||
|
|
balk.querySelector('[data-kaders]').innerHTML = kaders;
|
|||
|
|
const gekozen = keuze === ALLE_JAREN ? [ALLE_JAREN] : jarenIn(keuze).map(String);
|
|||
|
|
for (const k of balk.querySelectorAll('[data-jaar]')) k.classList.toggle('is-on', gekozen.includes(k.dataset.jaar));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function assetRendement(reeks, id, venster) {
|
|||
|
|
return windowTwr(seriesFor(reeks, id), meetVenster(venster, reeks.dagen.length));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function rendementVanVenster(reeks, venster) {
|
|||
|
|
return windowTwr(seriesFor(reeks), meetVenster(venster, reeks.dagen.length));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let verdeling = null;
|
|||
|
|
let ringenBelofte = null;
|
|||
|
|
let hoverGekoppeld = false;
|
|||
|
|
let selectie = null;
|
|||
|
|
let verborgen = new Set();
|
|||
|
|
let verborgenSoorten = new Set();
|
|||
|
|
|
|||
|
|
const GROEP = Object.freeze({ ASSET: 'asset', ACCOUNT: 'account' });
|
|||
|
|
let groepering = GROEP.ASSET;
|
|||
|
|
let alleAccounts = [];
|
|||
|
|
let getekendeAccounts = [];
|
|||
|
|
const accountSleutel = c => c.accountId ?? 0;
|
|||
|
|
const reeksSleutel = sleutel => (sleutel === 0 ? null : sleutel);
|
|||
|
|
const accountNaam = c => c.account?.name ?? t('wealth.noAccount');
|
|||
|
|
|
|||
|
|
function koppelGroepering(host) {
|
|||
|
|
const bar = host.querySelector('[data-groepering]');
|
|||
|
|
if (!bar) return;
|
|||
|
|
const knop = (id, label) => `<button type="button" class="fbar-item${groepering === id ? ' is-on' : ''}"
|
|||
|
|
data-groep="${id}" aria-pressed="${groepering === id}">${label}</button>`;
|
|||
|
|
const teken = () => {
|
|||
|
|
bar.innerHTML = knop(GROEP.ASSET, t('wealth.perAsset')) + knop(GROEP.ACCOUNT, t('wealth.perAccount'));
|
|||
|
|
};
|
|||
|
|
teken();
|
|||
|
|
bar.onclick = e => {
|
|||
|
|
const id = e.target.closest('[data-groep]')?.dataset.groep;
|
|||
|
|
if (!id || id === groepering) return;
|
|||
|
|
groepering = id;
|
|||
|
|
setPref('page.wealth', 'grouping', id).catch(() => { });
|
|||
|
|
teken();
|
|||
|
|
hertekenRaster(host);
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function hertekenRaster(host) {
|
|||
|
|
if (!host?.isConnected || !getekendeStanden) return;
|
|||
|
|
tekenKaarten(host, getekendeStanden, getekendPeil);
|
|||
|
|
if (nuReeks && getekendVenster) { vulSparklines(host, nuReeks, getekendVenster); vulTwr(host, nuReeks, getekendVenster); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function tekenFilter(host, items, hidden) {
|
|||
|
|
const el = host.querySelector('[data-filter]');
|
|||
|
|
if (!el) return;
|
|||
|
|
createFilter(el, {
|
|||
|
|
items,
|
|||
|
|
selected: new Set(items.map(i => i.id).filter(id => !hidden.has(id))),
|
|||
|
|
onChange: sel => {
|
|||
|
|
const lijst = hiddenAfterFilter(hidden, items.map(i => i.id), sel);
|
|||
|
|
if (groepering === GROEP.ACCOUNT) verborgenSoorten = new Set(lijst);
|
|||
|
|
else verborgen = new Set(lijst);
|
|||
|
|
setPref('page.wealth', groepering === GROEP.ACCOUNT ? 'hiddenAccountKinds' : 'hidden', lijst).catch(() => { });
|
|||
|
|
hertekenRaster(host);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function accountsInVenster(reeks, venster) {
|
|||
|
|
const uit = new Set();
|
|||
|
|
if (!reeks || !venster) return uit;
|
|||
|
|
for (const [id, bezit] of (reeks.bezitPerAccount || LEEG_MAP)) {
|
|||
|
|
for (let i = venster.van; i <= venster.tot; i++) if (bezit[i]) { uit.add(id); break; }
|
|||
|
|
}
|
|||
|
|
return uit;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const zelfdeSelectie = sameSelection;
|
|||
|
|
|
|||
|
|
function zetSelectie(host, sel) {
|
|||
|
|
pasSelectieToe(host, zelfdeSelectie(selectie, sel) ? null : sel);
|
|||
|
|
setPref('page.wealth', 'selection', selectie).catch(() => { });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function pasSelectieToe(host, sel) {
|
|||
|
|
selectie = sel;
|
|||
|
|
for (const el of host.querySelectorAll('.wealth-card.is-selected')) el.classList.remove('is-selected');
|
|||
|
|
for (const el of kaartenVoor(host, selectie)) el.classList.add('is-selected');
|
|||
|
|
verdeling?.selecteer(selectie);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function kaartenVoor(host, sel) {
|
|||
|
|
if (!sel) return [];
|
|||
|
|
const accountKaarten = [...host.querySelectorAll('[data-account]')];
|
|||
|
|
if (accountKaarten.length) {
|
|||
|
|
if (sel.account != null) return accountKaarten.filter(el => Number(el.dataset.account) === sel.account);
|
|||
|
|
if (sel.kind) return accountKaarten.filter(el => el.dataset.kinds.split(' ').includes(sel.kind));
|
|||
|
|
const ids = new Set((sel.assetIds || []).map(String));
|
|||
|
|
return accountKaarten.filter(el => el.dataset.assets.split(' ').some(id => ids.has(id)));
|
|||
|
|
}
|
|||
|
|
const kies = sel.assetIds?.length
|
|||
|
|
? sel.assetIds.map(id => `[data-asset="${CSS.escape(String(id))}"]`).join(',')
|
|||
|
|
: (sel.kind ? `[data-kind="${CSS.escape(sel.kind)}"]` : null);
|
|||
|
|
return kies ? [...host.querySelectorAll(kies)] : [];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function markeerKaarten(host, sel) {
|
|||
|
|
for (const el of host.querySelectorAll('.wealth-card.is-linked')) el.classList.remove('is-linked');
|
|||
|
|
for (const el of kaartenVoor(host, sel)) el.classList.add('is-linked');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function koppelKaartHover(host) {
|
|||
|
|
const grid = host.querySelector('.wealth-grid');
|
|||
|
|
if (!grid) return;
|
|||
|
|
let actief = null;
|
|||
|
|
grid.addEventListener('mouseover', e => {
|
|||
|
|
const kaart = e.target.closest('[data-asset], [data-account]');
|
|||
|
|
const id = kaart ? (kaart.dataset.asset ? `a${kaart.dataset.asset}` : `r${kaart.dataset.account}`) : null;
|
|||
|
|
if (id === actief) return;
|
|||
|
|
actief = id;
|
|||
|
|
markeerKaarten(host, null);
|
|||
|
|
const assetIds = !kaart ? null : kaart.dataset.asset ? [Number(kaart.dataset.asset)]
|
|||
|
|
: kaart.dataset.assets.split(' ').filter(Boolean).map(Number);
|
|||
|
|
verdeling?.markeer(assetIds?.length ? { assetIds } : null);
|
|||
|
|
});
|
|||
|
|
grid.addEventListener('mouseleave', () => {
|
|||
|
|
actief = null;
|
|||
|
|
markeerKaarten(host, null);
|
|||
|
|
verdeling?.markeer(null);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function openDetail(k, holdings) {
|
|||
|
|
const [alleRijen, accounts, alleTrades, reeks] = await Promise.all([
|
|||
|
|
assetPositionsByAccount(k.asset.id),
|
|||
|
|
listAccounts(),
|
|||
|
|
listAssetTrades(k.asset.id).catch(() => []),
|
|||
|
|
(reeksBelofte || Promise.resolve(null)).catch(() => null),
|
|||
|
|
]);
|
|||
|
|
const buiten = new Set(ownOnly() ? excludedAccounts.map(a => a.id) : []);
|
|||
|
|
const accountVan = tr => (tr.type === 'sell' ? tr.fromAccountId : tr.toAccountId);
|
|||
|
|
const vanDerden = new Set(k.m.derden ? (k.m.beheerd || []).map(d => d.accountId).filter(id => scopeOf(id) === 'managed') : []);
|
|||
|
|
const deel = derden => ({
|
|||
|
|
rijen: alleRijen.filter(r => !buiten.has(r.accountId) && vanDerden.has(r.accountId) === derden),
|
|||
|
|
trades: alleTrades.filter(tr => !buiten.has(accountVan(tr)) && vanDerden.has(accountVan(tr)) === derden),
|
|||
|
|
});
|
|||
|
|
const eigen = deel(false);
|
|||
|
|
const derden = vanDerden.size ? deel(true) : null;
|
|||
|
|
const naam = new Map(accounts.map(a => [a.id, a.name]));
|
|||
|
|
dialog.detail({ title: assetLabel(k.asset), titleHtml: assetLabelHtml(k.asset),
|
|||
|
|
asideHtml: `<span class="badge">${t('enums.asset_kind.' + k.asset.kind)}</span>`, html: detailHtml() });
|
|||
|
|
const body = document.querySelector('.dialog-overlay .dialog-body--rich');
|
|||
|
|
if (!body) return;
|
|||
|
|
body.closest('.dialog')?.insertAdjacentHTML('afterbegin',
|
|||
|
|
`<span class="wealth-year-chip wealth-detail-periode">${jaarInhoud(t('wealth.allYears'), null)}</span>`);
|
|||
|
|
mountDetailTabs(body, k, { eigen, derden }, naam, holdings, reeks);
|
|||
|
|
tekenDetailGrafiek(body, k, reeks, derden ? [...eigen.trades, ...derden.trades] : eigen.trades);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function tekenDetailGrafiek(body, k, reeks, trades) {
|
|||
|
|
const el = body.querySelector('[data-detail-spark]');
|
|||
|
|
if (!el) return;
|
|||
|
|
const leeg = () => {
|
|||
|
|
el.innerHTML = `<div class="wealth-detail-leeg t-muted-cell">${t('wealth.noChart')}</div>`;
|
|||
|
|
};
|
|||
|
|
if (!reeks?.dagen?.length) return leeg();
|
|||
|
|
const dagenMs = reeks.dagen.map(d => Date.parse(`${d}T00:00:00Z`));
|
|||
|
|
const heel = { van: 0, tot: reeks.dagen.length - 1 };
|
|||
|
|
const gesneden = assetReeks(reeks, k.asset.id, heel, dagenMs);
|
|||
|
|
if (!gesneden) return leeg();
|
|||
|
|
const markers = trades
|
|||
|
|
.map(tr => ({ x: dagMs(tr.occurredAt), variant: tr.type === 'sell' ? 'sell' : 'buy', key: `t${tr.id}`,
|
|||
|
|
label: tr.label_names || undefined }))
|
|||
|
|
.filter(m => m.x != null && m.x >= gesneden.vanMs && m.x <= gesneden.totMs);
|
|||
|
|
mountSparkline(el, gesneden.punten, {
|
|||
|
|
tone: 'accent', vergelijk: gesneden.inleg, formatValue: sparkLabel, markers,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const licht = (id, aan) => {
|
|||
|
|
body.querySelector(`[data-trade="${CSS.escape(String(id))}"]`)?.classList.toggle('is-active', aan);
|
|||
|
|
el.querySelector(`[data-mark="t${CSS.escape(String(id))}"]`)?.classList.toggle('is-active', aan);
|
|||
|
|
};
|
|||
|
|
let actief = null;
|
|||
|
|
body.addEventListener('mouseover', e => {
|
|||
|
|
const rij = e.target.closest('[data-trade]');
|
|||
|
|
const id = rij ? rij.dataset.trade : null;
|
|||
|
|
if (id === actief) return;
|
|||
|
|
if (actief) licht(actief, false);
|
|||
|
|
actief = id;
|
|||
|
|
if (actief) licht(actief, true);
|
|||
|
|
});
|
|||
|
|
body.addEventListener('mouseleave', () => { if (actief) { licht(actief, false); actief = null; } });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function dagMs(iso) {
|
|||
|
|
const d = String(iso || '').slice(0, 10);
|
|||
|
|
const ms = Date.parse(`${d}T00:00:00Z`);
|
|||
|
|
return Number.isFinite(ms) ? ms : null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function detailHtml() {
|
|||
|
|
return `
|
|||
|
|
<div class="wealth-detail">
|
|||
|
|
<div class="wealth-detail-tabs" data-detail-tabs></div>
|
|||
|
|
<div class="wealth-detail-spark">
|
|||
|
|
<div class="wealth-detail-plot" data-detail-spark></div>
|
|||
|
|
</div>
|
|||
|
|
</div>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function detailCijfersHtml(asset, f) {
|
|||
|
|
const cijfers = [
|
|||
|
|
[t('wealth.avgSpot'), f.avgMain != null ? val(fmtBasePrice(priceFromBase(f.avgMain, asset))) : null],
|
|||
|
|
[t('wealth.avgPremium'), f.avgPremium != null ? val(fmtBase(f.avgPremium)) : null],
|
|||
|
|
[t('wealth.invested'), isBaseAsset(asset) ? null : val(fmtBase(f.invested))],
|
|||
|
|
].filter(([, v]) => v != null);
|
|||
|
|
if (!cijfers.length) return '';
|
|||
|
|
return `<div class="wealth-detail-cijfers"><div class="wealth-detail-stats">${cijfers.map(([l, v]) => stat(l, v)).join('')}</div></div>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function derdenRegelsHtml({ asset, m }) {
|
|||
|
|
const delen = (m.beheerd || []).filter(d => d.saldo && scopeOf(d.accountId) === 'managed');
|
|||
|
|
if (!delen.length) return '';
|
|||
|
|
const naam = id => alleAccounts.find(a => a.id === id)?.name ?? `#${id}`;
|
|||
|
|
return `
|
|||
|
|
<div class="wealth-detail-beheerd">
|
|||
|
|
<table><thead><tr>
|
|||
|
|
<th class="text">${t('wealth.account')}</th>
|
|||
|
|
<th class="num">${t('wealth.quantity')}</th>
|
|||
|
|
<th class="num">${t('wealth.costOnArrival')}</th>
|
|||
|
|
<th class="num">${t('wealth.unrealized')}</th>
|
|||
|
|
</tr></thead><tbody>${delen.map(d => `<tr>
|
|||
|
|
<td class="text">${esc(naam(d.accountId))}</td>
|
|||
|
|
<td class="num">${val(fmtUnit(d.saldo, asset))}</td>
|
|||
|
|
<td class="num">${d.cost == null ? DASH : val(fmtBase(d.cost))}</td>
|
|||
|
|
<td class="num">${d.unrealized == null ? DASH : kleurBedrag(d.unrealized)}</td>
|
|||
|
|
</tr>`).join('')}</tbody></table>
|
|||
|
|
</div>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function mountDetailTabs(body, k, delen, naam, holdings, reeks) {
|
|||
|
|
const host = body.querySelector('[data-detail-tabs]');
|
|||
|
|
if (!host) return;
|
|||
|
|
const { eigen, derden } = delen;
|
|||
|
|
createTabs(host, {
|
|||
|
|
items: [
|
|||
|
|
{ id: 'accounts', label: t('wealth.ownWealth') },
|
|||
|
|
{ id: 'others', label: t('wealth.othersWealth'), when: () => !!derden },
|
|||
|
|
{ id: 'products', label: t('wealth.perProduct'), when: () => isMetal(k.asset) },
|
|||
|
|
{ id: 'return', label: t('wealth.returnTitle') },
|
|||
|
|
{ id: 'notes', label: t('notes.tab') },
|
|||
|
|
],
|
|||
|
|
render: (id, panel) => {
|
|||
|
|
body.querySelector('.wealth-detail')?.classList.toggle('is-return', id === 'return');
|
|||
|
|
if (id === 'notes') { panel.innerHTML = notesHtml(); mountNotes(panel, k.asset.id); return; }
|
|||
|
|
if (id === 'return') {
|
|||
|
|
panel.innerHTML = `<div class="wealth-return-tab">
|
|||
|
|
<div class="wealth-return-years">${perJaarHtml(k.asset, reeks)}</div>
|
|||
|
|
<div class="return-view"></div>
|
|||
|
|
</div>`;
|
|||
|
|
mountReturnPanel(panel.querySelector('.return-view'), { reeks, asset: k.asset });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (id === 'products') { panel.innerHTML = metalBreakdown(k.asset, k.p, k.m.fiatPrice, holdings); return; }
|
|||
|
|
panel.innerHTML = id === 'others'
|
|||
|
|
? detailCijfersHtml(k.asset, k.m.derden) + derdenRegelsHtml(k) + perAccountHtml(k, derden.rijen, naam, derden.trades)
|
|||
|
|
: detailCijfersHtml(k.asset, k.m) + perAccountHtml(k, eigen.rijen, naam, eigen.trades);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function openJaaroverzicht() {
|
|||
|
|
const reeks = await (reeksBelofte || Promise.resolve(null)).catch(() => null);
|
|||
|
|
const startjaar = Number(await getPref('app', 'start_year').catch(() => null)) || CONFIG.START_YEAR.AUTO;
|
|||
|
|
const rijen = jaaroverzichtRijen(reeks, startjaar);
|
|||
|
|
const grafiek = rijen?.jaren.length ? `<div class="wealth-overview-plot"><canvas data-jaargrafiek></canvas></div>` : '';
|
|||
|
|
const klaar = dialog.detail({
|
|||
|
|
title: t('wealth.yearOverviewTitle'),
|
|||
|
|
html: `<div class="wealth-overview">${grafiek}${jaaroverzichtHtml(rijen)}</div>`,
|
|||
|
|
});
|
|||
|
|
const canvas = document.querySelector('.dialog-overlay [data-jaargrafiek]');
|
|||
|
|
if (!canvas) return;
|
|||
|
|
const chart = await tekenJaarGrafiek(canvas, [...rijen.jaren].reverse()).catch(() => null);
|
|||
|
|
klaar.then(() => chart?.destroy());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function openRendement() {
|
|||
|
|
const reeks = await (reeksBelofte || Promise.resolve(null)).catch(() => null);
|
|||
|
|
const kaarten = (getekendeStanden || nuStanden)?.kaarten || [];
|
|||
|
|
let page = null;
|
|||
|
|
if (reeks?.dagen?.length && keuze !== ALLE_JAREN) {
|
|||
|
|
const jaren = jarenIn(keuze);
|
|||
|
|
page = { label: jaren.length === 1 ? String(jaren[0]) : `${jaren[0]}-${jaren[jaren.length - 1]}`, venster: vensterVan(reeks) };
|
|||
|
|
}
|
|||
|
|
openReturnDialog({ reeks, assets: kaarten.map(k => k.asset), page, selection: selectie });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function jaaroverzichtRijen(reeks, startjaar = CONFIG.START_YEAR.AUTO) {
|
|||
|
|
if (!reeks?.dagen?.length) return null;
|
|||
|
|
const lengte = reeks.dagen.length;
|
|||
|
|
const ids = [...(reeks.perAsset || new Map()).keys()];
|
|||
|
|
const totaal = seriesFor(reeks);
|
|||
|
|
const rij = (label, venster) => {
|
|||
|
|
const meet = meetVenster(venster, lengte);
|
|||
|
|
let gerealiseerd = 0, verkocht = false, onbekend = false;
|
|||
|
|
for (const id of ids) {
|
|||
|
|
const g = gerealiseerdOver(reeks, id, meet);
|
|||
|
|
if (g.onbekend) onbekend = true;
|
|||
|
|
else if (g.bedrag != null) { gerealiseerd += g.bedrag; verkocht = true; }
|
|||
|
|
}
|
|||
|
|
return {
|
|||
|
|
label,
|
|||
|
|
waarde: reeks.waarde[meet.tot],
|
|||
|
|
inleg: windowDelta(reeks.ingelegd, meet),
|
|||
|
|
pct: rendementVanVenster(reeks, venster),
|
|||
|
|
sinds: rendementVanVenster(reeks, { van: 0, tot: venster.tot }),
|
|||
|
|
resultaat: windowResult(totaal, meet),
|
|||
|
|
gerealiseerd: onbekend || !verkocht ? null : gerealiseerd,
|
|||
|
|
};
|
|||
|
|
};
|
|||
|
|
const jaren = [...jaarVensters(reeks.dagen)]
|
|||
|
|
.filter(([jaar]) => jaar >= startjaar)
|
|||
|
|
.map(([jaar, v]) => rij(String(jaar), v))
|
|||
|
|
.filter(r => (r.waarde || 0) >= 0.01 || Math.abs(r.inleg) >= 0.01 || r.gerealiseerd != null || Math.abs(r.resultaat) >= 0.01)
|
|||
|
|
.reverse();
|
|||
|
|
return { jaren, alles: rij(t('wealth.allYears'), { van: 0, tot: lengte - 1 }) };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function jaaroverzichtHtml(rijen) {
|
|||
|
|
if (!rijen?.jaren.length) return `<div class="t-muted-cell">${t('wealth.noYears')}</div>`;
|
|||
|
|
const { jaren, alles } = rijen;
|
|||
|
|
const bedrag = v => (v == null || Math.abs(v) < 0.01 ? DASH : val(fmtBase(v)));
|
|||
|
|
const cel = r => `
|
|||
|
|
<td class="num">${bedrag(r.waarde)}</td>
|
|||
|
|
<td class="num">${bedrag(r.inleg)}</td>
|
|||
|
|
<td class="num">${pctCel(r.pct)}</td>
|
|||
|
|
<td class="num">${pctCel(r.sinds)}</td>
|
|||
|
|
<td class="num">${bedragCel(r.resultaat)}</td>
|
|||
|
|
<td class="num">${r.gerealiseerd == null ? DASH : kleurBedrag(r.gerealiseerd)}</td>`;
|
|||
|
|
return `<div class="table-wrap"><table>
|
|||
|
|
<thead><tr>
|
|||
|
|
<th class="text">${t('wealth.year')}</th>
|
|||
|
|
<th class="num">${t('wealth.valueEnd')}</th>
|
|||
|
|
<th class="num">${t('wealth.investedNet')}</th>
|
|||
|
|
<th class="num">${t('wealth.twr')}</th>
|
|||
|
|
<th class="num">${t('wealth.sinceStart')}</th>
|
|||
|
|
<th class="num">${t('wealth.result')}</th>
|
|||
|
|
<th class="num">${t('wealth.realized')}</th>
|
|||
|
|
</tr></thead>
|
|||
|
|
<tbody>
|
|||
|
|
${jaren.map(r => `<tr><td class="text">${esc(r.label)}</td>${cel(r)}</tr>`).join('')}
|
|||
|
|
<tr><td class="text"><strong>${esc(alles.label)}</strong></td>${cel(alles)}</tr>
|
|||
|
|
</tbody>
|
|||
|
|
</table></div>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function perJaarHtml(asset, reeks) {
|
|||
|
|
if (!reeks?.dagen?.length) return `<div class="t-muted-cell">${t('wealth.noYears')}</div>`;
|
|||
|
|
const lengte = reeks.dagen.length;
|
|||
|
|
const rij = (label, venster) => {
|
|||
|
|
const meet = meetVenster(venster, lengte);
|
|||
|
|
const pctOver = v => (isBaseAsset(asset) ? null : assetRendement(reeks, asset.id, v));
|
|||
|
|
return {
|
|||
|
|
label,
|
|||
|
|
waarde: reeks.perAsset?.get(asset.id)?.[meet.tot],
|
|||
|
|
inleg: windowDelta(reeks.inlegPerAsset?.get(asset.id), meet),
|
|||
|
|
pct: pctOver(venster),
|
|||
|
|
sinds: pctOver({ van: 0, tot: venster.tot }),
|
|||
|
|
g: gerealiseerdOver(reeks, asset.id, meet),
|
|||
|
|
rente: overVenster(reeks.rentePerAsset?.get(asset.id), meet),
|
|||
|
|
resultaat: windowResult(seriesFor(reeks, asset.id), meet),
|
|||
|
|
};
|
|||
|
|
};
|
|||
|
|
const jaren = [...jaarVensters(reeks.dagen)].map(([jaar, v]) => rij(String(jaar), v))
|
|||
|
|
.filter(r => r.pct != null || r.g.bedrag != null || r.g.onbekend || Math.abs(r.rente) >= 0.01 || Math.abs(r.resultaat) >= 0.01)
|
|||
|
|
.reverse();
|
|||
|
|
if (!jaren.length) return `<div class="t-muted-cell">${t('wealth.noYears')}</div>`;
|
|||
|
|
const alles = rij(t('wealth.allYears'), { van: 0, tot: lengte - 1 });
|
|||
|
|
|
|||
|
|
const bedrag = v => (v == null || Math.abs(v) < 0.01 ? DASH : val(fmtBase(v)));
|
|||
|
|
const cel = r => `
|
|||
|
|
<td class="num">${bedrag(r.waarde)}</td>
|
|||
|
|
<td class="num">${bedrag(r.inleg)}</td>
|
|||
|
|
<td class="num">${pctCel(r.pct)}</td>
|
|||
|
|
<td class="num">${pctCel(r.sinds)}</td>
|
|||
|
|
<td class="num">${bedragCel(r.resultaat)}</td>
|
|||
|
|
<td class="num">${r.g.bedrag == null ? DASH : kleurBedrag(r.g.bedrag)}</td>`;
|
|||
|
|
return `<div class="table-wrap"><table>
|
|||
|
|
<thead><tr>
|
|||
|
|
<th class="text">${t('wealth.year')}</th>
|
|||
|
|
<th class="num">${t('wealth.valueEnd')}</th>
|
|||
|
|
<th class="num">${t('wealth.investedNet')}</th>
|
|||
|
|
<th class="num">${t('wealth.twr')}</th>
|
|||
|
|
<th class="num">${t('wealth.sinceStart')}</th>
|
|||
|
|
<th class="num">${t('wealth.result')}</th>
|
|||
|
|
<th class="num">${t('wealth.realized')}</th>
|
|||
|
|
</tr></thead>
|
|||
|
|
<tbody>
|
|||
|
|
${jaren.map(r => `<tr><td class="text">${esc(r.label)}</td>${cel(r)}</tr>`).join('')}
|
|||
|
|
<tr><td class="text"><strong>${esc(alles.label)}</strong></td>${cel(alles)}</tr>
|
|||
|
|
</tbody>
|
|||
|
|
</table></div>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function perAccountHtml(k, rijen, naam, trades) {
|
|||
|
|
const { asset, m } = k;
|
|||
|
|
const rekeningVan = tr => (tr.type === 'sell' ? tr.fromAccountId : tr.toAccountId) ?? null;
|
|||
|
|
const perRekening = new Map();
|
|||
|
|
for (const tr of trades) {
|
|||
|
|
const id = rekeningVan(tr);
|
|||
|
|
if (!perRekening.has(id)) perRekening.set(id, []);
|
|||
|
|
perRekening.get(id).push(tr);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const standen = new Map(rijen.map(r => [r.accountId ?? null, r]));
|
|||
|
|
const ids = [...standen.keys()];
|
|||
|
|
for (const id of perRekening.keys()) if (!standen.has(id)) ids.push(id);
|
|||
|
|
ids.sort((a, b) => (a === null ? 1 : 0) - (b === null ? 1 : 0));
|
|||
|
|
if (!ids.length) return `<div class="t-muted-cell">${t('wealth.noAccountRows')}</div>`;
|
|||
|
|
|
|||
|
|
const groepen = ids.map(id => {
|
|||
|
|
const r = standen.get(id);
|
|||
|
|
const saldo = r ? r.saldo : null;
|
|||
|
|
const waarde = (saldo != null && m.fiatPrice != null)
|
|||
|
|
? fmtBase((saldo / Math.pow(10, asset.decimals)) * m.fiatPrice) : null;
|
|||
|
|
const label = id == null ? t('wealth.noAccount') : (naam.get(id) ?? `#${id}`);
|
|||
|
|
const samenvatting = [saldo != null ? fmtUnit(saldo, asset) : null, waarde]
|
|||
|
|
.filter(Boolean).map(x => val(x)).join(' · ');
|
|||
|
|
const boekingen = handelRijen(asset, perRekening.get(id) || []);
|
|||
|
|
return `<tr class="group-row"><td colspan="5"><div class="group-header">
|
|||
|
|
<span>${esc(label)}</span>
|
|||
|
|
${samenvatting ? `<span class="group-summary">${samenvatting}${waarde ? originHtml(m.origin, asset) : ''}</span>` : ''}
|
|||
|
|
</div></td></tr>${boekingen}`;
|
|||
|
|
}).join('');
|
|||
|
|
|
|||
|
|
return `<div class="table-wrap"><table>
|
|||
|
|
<thead><tr>
|
|||
|
|
<th class="text">${t('wealth.tradeDate')}</th>
|
|||
|
|
<th class="text">${t('wealth.tradeType')}</th>
|
|||
|
|
<th class="num">${t('wealth.quantity')}</th>
|
|||
|
|
<th class="num">${t('wealth.tradePrice')}</th>
|
|||
|
|
<th class="num">${t('wealth.tradeAmount')}</th>
|
|||
|
|
</tr></thead>
|
|||
|
|
<tbody>${groepen}</tbody>
|
|||
|
|
</table></div>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handelRijen(asset, trades) {
|
|||
|
|
if (!trades.length) {
|
|||
|
|
return `<tr class="wealth-trade-leeg"><td colspan="5" class="text t-muted-cell">${t('wealth.noTrades')}</td></tr>`;
|
|||
|
|
}
|
|||
|
|
const deler = Math.pow(10, asset.decimals);
|
|||
|
|
return trades.map(tr => {
|
|||
|
|
const koop = tr.type !== 'sell';
|
|||
|
|
const bedrag = ((Number(tr.amount) || 0) / deler) * (Number(tr.fiatPrice) || 0);
|
|||
|
|
const kosten = Number(tr.fiatFee) || 0;
|
|||
|
|
const soort = t(koop ? 'enums.event_type.buy' : 'enums.event_type.sell');
|
|||
|
|
const labels = tr.label_names ? subline(`<span class="t-muted-cell">${esc(tr.label_names)}</span>`) : '';
|
|||
|
|
return `<tr data-trade="${esc(String(tr.id))}">
|
|||
|
|
<td class="text">${esc(sparkDatum(dagMs(tr.occurredAt) ?? Date.now()))}${labels}</td>
|
|||
|
|
<td class="text"><span class="badge ${koop ? 'badge-accent' : 'badge-neutral'} badge-icon" title="${esc(soort)}" aria-label="${esc(soort)}">${koop ? ARROW_DOWN : ARROW_UP}</span></td>
|
|||
|
|
<td class="num">${val(fmtUnit(tr.amount, asset))}</td>
|
|||
|
|
<td class="num">${tr.fiatPrice == null ? DASH : val(fmtBasePrice(priceFromBase(tr.fiatPrice, asset)))}</td>
|
|||
|
|
<td class="num">${val(fmtBase(bedrag))}${kosten ? ` <span class="t-muted-cell">${koop ? '+' : '−'} ${val(fmtBase(kosten))}</span>` : ''}</td>
|
|||
|
|
</tr>`;
|
|||
|
|
}).join('');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function aandeel(waarde, total) {
|
|||
|
|
if (waarde == null || !(total > 0)) return '-';
|
|||
|
|
return val(`${((waarde / total) * 100).toFixed(1).replace('.', ',')}%`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function legendaAanmelden(host, buiten = []) {
|
|||
|
|
setPageHint(host, {
|
|||
|
|
title: t('wealth.legendTitle'),
|
|||
|
|
rows: [
|
|||
|
|
...buiten,
|
|||
|
|
{ sample: SAMPLES.line, text: t('wealth.legendSparkValue') },
|
|||
|
|
{ sample: SAMPLES.dashed, text: t('wealth.legendSparkInvested') },
|
|||
|
|
{ sample: SAMPLES.dot, text: t('wealth.legendBuy') },
|
|||
|
|
{ sample: SAMPLES.dotOpen, text: t('wealth.legendSell') },
|
|||
|
|
{ sample: SAMPLES.ring, text: t('wealth.legendRings') },
|
|||
|
|
t('wealth.legendInvested'),
|
|||
|
|
t('wealth.legendTwr'),
|
|||
|
|
t('wealth.legendUnexplained'),
|
|||
|
|
t('wealth.legendRange'),
|
|||
|
|
t('wealth.legendChain'),
|
|||
|
|
t('wealth.legendDetail'),
|
|||
|
|
t('wealth.legendFilter'),
|
|||
|
|
t('wealth.legendAccounts'),
|
|||
|
|
t('wealth.legendUnrealized'),
|
|||
|
|
t('wealth.legendResult'),
|
|||
|
|
t('wealth.legendNoPrice'),
|
|||
|
|
...(excludedAccounts.length ? [t('wealth.legendShowAll')] : []),
|
|||
|
|
],
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function buitenRegels() {
|
|||
|
|
if (!excludedAccounts.length) return [];
|
|||
|
|
if (showAll) return [t('wealth.legendShowAllOn')];
|
|||
|
|
const soort = new Map(excludedAccounts.map(a => [a.id, a.wealthScope]));
|
|||
|
|
const rijen = await accountHoldings(excludedAccounts.map(a => a.id));
|
|||
|
|
const assets = (await listAssets()).filter(a => rijen.some(r => r.assetId === a.id));
|
|||
|
|
const spot = assets.length ? await loadFiat(assets) : new Map();
|
|||
|
|
const som = { managed: 0, none: 0 };
|
|||
|
|
for (const r of rijen) {
|
|||
|
|
const asset = assets.find(a => a.id === r.assetId);
|
|||
|
|
const prijs = basePriceOf(spot.get(r.assetId));
|
|||
|
|
if (!asset || prijs == null) continue;
|
|||
|
|
som[soort.get(r.accountId)] += (r.saldo / Math.pow(10, asset.decimals)) * prijs;
|
|||
|
|
}
|
|||
|
|
const scopes = new Set(excludedAccounts.map(a => a.wealthScope));
|
|||
|
|
return [
|
|||
|
|
...(scopes.has('managed') ? [t('wealth.legendManaged', { bedrag: fmtBase(som.managed) })] : []),
|
|||
|
|
...(scopes.has('none') ? [t('wealth.legendNotCounted', { bedrag: fmtBase(som.none) })] : []),
|
|||
|
|
];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const tekenKleur = (getal, html) => `<span class="${getal >= 0 ? 't-pos' : 't-neg'}">${html}</span>`;
|
|||
|
|
const kleurBedrag = v => tekenKleur(v, val(fmtBaseSigned(v, colouredSigns())));
|
|||
|
|
|
|||
|
|
function stat(label, value) {
|
|||
|
|
return `<div class="wealth-stat${value == null ? ' is-leeg' : ''}"><div class="t-muted-cell">${label}</div><div class="t-value">${value ?? DASH}</div></div>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function statLeeg(label, sleutel) {
|
|||
|
|
return `<div class="wealth-stat is-leeg" data-nietverklaard-rij>
|
|||
|
|
<div class="t-muted-cell">${label}</div><div class="t-muted" data-nietverklaard="${sleutel}"></div>
|
|||
|
|
</div>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function cardHtml(asset, p, m, total) {
|
|||
|
|
const valueStr = m.value != null
|
|||
|
|
? `<span class="po-host">${val(fmtBase(m.value))}${originHtml(m.origin, asset)}</span>`
|
|||
|
|
: null;
|
|||
|
|
const unrealStr = m.unrealized != null ? kleurBedrag(m.unrealized) : null;
|
|||
|
|
const aandeelStr = m.value != null && total > 0 ? aandeel(m.value, total) : null;
|
|||
|
|
|
|||
|
|
const geenKoers = m.fiatPrice == null
|
|||
|
|
? `<div class="t-caption t-muted wealth-noprice">${t('wealth.noPrice')}</div>`
|
|||
|
|
: '';
|
|||
|
|
|
|||
|
|
const stats = `
|
|||
|
|
<div class="wealth-stats">
|
|||
|
|
${stat(t('wealth.quantity'), `<span class="po-host">${val(fmtUnit(p.net, asset))}${p.net === 0 ? `<span data-nul="${asset.id}"></span>` : ''}</span>`)}
|
|||
|
|
${stat(t('wealth.value'), valueStr)}
|
|||
|
|
${stat(t('wealth.share'), aandeelStr)}
|
|||
|
|
${stat(t('wealth.unrealized'), unrealStr)}
|
|||
|
|
${stat(t('wealth.result'), `<span data-resultaat="${asset.id}" class="t-muted-cell">…</span>`)}
|
|||
|
|
${stat(t('wealth.twr'), `<span data-twr="${asset.id}" class="t-muted-cell">…</span>`)}
|
|||
|
|
${statLeeg(t('wealth.unexplained'), asset.id)}
|
|||
|
|
</div>`;
|
|||
|
|
|
|||
|
|
return `<div class="card wealth-card" data-asset="${asset.id}" data-kind="${esc(asset.kind)}"${isBaseAsset(asset) ? ' data-rekenvaluta' : ''}${m.fiatPrice == null ? ' data-geenkoers' : ''}${m.unrealized != null ? ` data-ongerealiseerd="${Number(m.unrealized)}"` : ''}>
|
|||
|
|
<div class="wealth-head">
|
|||
|
|
<strong>${assetLabelHtml(asset)}</strong>
|
|||
|
|
<span class="badge">${t('enums.asset_kind.' + asset.kind)}</span>
|
|||
|
|
<button type="button" class="wealth-detail-btn" data-detail="${asset.id}" title="${esc(t('wealth.openDetail'))}"
|
|||
|
|
aria-label="${esc(t('wealth.openDetail'))}: ${esc(asset.name)}">${CHEVRON_RIGHT}</button>
|
|||
|
|
</div>
|
|||
|
|
${stats}
|
|||
|
|
${geenKoers}
|
|||
|
|
<div class="wealth-spark-wrap" data-spark-wrap="${asset.id}">
|
|||
|
|
<div class="wealth-spark" data-spark-asset="${asset.id}"></div>
|
|||
|
|
</div>
|
|||
|
|
</div>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function accountCardHtml(c, total) {
|
|||
|
|
const key = accountSleutel(c);
|
|||
|
|
const herkomsten = c.lines.filter(l => l.value != null).map(l => ({ origin: l.origin, asset: l.asset }));
|
|||
|
|
const valueStr = c.value != null ? `<span class="po-host">${val(fmtBase(c.value))}${originsHtml(herkomsten)}</span>` : null;
|
|||
|
|
const unrealStr = c.unrealized != null ? kleurBedrag(c.unrealized) : null;
|
|||
|
|
const aandeelStr = c.value != null && total > 0 ? aandeel(c.value, total) : null;
|
|||
|
|
const rekenvaluta = c.lines.length > 0 && c.lines.every(l => isBaseAsset(l.asset));
|
|||
|
|
const geenKoers = c.lines.some(l => l.value == null);
|
|||
|
|
const naam = accountNaam(c);
|
|||
|
|
return `<div class="card wealth-card" data-account="${key}" data-assets="${c.assetIds.join(' ')}"
|
|||
|
|
data-kinds="${esc(c.kinds.join(' '))}"${rekenvaluta ? ' data-rekenvaluta' : ''}${geenKoers ? ' data-geenkoers' : ''}${c.unrealized != null ? ` data-ongerealiseerd="${Number(c.unrealized)}"` : ''}>
|
|||
|
|
<div class="wealth-head">
|
|||
|
|
<strong>${esc(naam)}</strong>
|
|||
|
|
${c.account ? `<span class="badge">${t('enums.account_kind.' + c.account.kind)}</span>` : ''}
|
|||
|
|
<button type="button" class="wealth-detail-btn" data-detail-account="${key}" title="${esc(t('wealth.openDetail'))}"
|
|||
|
|
aria-label="${esc(t('wealth.openDetail'))}: ${esc(naam)}">${CHEVRON_RIGHT}</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="wealth-stats">
|
|||
|
|
${stat(t('wealth.share'), aandeelStr)}
|
|||
|
|
${stat(t('wealth.value'), valueStr)}
|
|||
|
|
${statLeeg(t('wealth.unexplained'), `r${key}`)}
|
|||
|
|
${stat(t('wealth.unrealized'), unrealStr)}
|
|||
|
|
${stat(t('wealth.result'), `<span data-resultaat="r${key}" class="t-muted-cell">…</span>`)}
|
|||
|
|
${stat(t('wealth.twr'), `<span data-twr="r${key}" class="t-muted-cell">…</span>`)}
|
|||
|
|
</div>
|
|||
|
|
<div class="wealth-spark-wrap">
|
|||
|
|
<div class="wealth-spark" data-spark-account="${key}"></div>
|
|||
|
|
</div>
|
|||
|
|
</div>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function accountReeks(reeks, key, venster, dagenMs) {
|
|||
|
|
const id = reeksSleutel(key);
|
|||
|
|
const s = accountSeries(reeks, id);
|
|||
|
|
const vanaf = Math.max(reeks.eersteDagPerAccount?.get(id) ?? 0, venster.van);
|
|||
|
|
const tot = venster.tot + 1;
|
|||
|
|
const dagen = dagenMs.slice(vanaf, tot);
|
|||
|
|
const punten = puntenVan(s.waarden.slice(vanaf, tot), dagen);
|
|||
|
|
if (punten.length < 2 || !punten.some(p => p[1] > 0)) return null;
|
|||
|
|
const ingebracht = s.waarden.map((_, i) => (s.inleg[i] || 0) + (s.ankers[i] || 0) - (s.rente[i] || 0));
|
|||
|
|
return { punten, inleg: puntenVan(ingebracht.slice(vanaf, tot), dagen), vanMs: dagenMs[vanaf], totMs: dagenMs[venster.tot] };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function openAccountDetail(c) {
|
|||
|
|
const reeks = await (reeksBelofte || Promise.resolve(null)).catch(() => null);
|
|||
|
|
const naam = accountNaam(c);
|
|||
|
|
dialog.detail({ title: naam,
|
|||
|
|
asideHtml: c.account ? `<span class="badge">${t('enums.account_kind.' + c.account.kind)}</span>` : '', html: detailHtml() });
|
|||
|
|
const body = document.querySelector('.dialog-overlay .dialog-body--rich');
|
|||
|
|
if (!body) return;
|
|||
|
|
body.closest('.dialog')?.insertAdjacentHTML('afterbegin',
|
|||
|
|
`<span class="wealth-year-chip wealth-detail-periode">${jaarInhoud(t('wealth.allYears'), null)}</span>`);
|
|||
|
|
createTabs(body.querySelector('[data-detail-tabs]'), {
|
|||
|
|
items: [{ id: 'assets', label: t('wealth.perAsset') }, { id: 'return', label: t('wealth.returnTitle') }],
|
|||
|
|
render: (id, panel) => {
|
|||
|
|
panel.innerHTML = id === 'assets' ? accountAssetsHtml(c) : accountJarenHtml(reeks, accountSleutel(c));
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const el = body.querySelector('[data-detail-spark]');
|
|||
|
|
if (!el) return;
|
|||
|
|
const dagenMs = reeks?.dagen?.map(d => Date.parse(`${d}T00:00:00Z`)) || [];
|
|||
|
|
const gesneden = reeks?.dagen?.length ? accountReeks(reeks, accountSleutel(c), { van: 0, tot: reeks.dagen.length - 1 }, dagenMs) : null;
|
|||
|
|
if (!gesneden) { el.innerHTML = `<div class="wealth-detail-leeg t-muted-cell">${t('wealth.noChart')}</div>`; return; }
|
|||
|
|
mountSparkline(el, gesneden.punten, { tone: 'accent', vergelijk: gesneden.inleg, formatValue: sparkLabel });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function accountAssetsHtml(c) {
|
|||
|
|
if (!c.lines.length) return `<div class="t-muted-cell">${t('wealth.noAccountAssets')}</div>`;
|
|||
|
|
const rijen = c.lines.map(l => `<tr>
|
|||
|
|
<td class="text">${assetLabelHtml(l.asset)}</td>
|
|||
|
|
<td class="num">${val(fmtUnit(l.saldo, l.asset))}</td>
|
|||
|
|
<td class="num">${l.value == null ? DASH : `<span class="po-host">${val(fmtBase(l.value))}${originHtml(l.origin, l.asset)}</span>`}</td>
|
|||
|
|
<td class="num">${l.value == null || !(c.value > 0) ? DASH : aandeel(l.value, c.value)}</td>
|
|||
|
|
</tr>`).join('');
|
|||
|
|
return `<div class="table-wrap"><table>
|
|||
|
|
<thead><tr>
|
|||
|
|
<th class="text">${t('wealth.asset')}</th>
|
|||
|
|
<th class="num">${t('wealth.quantity')}</th>
|
|||
|
|
<th class="num">${t('wealth.value')}</th>
|
|||
|
|
<th class="num">${t('wealth.shareOfAccount')}</th>
|
|||
|
|
</tr></thead>
|
|||
|
|
<tbody>${rijen}</tbody>
|
|||
|
|
</table></div>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function accountJarenHtml(reeks, key) {
|
|||
|
|
if (!reeks?.dagen?.length) return `<div class="t-muted-cell">${t('wealth.noYears')}</div>`;
|
|||
|
|
const lengte = reeks.dagen.length;
|
|||
|
|
const s = accountSeries(reeks, reeksSleutel(key));
|
|||
|
|
const ingebracht = s.waarden.map((_, i) => (s.inleg[i] || 0) + (s.ankers[i] || 0) - (s.rente[i] || 0));
|
|||
|
|
const rij = (label, venster) => {
|
|||
|
|
const meet = meetVenster(venster, lengte);
|
|||
|
|
return {
|
|||
|
|
label,
|
|||
|
|
waarde: s.waarden[meet.tot],
|
|||
|
|
inleg: windowDelta(ingebracht, meet),
|
|||
|
|
pct: windowTwr(s, meet),
|
|||
|
|
sinds: windowTwr(s, meetVenster({ van: 0, tot: venster.tot }, lengte)),
|
|||
|
|
resultaat: windowResult(s, meet),
|
|||
|
|
};
|
|||
|
|
};
|
|||
|
|
const jaren = [...jaarVensters(reeks.dagen)].map(([jaar, v]) => rij(String(jaar), v))
|
|||
|
|
.filter(r => r.pct != null || Math.abs(r.resultaat) >= 0.01 || Math.abs(r.inleg) >= 0.01)
|
|||
|
|
.reverse();
|
|||
|
|
if (!jaren.length) return `<div class="t-muted-cell">${t('wealth.noYears')}</div>`;
|
|||
|
|
const alles = rij(t('wealth.allYears'), { van: 0, tot: lengte - 1 });
|
|||
|
|
const bedrag = v => (v == null || Math.abs(v) < 0.01 ? DASH : val(fmtBase(v)));
|
|||
|
|
const cel = r => `
|
|||
|
|
<td class="num">${bedrag(r.waarde)}</td>
|
|||
|
|
<td class="num">${bedrag(r.inleg)}</td>
|
|||
|
|
<td class="num">${pctCel(r.pct)}</td>
|
|||
|
|
<td class="num">${pctCel(r.sinds)}</td>
|
|||
|
|
<td class="num">${bedragCel(r.resultaat)}</td>`;
|
|||
|
|
return `<div class="table-wrap"><table>
|
|||
|
|
<thead><tr>
|
|||
|
|
<th class="text">${t('wealth.year')}</th>
|
|||
|
|
<th class="num">${t('wealth.valueEnd')}</th>
|
|||
|
|
<th class="num">${t('wealth.broughtInNet')}</th>
|
|||
|
|
<th class="num">${t('wealth.twr')}</th>
|
|||
|
|
<th class="num">${t('wealth.sinceStart')}</th>
|
|||
|
|
<th class="num">${t('wealth.result')}</th>
|
|||
|
|
</tr></thead>
|
|||
|
|
<tbody>
|
|||
|
|
${jaren.map(r => `<tr><td class="text">${esc(r.label)}</td>${cel(r)}</tr>`).join('')}
|
|||
|
|
<tr><td class="text"><strong>${esc(alles.label)}</strong></td>${cel(alles)}</tr>
|
|||
|
|
</tbody>
|
|||
|
|
</table></div>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function assetReeks(reeks, id, venster, dagenMs) {
|
|||
|
|
const vanaf = Math.max(reeks.eersteDagPerAsset?.get(id) ?? 0, venster.van);
|
|||
|
|
const tot = venster.tot + 1;
|
|||
|
|
const dagenVanAsset = dagenMs.slice(vanaf, tot);
|
|||
|
|
const punten = puntenVan((reeks.perAsset.get(id) || []).slice(vanaf, tot), dagenVanAsset);
|
|||
|
|
if (punten.length < 2 || !punten.some(p => p[1] > 0)) return null;
|
|||
|
|
const inleg = puntenVan((reeks.inlegPerAsset?.get(id) || []).slice(vanaf, tot), dagenVanAsset);
|
|||
|
|
return { punten, inleg, vanMs: dagenMs[vanaf], totMs: dagenMs[venster.tot] };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const sparkLabel = (v, ms, in_) => [fmtBase(v), in_ == null ? null : gatTekst(v - in_), sparkDatum(ms)]
|
|||
|
|
.filter(Boolean).join(' · ');
|
|||
|
|
|
|||
|
|
async function vulSparklines(host, reeks, venster) {
|
|||
|
|
const dagenMs = reeks.dagen.map(d => Date.parse(`${d}T00:00:00Z`));
|
|||
|
|
for (const el of host.querySelectorAll('[data-spark-asset]')) {
|
|||
|
|
const id = Number(el.dataset.sparkAsset);
|
|||
|
|
const gesneden = assetReeks(reeks, id, venster, dagenMs);
|
|||
|
|
if (!gesneden) { el.innerHTML = ''; continue; }
|
|||
|
|
const { punten, inleg } = gesneden;
|
|||
|
|
const stippen = [
|
|||
|
|
...(reeks.aankopen?.get(id) || []).map(i => ({ x: dagenMs[i], variant: 'buy' })),
|
|||
|
|
...(reeks.verkopen?.get(id) || []).map(i => ({ x: dagenMs[i], variant: 'sell' })),
|
|||
|
|
].filter(s => s.x != null && s.x >= gesneden.vanMs && s.x <= gesneden.totMs);
|
|||
|
|
mountSparkline(el, punten, {
|
|||
|
|
tone: 'accent',
|
|||
|
|
vergelijk: inleg,
|
|||
|
|
formatValue: sparkLabel,
|
|||
|
|
markers: stippen,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
for (const el of host.querySelectorAll('[data-spark-account]')) {
|
|||
|
|
const gesneden = accountReeks(reeks, Number(el.dataset.sparkAccount), venster, dagenMs);
|
|||
|
|
if (!gesneden) { el.innerHTML = ''; continue; }
|
|||
|
|
mountSparkline(el, gesneden.punten, { tone: 'accent', vergelijk: gesneden.inleg, formatValue: sparkLabel });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function vulTwr(host, reeks, venster) {
|
|||
|
|
const zet = (sleutel, waarde) => {
|
|||
|
|
const el = host.querySelector(`[data-twr="${CSS.escape(String(sleutel))}"]`);
|
|||
|
|
if (!el) return;
|
|||
|
|
el.textContent = waarde == null ? DASH : fmtPct(waarde, gekleurd());
|
|||
|
|
el.classList.toggle('t-muted-cell', waarde == null);
|
|||
|
|
el.closest('.wealth-card .wealth-stat')?.classList.toggle('is-leeg', waarde == null);
|
|||
|
|
const toon = waarde == null ? '' : pctToon(waarde);
|
|||
|
|
el.classList.toggle('t-pos', toon === 't-pos');
|
|||
|
|
el.classList.toggle('t-neg', toon === 't-neg');
|
|||
|
|
el.classList.toggle('delta', waarde != null);
|
|||
|
|
};
|
|||
|
|
zet('totaal', rendementVanVenster(reeks, venster));
|
|||
|
|
const rekenvaluta = id => !!host.querySelector(`[data-asset="${CSS.escape(String(id))}"][data-rekenvaluta]`);
|
|||
|
|
for (const id of reeks.perAsset.keys()) zet(id, rekenvaluta(id) ? null : assetRendement(reeks, id, venster));
|
|||
|
|
for (const el of host.querySelectorAll('[data-account]')) {
|
|||
|
|
const key = Number(el.dataset.account);
|
|||
|
|
zet(`r${key}`, el.hasAttribute('data-rekenvaluta') ? null
|
|||
|
|
: windowTwr(accountSeries(reeks, reeksSleutel(key)), meetVenster(venster, reeks.dagen.length)));
|
|||
|
|
}
|
|||
|
|
vulNietVerklaard(host, reeks, meetVenster(venster, reeks.dagen.length));
|
|||
|
|
vulResultaat(host, reeks, meetVenster(venster, reeks.dagen.length));
|
|||
|
|
vulNulDatum(host, reeks, venster);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function vulResultaat(host, reeks, venster) {
|
|||
|
|
const zet = (sleutel, bedrag) => {
|
|||
|
|
const el = host.querySelector(`[data-resultaat="${CSS.escape(String(sleutel))}"]`);
|
|||
|
|
if (!el) return;
|
|||
|
|
const kaart = el.closest('.wealth-card');
|
|||
|
|
const ongerealiseerd = kaart?.dataset.ongerealiseerd;
|
|||
|
|
if (bedrag != null && ongerealiseerd !== undefined && ongerealiseerd !== ''
|
|||
|
|
&& Math.abs(bedrag - Number(ongerealiseerd)) < 0.005) bedrag = null;
|
|||
|
|
const nul = bedrag != null && Math.abs(bedrag) < 0.005;
|
|||
|
|
el.textContent = bedrag == null ? DASH : fmtBaseSigned(bedrag, colouredSigns());
|
|||
|
|
el.classList.toggle('t-muted-cell', bedrag == null);
|
|||
|
|
el.classList.toggle('val', bedrag != null);
|
|||
|
|
el.classList.toggle('t-pos', !nul && bedrag > 0);
|
|||
|
|
el.classList.toggle('t-neg', !nul && bedrag < 0);
|
|||
|
|
el.closest('.wealth-card .wealth-stat')?.classList.toggle('is-leeg', bedrag == null);
|
|||
|
|
};
|
|||
|
|
const geenKoers = sel => !!host.querySelector(`${sel}[data-geenkoers]`);
|
|||
|
|
zet('totaal', windowResult(seriesFor(reeks), venster));
|
|||
|
|
for (const id of reeks.perAsset.keys()) {
|
|||
|
|
zet(id, geenKoers(`[data-asset="${CSS.escape(String(id))}"]`) ? null : windowResult(seriesFor(reeks, id), venster));
|
|||
|
|
}
|
|||
|
|
for (const el of host.querySelectorAll('[data-account]')) {
|
|||
|
|
const key = Number(el.dataset.account);
|
|||
|
|
zet(`r${key}`, el.hasAttribute('data-geenkoers') ? null : windowResult(accountSeries(reeks, reeksSleutel(key)), venster));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function vulNulDatum(host, reeks, venster) {
|
|||
|
|
for (const el of host.querySelectorAll('[data-nul]')) {
|
|||
|
|
const eenheden = reeks.eenhedenPerAsset?.get(Number(el.dataset.nul)) || [];
|
|||
|
|
let i = Math.min(venster.tot, eenheden.length - 1);
|
|||
|
|
while (i >= 0 && !eenheden[i]) i--;
|
|||
|
|
const dag = i >= 0 ? reeks.dagen[i + 1] : null;
|
|||
|
|
el.innerHTML = dag
|
|||
|
|
? infoLinesHtml([t('wealth.noLongerHeld'), t('wealth.noLongerHeldSince', { dag: sparkDatum(Date.parse(`${dag}T00:00:00Z`)) })])
|
|||
|
|
: '';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function gerealiseerdOver(reeks, id, venster) {
|
|||
|
|
const bedrag = overVenster(reeks.gerealiseerdPerAsset?.get(id), venster);
|
|||
|
|
const onbekend = overVenster(reeks.onbekendPerAsset?.get(id), venster) > 0;
|
|||
|
|
const verkocht = Math.abs(bedrag) >= 0.005 || onbekend || verkochtIn(reeks, id, venster);
|
|||
|
|
return { bedrag: verkocht && !onbekend ? bedrag : null, onbekend };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const verkochtIn = (reeks, id, venster) =>
|
|||
|
|
(reeks.verkopen?.get(id) || []).some(i => (venster.van > 0 ? i > venster.van : i >= 0) && i <= venster.tot);
|
|||
|
|
|
|||
|
|
const overVenster = windowDelta;
|
|||
|
|
|
|||
|
|
function vulNietVerklaard(host, reeks, venster) {
|
|||
|
|
const zet = (sleutel, amount) => {
|
|||
|
|
const el = host.querySelector(`[data-nietverklaard="${CSS.escape(String(sleutel))}"]`);
|
|||
|
|
if (!el) return;
|
|||
|
|
const rij = el.closest('[data-nietverklaard-rij]');
|
|||
|
|
el.textContent = '';
|
|||
|
|
rij?.classList.add('is-leeg');
|
|||
|
|
if (amount == null || Math.abs(amount) < 0.01) return;
|
|||
|
|
el.textContent = fmtBaseSigned(amount, 'both');
|
|||
|
|
rij?.classList.remove('is-leeg');
|
|||
|
|
};
|
|||
|
|
let totaal = 0;
|
|||
|
|
for (const [id, r] of (reeks.nietVerklaardPerAsset || new Map())) { totaal += overVenster(r, venster); zet(id, overVenster(r, venster)); }
|
|||
|
|
zet('totaal', totaal);
|
|||
|
|
for (const [id, r] of (reeks.nietVerklaardPerAccount || new Map())) zet(`r${id ?? 0}`, overVenster(r, venster));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const puntenVan = (reeks, dagenMs) => (reeks || [])
|
|||
|
|
.map((v, i) => [dagenMs[i], v])
|
|||
|
|
.filter(p => typeof p[1] === 'number');
|
|||
|
|
|
|||
|
|
const gatTekst = v => fmtBaseSigned(v, 'both');
|
|||
|
|
|
|||
|
|
const sparkDatum = ms => localDateNl(new Date(ms).toISOString());
|
|||
|
|
|
|||
|
|
function metalBreakdown(asset, p, fiatPrice, holdings) {
|
|||
|
|
const prods = holdings.filter(h => h.assetId === p.id);
|
|||
|
|
const valOfBase = base => fiatPrice != null ? (base / Math.pow(10, BASE)) * fiatPrice : null;
|
|||
|
|
const weightCell = base => {
|
|||
|
|
const { primary, secondary } = fmtAmount(base, asset);
|
|||
|
|
return val(primary) + (secondary ? ` <span class="t-muted-cell">· ${val(secondary)}</span>` : '');
|
|||
|
|
};
|
|||
|
|
const valueCell = base => { const v = valOfBase(base); return v != null ? val(fmtBase(v)) : '-'; };
|
|||
|
|
|
|||
|
|
const perProduct = h => assetMetrics({
|
|||
|
|
decimals: BASE, net: h.net_count * h.fineGrams,
|
|||
|
|
buy_amount: h.buy_amount, buy_spot_raw: h.buy_spot_raw,
|
|||
|
|
buy_fee_total: h.buy_premium, buy_events: h.buy_events,
|
|||
|
|
gift_amount: h.gift_amount,
|
|||
|
|
}, fiatPrice);
|
|||
|
|
|
|||
|
|
const rows = prods.map(h => {
|
|||
|
|
const base = h.net_count * h.fineGrams;
|
|||
|
|
const pm = perProduct(h);
|
|||
|
|
const kostprijs = pm.avgCost != null ? (base / Math.pow(10, BASE)) * pm.avgCost : null;
|
|||
|
|
const pct = (pm.unrealized != null && kostprijs) ? pm.unrealized / kostprijs : null;
|
|||
|
|
return `<tr>
|
|||
|
|
<td class="text">${esc(h.name)} <span class="t-muted-cell">${t('enums.metal_form.' + h.form)}</span></td>
|
|||
|
|
<td class="num">${val(String(h.net_count))}</td>
|
|||
|
|
<td class="num">${weightCell(base)}</td>
|
|||
|
|
<td class="num">${valueCell(base)}</td>
|
|||
|
|
<td class="num">${pm.unrealized == null ? '-' : kleurBedrag(pm.unrealized)}</td>
|
|||
|
|
<td class="num">${pct == null ? '-' : `<span class="${pct >= 0 ? 't-pos' : 't-neg'}">${fmtPct(pct, gekleurd())}</span>`}</td>
|
|||
|
|
</tr>`;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const allocBase = prods.reduce((s, h) => s + h.net_count * h.fineGrams, 0);
|
|||
|
|
const unallocBase = p.net - allocBase;
|
|||
|
|
if (unallocBase > 0) {
|
|||
|
|
rows.push(`<tr>
|
|||
|
|
<td class="text"><span class="t-muted-cell">${t('wealth.unallocated')}</span></td>
|
|||
|
|
<td class="num">-</td>
|
|||
|
|
<td class="num">${weightCell(unallocBase)}</td>
|
|||
|
|
<td class="num">${valueCell(unallocBase)}</td>
|
|||
|
|
<td class="num">-</td>
|
|||
|
|
<td class="num">-</td>
|
|||
|
|
</tr>`);
|
|||
|
|
}
|
|||
|
|
if (!rows.length) return '';
|
|||
|
|
|
|||
|
|
return `<div style="margin-top:.8rem">
|
|||
|
|
<div class="table-wrap"><table>
|
|||
|
|
<thead><tr>
|
|||
|
|
<th class="text">${t('wealth.product')}</th>
|
|||
|
|
<th class="num">${t('wealth.count')}</th>
|
|||
|
|
<th class="num">${t('wealth.weight')}</th>
|
|||
|
|
<th class="num">${t('wealth.value')}</th>
|
|||
|
|
<th class="num">${t('wealth.unrealized')}</th>
|
|||
|
|
<th class="num">${t('wealth.result')}</th>
|
|||
|
|
</tr></thead>
|
|||
|
|
<tbody>${rows.join('')}</tbody>
|
|||
|
|
</table></div>
|
|||
|
|
</div>`;
|
|||
|
|
}
|