// 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 = `
`; 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 = `` + emptyStateHtml(t('wealth.none')); return; } const holdings = await metalHoldings({ ownOnly: ownOnly() }); const kolom = ` `; const reeksVooraf = wachtOpReeks ? await reeksBelofte.catch(() => null) : null; host.innerHTML = `
${kolom}` + `
` + `` + `
`; 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', `${val(fmtBase(standen.total))}${originsHtml(herkomsten)}`); 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 = `
${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))}
${t('wealth.yearRangeTip')}
`; 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 ``; } function jaarInhoud(label, pct) { if (pct == null) return esc(label); return `${esc(label)}${fmtPct(pct, gekleurd())}`; } const pctCel = p => (p == null ? DASH : `${fmtPct(p, gekleurd())}`); 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 ? `${jaarInhoud(label, pct)}` : ``); 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) => ``; 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: `${t('enums.asset_kind.' + k.asset.kind)}`, html: detailHtml() }); const body = document.querySelector('.dialog-overlay .dialog-body--rich'); if (!body) return; body.closest('.dialog')?.insertAdjacentHTML('afterbegin', `${jaarInhoud(t('wealth.allYears'), null)}`); 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 = `
${t('wealth.noChart')}
`; }; 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 `
`; } 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 `
${cijfers.map(([l, v]) => stat(l, v)).join('')}
`; } 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 `
${delen.map(d => ``).join('')}
${t('wealth.account')} ${t('wealth.quantity')} ${t('wealth.costOnArrival')} ${t('wealth.unrealized')}
${esc(naam(d.accountId))} ${val(fmtUnit(d.saldo, asset))} ${d.cost == null ? DASH : val(fmtBase(d.cost))} ${d.unrealized == null ? DASH : kleurBedrag(d.unrealized)}
`; } 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 = `
${perJaarHtml(k.asset, reeks)}
`; 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 ? `
` : ''; const klaar = dialog.detail({ title: t('wealth.yearOverviewTitle'), html: `
${grafiek}${jaaroverzichtHtml(rijen)}
`, }); 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 `
${t('wealth.noYears')}
`; const { jaren, alles } = rijen; const bedrag = v => (v == null || Math.abs(v) < 0.01 ? DASH : val(fmtBase(v))); const cel = r => ` ${bedrag(r.waarde)} ${bedrag(r.inleg)} ${pctCel(r.pct)} ${pctCel(r.sinds)} ${bedragCel(r.resultaat)} ${r.gerealiseerd == null ? DASH : kleurBedrag(r.gerealiseerd)}`; return `
${jaren.map(r => `${cel(r)}`).join('')} ${cel(alles)}
${t('wealth.year')} ${t('wealth.valueEnd')} ${t('wealth.investedNet')} ${t('wealth.twr')} ${t('wealth.sinceStart')} ${t('wealth.result')} ${t('wealth.realized')}
${esc(r.label)}
${esc(alles.label)}
`; } function perJaarHtml(asset, reeks) { if (!reeks?.dagen?.length) return `
${t('wealth.noYears')}
`; 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 `
${t('wealth.noYears')}
`; 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 => ` ${bedrag(r.waarde)} ${bedrag(r.inleg)} ${pctCel(r.pct)} ${pctCel(r.sinds)} ${bedragCel(r.resultaat)} ${r.g.bedrag == null ? DASH : kleurBedrag(r.g.bedrag)}`; return `
${jaren.map(r => `${cel(r)}`).join('')} ${cel(alles)}
${t('wealth.year')} ${t('wealth.valueEnd')} ${t('wealth.investedNet')} ${t('wealth.twr')} ${t('wealth.sinceStart')} ${t('wealth.result')} ${t('wealth.realized')}
${esc(r.label)}
${esc(alles.label)}
`; } 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 `
${t('wealth.noAccountRows')}
`; 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 `
${esc(label)} ${samenvatting ? `${samenvatting}${waarde ? originHtml(m.origin, asset) : ''}` : ''}
${boekingen}`; }).join(''); return `
${groepen}
${t('wealth.tradeDate')} ${t('wealth.tradeType')} ${t('wealth.quantity')} ${t('wealth.tradePrice')} ${t('wealth.tradeAmount')}
`; } function handelRijen(asset, trades) { if (!trades.length) { return `${t('wealth.noTrades')}`; } 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(`${esc(tr.label_names)}`) : ''; return ` ${esc(sparkDatum(dagMs(tr.occurredAt) ?? Date.now()))}${labels} ${koop ? ARROW_DOWN : ARROW_UP} ${val(fmtUnit(tr.amount, asset))} ${tr.fiatPrice == null ? DASH : val(fmtBasePrice(priceFromBase(tr.fiatPrice, asset)))} ${val(fmtBase(bedrag))}${kosten ? ` ${koop ? '+' : '−'} ${val(fmtBase(kosten))}` : ''} `; }).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) => `${html}`; const kleurBedrag = v => tekenKleur(v, val(fmtBaseSigned(v, colouredSigns()))); function stat(label, value) { return `
${label}
${value ?? DASH}
`; } function statLeeg(label, sleutel) { return `
${label}
`; } function cardHtml(asset, p, m, total) { const valueStr = m.value != null ? `${val(fmtBase(m.value))}${originHtml(m.origin, asset)}` : 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 ? `
${t('wealth.noPrice')}
` : ''; const stats = `
${stat(t('wealth.quantity'), `${val(fmtUnit(p.net, asset))}${p.net === 0 ? `` : ''}`)} ${stat(t('wealth.value'), valueStr)} ${stat(t('wealth.share'), aandeelStr)} ${stat(t('wealth.unrealized'), unrealStr)} ${stat(t('wealth.result'), `…`)} ${stat(t('wealth.twr'), `…`)} ${statLeeg(t('wealth.unexplained'), asset.id)}
`; return `
${assetLabelHtml(asset)} ${t('enums.asset_kind.' + asset.kind)}
${stats} ${geenKoers}
`; } 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 ? `${val(fmtBase(c.value))}${originsHtml(herkomsten)}` : 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 `
${esc(naam)} ${c.account ? `${t('enums.account_kind.' + c.account.kind)}` : ''}
${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'), `…`)} ${stat(t('wealth.twr'), `…`)}
`; } 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 ? `${t('enums.account_kind.' + c.account.kind)}` : '', html: detailHtml() }); const body = document.querySelector('.dialog-overlay .dialog-body--rich'); if (!body) return; body.closest('.dialog')?.insertAdjacentHTML('afterbegin', `${jaarInhoud(t('wealth.allYears'), null)}`); 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 = `
${t('wealth.noChart')}
`; return; } mountSparkline(el, gesneden.punten, { tone: 'accent', vergelijk: gesneden.inleg, formatValue: sparkLabel }); } function accountAssetsHtml(c) { if (!c.lines.length) return `
${t('wealth.noAccountAssets')}
`; const rijen = c.lines.map(l => ` ${assetLabelHtml(l.asset)} ${val(fmtUnit(l.saldo, l.asset))} ${l.value == null ? DASH : `${val(fmtBase(l.value))}${originHtml(l.origin, l.asset)}`} ${l.value == null || !(c.value > 0) ? DASH : aandeel(l.value, c.value)} `).join(''); return `
${rijen}
${t('wealth.asset')} ${t('wealth.quantity')} ${t('wealth.value')} ${t('wealth.shareOfAccount')}
`; } function accountJarenHtml(reeks, key) { if (!reeks?.dagen?.length) return `
${t('wealth.noYears')}
`; 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 `
${t('wealth.noYears')}
`; 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 => ` ${bedrag(r.waarde)} ${bedrag(r.inleg)} ${pctCel(r.pct)} ${pctCel(r.sinds)} ${bedragCel(r.resultaat)}`; return `
${jaren.map(r => `${cel(r)}`).join('')} ${cel(alles)}
${t('wealth.year')} ${t('wealth.valueEnd')} ${t('wealth.broughtInNet')} ${t('wealth.twr')} ${t('wealth.sinceStart')} ${t('wealth.result')}
${esc(r.label)}
${esc(alles.label)}
`; } 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 ? ` · ${val(secondary)}` : ''); }; 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 ` ${esc(h.name)} ${t('enums.metal_form.' + h.form)} ${val(String(h.net_count))} ${weightCell(base)} ${valueCell(base)} ${pm.unrealized == null ? '-' : kleurBedrag(pm.unrealized)} ${pct == null ? '-' : `${fmtPct(pct, gekleurd())}`} `; }); const allocBase = prods.reduce((s, h) => s + h.net_count * h.fineGrams, 0); const unallocBase = p.net - allocBase; if (unallocBase > 0) { rows.push(` ${t('wealth.unallocated')} - ${weightCell(unallocBase)} ${valueCell(unallocBase)} - - `); } if (!rows.length) return ''; return `
${rows.join('')}
${t('wealth.product')} ${t('wealth.count')} ${t('wealth.weight')} ${t('wealth.value')} ${t('wealth.unrealized')} ${t('wealth.result')}
`; }