Files
AtreidesAssetsFree/client/pages/wealth.js
T

1444 lines
66 KiB
JavaScript
Raw Permalink Normal View History

2026-10-29 11:57:29 +01:00
// 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>`;
}