Files
AtreidesAssetsFree/client/pages/wealth.js
T
2026-10-29 11:57:29 +01:00

1445 lines
66 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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>`;
}